Files
msh-system/客户反馈分析报告.md
msh-agent b164d8ba11 feat(ai-chat): 新增豆包API + AI模型配置项支持动态切换
- 后端新增豆包(火山引擎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>
2026-04-11 18:03:21 +08:00

11 KiB
Raw Blame History

测试问题与优化建议 — 修改解决方案

分析日期: 2026年4月11日 项目: msh_single_uniappUniApp 小程序) 涉及页面: 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 为份数值。

修改方案:

  1. 后端接口改造(推荐):getCalculatorResult 接口返回数据中增加 gram 字段,或将 portion 的含义改为克数。

  2. 前端模板修改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>
  1. 数据层适配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天


问题2AI营养师对话页面新增交互按钮

页面: pages/tool/ai-nutritionist.vue

现状分析: 当前每条 AI 消息仅有 1个操作按钮TTS 语音朗读约第84-90行。消息数据结构为 { role, content, type, loading, streaming }。AI 回复通过 api.kieaiGeminiChat() 调用(实际走 /api/front/kieai/gemini/chatstream: 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天


问题3AI回复速度慢已优化 — 切换至 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)

已完成的优化:

  1. 前端切换至 Coze 流式 API sendToAI() 改为调用 api.cozeChatStream(),直接使用 Coze 的流式事件(conversation.message.delta),逐字渲染 AI 回复。首字可见时间大幅缩短。

  2. Coze 事件解析: 正确解析 Coze SSE 事件格式 { event, conversation_id, chat_id, content, role, type },仅累积 event=conversation.message.deltatype=answer 的文本增量。

  3. 多轮对话支持: 自动保存 conversation_id后续消息在同一会话中进行Coze Bot 可获取完整上下文。

  4. 消息格式适配: 新增 buildCozeMessages() / buildCozeRequest() 方法,将用户输入(文本/图片/多模态)转换为 Coze 格式的 additionalMessages

  5. 降级策略: 流式失败时自动降级为非流式 api.cozeChat(),并通过 pollCozeResult() 轮询获取最终回复。

Coze 相比 KieAI 代理的优势:

  • 去掉一层 KieAI 代理网关,减少一跳网络延迟
  • Coze 后端已配置 X-Accel-Buffering: no + Cache-Control: no-cacheSSE 不会被 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 后,卡片点击跳转详情页的功能即可正常工作。需确认:

  1. food-detail.vue 详情页内容是否完整可用
  2. pages.json 中是否已注册 /pages/tool/food-detail 路由

工作量估计: 与问题7合并处理0.5天


问题6角色权限与分销系统

决策:暂不处理。 临时方案为运营人员在后台手动为指定用户账号配置角色标签(医生/护士/普通用户)。


问题7Bug食物百科点击报错 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 为 undefinednull 时(可能因 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 项目代码分析生成,所有代码引用均已核对源文件。