Files
msh-system/客户反馈分析报告.md

323 lines
11 KiB
Markdown
Raw Normal View 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行食物列表渲染逻辑为
```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天
---
### 问题2AI营养师对话页面新增交互按钮
**页面:** `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天
---
### 问题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.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角色权限与分销系统
**决策:暂不处理。** 临时方案为运营人员在后台手动为指定用户账号配置角色标签(医生/护士/普通用户)。
---
### 问题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 为 `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 项目代码分析生成,所有代码引用均已核对源文件。*