- 后端新增豆包(火山引擎Ark)API集成:DoubaoController、ToolDoubaoServiceImpl, 使用OkHttp3 SSE流式对话,兼容OpenAI Chat Completions格式 - 新增DoubaoConfig配置类,读取doubao.api.*配置 - 在eb_system_config表新增ai_chat_model配置项,支持doubao/coze/gemini三种模型切换 - 新增GET /api/front/doubao/ai-model-config接口供前端读取当前模型配置 - 前端ai-nutritionist.vue的sendToAI按系统配置分发到_sendViaDoubao/_sendViaCoze/_sendViaGemini - 前端models-api.js新增doubaoChatStream/doubaoChat/getAiModelConfig函数 - 附带豆包API测试脚本和数据库初始化SQL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
11 KiB
测试问题与优化建议 — 修改解决方案
分析日期: 2026年4月11日 项目: msh_single_uniapp(UniApp 小程序) 涉及页面: calculator-result / ai-nutritionist / food-encyclopedia
一、总览:需处理项 vs 暂不处理项
| # | 问题 | 决策 | 涉及文件 |
|---|---|---|---|
| 1 | 食物份数 → 克数 | 采纳 | pages/tool/calculator-result.vue |
| 2 | AI对话交互按钮 | 部分采纳 | pages/tool/ai-nutritionist.vue |
| 3 | AI回复速度慢 | 已优化(切换Coze流式API) | ai-nutritionist.vue + models-api.js |
| 4 | 食物百科图片缺失 | 不处理(后台可改) | — |
| 5 | 百科卡片误导点击 | 采纳(跳转详情页) | pages/tool/food-encyclopedia.vue |
| 6 | 角色权限与分销 | 暂不处理(后台人工配置) | — |
| 7 | 百科点击报错 Bug | 必须修复 | pages/tool/food-encyclopedia.vue |
二、需修改的问题及代码级解决方案
问题1:食物份数建议改为克数
页面: pages/tool/calculator-result.vue
现状分析: 当前"食物份数建议"卡片(约第94-113行)中,食物列表渲染逻辑为:
<text class="food-portion">{{ item.portion }} 份</text>
数据结构 foodList 中每个 item 包含 { number, name, portion },portion 为份数值。
修改方案:
-
后端接口改造(推荐):
getCalculatorResult接口返回数据中增加gram字段,或将portion的含义改为克数。 -
前端模板修改(
calculator-result.vue约第94-113行):
<!-- 修改前 -->
<text class="card-title">食物份数建议</text>
...
<text class="food-portion">{{ item.portion }} 份</text>
<!-- 修改后 -->
<text class="card-title">每日食物建议</text>
...
<text class="food-portion">{{ item.gram || item.portion }} 克</text>
- 数据层适配(
applyResult方法,约第320-350行):解析接口返回时,确保foodList中的 item 携带gram字段。如后端暂未改造,可前端用换算公式临时过渡:
// 在 applyResult 方法中
this.foodList = (res.data.foodList || []).map(item => ({
...item,
gram: item.gram || Math.round(item.portion * item.gramPerServing) || item.portion
}))
工作量估计: 前端 0.5天,后端(如需改接口)0.5天
问题2:AI营养师对话页面新增交互按钮
页面: pages/tool/ai-nutritionist.vue
现状分析:
当前每条 AI 消息仅有 1个操作按钮:TTS 语音朗读(约第84-90行)。消息数据结构为 { role, content, type, loading, streaming }。AI 回复通过 api.kieaiGeminiChat() 调用(实际走 /api/front/kieai/gemini/chat,stream: false 非流式),整体响应一次性返回。
部分采纳后的需求: 新增"复制"、"重新生成"、"删除"按钮(语音朗读已有)。
修改方案:
在消息气泡下方的操作区域(约第84-90行 TTS 按钮位置),扩展为按钮组:
<!-- 修改前:仅 TTS 按钮 -->
<view class="tts-play-btn" v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'" @click="...">
<text>{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
<!-- 修改后:操作按钮组 -->
<view class="msg-actions" v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'">
<!-- 复制 -->
<view class="action-btn" @click="copyMessage(index)">
<text class="action-icon">📋</text>
</view>
<!-- 重新生成(仅最后一条AI消息显示) -->
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
<text class="action-icon">🔄</text>
</view>
<!-- 语音朗读(已有) -->
<view class="action-btn" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
<text class="action-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
<!-- 删除 -->
<view class="action-btn" @click="deleteMessage(index)">
<text class="action-icon">🗑️</text>
</view>
</view>
新增 methods:
// 复制消息
copyMessage(index) {
const msg = this.messageList[index]
uni.setClipboardData({
data: msg.content,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
},
// 删除单条消息
deleteMessage(index) {
uni.showModal({
title: '提示',
content: '确定删除这条消息吗?',
success: (res) => {
if (res.confirm) {
this.messageList.splice(index, 1)
}
}
})
},
// 重新生成(重发上一条用户消息)
regenerateMessage(index) {
// 找到该AI消息对应的上一条用户消息
let userMsgIndex = index - 1
while (userMsgIndex >= 0 && this.messageList[userMsgIndex].role !== 'user') {
userMsgIndex--
}
if (userMsgIndex < 0) return
// 移除当前AI回复
this.messageList.splice(index, 1)
// 重新发送
this.sendToAI()
},
// 判断是否为最后一条AI消息
isLastAiMessage(index) {
for (let i = this.messageList.length - 1; i >= 0; i--) {
if (this.messageList[i].role === 'ai') return i === index
}
return false
}
新增样式:
.msg-actions {
display: flex;
gap: 16rpx;
margin-top: 8rpx;
justify-content: flex-start;
}
.action-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f4f5f7;
}
.action-icon {
font-size: 24rpx;
}
工作量估计: 1天
问题3:AI回复速度慢(已优化 — 切换至 Coze API)
优化前调用链路:
小程序 → POST /api/front/kieai/gemini/chat/stream
→ Spring KieAIController (SseEmitter)
→ OkHttp3 异步 POST https://api.kie.ai/gemini-2.5-flash/v1/chat/completions
→ KieAI 代理网关 → Gemini 2.5 Flash 模型
优化后调用链路:
小程序 → POST /api/front/coze/chat/stream
→ Spring CozeController (SseEmitter, 120s timeout, 15s heartbeat)
→ Coze 官方 SDK (client.chat().stream())
→ https://api.coze.cn → Coze Bot (7591133240535449654)
已完成的优化:
-
前端切换至 Coze 流式 API:
sendToAI()改为调用api.cozeChatStream(),直接使用 Coze 的流式事件(conversation.message.delta),逐字渲染 AI 回复。首字可见时间大幅缩短。 -
Coze 事件解析: 正确解析 Coze SSE 事件格式
{ event, conversation_id, chat_id, content, role, type },仅累积event=conversation.message.delta且type=answer的文本增量。 -
多轮对话支持: 自动保存
conversation_id,后续消息在同一会话中进行,Coze Bot 可获取完整上下文。 -
消息格式适配: 新增
buildCozeMessages()/buildCozeRequest()方法,将用户输入(文本/图片/多模态)转换为 Coze 格式的additionalMessages。 -
降级策略: 流式失败时自动降级为非流式
api.cozeChat(),并通过pollCozeResult()轮询获取最终回复。
Coze 相比 KieAI 代理的优势:
- 去掉一层 KieAI 代理网关,减少一跳网络延迟
- Coze 后端已配置
X-Accel-Buffering: no+Cache-Control: no-cache,SSE 不会被 nginx 缓冲 - Coze SDK 内置连接池管理,不再每次请求 new OkHttpClient
- 内置 15s heartbeat 防止连接超时断开
- 支持通过
conversation_id进行多轮上下文对话
问题4:食物百科图片缺失
决策:不处理。 图片数据通过商城 PC 管理后台维护,属运营侧工作。
问题5:百科卡片点击 → 跳转食物详情页
页面: pages/tool/food-encyclopedia.vue
现状分析:
当前卡片已绑定了 @click="goToFoodDetail(item)"(约第103行),goToFoodDetail 方法(约第1012-1030行)会尝试提取 item 的 id 并跳转到 /pages/tool/food-detail。项目中已存在 pages/tool/food-detail.vue 页面。
问题: 跳转逻辑本身已实现,但存在 Bug(见问题7),导致点击报错而非正常跳转,用户看到的效果就是"点击无反应"或"点击后闪退"。
修改方案: 修复问题7的 Bug 后,卡片点击跳转详情页的功能即可正常工作。需确认:
food-detail.vue详情页内容是否完整可用pages.json中是否已注册/pages/tool/food-detail路由
工作量估计: 与问题7合并处理,0.5天
问题6:角色权限与分销系统
决策:暂不处理。 临时方案为运营人员在后台手动为指定用户账号配置角色标签(医生/护士/普通用户)。
问题7(Bug):食物百科点击报错 TypeError
页面: pages/tool/food-encyclopedia.vue
错误信息:
TypeError: Cannot read property 'id' of undefined
at pickNumericIdStr (food-encyclopedia.vue:1015)
at VueComponent.goToFoodDetail (food-encyclopedia.vue:1022)
根因分析:
goToFoodDetail(item) 方法(第1012行)中,内部闭包 pickNumericIdStr 直接访问 item.id(第1015行),但未做空值校验。当 filteredFoodList 中的某个 item 为 undefined 或 null 时(可能因 API 返回脏数据、normalizeFoodItem 边界情况),点击即触发 TypeError。
修复方案:
在 goToFoodDetail 方法开头增加防御性校验(约第1012行):
goToFoodDetail(item) {
// 防御性校验:避免 item 为空时报错
if (!item || typeof item !== 'object') {
console.warn('[food-encyclopedia] goToFoodDetail: item 为空,跳过跳转')
return
}
const pickNumericIdStr = () => {
const cands = [item.id, item.foodId, item.food_id, item.v2FoodId, item.v2_food_id, item.foodID]
// ... 原有逻辑不变
}
// ... 后续逻辑不变
}
同时建议加固 filteredFoodList 计算属性(约第261行),确保过滤掉无效项:
filteredFoodList() {
const list = (this.foodList || []).filter(item => item != null && typeof item === 'object' && item.name)
// ... 后续过滤逻辑不变
}
工作量估计: 0.5天(含测试验证)
三、执行优先级排序
| 优先级 | 问题 | 预估工时 | 负责方 |
|---|---|---|---|
| P0 | #7 百科点击报错 Bug | 0.5天 | 前端 |
| P1 | #1 份数→克数 | 0.5-1天 | 前端+后端 |
| P1 | #5 百科卡片跳转详情页 | 与#7合并 | 前端 |
| P2 | #3 AI响应速度(已切换Coze流式API) | ✅ 已完成 | 前端 |
| P2 | #2 AI对话新增操作按钮 | 1天 | 前端 |
| — | #4 百科图片 | 不处理 | 运营 |
| — | #6 角色权限 | 暂不处理 | 运营(后台手动) |
总预估: 前端约 3-4天,后端约 2-3天
基于 msh_single_uniapp 项目代码分析生成,所有代码引用均已核对源文件。