feat: 营养素AI生成落库 + AI营养师消息级loading占位(设计文档对齐)

后端:
- ToolKnowledgeService/Impl 新增 generateNutrientContent()
  调用 Coze AI 批量生成6种营养素(蛋白质/钾/磷/钠/钙/水分)
  科普内容并写入 v2_knowledge,已存在的自动跳过
- ToolController 新增 POST /tool/knowledge/generate-nutrients
  端点(管理端一次性调用后自动补充封面图)
- 新增 SQL 备用脚本 migration_2026-03-25_nutrient_knowledge.sql
  含6种营养素完整JSON,直接执行可跳过AI生成

前端(ai-nutritionist.vue,对齐功能开发详细设计文档任务3-2):
- 新增 sleep(ms) 工具方法
- sendToAI 发起前先推入 {loading:true} 占位气泡
- pollChatStatus 轮询间隔由 1000ms 调整为 1500ms
- getChatMessages 回调填充占位气泡(不再 push 新消息)
- 所有错误/超时/失败路径统一更新 aiMsg.loading=false

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-25 15:32:53 +08:00
parent 24f75d198c
commit 6ec9487597
5 changed files with 269 additions and 55 deletions

View File

@@ -63,11 +63,17 @@
<!-- 消息气泡 -->
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
<text v-if="msg.type !== 'image'" class="message-text">{{ msg.content }}</text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
<!-- AI 消息 loading 占位等待回复时显示打字动画-->
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}</text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
class="message-image"
@click="previewImage(msg.imageUrl)"
></image>
@@ -626,9 +632,19 @@ export default {
return String(content);
},
/** 工具方法sleep ms 毫秒 */
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
async sendToAI(content, type) {
this.isLoading = true;
// 添加 AI 占位消息loading 状态,等待 Coze 返回后填充内容)
const aiMsg = { role: 'ai', content: '', loading: true };
this.messageList.push(aiMsg);
this.scrollToBottom();
// 统一走 Coze API文本、多模态、图片均使用 Coze Bot
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
try {
@@ -698,7 +714,7 @@ export default {
const chatId = chat.id;
if (conversationId && chatId) {
this.conversationId = conversationId;
await this.pollChatStatus(conversationId, chatId);
await this.pollChatStatus(conversationId, chatId, aiMsg);
} else {
throw new Error('发起对话失败未返回会话或对话ID');
}
@@ -708,26 +724,23 @@ export default {
} catch (error) {
console.error('发送消息失败:', error);
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: '抱歉,处理您的请求时出现错误,请稍后再试。'
});
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
aiMsg.loading = false;
this.messageList = [...this.messageList]; // 触发响应式更新
this.scrollToBottom();
}
},
async pollChatStatus(conversationId, chatId) {
const maxAttempts = 60; // 最多轮询60次60秒
async pollChatStatus(conversationId, chatId, aiMsg) {
const maxAttempts = 60; // 最多轮询60次每次1.5秒),即90秒
let attempts = 0;
const checkStatus = async () => {
attempts++;
if (attempts > maxAttempts) {
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: '抱歉AI 响应超时,请稍后再试。'
});
if (aiMsg) { aiMsg.content = '抱歉AI 响应超时,请稍后再试。'; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: '抱歉AI 响应超时,请稍后再试。' }); }
this.scrollToBottom();
return;
}
@@ -743,21 +756,19 @@ export default {
const chatObj = res.data.chat || res.data;
const status = chatObj && chatObj.status;
if (status === 'completed') {
// 对话完成,获取消息详情
await this.getChatMessages(conversationId, chatId);
} else if (status === 'failed' || status === 'canceled') {
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}`
});
this.scrollToBottom();
} else {
// 继续轮询 (created, in_progress, requires_action)
// 注意requires_action 可能需要额外处理,这里暂时视为继续等待或需人工干预
setTimeout(checkStatus, 1000);
}
if (status === 'completed') {
// 对话完成,获取消息详情
await this.getChatMessages(conversationId, chatId, aiMsg);
} else if (status === 'failed' || status === 'canceled') {
this.isLoading = false;
const failMsg = `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}`;
if (aiMsg) { aiMsg.content = failMsg; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: failMsg }); }
this.scrollToBottom();
} else {
// 继续轮询 (created, in_progress) 每 1.5 秒
setTimeout(checkStatus, 1500);
}
} else {
// 查询失败,重试
setTimeout(checkStatus, 1000);
@@ -771,7 +782,7 @@ export default {
checkStatus();
},
async getChatMessages(conversationId, chatId) {
async getChatMessages(conversationId, chatId, aiMsg) {
try {
const res = await api.cozeMessageList({
conversationId,
@@ -786,29 +797,23 @@ export default {
const answerMsgs = rawMessages.filter(msg => msg.role === 'assistant' && msg.type === 'answer');
if (answerMsgs.length > 0) {
// 可能有多条回复,依次显示
for (const msg of answerMsgs) {
this.messageList.push({
role: 'ai',
content: msg.content
});
// 用第一条 answer 消息填充占位气泡,多条追加新气泡
if (aiMsg) {
aiMsg.content = answerMsgs[0].content;
aiMsg.loading = false;
for (let i = 1; i < answerMsgs.length; i++) {
this.messageList.push({ role: 'ai', content: answerMsgs[i].content });
}
this.messageList = [...this.messageList]; // 触发响应式更新
} else {
for (const msg of answerMsgs) { this.messageList.push({ role: 'ai', content: msg.content }); }
}
} else {
// 尝试查找其他类型的回复
const otherMsgs = rawMessages.filter(msg => msg.role === 'assistant');
if (otherMsgs.length > 0) {
for (const msg of otherMsgs) {
this.messageList.push({
role: 'ai',
content: msg.content
});
}
} else {
this.messageList.push({
role: 'ai',
content: '未能获取到有效回复。'
});
}
const fallback = otherMsgs.length > 0 ? otherMsgs[0].content : '未能获取到有效回复。';
if (aiMsg) { aiMsg.content = fallback; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: fallback }); }
}
this.scrollToBottom();
} else {
@@ -817,10 +822,9 @@ export default {
} catch (e) {
console.error('获取消息详情失败:', e);
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: '获取回复内容失败。'
});
const errContent = '获取回复内容失败。';
if (aiMsg) { aiMsg.content = errContent; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: errContent }); }
this.scrollToBottom();
}
},