feat(ai-chat): 优化加载状态动画与流式消息过渡

This commit is contained in:
2026-05-13 09:19:38 +08:00
parent b2688dc8a1
commit 80b18cee0f

View File

@@ -17,7 +17,7 @@
<!-- 消息列表 --> <!-- 消息列表 -->
<view <view
v-for="(message, index) in messages.filter(msg => msg.content && msg.content.trim() !== '')" v-for="(message, index) in messages.filter(msg => msg.type === 'loading' || (msg.content && msg.content.trim() !== ''))"
:key="getMessageKey(message, index)" :key="getMessageKey(message, index)"
:id="getMessageKey(message, index)" :id="getMessageKey(message, index)"
class="message-item" class="message-item"
@@ -159,14 +159,23 @@
</view> </view>
<!-- 加载中状态 --> <!-- 加载/处理中状态 -->
<view v-else-if="message.type === 'loading'" class="loading-content"> <view v-else-if="message.type === 'loading'" class="loading-content">
<view class="loading-dots"> <view class="loading-typing">
<text class="dot"></text> <view class="typing-dots">
<text class="dot"></text> <text class="dot"></text>
<text class="dot"></text> <text class="dot"></text>
<text class="dot"></text>
</view>
<text class="loading-text">
<text class="loading-status">{{ message.loadingText || 'AI正在思考' }}</text>
<text class="loading-ellipsis">
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
</text>
</text>
</view> </view>
<text class="loading-text">AI正在思考中...</text>
</view> </view>
<!-- 操作按钮 --> <!-- 操作按钮 -->
@@ -558,9 +567,15 @@ export default {
} }
// 会话未变化且已有消息 → 跳过加载,保留当前消息 // 会话未变化且已有消息 → 跳过加载,保留当前消息
}, },
// 新增:组件销毁时清除防抖定时器 // 新增:组件销毁时清除所有定时器
beforeDestroy() { beforeDestroy() {
if (this.scrollTimer) clearTimeout(this.scrollTimer); if (this.scrollTimer) clearTimeout(this.scrollTimer);
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
clearInterval(msg._rotationTimer);
}
});
}, },
created() { created() {
this.messages = [...this.initialMessages] this.messages = [...this.initialMessages]
@@ -900,17 +915,25 @@ async loadChatHistoryIfExist() {
this.messages.push(userMessage) this.messages.push(userMessage)
this.inputText = '' this.inputText = ''
// 显示AI正在输入 // 显示AI正在处理 - 带状态轮换提示
const loadingMessage = { const loadingMessage = {
id: ++this.messageId, id: ++this.messageId,
role: 'ai', role: 'ai',
type: 'loading', type: 'loading',
content: '', content: '',
timestamp: Date.now() timestamp: Date.now(),
loadingText: 'AI正在思考',
_loadingIndex: 0
} }
this.shouldScrollToBottom = true this.shouldScrollToBottom = true
this.messages.push(loadingMessage) this.messages.push(loadingMessage)
// 启动处理状态文字轮换
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 先渲染 loading 动画,再发起请求
await this.$nextTick()
try { try {
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判) // 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理 // 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
@@ -925,7 +948,7 @@ async loadChatHistoryIfExist() {
console.error('AI服务调用失败:', error) console.error('AI服务调用失败:', error)
// 移除加载状态 // 移除加载状态
this.messages = this.messages.filter(msg => msg.type !== 'loading') this._removeLoadingMessages()
// 显示错误消息 // 显示错误消息
const errorMessage = { const errorMessage = {
@@ -955,7 +978,7 @@ async loadChatHistoryIfExist() {
}) })
// 移除加载状态 // 移除加载状态
this.messages = this.messages.filter(msg => msg.type !== 'loading') this._removeLoadingMessages()
// 更新会话ID // 更新会话ID
if (response.conversationId) { if (response.conversationId) {
@@ -982,134 +1005,193 @@ async loadChatHistoryIfExist() {
this.$emit('ai-response', aiMessage) this.$emit('ai-response', aiMessage)
}, },
// 发送流式消息(自动适配 H5 / 微信小程序) // 发送流式消息(自动适配 H5 / 微信小程序)
async sendStreamMessage(userMessage) { async sendStreamMessage(userMessage) {
// 创建流式消息对象 // 找到已有的 loading 消息,复用它在 DOM 中的位置,实现无缝过渡
const streamMessage = { const loadingIdx = this.messages.findIndex(msg => msg.type === 'loading')
id: ++this.messageId, if (loadingIdx === -1) {
role: 'ai', // 安全回退:没有 loading 消息时创建新消息
type: 'text', const fallback = {
content: '', id: ++this.messageId,
timestamp: Date.now(), role: 'ai',
isStreaming: true type: 'text',
}; content: '',
// 标记正在流式响应 timestamp: Date.now(),
this.isStreaming = true; isStreaming: true
// 移除加载状态,添加流式消息 }
this.messages = this.messages.filter(msg => msg.type !== 'loading'); this.isStreaming = true
this.shouldScrollToBottom = true; this.messages.push(fallback)
this.messages.push(streamMessage); this.scrollToBottom()
// 添加消息后立即滚动一次 try {
this.$nextTick(() => { // #ifdef H5
this.scrollToBottom(); await aiService.sendHttpStream(userMessage,
}); (chunk) => { fallback.content += chunk; this.scrollToBottom() },
(result) => this._finishStream(fallback, result))
try { // #endif
// #ifdef H5 // #ifdef MP-WEIXIN
await aiService.sendHttpStream( await aiService.sendStreamMessage(userMessage,
userMessage, (chunk) => { fallback.content += chunk; this.scrollToBottom() },
(chunk) => { (result) => this._finishStream(fallback, result))
// 实时更新内容 // #endif
streamMessage.content += chunk; } catch (error) {
// 流式更新时滚动到底部显示最新内容(防抖) this._handleStreamError(fallback)
this.scrollToBottom(); }
return
}
this.isStreaming = true
const loadingMsg = this.messages[loadingIdx]
this._stopLoadingTextRotation(loadingMsg)
loadingMsg.loadingText = '正在接收回复'
let transformed = false
const transformOnFirstChunk = () => {
if (transformed) return this.messages[loadingIdx]
transformed = true
const msg = this.messages[loadingIdx]
msg.type = 'text'
msg.content = ''
this.$set(msg, 'isStreaming', true)
this.$set(msg, 'actions', [])
delete msg.loadingText
delete msg._loadingIndex
return msg
}
try {
// #ifdef H5
await aiService.sendHttpStream(
userMessage,
(chunk) => {
const msg = transformOnFirstChunk()
msg.content += chunk
this.scrollToBottom()
}, },
(completeResult) => { (completeResult) => {
// 流结束回调 const msg = this.messages[loadingIdx]
let convId = ''; if (!this._finishStream(msg, completeResult)) return
if (typeof completeResult !== 'string') {
convId = completeResult.conversation_id || '';
}
// 标记流式结束
streamMessage.isStreaming = false;
this.isStreaming = false;
// 检查内容是否为空,如果为空则移除该消息
if (!streamMessage.content || streamMessage.content.trim() === '') {
this.messages = this.messages.filter(msg => msg.id !== streamMessage.id);
return;
}
// 保存会话 ID
if (convId) {
this.currentConversationId = convId;
aiService.setConversationId(convId);
this.saveConversationIdToLocal(convId);
}
// 流结束后再滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
});
// 触发事件
this.$emit('ai-response', streamMessage);
}
);
// #endif
// #ifdef MP-WEIXIN
// ===== 微信小程序: 使用 WebSocket =====
await aiService.sendStreamMessage(
userMessage,
(chunk) => {
// 实时更新内容
streamMessage.content += chunk;
// 流式更新时滚动到底部显示最新内容(防抖)
this.scrollToBottom();
},
(completeResult) => {
// 流结束回调
let convId = completeResult?.conversation_id || '';
// 标记流式结束
streamMessage.isStreaming = false;
this.isStreaming = false;
// 检查内容是否为空,如果为空则移除该消息
if (!streamMessage.content || streamMessage.content.trim() === '') {
this.messages = this.messages.filter(msg => msg.id !== streamMessage.id);
return;
}
// 保存会话 ID
if (convId) {
this.currentConversationId = convId;
aiService.setConversationId(convId);
this.saveConversationIdToLocal(convId);
}
// 流结束后再滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
});
// 触发事件
this.$emit('ai-response', streamMessage);
} }
); )
// #endif // #endif
} catch (error) {
console.error('流式请求失败:', error); // #ifdef MP-WEIXIN
// 标记流式结束 await aiService.sendStreamMessage(
this.isStreaming = false; userMessage,
// 移除流式消息 (chunk) => {
this.messages = this.messages.filter(msg => msg.id !== streamMessage.id); const msg = transformOnFirstChunk()
// 显示错误 msg.content += chunk
const errorMsg = { this.scrollToBottom()
id: ++this.messageId, },
role: 'ai', (completeResult) => {
type: 'text', const msg = this.messages[loadingIdx]
content: '抱歉,服务暂时不可用,请稍后重试。', if (!this._finishStream(msg, completeResult)) return
timestamp: Date.now(), }
actions: [{ id: 1, text: '重试', type: 'retry' }] )
}; // #endif
this.messages.push(errorMsg); } catch (error) {
this.$emit('ai-response', errorMsg); console.error('流式请求失败:', error)
} this.isStreaming = false
},
if (!transformed) {
// 在第一个 chunk 到达前出错 → 移除 loading 消息
this._removeLoadingMessages()
} else {
// 流传输中出错 → 移除空的流式消息
const msg = this.messages[loadingIdx]
if (msg && (!msg.content || msg.content.trim() === '')) {
this.messages.splice(loadingIdx, 1)
}
}
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: '抱歉,服务暂时不可用,请稍后重试。',
timestamp: Date.now(),
actions: [{ id: 1, text: '重试', type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
}
},
// 完成流式消息处理(共用逻辑)
_finishStream(msg, result) {
msg.isStreaming = false
this.isStreaming = false
if (!msg.content || msg.content.trim() === '') {
this._removeLoadingMessages()
return false
}
if (result) {
const convId = typeof result === 'string' ? '' : (result.conversation_id || '')
if (convId) {
this.currentConversationId = convId
aiService.setConversationId(convId)
this.saveConversationIdToLocal(convId)
}
}
msg.actions = [
{ id: 1, text: '有帮助', type: 'like' },
{ id: 2, text: '没帮助', type: 'dislike' }
]
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
return true
},
// 流式请求出错处理
_handleStreamError(msg) {
console.error('流式请求失败')
this.isStreaming = false
this.messages = this.messages.filter(m => m.id !== msg.id)
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: '抱歉,服务暂时不可用,请稍后重试。',
timestamp: Date.now(),
actions: [{ id: 1, text: '重试', type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
},
// 加载状态文字轮换 - 让AI看起来在处理不同阶段
_startLoadingTextRotation(loadingMsg) {
const statusTexts = [
'AI正在思考',
'AI正在分析',
'AI正在检索',
'AI正在生成'
]
loadingMsg._rotationTimer = setInterval(() => {
loadingMsg._loadingIndex = (loadingMsg._loadingIndex + 1) % statusTexts.length
loadingMsg.loadingText = statusTexts[loadingMsg._loadingIndex]
}, 2000)
},
// 停止加载状态文字轮换
_stopLoadingTextRotation(loadingMsg) {
if (loadingMsg && loadingMsg._rotationTimer) {
clearInterval(loadingMsg._rotationTimer)
loadingMsg._rotationTimer = null
}
},
// 移除加载消息并清理定时器
_removeLoadingMessages() {
const loadingMsgs = this.messages.filter(msg => msg.type === 'loading');
loadingMsgs.forEach(msg => this._stopLoadingTextRotation(msg));
this.messages = this.messages.filter(msg => msg.type !== 'loading');
},
generateAIResponse(userMessage) { generateAIResponse(userMessage) {
const responses = { const responses = {
'你好': '您好!我是AI智能客服,很高兴为您服务!有什么可以帮助您的吗?', '你好': '您好!我是AI智能客服,很高兴为您服务!有什么可以帮助您的吗?',
@@ -1713,11 +1795,17 @@ async loadChatHistoryIfExist() {
role: 'ai', role: 'ai',
type: 'loading', type: 'loading',
content: '', content: '',
timestamp: Date.now() timestamp: Date.now(),
loadingText: 'AI正在思考',
_loadingIndex: 0
} }
this.messages.push(loadingMessage) this.messages.push(loadingMessage)
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 渲染 loading 动画
await this.$nextTick()
try { try {
// 使用AI服务获取回复 // 使用AI服务获取回复
if (this.enableStreaming) { if (this.enableStreaming) {
@@ -1732,7 +1820,7 @@ async loadChatHistoryIfExist() {
console.error('AI服务调用失败:', error) console.error('AI服务调用失败:', error)
// 移除加载状态 // 移除加载状态
this.messages = this.messages.filter(msg => msg.type !== 'loading') this._removeLoadingMessages()
// 显示错误消息 // 显示错误消息
const errorMessage = { const errorMessage = {
@@ -1900,6 +1988,13 @@ async loadChatHistoryIfExist() {
// 清空聊天记录 // 清空聊天记录
clearMessages() { clearMessages() {
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
clearInterval(msg._rotationTimer)
msg._rotationTimer = null
}
})
// 清空消息数组 // 清空消息数组
this.messages = [] this.messages = []
// 重置消息 ID // 重置消息 ID
@@ -2562,31 +2657,68 @@ $radius-lg: 36rpx;
align-items: center; align-items: center;
padding: 32rpx 0; padding: 32rpx 0;
.loading-dots { .loading-typing {
display: flex; display: flex;
margin-right: 16rpx; flex-direction: column;
align-items: flex-start;
gap: 12rpx;
.dot { .typing-dots {
width: 14rpx; display: flex;
height: 14rpx; align-items: center;
border-radius: 50%;
background-color: $color-primary;
margin: 0 6rpx;
animation: dotPulse 1.5s infinite ease-in-out;
&:nth-child(2) { .dot {
animation-delay: 0.2s; width: 14rpx;
} height: 14rpx;
border-radius: 50%;
&:nth-child(3) { background-color: $color-primary;
animation-delay: 0.4s; margin: 0 6rpx;
animation: dotPulse 1.5s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.loading-text {
color: $color-text-light;
font-size: 24rpx;
display: flex;
align-items: center;
.loading-status {
color: $color-primary;
font-weight: 500;
transition: opacity 0.3s ease;
}
.loading-ellipsis {
display: inline-flex;
margin-left: 4rpx;
.ellipsis-dot {
animation: ellipsisBlink 1.4s infinite ease-in-out;
opacity: 0;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
} }
} }
}
.loading-text {
color: $color-text-light;
font-size: 26rpx;
} }
} }
@@ -3478,6 +3610,15 @@ $radius-lg: 36rpx;
} }
} }
@keyframes ellipsisBlink {
0%, 100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes wave { @keyframes wave {
0%, 100% { 0%, 100% {
transform: scaleY(0.5); transform: scaleY(0.5);