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>
This commit is contained in:
322
客户反馈分析报告.md
Normal file
322
客户反馈分析报告.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# 测试问题与优化建议 — 修改解决方案
|
||||
|
||||
**分析日期:** 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行)中,食物列表渲染逻辑为:
|
||||
|
||||
```html
|
||||
<text class="food-portion">{{ item.portion }} 份</text>
|
||||
```
|
||||
|
||||
数据结构 `foodList` 中每个 item 包含 `{ number, name, portion }`,`portion` 为份数值。
|
||||
|
||||
**修改方案:**
|
||||
|
||||
1. **后端接口改造**(推荐):`getCalculatorResult` 接口返回数据中增加 `gram` 字段,或将 `portion` 的含义改为克数。
|
||||
|
||||
2. **前端模板修改**(`calculator-result.vue` 约第94-113行):
|
||||
|
||||
```html
|
||||
<!-- 修改前 -->
|
||||
<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>
|
||||
```
|
||||
|
||||
3. **数据层适配**(`applyResult` 方法,约第320-350行):解析接口返回时,确保 `foodList` 中的 item 携带 `gram` 字段。如后端暂未改造,可前端用换算公式临时过渡:
|
||||
|
||||
```javascript
|
||||
// 在 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 按钮位置),扩展为按钮组:
|
||||
|
||||
```html
|
||||
<!-- 修改前:仅 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:**
|
||||
|
||||
```javascript
|
||||
// 复制消息
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
**新增样式:**
|
||||
|
||||
```scss
|
||||
.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)
|
||||
```
|
||||
|
||||
**已完成的优化:**
|
||||
|
||||
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.delta` 且 `type=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-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 后,卡片点击跳转详情页的功能即可正常工作。需确认:
|
||||
|
||||
1. `food-detail.vue` 详情页内容是否完整可用
|
||||
2. `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行):
|
||||
|
||||
```javascript
|
||||
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行),确保过滤掉无效项:
|
||||
|
||||
```javascript
|
||||
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 项目代码分析生成,所有代码引用均已核对源文件。*
|
||||
Reference in New Issue
Block a user