From a5de6fb46d84e6a26c7fe7fd729db36d5b24fc79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 11:23:21 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E8=AF=A6=E7=BB=86=E8=AE=BE=E8=AE=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=88=E4=BB=A5=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=BA=E7=BB=B4=E5=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于测试问题分析报告,按6个前端页面拆分开发任务,含代码示例、自测清单和工时估算 Co-Authored-By: Claude Opus 4.6 --- docs/功能开发详细设计_2026-03-25.md | 925 ++++++++++++++++++++++++++++ 1 file changed, 925 insertions(+) create mode 100644 docs/功能开发详细设计_2026-03-25.md diff --git a/docs/功能开发详细设计_2026-03-25.md b/docs/功能开发详细设计_2026-03-25.md new file mode 100644 index 0000000..49a8e1a --- /dev/null +++ b/docs/功能开发详细设计_2026-03-25.md @@ -0,0 +1,925 @@ +# 功能开发详细设计文档 + +> **版本:** 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 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 + + +``` + +```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 + +每100g + +每100g +``` + +**修改后(动态):** + +```html +{{ foodData.servingSize || '每100g' }} +{{ foodData.servingSize || '每100g' }} +``` + +**在数据解析方法中,从 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 + +``` + +**修改后:** + +```html + +``` + +**修改 `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 generateNutrientContent() { + String[] nutrients = {"蛋白质", "钾", "磷", "钠", "钙", "水分"}; + int success = 0; + + for (String nutrient : nutrients) { + try { + // 1. 检查是否已存在 + V2Knowledge existing = knowledgeDao.selectOne( + new LambdaQueryWrapper() + .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 + +``` + +**预计工时:** 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 人天** |