优化搜索定位反馈:修复Flash动画时序、添加脉冲动画、增加滚动留白、内联无结果提示

This commit is contained in:
2026-05-19 14:55:40 +08:00
parent a954f9cc49
commit b1491ad69e
2 changed files with 133 additions and 20 deletions

View File

@@ -32,7 +32,7 @@
:key="getMessageKey(message, index)"
:id="getMessageKey(message, index)"
class="message-item"
:class="[message.role, { 'first-message': index === 0, 'search-active': showSearchBar && searchResultFilteredIndex === index }]">
:class="[message.role, { 'first-message': index === 0, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]">
<!-- 用户消息 -->
<view v-if="message.role === 'user'" class="user-message">
@@ -441,7 +441,9 @@ export default {
// 聊天记录搜索状态(由 AiChatSearch 组件事件同步)
showSearchBar: false,
searchKeyword: '',
searchResultFilteredIndex: -1
searchResultFilteredIndex: -1,
searchFlashAnimatingIndex: -1, // 当前正在播放 flash 动画的消息索引
searchFlashTimer: null // flash 动画定时器
}
},
computed: {
@@ -477,6 +479,10 @@ export default {
// 新增:组件销毁时清除所有定时器
beforeDestroy() {
if (this.scrollTimer) clearTimeout(this.scrollTimer);
if (this.searchFlashTimer) {
clearTimeout(this.searchFlashTimer);
this.searchFlashTimer = null;
}
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
@@ -522,8 +528,25 @@ export default {
methods: {
// ==================== 搜索组件事件处理 ====================
onSearchScrollTo(messageKey) {
this.scrollTop = undefined
this.scrollIntoViewId = messageKey
this.scrollIntoViewId = ''
// 清除之前的 flash 定时器
if (this.searchFlashTimer) {
clearTimeout(this.searchFlashTimer)
this.searchFlashTimer = null
}
this.searchFlashAnimatingIndex = -1
this.$nextTick(() => {
this.scrollIntoViewId = messageKey
// 触发 flash 脉冲动画 - 使用 setTimeout 让动画持续足够时间
if (this.searchResultFilteredIndex >= 0) {
this.searchFlashAnimatingIndex = this.searchResultFilteredIndex
// 动画持续 1.2s 后自动移除 flash 状态
this.searchFlashTimer = setTimeout(() => {
this.searchFlashAnimatingIndex = -1
this.searchFlashTimer = null
}, 1200)
}
})
},
onSearchActiveChange(isActive) {
@@ -531,7 +554,10 @@ export default {
},
onSearchResultChange(filteredIndex) {
this.searchResultFilteredIndex = filteredIndex
this.searchResultFilteredIndex = -1
this.$nextTick(() => {
this.searchResultFilteredIndex = filteredIndex
})
},
onSearchKeywordChange(keyword) {
@@ -663,7 +689,9 @@ export default {
// 生成消息的唯一 key(兼容非 H5 平台)
getMessageKey(message, index) {
const id = message.id || message.timestamp || index;
const rawId = message.id || message.timestamp || index;
// 标准化 ID:转为字符串并替换非法字符(. 和其他非 HTML-safe 字符)
const id = String(rawId).replace(/[^a-zA-Z0-9_-]/g, '-');
return 'msg-' + id + '-' + index;
},
@@ -2151,6 +2179,26 @@ _handleStreamError(msg) {
.message-item {
margin-bottom: 20rpx;
scroll-margin-top: 300rpx;
scroll-margin-bottom: 200rpx;
// 搜索定位 - 当前结果:持续绿色发光效果
&.search-current {
position: relative;
z-index: 1;
border-radius: 18rpx;
.user-message .message-bubble,
.ai-message .message-bubble {
box-shadow: 0 0 0 4rpx rgba(7, 193, 96, 0.7), 0 4rpx 24rpx rgba(7, 193, 96, 0.35) !important;
transition: box-shadow 0.3s ease;
}
}
// 搜索定位 - Flash 脉冲动画(导航到新结果时触发)
&.search-flash {
animation: searchPulse 1.2s ease-out;
}
&.first-message {
margin-top: 16rpx;
@@ -2978,6 +3026,31 @@ _handleStreamError(msg) {
}
}
/* 搜索定位脉冲动画 */
@keyframes searchPulse {
0% {
transform: translateY(0);
}
15% {
transform: translateY(-12rpx);
}
30% {
transform: translateY(4rpx);
}
45% {
transform: translateY(-4rpx);
}
60% {
transform: translateY(0);
}
0%, 60%, 100% {
filter: brightness(1);
}
15%, 45% {
filter: brightness(1.15);
}
}
@keyframes shine {
0% {
left: -100%;
@@ -3343,16 +3416,6 @@ _handleStreamError(msg) {
-webkit-user-select: text;
}
// 当前搜索匹配的消息高亮(使用 outline + !important 覆盖用户/AI气泡的独立样式)
.message-item.search-active {
.message-bubble {
outline: 3rpx solid #07c160 !important;
outline-offset: 2rpx;
box-shadow: 0 4rpx 16rpx rgba(7, 193, 96, 0.25) !important;
transition: outline 0.25s ease, box-shadow 0.25s ease;
}
}
// 用户消息 rich-text 高亮(搜索时替代 <text>)
.message-rich-text {
font-size: 28rpx;

View File

@@ -9,7 +9,7 @@
<!-- 搜索浮条 -->
<view class="search-bar" v-if="showSearchBar">
<view class="search-bar-inner">
<view class="search-bar-input-wrap">
<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"
@@ -29,6 +29,10 @@
</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>
@@ -68,6 +72,7 @@ export default {
searchKeyword: '',
searchResults: [], // 匹配结果在过滤后数组中的索引列表
searchResultIndex: -1, // 当前在 searchResults 中的位置
searched: false, // 是否已执行过搜索(用于显示无结果提示)
}
},
@@ -92,7 +97,9 @@ export default {
methods: {
// 生成消息唯一 key(与父组件 getMessageKey 保持一致)
getMessageKey(message, index) {
return `msg-${message.id || message.tempId || index}-${message.role}`
const rawId = message.id || message.timestamp || index;
const id = String(rawId).replace(/[^a-zA-Z0-9_-]/g, '-');
return 'msg-' + id + '-' + index;
},
toggleSearch() {
@@ -113,6 +120,7 @@ export default {
this.searchKeyword = ''
this.searchResults = []
this.searchResultIndex = -1
this.searched = false
this.$emit('result-change', -1)
this.$emit('keyword-change', '')
},
@@ -132,6 +140,7 @@ export default {
if (!keyword) {
this.searchResults = []
this.searchResultIndex = -1
this.searched = false
this.$emit('result-change', -1)
this.$emit('keyword-change', '')
return
@@ -147,6 +156,7 @@ export default {
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)
@@ -156,8 +166,6 @@ export default {
this.$nextTick(() => {
this._emitScrollTo(filteredIdx)
})
} else {
uni.showToast({ title: this.$lang('noMatchResult'), icon: 'none' })
}
},
@@ -413,4 +421,46 @@ export default {
}
}
}
// 搜索状态提示
.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>