- 修复油脂类食物推荐量系数 (5.7→2.5) [ToolCalculatorServiceImpl] - AI营养师接入真实Coze API,替换Mock回复 [ToolAiNutritionistServiceImpl] - 食物百科详情新增钙/铁/维C/嘌呤/重量基准字段返回 [ToolFoodServiceImpl] - V2Food模型新增purine、servingSize字段 [V2Food.java] - 食物百科详情页动态重量标注+新增4项营养展示+替换Figma URL [food-detail.vue] - 修复营养素列表dataset传参Bug(WeChat camelCase) [nutrition-knowledge.vue] - 营养素详情页接入后端API+兜底本地数据+替换Figma URL [nutrient-detail.vue] - 新增数据库迁移脚本及参考初始化数据 [docs/sql/] - 新增前端占位图标5个 [static/images/] - 新增开发任务完成报告 [docs/] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
926 lines
32 KiB
Markdown
926 lines
32 KiB
Markdown
# 功能开发详细设计文档
|
||
|
||
> **版本:** v1.0
|
||
> **日期:** 2026-03-25
|
||
> **依据:** 《测试问题分析报告_2026-03-22》
|
||
> **项目:** 慢生活- 慢性肾病营养管理小程序
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [公共前置工作(数据库变更 + 数据初始化)](#一公共前置工作)
|
||
2. [页面一:食谱计算器结果页](#二页面一食谱计算器结果页)
|
||
3. [页面二:AI 营养师对话页](#三页面二ai-营养师对话页)
|
||
4. [页面三:食物百科列表页](#四页面三食物百科列表页)
|
||
5. [页面四:食物百科详情页](#五页面四食物百科详情页)
|
||
6. [页面五:健康知识营养素列表页](#六页面五健康知识营养素列表页)
|
||
7. [页面六:营养素详情页](#七页面六营养素详情页)
|
||
8. [联调与测试检查清单](#八联调与测试检查清单)
|
||
|
||
---
|
||
|
||
## 一、公共前置工作
|
||
|
||
> 以下数据库变更和数据初始化需在所有页面开发之前完成。
|
||
|
||
### 1.1 数据库表结构变更
|
||
|
||
#### 1.1.1 `v2_food` 表新增字段
|
||
|
||
```sql
|
||
-- 新增嘌呤含量字段
|
||
ALTER TABLE v2_food ADD COLUMN purine DECIMAL(10,2) DEFAULT NULL COMMENT '嘌呤含量(mg)';
|
||
|
||
-- 新增营养成分对应的重量基准字段
|
||
ALTER TABLE v2_food ADD COLUMN serving_size VARCHAR(50) DEFAULT '每100g' COMMENT '营养成分对应的食物重量基准,如"每100g"、"每份(50g)"';
|
||
```
|
||
|
||
**验证方式:** 执行后 `DESC v2_food;` 确认新增列存在。
|
||
|
||
#### 1.1.2 `v2_food` 数据模型变更
|
||
|
||
**文件:** `msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/model/tool/V2Food.java`
|
||
|
||
```java
|
||
// 新增字段
|
||
/** 嘌呤含量(mg) */
|
||
@TableField("purine")
|
||
private BigDecimal purine;
|
||
|
||
/** 营养成分重量基准 */
|
||
@TableField("serving_size")
|
||
private String servingSize;
|
||
```
|
||
|
||
### 1.2 食物营养数据补全
|
||
|
||
**参考来源:** https://www.ishen365.com/article/cereal (爱肾网-食物营养成分表)
|
||
|
||
**执行方式:** 编写数据迁移脚本(SQL 或 Python),将 ishen365 上各分类(谷薯类、蔬菜类、水果类、肉蛋类、水产类、奶类、豆类、坚果类)的食物营养数据批量更新到 `v2_food` 表。
|
||
|
||
**需要补全的字段:**
|
||
|
||
| 字段 | 说明 | 数据来源 |
|
||
|------|------|----------|
|
||
| `calcium` | 钙含量(mg) | ishen365 / 《中国食物成分表》 |
|
||
| `iron` | 铁含量(mg) | ishen365 / 《中国食物成分表》 |
|
||
| `vitamin_c` | 维生素C含量(mg) | ishen365 / 《中国食物成分表》 |
|
||
| `purine` | 嘌呤含量(mg) | ishen365 / 专业嘌呤数据库 |
|
||
| `serving_size` | 重量基准 | 统一填写 `"每100g"` |
|
||
|
||
**示例 SQL:**
|
||
|
||
```sql
|
||
-- 以大米为例
|
||
UPDATE v2_food SET
|
||
calcium = 13,
|
||
iron = 2.3,
|
||
vitamin_c = 0,
|
||
purine = 18.4,
|
||
serving_size = '每100g'
|
||
WHERE name = '大米' AND category = '谷薯类';
|
||
```
|
||
|
||
**负责人:** 后端开发
|
||
**预计工时:** 1 天(含数据核对)
|
||
|
||
### 1.3 营养素知识内容 AI 生成并落库
|
||
|
||
**目标:** 为 `v2_knowledge` 表插入 6 条营养素科普记录(蛋白质、钾、磷、钠、钙、水分),内容由 Coze AI 生成。
|
||
|
||
**执行方式:** 编写后端管理脚本或一次性接口。
|
||
|
||
**详见 [页面六:营养素详情页 - 步骤 1](#步骤-1后端---ai-生成营养素内容并落库) 。**
|
||
|
||
---
|
||
|
||
## 二、页面一:食谱计算器结果页
|
||
|
||
### 页面信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 前端路径 | `msh_single_uniapp/pages/tool/calculator-result.vue` |
|
||
| 后端服务 | `ToolCalculatorServiceImpl.java` |
|
||
| API 接口 | `POST /api/front/tool/calculator/calculate` |
|
||
| 关联接口 | `GET /api/front/tool/calculator/result/{id}` |
|
||
|
||
### 问题概述
|
||
|
||
食谱计算器中油脂类份数计算系数错误(与谷薯类使用了相同系数 5.7),导致计算出的每日用油量约 60g,远超膳食指南推荐的 25-30g。
|
||
|
||
### 开发任务
|
||
|
||
#### 任务 2-1:修改油脂类计算系数(后端)
|
||
|
||
**文件:** `msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCalculatorServiceImpl.java`
|
||
|
||
**方法:** `generateFoodPortions()`
|
||
|
||
**修改内容:**
|
||
|
||
```java
|
||
// ❌ 修改前(第 516 行)
|
||
list.add(createFoodPortion(7, "油脂类10g", round(5.7 * energyRatio)));
|
||
|
||
// ✅ 修改后
|
||
list.add(createFoodPortion(7, "油脂类10g", round(2.5 * energyRatio)));
|
||
```
|
||
|
||
**验证计算:**
|
||
|
||
| 患者 | 标准体重 | 每日能量 | energyRatio | 修改前油脂份数 | 修改后油脂份数 | 修改后每日用油 |
|
||
|------|----------|----------|-------------|----------------|----------------|----------------|
|
||
| 男性170cm | 63kg | 2205 kcal | 1.10 | 6.3份(63g) | 2.8份(28g) | 28g |
|
||
| 女性160cm | 54kg | 1890 kcal | 0.95 | 5.4份(54g) | 2.4份(24g) | 24g |
|
||
| 男性175cm | 66.5kg | 2328 kcal | 1.16 | 6.6份(66g) | 2.9份(29g) | 29g |
|
||
|
||
**预计工时:** 0.5 小时
|
||
**自测要点:** 输入不同体型参数,验证结果页中"油脂类"份数是否在 2-3 份范围内。
|
||
|
||
---
|
||
|
||
## 三、页面二:AI 营养师对话页
|
||
|
||
### 页面信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 前端路径 | `msh_single_uniapp/pages/tool/ai-nutritionist.vue` |
|
||
| 后端服务 | `ToolAiNutritionistServiceImpl.java` |
|
||
| Coze 服务 | `ToolCozeServiceImpl.java` |
|
||
| Coze 控制器 | `CozeController.java` |
|
||
| 前端 API | `msh_single_uniapp/api/tool.js` + `api/models-api.js` |
|
||
|
||
### 问题概述
|
||
|
||
后端 `sendMessage()` 使用 Mock 回复;前端文本消息走 KieAI Gemini、图片消息走 Coze,双路径导致体验混乱且响应慢。
|
||
|
||
### 开发任务
|
||
|
||
#### 任务 3-1:后端 sendMessage() 对接 Coze API(后端)
|
||
|
||
**文件:** `ToolAiNutritionistServiceImpl.java`
|
||
|
||
**修改内容:** 将 `sendMessage()` 方法中的 Mock 逻辑替换为 Coze API 调用。
|
||
|
||
```java
|
||
// 注入 Coze 服务
|
||
@Autowired
|
||
private ToolCozeServiceImpl cozeService;
|
||
|
||
// sendMessage() 方法内,替换第 89-97 行的 Mock 逻辑:
|
||
// ❌ 删除
|
||
message.setAiResponse("这是一个模拟的AI回复。");
|
||
message.setAiResponseStatus("success");
|
||
|
||
// ✅ 替换为
|
||
try {
|
||
CozeChatRequest chatRequest = new CozeChatRequest();
|
||
chatRequest.setBotId("7591133240535449654");
|
||
chatRequest.setUserId(String.valueOf(userId));
|
||
chatRequest.setContent(message.getContent());
|
||
if (conversationId != null) {
|
||
chatRequest.setConversationId(conversationId.toString());
|
||
}
|
||
CreateChatResp resp = cozeService.chat(chatRequest);
|
||
|
||
// 从 Coze 响应中提取 AI 回复内容
|
||
String aiContent = extractAiContent(resp);
|
||
message.setAiResponse(aiContent);
|
||
message.setAiResponseStatus("success");
|
||
message.setAiResponseTime(new Date());
|
||
} catch (Exception e) {
|
||
log.error("Coze AI 回复失败, userId={}", userId, e);
|
||
message.setAiResponse("抱歉,AI 营养师暂时无法回答,请稍后再试。");
|
||
message.setAiResponseStatus("failed");
|
||
}
|
||
```
|
||
|
||
**新增辅助方法:**
|
||
|
||
```java
|
||
/**
|
||
* 从 Coze 对话响应中提取 AI 回复文本
|
||
*/
|
||
private String extractAiContent(CreateChatResp resp) {
|
||
if (resp == null) return "暂无回复";
|
||
// 根据 Coze SDK 实际返回结构提取 answer 类型消息
|
||
// 需调用 listMessages() 获取 role=assistant, type=answer 的消息
|
||
String conversationId = resp.getConversationId();
|
||
String chatId = resp.getId();
|
||
List<Message> messages = cozeService.listMessages(conversationId, chatId);
|
||
return messages.stream()
|
||
.filter(m -> "assistant".equals(m.getRole()) && "answer".equals(m.getType()))
|
||
.map(Message::getContent)
|
||
.findFirst()
|
||
.orElse("暂无回复");
|
||
}
|
||
```
|
||
|
||
**预计工时:** 2 小时
|
||
|
||
#### 任务 3-2:前端统一对话路径为 Coze(前端)
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/ai-nutritionist.vue`
|
||
|
||
**修改范围:** `sendMessage()` / `handleSend()` 方法
|
||
|
||
**当前逻辑(需重构):**
|
||
- 文本消息 → `api.kieaiGeminiChat()` (KieAI Gemini)
|
||
- 图片消息 → `api.cozeChat()` + 轮询 `api.cozeRetrieveChat()`
|
||
|
||
**修改为:** 统一走 Coze SSE 流式端点
|
||
|
||
```javascript
|
||
// ✅ 统一的消息发送方法
|
||
async sendToCoze(content, images = []) {
|
||
// 1. 如果有图片,先上传到 Coze 获取 file_id
|
||
let messageContent = content;
|
||
if (images.length > 0) {
|
||
const fileIds = [];
|
||
for (const img of images) {
|
||
const res = await api.cozeUploadFile(img.path);
|
||
fileIds.push({ type: 'image', file_id: res.id });
|
||
}
|
||
// 构建多模态消息
|
||
messageContent = JSON.stringify([
|
||
{ type: 'text', text: content },
|
||
...fileIds
|
||
]);
|
||
}
|
||
|
||
// 2. 添加用户消息到列表
|
||
this.messageList.push({ role: 'user', content, images });
|
||
|
||
// 3. 添加 AI 占位消息(用于流式填充)
|
||
const aiMsg = { role: 'ai', content: '', loading: true };
|
||
this.messageList.push(aiMsg);
|
||
this.scrollToBottom();
|
||
|
||
// 4. 调用 Coze 非流式接口(小程序不支持 SSE,改用轮询)
|
||
try {
|
||
const chatRes = await api.cozeChat({
|
||
botId: this.botId,
|
||
userId: this.userId,
|
||
additionalMessages: [{
|
||
role: 'user',
|
||
content: messageContent,
|
||
content_type: images.length > 0 ? 'object_string' : 'text'
|
||
}],
|
||
conversationId: this.conversationId || undefined
|
||
});
|
||
|
||
this.conversationId = chatRes.conversation_id;
|
||
const chatId = chatRes.id;
|
||
|
||
// 5. 轮询等待完成(每 1.5 秒,最多 40 次 = 60 秒)
|
||
let status = '';
|
||
let attempts = 0;
|
||
while (status !== 'completed' && attempts < 40) {
|
||
await this.sleep(1500);
|
||
const statusRes = await api.cozeRetrieveChat(this.conversationId, chatId);
|
||
status = statusRes.status;
|
||
attempts++;
|
||
if (status === 'failed') throw new Error('AI 回复失败');
|
||
}
|
||
|
||
// 6. 获取 AI 回复
|
||
const msgRes = await api.cozeMessageList(this.conversationId, chatId);
|
||
const answer = msgRes.find(m => m.role === 'assistant' && m.type === 'answer');
|
||
aiMsg.content = answer ? answer.content : '暂无回复';
|
||
} catch (e) {
|
||
aiMsg.content = '抱歉,AI 营养师暂时无法回答,请稍后再试。';
|
||
console.error('Coze 对话失败:', e);
|
||
} finally {
|
||
aiMsg.loading = false;
|
||
this.isLoading = false;
|
||
this.scrollToBottom();
|
||
}
|
||
}
|
||
```
|
||
|
||
**需要删除/注释的代码:**
|
||
- `api.kieaiGeminiChat()` 调用逻辑(文本消息路径 A)
|
||
- 图片消息的独立处理分支(合并到统一方法中)
|
||
|
||
**保留的能力:**
|
||
- 图片选择 + 预览(最多 3 张)
|
||
- 语音输入(ASR 转文本后作为文本发送)
|
||
- 快捷问题按钮
|
||
- 清空对话历史
|
||
|
||
**预计工时:** 4 小时
|
||
|
||
#### 任务 3-3:优化 Coze Bot Prompt(运营配置)
|
||
|
||
**操作位置:** Coze 平台 → Bot `7591133240535449654` → 系统提示词
|
||
|
||
**建议系统提示词:**
|
||
|
||
```
|
||
你是慢生活小程序的 AI 营养师,专注于慢性肾脏病(CKD)患者的饮食营养指导。
|
||
|
||
回复规范:
|
||
1. 【一句话建议】用一句话直接回答用户问题
|
||
2. 【营养分析】针对用户的具体情况,从蛋白质、钾、磷、钠、能量等维度分析
|
||
3. 【推荐方案】给出 2-3 个具体可操作的饮食建议
|
||
4. 【注意事项】提醒需要警惕的风险,如高钾、高磷食物
|
||
|
||
约束:
|
||
- 不提供药物建议,仅限饮食营养指导
|
||
- 涉及具体用药、透析方案时,提醒用户咨询主治医生
|
||
- 语言通俗易懂,避免过多专业术语
|
||
- 每条建议附带具体食材示例
|
||
```
|
||
|
||
**预计工时:** 0.5 小时
|
||
|
||
#### 自测检查清单
|
||
|
||
- [ ] 发送文本消息 → AI 在 5 秒内开始回复
|
||
- [ ] 发送图片+文本 → AI 能识别图片并给出营养建议
|
||
- [ ] 多轮对话 → AI 能基于上下文连续回答
|
||
- [ ] 清空对话 → 重新开始新会话
|
||
- [ ] 网络异常 → 显示友好错误提示,不崩溃
|
||
- [ ] AI 回复内容具有结构性(有摘要、分析、建议)
|
||
|
||
---
|
||
|
||
## 四、页面三:食物百科列表页
|
||
|
||
### 页面信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 前端路径 | `msh_single_uniapp/pages/tool/food-encyclopedia.vue` |
|
||
| 后端服务 | `ToolFoodServiceImpl.java` |
|
||
| API 接口 | `GET /api/front/tool/food/search` / `GET /api/front/tool/food/list` |
|
||
|
||
### 问题概述
|
||
|
||
食物列表中部分食物图片显示异常(空白或破图)。
|
||
|
||
### 开发任务
|
||
|
||
#### 任务 4-1:批量刷新食物图片(后端/运维)
|
||
|
||
**已有接口:** `POST /api/front/tool/food/refresh-images?limit=20`
|
||
|
||
**执行流程(无需额外开发):**
|
||
|
||
```
|
||
调用接口 → ToolFoodServiceImpl.refreshFoodImages(limit)
|
||
→ 查询 v2_food 中 image 为空或非 OSS 链接的记录
|
||
→ 遍历调用 DishImageService.ensureFoodImageAndUpdateDb(foodId)
|
||
→ KieAI 生成食物照片
|
||
→ 下载 → 压缩至 ≤100KB
|
||
→ 上传阿里云 OSS(路径: foods/xxx.jpg)
|
||
→ 更新 v2_food.image 字段
|
||
→ 写入 v2_dish_image_cache 缓存表
|
||
```
|
||
|
||
**操作步骤:**
|
||
|
||
```bash
|
||
# 多次调用,每次处理 20 条,直到所有食物都有有效图片
|
||
curl -X POST "https://your-domain/api/front/tool/food/refresh-images?limit=20" \
|
||
-H "Authori-zation: {管理员token}"
|
||
|
||
# 查询还有多少条无图记录
|
||
SELECT COUNT(*) FROM v2_food WHERE status='active' AND (image IS NULL OR image = '' OR image NOT LIKE '%aliyuncs.com%');
|
||
```
|
||
|
||
**预计工时:** 0.5 天(含执行等待时间,KieAI 生图每张约 20-60 秒)
|
||
|
||
#### 任务 4-2:前端图片加载容错(前端)
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/food-encyclopedia.vue`
|
||
|
||
**修改内容:** 为食物列表图片增加 `@error` 兜底。
|
||
|
||
```html
|
||
<!-- 食物卡片图片,增加 @error 处理 -->
|
||
<image
|
||
class="food-img"
|
||
:src="item.image || '/static/images/food-placeholder.png'"
|
||
mode="aspectFill"
|
||
@error="onImageError($event, item)"
|
||
/>
|
||
```
|
||
|
||
```javascript
|
||
methods: {
|
||
onImageError(e, item) {
|
||
// 图片加载失败时替换为占位图
|
||
item.image = '/static/images/food-placeholder.png';
|
||
}
|
||
}
|
||
```
|
||
|
||
**需要新增的占位图:** `/static/images/food-placeholder.png`(一个通用的食物灰色占位图,建议 200x200px)
|
||
|
||
**预计工时:** 1 小时
|
||
|
||
---
|
||
|
||
## 五、页面四:食物百科详情页
|
||
|
||
### 页面信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 前端路径 | `msh_single_uniapp/pages/tool/food-detail.vue` |
|
||
| 后端服务 | `ToolFoodServiceImpl.java` → `getDetail()` |
|
||
| API 接口 | `GET /api/front/tool/food/detail/{id}` |
|
||
| 数据模型 | `V2Food.java` |
|
||
|
||
### 问题概述
|
||
|
||
1. 营养成分表只显示 7 项(缺少钙、铁、维生素C、嘌呤)
|
||
2. 图标使用了 Figma 临时 URL,过期后不显示
|
||
3. "每100g" 标注是硬编码,不根据实际数据变化
|
||
|
||
### 开发任务
|
||
|
||
#### 任务 5-1:后端接口补充返回字段(后端)
|
||
|
||
**文件:** `ToolFoodServiceImpl.java` → `getDetail()` 方法
|
||
|
||
**在现有 `map.put(...)` 代码块之后,追加以下字段返回:**
|
||
|
||
```java
|
||
// ====== 新增返回字段 ======
|
||
map.put("calcium", food.getCalcium()); // 钙(mg)
|
||
map.put("iron", food.getIron()); // 铁(mg)
|
||
map.put("vitaminC", food.getVitaminC()); // 维生素C(mg)
|
||
map.put("purine", food.getPurine()); // 嘌呤(mg) —— 新增字段
|
||
map.put("servingSize", food.getServingSize()); // 重量基准 —— 新增字段
|
||
map.put("nutrientsJson", food.getNutrientsJson());// 扩展营养素JSON
|
||
map.put("recommendedAmount", food.getRecommendedAmount()); // 推荐摄入量
|
||
```
|
||
|
||
**同时在 `search()` 方法的列表返回中,也补充 `servingSize` 字段:**
|
||
|
||
```java
|
||
map.put("servingSize", food.getServingSize());
|
||
```
|
||
|
||
**预计工时:** 0.5 小时
|
||
|
||
#### 任务 5-2:前端成分表解析增加新字段(前端)
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/food-detail.vue`
|
||
|
||
**修改 `parseNutritionTable()` 或 `computed` 中的成分表构建逻辑,确保包含以下完整字段:**
|
||
|
||
```javascript
|
||
// displayNutritionTable 的构建逻辑
|
||
buildNutritionTable(data) {
|
||
const table = [
|
||
{ name: '能量', value: data.energy, unit: 'kcal', level: this.getLevel('energy', data.energy) },
|
||
{ name: '蛋白质', value: data.protein, unit: 'g', level: this.getLevel('protein', data.protein) },
|
||
{ name: '脂肪', value: data.fat, unit: 'g', level: 'normal' },
|
||
{ name: '碳水化合物', value: data.carbohydrate, unit: 'g', level: 'normal' },
|
||
{ name: '钾', value: data.potassium, unit: 'mg', level: this.getLevel('potassium', data.potassium) },
|
||
{ name: '磷', value: data.phosphorus, unit: 'mg', level: this.getLevel('phosphorus', data.phosphorus) },
|
||
{ name: '钠', value: data.sodium, unit: 'mg', level: this.getLevel('sodium', data.sodium) },
|
||
// ====== 以下为新增 ======
|
||
{ name: '钙', value: data.calcium, unit: 'mg', level: 'normal' },
|
||
{ name: '铁', value: data.iron, unit: 'mg', level: 'normal' },
|
||
{ name: '维生素C', value: data.vitaminC, unit: 'mg', level: 'normal' },
|
||
{ name: '嘌呤', value: data.purine, unit: 'mg', level: this.getLevel('purine', data.purine) },
|
||
];
|
||
// 过滤掉值为 null/undefined 的条目(后端可能部分食物没有数据)
|
||
return table.filter(item => item.value != null && item.value !== '');
|
||
}
|
||
```
|
||
|
||
**预计工时:** 1 小时
|
||
|
||
#### 任务 5-3:动态显示重量标注(前端)
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/food-detail.vue`
|
||
|
||
**修改前(硬编码):**
|
||
|
||
```html
|
||
<!-- 第 32 行 -->
|
||
<view class="unit-badge">每100g</view>
|
||
<!-- 第 54 行 -->
|
||
<text class="unit-text">每100g</text>
|
||
```
|
||
|
||
**修改后(动态):**
|
||
|
||
```html
|
||
<view class="unit-badge">{{ foodData.servingSize || '每100g' }}</view>
|
||
<text class="unit-text">{{ foodData.servingSize || '每100g' }}</text>
|
||
```
|
||
|
||
**在数据解析方法中,从 API 返回值提取 `servingSize`:**
|
||
|
||
```javascript
|
||
// API 返回数据解析时
|
||
this.foodData.servingSize = res.data.servingSize || '每100g';
|
||
```
|
||
|
||
**预计工时:** 0.5 小时
|
||
|
||
#### 任务 5-4:替换 Figma 临时 URL(前端)
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/food-detail.vue`
|
||
|
||
**需替换的 URL(第 95-96 行 `data()` 中):**
|
||
|
||
| 变量名 | 当前值(Figma 临时 URL) | 替换方案 |
|
||
|--------|--------------------------|----------|
|
||
| `iconShare` | `https://www.figma.com/api/mcp/asset/f9f0d7b9-...` | 替换为 OSS 图片或本地 `/static/icons/share.png` |
|
||
| `iconSearch` | `https://www.figma.com/api/mcp/asset/aa6bb75b-...` | 替换为 OSS 图片或本地 `/static/icons/search.png` |
|
||
| `defaultFoodData.image` | `https://www.figma.com/api/mcp/asset/bf4ff04c-...` | 替换为 `/static/images/food-placeholder.png` |
|
||
|
||
**同时检查 `nutrient-detail.vue` 中的 Figma URL(第 111-113 行):**
|
||
|
||
| 变量名 | 替换方案 |
|
||
|--------|----------|
|
||
| `iconWhyImportant` | 本地 `/static/icons/why-important.png` |
|
||
| `iconRecommendation` | 本地 `/static/icons/recommendation.png` |
|
||
| `iconSuggestions` | 本地 `/static/icons/suggestions.png` |
|
||
|
||
**操作方式:**
|
||
1. 从 Figma 设计稿导出对应图标为 PNG(建议 64x64px,2x 为 128x128px)
|
||
2. 放置到 `msh_single_uniapp/static/icons/` 目录
|
||
3. 或上传到 OSS 获取稳定 URL 后替换
|
||
|
||
**预计工时:** 1 小时
|
||
|
||
#### 自测检查清单
|
||
|
||
- [ ] 食物详情页显示完整的 11 项营养成分(能量、蛋白质、脂肪、碳水、钾、磷、钠、钙、铁、维C、嘌呤)
|
||
- [ ] 如果某食物缺少部分营养数据(如嘌呤为 null),该项不显示而非显示 "null"
|
||
- [ ] 重量标注动态显示(如"每100g"),而非硬编码
|
||
- [ ] 分享图标、搜索图标正常显示
|
||
- [ ] 默认占位图在食物图片加载失败时正常显示
|
||
- [ ] 与 ishen365 上同一食物的数据进行交叉对比,确保数值一致
|
||
|
||
---
|
||
|
||
## 六、页面五:健康知识营养素列表页
|
||
|
||
### 页面信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 前端路径 | `msh_single_uniapp/pages/tool/nutrition-knowledge.vue` |
|
||
| 后端服务 | `ToolKnowledgeServiceImpl.java` |
|
||
| API 接口 | 营养素列表本地静态、饮食指南/科普文章从 `GET /api/front/tool/knowledge/list` 获取 |
|
||
|
||
### 问题概述
|
||
|
||
营养素卡片的点击事件使用 `dataset` 传参,在微信小程序中因属性名大小写转换导致取值失败,用户点击后无法跳转到对应的详情页。
|
||
|
||
### 开发任务
|
||
|
||
#### 任务 6-1:修复营养素卡片点击传参(前端)
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/nutrition-knowledge.vue`
|
||
|
||
**修改前(第 39 行):**
|
||
|
||
```html
|
||
<view
|
||
class="nutrient-card"
|
||
v-for="(item, index) in nutrientList"
|
||
:key="index"
|
||
@click="goToNutrientDetail" :data-nutrient-index="index"
|
||
>
|
||
```
|
||
|
||
**修改后:**
|
||
|
||
```html
|
||
<view
|
||
class="nutrient-card"
|
||
v-for="(item, index) in nutrientList"
|
||
:key="index"
|
||
@click="goToNutrientDetail(index)"
|
||
>
|
||
```
|
||
|
||
**修改 `methods` 中的 `goToNutrientDetail` 方法:**
|
||
|
||
```javascript
|
||
// ❌ 修改前
|
||
goToNutrientDetail(event) {
|
||
const index = event.currentTarget.dataset.nutrientIndex;
|
||
const item = this.nutrientList[index];
|
||
if (!item) return;
|
||
uni.navigateTo({
|
||
url: `/pages/tool/nutrient-detail?name=${encodeURIComponent(item.name)}`
|
||
});
|
||
}
|
||
|
||
// ✅ 修改后
|
||
goToNutrientDetail(index) {
|
||
const item = this.nutrientList[index];
|
||
if (!item) return;
|
||
uni.navigateTo({
|
||
url: `/pages/tool/nutrient-detail?name=${encodeURIComponent(item.name)}`
|
||
});
|
||
}
|
||
```
|
||
|
||
**预计工时:** 0.5 小时
|
||
|
||
#### 自测检查清单
|
||
|
||
- [ ] 依次点击 6 个营养素卡片(蛋白质、钾、磷、钠、钙、水分),均能正常跳转
|
||
- [ ] 在微信开发者工具 + 真机预览中均测试通过
|
||
- [ ] 跳转后详情页标题与点击的营养素名称一致
|
||
|
||
---
|
||
|
||
## 七、页面六:营养素详情页
|
||
|
||
### 页面信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 前端路径 | `msh_single_uniapp/pages/tool/nutrient-detail.vue` |
|
||
| 后端服务 | `ToolKnowledgeServiceImpl.java` → `getNutrientDetail(name)` |
|
||
| API 接口 | `GET /api/front/tool/knowledge/nutrient/{name}` |
|
||
| 数据表 | `v2_knowledge`(type='nutrients') |
|
||
| 封面图生成 | `POST /api/front/tool/knowledge/fill-cover-images?limit=6` |
|
||
|
||
### 问题概述
|
||
|
||
1. 详情页数据完全来自前端硬编码的 `nutrientMap`,未调用后端接口
|
||
2. `data()` 默认值为"钠"的内容,参数丢失时所有页面都显示"钠"
|
||
3. 内容质量参差不齐,需要用 AI 生成专业科普内容
|
||
|
||
### 开发任务
|
||
|
||
#### 步骤 1:后端 — AI 生成营养素内容并落库
|
||
|
||
**实现方式 A(推荐):编写后端管理接口**
|
||
|
||
**新建文件或在 `ToolController.java` 中增加管理端接口:**
|
||
|
||
```java
|
||
/**
|
||
* 批量生成营养素科普内容(管理端一次性调用)
|
||
*/
|
||
@PostMapping("/admin/tool/knowledge/generate-nutrients")
|
||
public CommonResult<String> generateNutrientContent() {
|
||
String[] nutrients = {"蛋白质", "钾", "磷", "钠", "钙", "水分"};
|
||
int success = 0;
|
||
|
||
for (String nutrient : nutrients) {
|
||
try {
|
||
// 1. 检查是否已存在
|
||
V2Knowledge existing = knowledgeDao.selectOne(
|
||
new LambdaQueryWrapper<V2Knowledge>()
|
||
.eq(V2Knowledge::getType, "nutrients")
|
||
.eq(V2Knowledge::getNutrientName, nutrient)
|
||
);
|
||
if (existing != null) {
|
||
log.info("营养素 {} 已存在,跳过", nutrient);
|
||
continue;
|
||
}
|
||
|
||
// 2. 调用 Coze AI 生成内容
|
||
CozeChatRequest req = new CozeChatRequest();
|
||
req.setBotId("7591133240535449654");
|
||
req.setContent(buildNutrientPrompt(nutrient));
|
||
CreateChatResp resp = cozeService.chat(req);
|
||
String content = extractAiContent(resp);
|
||
|
||
// 3. 写入 v2_knowledge 表
|
||
V2Knowledge knowledge = new V2Knowledge();
|
||
knowledge.setType("nutrients");
|
||
knowledge.setNutrientName(nutrient);
|
||
knowledge.setTitle(nutrient + " — CKD患者膳食管理");
|
||
knowledge.setContent(content); // JSON 格式
|
||
knowledge.setSummary("了解" + nutrient + "在慢性肾病饮食中的重要性");
|
||
knowledge.setStatus("published");
|
||
knowledge.setSortOrder(success + 1);
|
||
knowledge.setCreatedAt(new Date());
|
||
knowledgeDao.insert(knowledge);
|
||
|
||
success++;
|
||
} catch (Exception e) {
|
||
log.error("生成营养素内容失败: {}", nutrient, e);
|
||
}
|
||
}
|
||
|
||
// 4. 自动补充封面图
|
||
knowledgeService.fillMissingCoverImages(6);
|
||
|
||
return CommonResult.success("成功生成 " + success + " 条营养素内容");
|
||
}
|
||
|
||
private String buildNutrientPrompt(String nutrient) {
|
||
return "请为慢性肾脏病(CKD)患者生成关于「" + nutrient + "」的科普内容。\n"
|
||
+ "要求严格按以下 JSON 格式返回(不要包含 markdown 标记):\n"
|
||
+ "{\n"
|
||
+ " \"name\": \"营养素名称\",\n"
|
||
+ " \"english\": \"英文名\",\n"
|
||
+ " \"icon\": \"一个合适的emoji\",\n"
|
||
+ " \"description\": \"一句话描述\",\n"
|
||
+ " \"status\": \"控制建议(如:需控制/严格控制/适量补充)\",\n"
|
||
+ " \"statusDesc\": \"状态补充说明\",\n"
|
||
+ " \"importance\": \"为什么重要(2-3句话)\",\n"
|
||
+ " \"recommendation\": \"推荐摄入量(按CKD分期分别说明)\",\n"
|
||
+ " \"foodSources\": [\"食物来源1\", \"食物来源2\", ...最多6个],\n"
|
||
+ " \"riskWarning\": \"风险提示(2-3句话)\",\n"
|
||
+ " \"suggestions\": [\"建议1\", \"建议2\", ...共6条实用建议],\n"
|
||
+ " \"disclaimer\": \"以上建议仅供参考,具体方案请咨询您的主治医生或营养师\"\n"
|
||
+ "}";
|
||
}
|
||
```
|
||
|
||
**实现方式 B(备选):直接执行 SQL 手动插入**
|
||
|
||
如果 AI 生成效果不理想,可手动整理内容后直接 SQL 插入:
|
||
|
||
```sql
|
||
INSERT INTO v2_knowledge (type, nutrient_name, title, content, summary, status, sort_order, created_at) VALUES
|
||
('nutrients', '蛋白质', '蛋白质 — CKD患者膳食管理', '{"name":"蛋白质","english":"Protein","icon":"🥩",...}', '了解蛋白质在CKD饮食中的重要性', 'published', 1, NOW()),
|
||
('nutrients', '钾', '钾 — CKD患者膳食管理', '{"name":"钾","english":"Potassium (K)","icon":"🍌",...}', '高钾血症的预防与饮食管理', 'published', 2, NOW()),
|
||
-- ... 其余 4 条
|
||
;
|
||
```
|
||
|
||
**插入后,调用封面图生成接口:**
|
||
|
||
```bash
|
||
POST /api/front/tool/knowledge/fill-cover-images?limit=6
|
||
```
|
||
|
||
**预计工时:** 2 小时
|
||
|
||
#### 步骤 2:前端 — 详情页改为从后端 API 获取数据
|
||
|
||
**文件:** `msh_single_uniapp/pages/tool/nutrient-detail.vue`
|
||
|
||
**修改 1:`data()` 默认值改为空状态**
|
||
|
||
```javascript
|
||
// ❌ 修改前:默认是"钠"的完整数据
|
||
data() {
|
||
return {
|
||
nutrientData: { name: '钠', english: 'Sodium (Na)', icon: '🧂', ... }
|
||
}
|
||
}
|
||
|
||
// ✅ 修改后:默认为空,显示加载状态
|
||
data() {
|
||
return {
|
||
loading: true,
|
||
loadError: false,
|
||
nutrientData: {
|
||
name: '', english: '', icon: '', description: '',
|
||
status: '', statusDesc: '', importance: '', recommendation: '',
|
||
foodSources: [], riskWarning: '', suggestions: [], disclaimer: ''
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**修改 2:`onLoad()` 中增加参数解码**
|
||
|
||
```javascript
|
||
onLoad(options) {
|
||
if (options.name) {
|
||
const name = decodeURIComponent(options.name);
|
||
uni.setNavigationBarTitle({ title: name });
|
||
this.loadNutrientData(name);
|
||
} else {
|
||
this.loadError = true;
|
||
}
|
||
}
|
||
```
|
||
|
||
**修改 3:`loadNutrientData()` 改为优先调用 API,兜底使用本地数据**
|
||
|
||
```javascript
|
||
async loadNutrientData(name) {
|
||
this.loading = true;
|
||
try {
|
||
// 优先从后端获取(v2_knowledge 表, type='nutrients')
|
||
const { getNutrientDetail } = await import('@/api/tool.js');
|
||
const result = await getNutrientDetail(encodeURIComponent(name));
|
||
|
||
if (result && result.data && result.data.content) {
|
||
const detail = result.data;
|
||
// content 字段存储的是 JSON 字符串
|
||
const parsed = typeof detail.content === 'string'
|
||
? JSON.parse(detail.content)
|
||
: detail.content;
|
||
this.nutrientData = parsed;
|
||
this.loading = false;
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.warn('从后端获取营养素详情失败,降级使用本地数据:', error);
|
||
}
|
||
|
||
// 降级:使用本地硬编码数据(保留原有 nutrientMap 作为兜底)
|
||
const localData = this.getLocalNutrientData(name);
|
||
if (localData) {
|
||
this.nutrientData = localData;
|
||
} else {
|
||
this.loadError = true;
|
||
}
|
||
this.loading = false;
|
||
},
|
||
|
||
getLocalNutrientData(name) {
|
||
const nutrientMap = {
|
||
'蛋白质': { name: '蛋白质', english: 'Protein', icon: '🥩', ... },
|
||
'钾': { ... },
|
||
// ... 保留原有 6 项数据作为兜底
|
||
};
|
||
return nutrientMap[name] || null;
|
||
}
|
||
```
|
||
|
||
**修改 4:增加加载态和错误态的模板**
|
||
|
||
```html
|
||
<template>
|
||
<view class="nutrient-detail-page">
|
||
<!-- 加载中 -->
|
||
<view v-if="loading" class="loading-wrap">
|
||
<text>加载中...</text>
|
||
</view>
|
||
<!-- 加载失败 -->
|
||
<view v-else-if="loadError" class="error-wrap">
|
||
<text>暂无该营养素的详细信息</text>
|
||
</view>
|
||
<!-- 正常内容(保持原有结构不变) -->
|
||
<scroll-view v-else class="content-scroll" scroll-y>
|
||
<!-- ... 原有内容区域 ... -->
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
```
|
||
|
||
**预计工时:** 2 小时
|
||
|
||
#### 步骤 3:替换 Figma 图标 URL
|
||
|
||
同 [任务 5-4](#任务-5-4替换-figma-临时-url前端),替换 `nutrient-detail.vue` 中第 111-113 行的 3 个 Figma URL。
|
||
|
||
**预计工时:** 0.5 小时
|
||
|
||
#### 自测检查清单
|
||
|
||
- [ ] 从列表页点击每个营养素 → 跳转到对应的详情页
|
||
- [ ] 详情页标题/图标/内容与营养素名称一致(不再全部显示"钠")
|
||
- [ ] 后端 `v2_knowledge` 有数据时 → 显示后端数据
|
||
- [ ] 后端数据不存在时 → 降级显示本地硬编码数据
|
||
- [ ] 图标正常显示(不是 Figma 的破图)
|
||
- [ ] 6 种营养素的内容专业准确,格式统一
|
||
|
||
---
|
||
|
||
## 八、联调与测试检查清单
|
||
|
||
### 全量回归测试
|
||
|
||
| 序号 | 测试场景 | 涉及页面 | 预期结果 |
|
||
|------|----------|----------|----------|
|
||
| 1 | 食谱计算器 → 输入标准体型参数 → 查看结果 | calculator-result | 油脂类份数在 2-3 份之间(每日 20-30g) |
|
||
| 2 | AI 营养师 → 发送文字问题 | ai-nutritionist | 5 秒内收到结构化回复 |
|
||
| 3 | AI 营养师 → 发送图片+文字 | ai-nutritionist | AI 识别图片并给出营养分析 |
|
||
| 4 | AI 营养师 → 连续多轮对话 | ai-nutritionist | 上下文连贯,不丢失历史 |
|
||
| 5 | 食物百科 → 浏览列表 | food-encyclopedia | 所有食物有图片,无破图 |
|
||
| 6 | 食物百科 → 点击查看详情 | food-detail | 成分表完整(11 项),有重量标注 |
|
||
| 7 | 食物详情 → 对比 ishen365 | food-detail | 营养数值与参考站点一致 |
|
||
| 8 | 健康知识 → 点击"蛋白质" | nutrition-knowledge → nutrient-detail | 跳转正常,显示蛋白质内容 |
|
||
| 9 | 健康知识 → 依次点击全部 6 项 | nutrient-detail | 每项内容不同,不再全部显示"钠" |
|
||
| 10 | 健康知识 → 弱网/断网测试 | nutrient-detail | 降级显示本地数据,不崩溃 |
|
||
|
||
### 工时汇总
|
||
|
||
| 任务 | 页面 | 开发人员 | 预计工时 |
|
||
|------|------|----------|----------|
|
||
| 数据库变更 + 数据补全 | 公共 | 后端 | 1 天 |
|
||
| 任务 2-1 油脂系数修改 | 计算器结果页 | 后端 | 0.5h |
|
||
| 任务 3-1 后端对接 Coze | AI 营养师 | 后端 | 2h |
|
||
| 任务 3-2 前端统一 Coze | AI 营养师 | 前端 | 4h |
|
||
| 任务 3-3 Coze Bot Prompt | AI 营养师 | 运营 | 0.5h |
|
||
| 任务 4-1 批量刷新图片 | 食物列表 | 后端/运维 | 0.5 天 |
|
||
| 任务 4-2 图片容错 | 食物列表 | 前端 | 1h |
|
||
| 任务 5-1 接口补字段 | 食物详情 | 后端 | 0.5h |
|
||
| 任务 5-2 成分表新字段 | 食物详情 | 前端 | 1h |
|
||
| 任务 5-3 动态重量标注 | 食物详情 | 前端 | 0.5h |
|
||
| 任务 5-4 替换 Figma URL | 食物详情+营养素详情 | 前端 | 1h |
|
||
| 任务 6-1 修复传参 Bug | 营养素列表 | 前端 | 0.5h |
|
||
| 步骤 1 AI 生成内容落库 | 营养素详情 | 后端 | 2h |
|
||
| 步骤 2 前端改 API 获取 | 营养素详情 | 前端 | 2h |
|
||
| 联调 + 回归测试 | 全部 | 全员 | 1 天 |
|
||
| **合计** | | | **约 4.5 人天** |
|