Files
lucky_shop/pages_tool/ai-chat/components/ai-chat-search.vue

467 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>