467 lines
13 KiB
Vue
467 lines
13 KiB
Vue
<template>
|
||
<view class="ai-chat-search">
|
||
<!-- 搜索入口按钮(浮动在消息区右上角) -->
|
||
<view class="search-entrance" :class="{ 'icon-only': !showLabel }" v-if="!showSearchBar" @click="toggleSearch">
|
||
<text class="iconfont icon-sousuo search-entrance-icon"></text>
|
||
<text class="search-entrance-label" v-if="showLabel">{{ $lang('ai-chat.index.searchEntrance') }}</text>
|
||
</view>
|
||
|
||
<!-- 搜索浮条 -->
|
||
<view class="search-bar" v-if="showSearchBar">
|
||
<view class="search-bar-inner">
|
||
<view class="search-bar-input-wrap" :class="{ 'has-results': searchResults.length > 0, 'no-results': searched && searchResults.length === 0 }">
|
||
<text class="iconfont icon-sousuo search-bar-icon"></text>
|
||
<input
|
||
class="search-bar-input"
|
||
v-model="searchKeyword"
|
||
:placeholder="$lang('ai-chat.index.searchPlaceholder')"
|
||
: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">{{ $lang('ai-chat.index.searchClose') }}</text>
|
||
</view>
|
||
<view class="search-bar-status" v-if="searched && searchKeyword && searchResults.length === 0">
|
||
<text class="search-bar-status-icon">!</text>
|
||
<text class="search-bar-status-text">{{ $lang('ai-chat.index.noMatchResult') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
/**
|
||
* ai-chat-search - 聊天记录搜索组件
|
||
*
|
||
* Props:
|
||
* messages: Array - 原始消息列表
|
||
* showLabel: Boolean - (默认 true) 是否显示"搜索"文字;设为 false 则只显示搜索图标
|
||
*
|
||
* 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: () => []
|
||
},
|
||
// 是否显示文字标签;设为 false 则只显示搜索图标
|
||
showLabel: {
|
||
type: Boolean,
|
||
default: true
|
||
}
|
||
},
|
||
|
||
data() {
|
||
return {
|
||
showSearchBar: false,
|
||
searchKeyword: '',
|
||
searchResults: [], // 匹配结果在过滤后数组中的索引列表
|
||
searchResultIndex: -1, // 当前在 searchResults 中的位置
|
||
searched: false, // 是否已执行过搜索(用于显示无结果提示)
|
||
}
|
||
},
|
||
|
||
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) {
|
||
const rawId = message.id || message.timestamp || index;
|
||
const id = String(rawId).replace(/[^a-zA-Z0-9_-]/g, '-');
|
||
return 'msg-' + id + '-' + index;
|
||
},
|
||
|
||
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.searched = false
|
||
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.searched = false
|
||
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
|
||
this.searched = true
|
||
|
||
const filteredIdx = results.length > 0 ? results[0] : -1
|
||
this.$emit('result-change', filteredIdx)
|
||
this.$emit('keyword-change', this.searchKeyword)
|
||
|
||
if (results.length > 0) {
|
||
this._emitScrollTo(filteredIdx)
|
||
}
|
||
},
|
||
|
||
_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: this.$lang('ai-chat.index.searchAtTop'), icon: 'none' })
|
||
}
|
||
},
|
||
|
||
scrollToNextResult() {
|
||
const idx = this.searchResultIndex + 1
|
||
if (idx < this.searchResults.length) {
|
||
this.scrollToSearchResult(idx)
|
||
} else {
|
||
uni.showToast({ title: this.$lang('ai-chat.index.searchAtBottom'), 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>
|
||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||
/* 引入全局变量 */
|
||
@import '../styles/ai-chat-global.scss';
|
||
|
||
.ai-chat-search {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
// 搜索入口按钮
|
||
.search-entrance {
|
||
position: absolute;
|
||
/* 与新对话按钮垂直居中对齐:
|
||
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
|
||
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
|
||
top: 14rpx;
|
||
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);
|
||
}
|
||
|
||
// 仅显示图标模式(圆形小按钮)
|
||
&.icon-only {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
padding: 0;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
}
|
||
}
|
||
|
||
// 搜索浮条
|
||
.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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 搜索状态提示
|
||
.search-bar-status {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
padding: 14rpx 16rpx;
|
||
background: #fffae6;
|
||
border-top: 1rpx solid #f0e6c0;
|
||
|
||
.search-bar-status-icon {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
border-radius: 50%;
|
||
background: #f5a623;
|
||
color: #fff;
|
||
font-size: 20rpx;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.search-bar-status-text {
|
||
font-size: 24rpx;
|
||
color: #8a7a4a;
|
||
line-height: 1.4;
|
||
}
|
||
}
|
||
|
||
// 搜索输入框 - 有结果时绿色边框
|
||
.search-bar-input-wrap.has-results {
|
||
border-color: #07c160;
|
||
}
|
||
|
||
// 搜索输入框 - 无结果时橙色边框
|
||
.search-bar-input-wrap.no-results {
|
||
border-color: #f5a623;
|
||
}
|
||
</style>
|