Files
lucky_shop/pages_tool/ai-chat/components/ai-chat-search.vue
ZF sun 8e218bb663 feat: 新增多语言国际化页面配置,修复部分页面硬编码文案
1. 新增了zh-cn和en-us两套语言下的大量页面国际化配置文件,覆盖商品、订单、会员、营销活动等多个业务模块
2. 修复了分销页面、VR页面、支付页面、商品不存在页面的硬编码提示文案,替换为国际化调用
3. 删除了原有的i18n检查脚本文件
2026-06-25 11:00:50 +08:00

465 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('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('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('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('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('searchAtTop'), icon: 'none' })
}
},
scrollToNextResult() {
const idx = this.searchResultIndex + 1
if (idx < this.searchResults.length) {
this.scrollToSearchResult(idx)
} else {
uni.showToast({ title: this.$lang('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>
/* 引入图标字体 */
@import url('/common/css/iconfont.css');
/* 引入全局变量 */
@import '../styles/ai-chat-global.scss';
.ai-chat-search {
position: relative;
width: 100%;
}
// 搜索入口按钮
.search-entrance {
position: absolute;
top: 44rpx;
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>