refactor(ai-chat): 重构AI聊天消息组件
This commit is contained in:
400
pages_tool/ai-chat/components/ai-chat-search.vue
Normal file
400
pages_tool/ai-chat/components/ai-chat-search.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<view class="ai-chat-search">
|
||||
<!-- 搜索入口按钮(浮动在消息区右上角) -->
|
||||
<view class="search-entrance" v-if="!showSearchBar" @click="toggleSearch">
|
||||
<text class="iconfont icon-sousuo search-entrance-icon"></text>
|
||||
<text class="search-entrance-label">搜索</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索浮条 -->
|
||||
<view class="search-bar" v-if="showSearchBar">
|
||||
<view class="search-bar-inner">
|
||||
<view class="search-bar-input-wrap">
|
||||
<text class="iconfont icon-sousuo search-bar-icon"></text>
|
||||
<input
|
||||
class="search-bar-input"
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索聊天记录"
|
||||
:focus="true"
|
||||
@input="onSearchInput"
|
||||
@confirm="doSearch" />
|
||||
<text class="search-bar-clear" v-if="searchKeyword" @click.stop="clearSearch">✕</text>
|
||||
</view>
|
||||
<view class="search-bar-actions" v-if="searchResults.length > 0">
|
||||
<text class="search-bar-count">{{ searchResultIndex + 1 }}/{{ searchResults.length }}</text>
|
||||
<view class="search-bar-nav-group">
|
||||
<text class="search-bar-nav" :class="{ disabled: searchResultIndex <= 0 }" @click="scrollToPrevResult">▲</text>
|
||||
<text class="search-bar-nav" :class="{ disabled: searchResultIndex >= searchResults.length - 1 }" @click="scrollToNextResult">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="search-bar-close" @click="closeSearch">完成</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* ai-chat-search - 聊天记录搜索组件
|
||||
*
|
||||
* Props:
|
||||
* messages: Array - 原始消息列表
|
||||
*
|
||||
* Events:
|
||||
* scroll-to: (messageKey: string) => void - 请求父组件滚动到指定消息
|
||||
* active-change: (isActive: boolean) => void - 搜索激活状态变化
|
||||
* result-change: (filteredIndex: number) => void - 当前匹配结果在过滤后数组中的索引
|
||||
* keyword-change: (keyword: string) => void - 搜索关键词变化(用于高亮)
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatSearch',
|
||||
|
||||
props: {
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showSearchBar: false,
|
||||
searchKeyword: '',
|
||||
searchResults: [], // 匹配结果在过滤后数组中的索引列表
|
||||
searchResultIndex: -1, // 当前在 searchResults 中的位置
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// 过滤后的消息列表(与 v-for 保持一致)
|
||||
filteredMessages() {
|
||||
return this.messages.filter(
|
||||
msg => msg.type === 'loading' || (msg.content && msg.content.trim() !== '')
|
||||
)
|
||||
},
|
||||
|
||||
// 当前匹配结果在过滤后数组中的索引
|
||||
searchResultFilteredIndex() {
|
||||
if (this.searchResultIndex < 0 || this.searchResultIndex >= this.searchResults.length) {
|
||||
return -1
|
||||
}
|
||||
return this.searchResults[this.searchResultIndex]
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 生成消息唯一 key(与父组件 getMessageKey 保持一致)
|
||||
getMessageKey(message, index) {
|
||||
return `msg-${message.id || message.tempId || index}-${message.role}`
|
||||
},
|
||||
|
||||
toggleSearch() {
|
||||
this.showSearchBar = !this.showSearchBar
|
||||
if (!this.showSearchBar) {
|
||||
this.clearSearch()
|
||||
}
|
||||
this.$emit('active-change', this.showSearchBar)
|
||||
},
|
||||
|
||||
closeSearch() {
|
||||
this.showSearchBar = false
|
||||
this.clearSearch()
|
||||
this.$emit('active-change', false)
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
this.searchKeyword = ''
|
||||
this.searchResults = []
|
||||
this.searchResultIndex = -1
|
||||
this.$emit('result-change', -1)
|
||||
this.$emit('keyword-change', '')
|
||||
},
|
||||
|
||||
onSearchInput(e) {
|
||||
const val = e.detail ? e.detail.value : e
|
||||
if (val) {
|
||||
this.searchKeyword = val
|
||||
this.doSearch()
|
||||
} else {
|
||||
this.clearSearch()
|
||||
}
|
||||
},
|
||||
|
||||
doSearch() {
|
||||
const keyword = this.searchKeyword.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
this.searchResults = []
|
||||
this.searchResultIndex = -1
|
||||
this.$emit('result-change', -1)
|
||||
this.$emit('keyword-change', '')
|
||||
return
|
||||
}
|
||||
|
||||
const results = []
|
||||
this.filteredMessages.forEach((msg, index) => {
|
||||
const content = (msg.content || '').toLowerCase()
|
||||
if (content.includes(keyword)) {
|
||||
results.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
this.searchResults = results
|
||||
this.searchResultIndex = results.length > 0 ? 0 : -1
|
||||
|
||||
const filteredIdx = results.length > 0 ? results[0] : -1
|
||||
this.$emit('result-change', filteredIdx)
|
||||
this.$emit('keyword-change', this.searchKeyword)
|
||||
|
||||
if (results.length > 0) {
|
||||
this.$nextTick(() => {
|
||||
this._emitScrollTo(filteredIdx)
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '未找到匹配结果', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
_emitScrollTo(filteredIdx) {
|
||||
const message = this.filteredMessages[filteredIdx]
|
||||
if (message) {
|
||||
this.$emit('scroll-to', this.getMessageKey(message, filteredIdx))
|
||||
}
|
||||
},
|
||||
|
||||
scrollToSearchResult(index) {
|
||||
if (index < 0 || index >= this.searchResults.length) return
|
||||
this.searchResultIndex = index
|
||||
const filteredIdx = this.searchResults[index]
|
||||
this.$emit('result-change', filteredIdx)
|
||||
this._emitScrollTo(filteredIdx)
|
||||
},
|
||||
|
||||
scrollToPrevResult() {
|
||||
const idx = this.searchResultIndex - 1
|
||||
if (idx >= 0) {
|
||||
this.scrollToSearchResult(idx)
|
||||
} else {
|
||||
uni.showToast({ title: '已到顶部', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
scrollToNextResult() {
|
||||
const idx = this.searchResultIndex + 1
|
||||
if (idx < this.searchResults.length) {
|
||||
this.scrollToSearchResult(idx)
|
||||
} else {
|
||||
uni.showToast({ title: '已到底部', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 关键词高亮(用于用户消息 rich-text)
|
||||
highlightContent(content) {
|
||||
if (!this.searchKeyword || !content) return content
|
||||
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) return content
|
||||
return this._wrapHighlight(content, this.searchKeyword)
|
||||
},
|
||||
|
||||
// AI 消息渲染(含关键词高亮)
|
||||
getAIDisplayContent(content) {
|
||||
if (!this.searchKeyword || !content) {
|
||||
return this.parseMarkdown ? this.parseMarkdown(content) : content
|
||||
}
|
||||
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
|
||||
return this.parseMarkdown ? this.parseMarkdown(content) : content
|
||||
}
|
||||
const mdHtml = this.parseMarkdown ? this.parseMarkdown(content) : content
|
||||
return this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
|
||||
},
|
||||
|
||||
_wrapHighlight(text, keyword) {
|
||||
if (!keyword || !text) return text
|
||||
const parts = text.split(new RegExp('(' + this._escapeRegExp(keyword) + ')', 'gi'))
|
||||
return parts.map(part => {
|
||||
if (part.toLowerCase() === keyword.toLowerCase()) {
|
||||
return `<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">${part}</span>`
|
||||
}
|
||||
return part
|
||||
}).join('')
|
||||
},
|
||||
|
||||
_wrapHighlightInHtml(html, keyword) {
|
||||
if (!keyword || !html) return html
|
||||
const escaped = this._escapeRegExp(keyword)
|
||||
return html.replace(/(>|^)([^<]+)(?=<|$)/g, (match, prefix, text) => {
|
||||
if (!text) return match
|
||||
const highlighted = text.replace(
|
||||
new RegExp('(' + escaped + ')', 'gi'),
|
||||
'<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">$1</span>'
|
||||
)
|
||||
return prefix + highlighted
|
||||
})
|
||||
},
|
||||
|
||||
_escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 引入图标字体 */
|
||||
@import url('/common/css/iconfont.css');
|
||||
/* 引入全局变量 */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.ai-chat-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 搜索入口按钮
|
||||
.search-entrance {
|
||||
position: absolute;
|
||||
top: 88rpx;
|
||||
right: 16rpx;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8rpx);
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
.search-entrance-icon {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-entrance-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索浮条
|
||||
.search-bar {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10rpx 16rpx;
|
||||
background: #f0f2f5;
|
||||
border-top: 1rpx solid #e0e4ea;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-bar-inner {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-bar-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 12rpx;
|
||||
height: 68rpx;
|
||||
border: 2rpx solid #d0d5dd;
|
||||
|
||||
.search-bar-icon {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar-input {
|
||||
flex: 1;
|
||||
height: 68rpx;
|
||||
font-size: 27rpx;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-bar-clear {
|
||||
font-size: 26rpx;
|
||||
color: #c0c4cc;
|
||||
flex-shrink: 0;
|
||||
padding: 0 4rpx;
|
||||
line-height: 1;
|
||||
|
||||
&:active {
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-bar-count {
|
||||
font-size: 24rpx;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
min-width: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-bar-nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rpx;
|
||||
}
|
||||
|
||||
.search-bar-nav {
|
||||
font-size: 16rpx;
|
||||
color: #555;
|
||||
width: 38rpx;
|
||||
height: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 6rpx;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:active {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #ccc;
|
||||
background: #f8f8f8;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar-close {
|
||||
font-size: 26rpx;
|
||||
color: #07c160;
|
||||
font-weight: 600;
|
||||
padding: 6rpx 4rpx;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user