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