feat(ai-chat): 优化加载状态动画与流式消息过渡
This commit is contained in:
@@ -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);
|
||||
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()
|
||||
|
||||
// 添加消息后立即滚动一次
|
||||
this.$nextTick(() => {
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
// #ifdef H5
|
||||
await aiService.sendHttpStream(
|
||||
userMessage,
|
||||
(chunk) => {
|
||||
// 实时更新内容
|
||||
streamMessage.content += chunk;
|
||||
// 流式更新时滚动到底部显示最新内容(防抖)
|
||||
this.scrollToBottom();
|
||||
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,10 +1795,16 @@ 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服务获取回复
|
||||
@@ -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;
|
||||
}
|
||||
.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(3) {
|
||||
animation-delay: 0.4s;
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: $color-text-light;
|
||||
font-size: 26rpx;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3478,6 +3610,15 @@ $radius-lg: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ellipsisBlink {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.5);
|
||||
|
||||
Reference in New Issue
Block a user