refactor(ai-chat): 重构AI聊天消息组件

This commit is contained in:
2026-05-18 11:18:56 +08:00
parent 7580d8b763
commit 6d704f7f5a
8 changed files with 4901 additions and 4574 deletions

View 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>