|
|
@@ -0,0 +1,756 @@
|
|
|
+# 饮食页面「换一批」&「生成食谱」端到端修复 Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 让饮食首页「🔄 换一批」与「✨ 生成食谱」、食谱推荐页「生成食谱/重新生成」真实可用:后端真正使用用户所选食材与共餐参与者生成 AI 菜单,前端正确解析 Result 结构与 LangGraph 菜单数据契约,并修复「添加食材」弹窗搜索空壳与按钮禁用态。
|
|
|
+
|
|
|
+**Architecture:** 后端(`DietRecommendationService` / `DietIngredientService` / `BeijingNutritionService` / `DietIngredientController`)补齐真实逻辑:解析 `selected_foods`、查询 `meal_configs` 参与者、给 `generateIngredientList` 传 seed 实现换一批、把 LangGraph 返回的菜单 JSON 归一化为前端期望结构并聚合营养汇总、新增食材搜索接口。前端(`index.vue` / `recommendation.vue`)修复 `res.data.*` 取值、实现弹窗搜索、同步 `selectedIngredients`。
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot 2.7.18 / MyBatis-Plus / uni-app Vue 2(Options API)微信小程序 / FastAPI LangGraph(仅消费,不改)
|
|
|
+
|
|
|
+**Spec:**
|
|
|
+- `cfc-backend/docs/superpowers/specs/2026-08-06-diet-module-design.md`(数据模型/接口/流程契约)
|
|
|
+- `cfc-frontend/docs/superpowers/specs/2026-08-28-diet-redesign.md`(仅样式,本次不改视觉)
|
|
|
+
|
|
|
+## Global Constraints
|
|
|
+
|
|
|
+- 后端接口统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
|
|
|
+- DI 用 `@Resource`,字段名与类型默认 Bean Name 一致
|
|
|
+- 响应统一 `Result<T>`(code/message/data)
|
|
|
+- 新增接口前查 `docs/superpowers/api/API_REFERENCE.md`,确认无重复,新增后同步记录
|
|
|
+- 后端验证唯一方式:`mvn clean compile`(在 `cfc-backend/` 下)
|
|
|
+- 前端验证:`node -e` 提取 `.vue` 的 `<script>` 块做语法校验(**禁止** `npm run build:mp-weixin`,打包由用户 HBuilderX 完成)
|
|
|
+- 前端禁止可选链 `?.`(用 `&&`)、禁止 CSS Grid、禁止 `:key` 表达式、禁止中文类名、禁止 `new Date(string)`(用 `parseDate()`)、Vue 2 Options API
|
|
|
+- 前端 `request` 包装器 resolve 的是整个 `Result` 对象 `{code, message, data}`——所有业务字段在 `res.data.*` 下
|
|
|
+- LangGraph 菜单契约(后端已实现,不改 Python):`{meals:[{type, name, dishes:[{name, ingredients:[{name, grams}], cooking_method, nutrition:{calories,protein,carbs,fat}, notes}]}]}`
|
|
|
+- 前端 `parseMenu` 期望(保持不变,由后端归一化):`{meals:[{type, name, participants, dishes:[{name, calories, ingredients:[{name, amount}], method}]}]}`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件 | 职责 | 变更类型 |
|
|
|
+|------|------|----------|
|
|
|
+| `cfc-backend/.../service/BeijingNutritionService.java` | `generateIngredientList` 加 seed 参数,换一批真正不同 | Modify |
|
|
|
+| `cfc-backend/.../service/DietIngredientService.java` | `refreshIngredients` 传 seed;新增 `searchFoods(keyword)` | Modify |
|
|
|
+| `cfc-backend/.../controller/diet/DietIngredientController.java` | 新增 `POST /api/diet/ingredients/search` | Modify |
|
|
|
+| `cfc-backend/.../service/DietRecommendationService.java` | `generateRecommendation` 解析食材/参与者/调 AI/归一化菜单/聚合营养 | Modify |
|
|
|
+| `cfc-frontend/pages/diet/index.vue` | `generateRecipe` 取值、`searchFood` 实现、`selectedIngredients` 同步、`addFood` | Modify |
|
|
|
+| `cfc-frontend/pages/diet/recommendation.vue` | `loadRecommendation`/`generateRecipe` 用 `res.data.*` | Modify |
|
|
|
+| `cfc-frontend/utils/api.js` | 新增 `searchDietFoods(keyword)` | Modify |
|
|
|
+| `docs/superpowers/api/API_REFERENCE.md` | 记录新增搜索接口 | Modify |
|
|
|
+
|
|
|
+> 后端两个 Service + 一个 Controller 相互独立可并行;前端两个页面相互独立可并行。同一文件多处改动时避免并发冲突,按任务 commit 边界操作。
|
|
|
+
|
|
|
+## 全局校验命令(每个任务提交前必跑)
|
|
|
+
|
|
|
+```bash
|
|
|
+# 1) 后端编译(在 cfc-backend 下)
|
|
|
+mvn clean compile
|
|
|
+
|
|
|
+# 2) 前端 .vue script 语法校验(在 cfc-frontend 下)
|
|
|
+node -e "
|
|
|
+const fs=require('fs');
|
|
|
+const s=fs.readFileSync(process.argv[1],'utf8');
|
|
|
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
|
|
|
+if(!m){console.log('no script');process.exit(0)}
|
|
|
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
|
|
|
+console.log('script OK');
|
|
|
+" pages/diet/index.vue
|
|
|
+
|
|
|
+# 3) 前端禁止模式扫描(输出应为空)
|
|
|
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/index.vue pages/diet/recommendation.vue
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: 后端「换一批」seed 生效 + 食材搜索接口
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java`(`generateIngredientList` 签名)
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `IngredientRecommendation`(foodId/name/reason/score/category)、`FoodService.listAll` 或 `FoodMapper`
|
|
|
+- Produces:
|
|
|
+ - `List<IngredientRecommendation> generateIngredientList(Long familyId, LocalDate date, int seed)`(重载保持旧签名 `(familyId, date)` 兼容)
|
|
|
+ - `Map<String, Object> refreshIngredients(Long familyId)`(返回 `{ingredients, selectedCount}`)
|
|
|
+ - `Map<String, Object> searchFoods(String keyword)`(返回 `{foods:[{id,name,category}]}`)
|
|
|
+ - `POST /api/diet/ingredients/search` body `{keyword}`
|
|
|
+
|
|
|
+- [ ] **步骤 1:`generateIngredientList` 加 seed 参数并实现偏移截取**
|
|
|
+
|
|
|
+`BeijingNutritionService.java` 现有 `generateIngredientList(Long familyId, LocalDate date)`(第 561 行)。改造:
|
|
|
+
|
|
|
+```java
|
|
|
+public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date) {
|
|
|
+ return generateIngredientList(familyId, date, 0);
|
|
|
+}
|
|
|
+
|
|
|
+public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
|
|
|
+ // 现有逻辑不变……(查 meal_configs → 参与者 → 禁忌 → 打分 → 排序)
|
|
|
+ // 仅将方法体末尾的截取逻辑替换为:
|
|
|
+ // 6. 排序后按 seed 偏移旋转再截取 Top 15(保证"换一批"返回不同组合)
|
|
|
+ result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
|
|
|
+ if (seed != 0 && result.size() > 1) {
|
|
|
+ int offset = Math.abs(seed) % result.size();
|
|
|
+ if (offset > 0) {
|
|
|
+ java.util.Collections.rotate(result, offset);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+注意:`Collections.rotate(list, distance)` 会就地旋转;旋转后再取前 15,可让每次换一批取到不同起始位置的食材组合。
|
|
|
+
|
|
|
+- [ ] **步骤 2:`DietIngredientService.refreshIngredients` 传 seed**
|
|
|
+
|
|
|
+`DietIngredientService.java` 第 33-45 行,改为:
|
|
|
+
|
|
|
+```java
|
|
|
+public Map<String, Object> refreshIngredients(Long familyId) {
|
|
|
+ Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
|
|
|
+ refreshSeed.put(familyId, seed);
|
|
|
+
|
|
|
+ // 重新生成(通过 seed 偏移截取,真正换一批)
|
|
|
+ List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
|
|
|
+ selectedIngredients.put(familyId, suggestions);
|
|
|
+
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ result.put("ingredients", suggestions);
|
|
|
+ result.put("selectedCount", 0);
|
|
|
+ return result;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+(`suggestIngredients` 保持调用无参重载 `generateIngredientList(familyId, LocalDate.now())` 即可。)
|
|
|
+
|
|
|
+- [ ] **步骤 3:`DietIngredientService` 新增 `searchFoods`**
|
|
|
+
|
|
|
+在 `DietIngredientService.java` 注入 `FoodMapper`(或复用现有 service)并新增方法。需要先确认注入方式——若 `BeijingNutritionService` 已有 `foodMapper`,本类新增注入:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private com.etotem.cfc.mapper.FoodMapper foodMapper;
|
|
|
+
|
|
|
+public Map<String, Object> searchFoods(String keyword) {
|
|
|
+ List<Map<String, Object>> foods = new ArrayList<>();
|
|
|
+ List<com.etotem.cfc.entity.Food> all = foodMapper.selectList(
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.Food>()
|
|
|
+ .eq(com.etotem.cfc.entity.Food::getStatus, "active")
|
|
|
+ .orderByAsc(com.etotem.cfc.entity.Food::getSortOrder)
|
|
|
+ );
|
|
|
+ String kw = keyword == null ? "" : keyword.trim();
|
|
|
+ int count = 0;
|
|
|
+ for (com.etotem.cfc.entity.Food f : all) {
|
|
|
+ if (kw.isEmpty() || (f.getName() != null && f.getName().contains(kw))) {
|
|
|
+ Map<String, Object> item = new HashMap<>();
|
|
|
+ item.put("id", f.getId());
|
|
|
+ item.put("name", f.getName());
|
|
|
+ item.put("category", f.getCategory());
|
|
|
+ foods.add(item);
|
|
|
+ if (++count >= 50) break; // 限制 50 条
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ result.put("foods", foods);
|
|
|
+ return result;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 若 `Food` 实体已有 `status`/`sortOrder` 字段(已确认存在),无需迁移。全量查询仅 50 条上限,内存过滤,符合"食材库不大"的现状。
|
|
|
+
|
|
|
+- [ ] **步骤 4:`DietIngredientController` 新增搜索接口**
|
|
|
+
|
|
|
+```java
|
|
|
+@PostMapping("/search")
|
|
|
+public Result<Map> searchFoods(@RequestBody(required = false) Map<String, Object> request) {
|
|
|
+ String keyword = request != null && request.get("keyword") != null
|
|
|
+ ? request.get("keyword").toString() : "";
|
|
|
+ return Result.success(dietIngredientService.searchFoods(keyword));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:编译验证**
|
|
|
+
|
|
|
+Run: `mvn clean compile`(`cfc-backend/` 下)
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java
|
|
|
+git commit -m "feat(diet): 换一批seed生效 + 食材搜索接口 /api/diet/ingredients/search"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: 后端「生成食谱」真实实现
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes:
|
|
|
+ - `Map<String, String> request`(date / meal_type / selected_foods 为 `[{food_id, name}]` JSON 字符串)
|
|
|
+ - `BeijingNutritionService.generateIngredientList(familyId, date)`(降级食材源)
|
|
|
+ - `MealConfigMapper`(查共餐参与者,参考 `BeijingNutritionService` 第 564-603 行逻辑)
|
|
|
+ - `FamilyMemberMapper`(无共餐配置时降级为全部成员,参考第 590-603 行)
|
|
|
+ - `AiGateway.generateMenu(String selectedFoodsJson, String participantsJson, String date)` → `menu_json` 字符串(LangGraph 契约见 Global Constraints)
|
|
|
+ - `com.fasterxml.jackson.databind.ObjectMapper`
|
|
|
+- Produces: `Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request)` 返回 `{id, menu, nutritionSummary, message}`,且落库 `diet_recommendations`(menu_json 已归一化、nutrition_summary 已聚合、participant_member_ids 已写入)
|
|
|
+
|
|
|
+- [ ] **步骤 1:重写 `generateRecommendation` 核心逻辑**
|
|
|
+
|
|
|
+`DietRecommendationService.java` 第 56-92 行替换为(保持方法签名):
|
|
|
+
|
|
|
+```java
|
|
|
+public Map<String, Object> generateRecommendation(Long familyId, Map<String, String> request) {
|
|
|
+ String dateStr = request.get("date");
|
|
|
+ String mealType = request.get("meal_type");
|
|
|
+ LocalDate date = LocalDate.parse(dateStr);
|
|
|
+
|
|
|
+ // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
|
|
|
+ List<Map<String, Object>> selectedFoods = new ArrayList<>();
|
|
|
+ String selectedFoodsJson = request.get("selected_foods");
|
|
|
+ if (selectedFoodsJson != null && !selectedFoodsJson.isEmpty()) {
|
|
|
+ try {
|
|
|
+ com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
|
+ com.fasterxml.jackson.databind.JsonNode arr = om.readTree(selectedFoodsJson);
|
|
|
+ if (arr.isArray()) {
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode node : arr) {
|
|
|
+ Map<String, Object> item = new HashMap<>();
|
|
|
+ item.put("food_id", node.has("food_id") ? node.get("food_id").asLong() : null);
|
|
|
+ item.put("name", node.has("name") ? node.get("name").asText() : "");
|
|
|
+ selectedFoods.add(item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析 selected_foods 失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 查询共餐参与者(参考 BeijingNutritionService.generateIngredientList 的 meal_configs 逻辑)
|
|
|
+ List<Long> participantIds = queryParticipantIds(familyId, date);
|
|
|
+
|
|
|
+ // 3. 调用 AI 生成菜单(参与者数量至少 1)
|
|
|
+ String participantsJson = "[]";
|
|
|
+ if (!participantIds.isEmpty()) {
|
|
|
+ try {
|
|
|
+ participantsJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(participantIds);
|
|
|
+ } catch (Exception ignore) {}
|
|
|
+ }
|
|
|
+ String menuJson = aiGateway.generateMenu(
|
|
|
+ selectedFoodsJson != null ? selectedFoodsJson : "[]",
|
|
|
+ participantsJson,
|
|
|
+ dateStr
|
|
|
+ );
|
|
|
+
|
|
|
+ // 4. AI 失败时降级:用规则推荐食材拼一个最小菜单,避免空壳
|
|
|
+ if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
|
|
|
+ menuJson = buildFallbackMenu(familyId, date, selectedFoods, participantIds.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
|
|
|
+ Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
|
|
|
+ String normalizedMenuJson = safeWrite(normalized);
|
|
|
+ Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
|
|
|
+
|
|
|
+ // 6. 保存推荐
|
|
|
+ DietRecommendation record = new DietRecommendation();
|
|
|
+ record.setFamilyId(familyId);
|
|
|
+ record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
|
|
|
+ record.setMealType(mealType != null ? mealType : "all");
|
|
|
+ record.setParticipantMemberIds(participantIds.toString());
|
|
|
+ record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
|
|
|
+ record.setNutritionSummary(safeWrite(nutritionSummary));
|
|
|
+ record.setStatus("pending");
|
|
|
+ record.setVersion(1);
|
|
|
+ record.setCreatedAt(new Date());
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
+ dietRecommendationMapper.insert(record);
|
|
|
+
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ result.put("id", record.getId());
|
|
|
+ result.put("menu", record.getMenuJson());
|
|
|
+ result.put("nutritionSummary", record.getNutritionSummary());
|
|
|
+ result.put("message", "食谱推荐已生成");
|
|
|
+ return result;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:新增私有辅助方法 `queryParticipantIds` / `normalizeMenu` / `aggregateNutrition` / `buildFallbackMenu` / `safeWrite`**
|
|
|
+
|
|
|
+在 `DietRecommendationService.java` 类内新增(需要 `@Resource MealConfigMapper mealConfigMapper; @Resource FamilyMemberMapper familyMemberMapper;`,Bean Name 冲突检查:`DietRecommendationService` 内注入与 `BeijingNutritionService` 相同 mapper 类不冲突,因为是不同实例注入):
|
|
|
+
|
|
|
+```java
|
|
|
+private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
|
|
|
+ boolean weekend = date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY
|
|
|
+ || date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
|
|
|
+ String dateType = weekend ? "weekend" : "weekday";
|
|
|
+ java.util.Set<Long> ids = new java.util.HashSet<>();
|
|
|
+ for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
|
|
|
+ com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.MealConfig> w =
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
|
|
|
+ w.eq(com.etotem.cfc.entity.MealConfig::getFamilyId, familyId)
|
|
|
+ .eq(com.etotem.cfc.entity.MealConfig::getConfigDateType, dateType)
|
|
|
+ .eq(com.etotem.cfc.entity.MealConfig::getMealType, mealType);
|
|
|
+ com.etotem.cfc.entity.MealConfig config = mealConfigMapper.selectOne(w);
|
|
|
+ if (config != null && config.getParticipantMemberIds() != null) {
|
|
|
+ try {
|
|
|
+ com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper()
|
|
|
+ .readTree(config.getParticipantMemberIds());
|
|
|
+ if (nodes.isArray()) {
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode node : nodes) ids.add(node.asLong());
|
|
|
+ }
|
|
|
+ } catch (Exception ignore) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (ids.isEmpty()) {
|
|
|
+ com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.FamilyMember> w =
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
|
|
|
+ w.eq(com.etotem.cfc.entity.FamilyMember::getFamilyId, familyId);
|
|
|
+ List<com.etotem.cfc.entity.FamilyMember> members = familyMemberMapper.selectList(w);
|
|
|
+ for (com.etotem.cfc.entity.FamilyMember m : members) ids.add(m.getId());
|
|
|
+ }
|
|
|
+ return new ArrayList<>(ids);
|
|
|
+}
|
|
|
+
|
|
|
+private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
|
|
|
+ // LangGraph: {meals:[{type,name,dishes:[{name,ingredients:[{name,grams}],cooking_method,nutrition:{calories,...},notes}]}]}
|
|
|
+ // 前端期望: {meals:[{type,name,participants,dishes:[{name,calories,ingredients:[{name,amount}],method}]}]}
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ List<Map<String, Object>> meals = new ArrayList<>();
|
|
|
+ result.put("meals", meals);
|
|
|
+ if (menuJson == null || menuJson.trim().isEmpty()) return result;
|
|
|
+ try {
|
|
|
+ com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
|
+ com.fasterxml.jackson.databind.JsonNode root = om.readTree(menuJson);
|
|
|
+ com.fasterxml.jackson.databind.JsonNode mealsNode = root.get("meals");
|
|
|
+ if (mealsNode != null && mealsNode.isArray()) {
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
|
|
|
+ Map<String, Object> meal = new LinkedHashMap<>();
|
|
|
+ meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
|
|
|
+ meal.put("name", mealNode.has("name") ? mealNode.get("name").asText() : "");
|
|
|
+ meal.put("participants", participantCount > 0 ? participantCount : 1);
|
|
|
+ List<Map<String, Object>> dishes = new ArrayList<>();
|
|
|
+ com.fasterxml.jackson.databind.JsonNode dishesNode = mealNode.get("dishes");
|
|
|
+ if (dishesNode != null && dishesNode.isArray()) {
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode dishNode : dishesNode) {
|
|
|
+ Map<String, Object> dish = new LinkedHashMap<>();
|
|
|
+ dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
|
|
|
+ com.fasterxml.jackson.databind.JsonNode nutrition = dishNode.get("nutrition");
|
|
|
+ if (nutrition != null) {
|
|
|
+ dish.put("calories", nutrition.has("calories") ? nutrition.get("calories").asInt() : 0);
|
|
|
+ } else {
|
|
|
+ dish.put("calories", 0);
|
|
|
+ }
|
|
|
+ List<Map<String, Object>> ingredients = new ArrayList<>();
|
|
|
+ com.fasterxml.jackson.databind.JsonNode ingNode = dishNode.get("ingredients");
|
|
|
+ if (ingNode != null && ingNode.isArray()) {
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode ing : ingNode) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("name", ing.has("name") ? ing.get("name").asText() : "");
|
|
|
+ item.put("amount", ing.has("grams") ? ing.get("grams").asInt() : 0);
|
|
|
+ ingredients.add(item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ dish.put("ingredients", ingredients);
|
|
|
+ dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
|
|
|
+ dishes.add(dish);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ meal.put("dishes", dishes);
|
|
|
+ meals.add(meal);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("归一化菜单失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
|
|
|
+ Map<String, Object> summary = new LinkedHashMap<>();
|
|
|
+ int calories = 0, protein = 0, carbs = 0, fat = 0;
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
|
|
|
+ if (meals != null) {
|
|
|
+ for (Map<String, Object> meal : meals) {
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
|
|
|
+ if (dishes == null) continue;
|
|
|
+ for (Map<String, Object> dish : dishes) {
|
|
|
+ Object cal = dish.get("calories");
|
|
|
+ if (cal instanceof Number) calories += ((Number) cal).intValue();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ summary.put("calories", calories);
|
|
|
+ summary.put("protein", protein);
|
|
|
+ summary.put("carbs", carbs);
|
|
|
+ summary.put("fat", fat);
|
|
|
+ return summary;
|
|
|
+}
|
|
|
+
|
|
|
+private String buildFallbackMenu(Long familyId, LocalDate date, List<Map<String, Object>> selectedFoods, int participantCount) {
|
|
|
+ // AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单
|
|
|
+ List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
|
|
|
+ beijingNutritionService.generateIngredientList(familyId, date);
|
|
|
+ String[] mealDefs = {"breakfast", "lunch", "dinner"};
|
|
|
+ String[] mealNames = {"早餐", "午餐", "晚餐"};
|
|
|
+ Map<String, Object> root = new LinkedHashMap<>();
|
|
|
+ List<Map<String, Object>> meals = new ArrayList<>();
|
|
|
+ for (int i = 0; i < mealDefs.length; i++) {
|
|
|
+ Map<String, Object> meal = new LinkedHashMap<>();
|
|
|
+ meal.put("type", mealDefs[i]);
|
|
|
+ meal.put("name", mealNames[i]);
|
|
|
+ meal.put("participants", participantCount > 0 ? participantCount : 1);
|
|
|
+ List<Map<String, Object>> dishes = new ArrayList<>();
|
|
|
+ Map<String, Object> dish = new LinkedHashMap<>();
|
|
|
+ dish.put("name", "营养餐");
|
|
|
+ dish.put("calories", 0);
|
|
|
+ List<Map<String, Object>> dishIngredients = new ArrayList<>();
|
|
|
+ for (int j = i * 3; j < Math.min(i * 3 + 3, ingredients.size()); j++) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("name", ingredients.get(j).getName());
|
|
|
+ item.put("amount", 100);
|
|
|
+ dishIngredients.add(item);
|
|
|
+ }
|
|
|
+ dish.put("ingredients", dishIngredients);
|
|
|
+ dish.put("method", "建议以清淡为主");
|
|
|
+ dishes.add(dish);
|
|
|
+ meal.put("dishes", dishes);
|
|
|
+ meals.add(meal);
|
|
|
+ }
|
|
|
+ root.put("meals", meals);
|
|
|
+ return safeWrite(root);
|
|
|
+}
|
|
|
+
|
|
|
+private String safeWrite(Object obj) {
|
|
|
+ try {
|
|
|
+ return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "{}";
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 说明:`aggregateNutrition` 目前只聚合 calories(LangGraph 未返回 protein/carbs/fat 结构一致字段时置 0);若 `nutrition` 含 protein/carbs/fat 字段可在后续补充,本期保证 calories 与前端 4 宫格可正常渲染(其余为 0)。
|
|
|
+
|
|
|
+- [ ] **步骤 3:新增 mapper 注入**
|
|
|
+
|
|
|
+在 `DietRecommendationService` 类顶部 `@Resource` 区新增:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private com.etotem.cfc.mapper.MealConfigMapper mealConfigMapper;
|
|
|
+
|
|
|
+@Resource
|
|
|
+private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
|
|
|
+```
|
|
|
+
|
|
|
+> Bean Name 冲突检查:`MealConfigMapper` / `FamilyMemberMapper` 在多个 Service 中已注入(如 `BeijingNutritionService`、`DietMealConfigService`),Spring 单例按类型注入,无命名冲突。
|
|
|
+
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
+
|
|
|
+Run: `mvn clean compile`(`cfc-backend/` 下)
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
|
|
|
+git commit -m "feat(diet): 生成食谱使用所选食材+共餐参与者,归一化LangGraph菜单并聚合营养"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: 前端「生成食谱」+「换一批」+ 弹窗搜索(index.vue + api.js)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/utils/api.js`(新增 `searchDietFoods`)
|
|
|
+- Modify: `cfc-frontend/pages/diet/index.vue`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `searchDietFoods(keyword)` → `POST /api/diet/ingredients/search` 返回 `{foods:[{id,name,category}]}`;`generateDietRecommendation` 返回 `{code,message,data:{id,...}}`
|
|
|
+- Produces: 修复后的 `index.vue`(generateRecipe 成功跳转、弹窗可搜索、按钮禁用态正确)
|
|
|
+
|
|
|
+- [ ] **步骤 1:`api.js` 新增搜索接口封装**
|
|
|
+
|
|
|
+在 `api.js` 现有 `getDietIngredients` 附近(第 2493-2494 行)新增:
|
|
|
+
|
|
|
+```js
|
|
|
+export const searchDietFoods = (keyword) => request('/api/diet/ingredients/search', 'POST', { keyword })
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:`index.vue` `generateRecipe` 取值修复**
|
|
|
+
|
|
|
+第 271-286 行,`if (res && res.id)` 改为:
|
|
|
+
|
|
|
+```js
|
|
|
+generateRecipe: function() {
|
|
|
+ var self = this
|
|
|
+ var foodIds = this.ingredients.map(function(item) { return { food_id: item.foodId, name: item.name } })
|
|
|
+ generateDietRecommendation({
|
|
|
+ date: self.formatDate(new Date()),
|
|
|
+ meal_type: 'all',
|
|
|
+ selected_foods: JSON.stringify(foodIds)
|
|
|
+ }).then(function(res) {
|
|
|
+ if (res && res.data && res.data.id) {
|
|
|
+ uni.showToast({ title: '食谱生成成功', icon: 'success' })
|
|
|
+ setTimeout(function() {
|
|
|
+ self.navTo('/pages/diet/recommendation')
|
|
|
+ }, 1000)
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:`index.vue` `selectedIngredients` 同步**
|
|
|
+
|
|
|
+`loadIngredients`(第 219-244 行)和 `refreshIngredients`(第 245-256 行)成功分支里,在 `self.ingredients = res.data.ingredients` 后追加:
|
|
|
+
|
|
|
+```js
|
|
|
+self.selectedIngredients = self.ingredients
|
|
|
+```
|
|
|
+
|
|
|
+同时 `removeIngredient` 成功回调里 `self.loadIngredients()` 已会重新同步;`addFood` 成功回调 `self.loadIngredients()` 同理。
|
|
|
+
|
|
|
+- [ ] **步骤 4:`index.vue` `searchFood` 实现 + `addFood` 修复**
|
|
|
+
|
|
|
+第 332-345 行替换为:
|
|
|
+
|
|
|
+```js
|
|
|
+searchFood: function() {
|
|
|
+ var self = this
|
|
|
+ var kw = (this.searchQuery || '').trim()
|
|
|
+ if (!kw) {
|
|
|
+ this.searchResults = []
|
|
|
+ return
|
|
|
+ }
|
|
|
+ searchDietFoods(kw).then(function(res) {
|
|
|
+ if (res && res.code === 200 && res.data && res.data.foods) {
|
|
|
+ self.searchResults = res.data.foods
|
|
|
+ }
|
|
|
+ })
|
|
|
+},
|
|
|
+addFood: function(food) {
|
|
|
+ var self = this
|
|
|
+ addDietIngredient({ food_id: food.id, name: food.name }).then(function(res) {
|
|
|
+ self.closePicker()
|
|
|
+ self.loadIngredients()
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> `addFood` 现有逻辑已正确(用 `food.id`),若未改动则保持。弹窗模板 `@tap="addFood(food)"` 已存在,`food.id`/`food.name` 与搜索返回字段一致,无需改模板。
|
|
|
+
|
|
|
+- [ ] **步骤 5:`index.vue` import 增加 `searchDietFoods`**
|
|
|
+
|
|
|
+第 161 行 import 行追加 `searchDietFoods`:
|
|
|
+
|
|
|
+```js
|
|
|
+import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig, searchDietFoods } from '@/utils/api.js'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 6:前端校验**
|
|
|
+
|
|
|
+Run(`cfc-frontend/` 下):
|
|
|
+```bash
|
|
|
+node -e "
|
|
|
+const fs=require('fs');
|
|
|
+const s=fs.readFileSync('pages/diet/index.vue','utf8');
|
|
|
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
|
|
|
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
|
|
|
+console.log('script OK');
|
|
|
+"
|
|
|
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/index.vue
|
|
|
+```
|
|
|
+Expected: `script OK` + grep 无输出
|
|
|
+
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js cfc-frontend/pages/diet/index.vue
|
|
|
+git commit -m "feat(diet): 首页生成食谱取值修复+弹窗搜索实现+selectedIngredients同步"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: 前端「食谱推荐页」取值修复(recommendation.vue)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/diet/recommendation.vue`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `getDietRecommendation` / `generateDietRecommendation` 返回 `{code,message,data:{id,menu,nutritionSummary,status}}`;后端已归一化的菜单结构
|
|
|
+- Produces: 修复后的 `recommendation.vue`(能加载今日推荐、空态生成可跳转、营养汇总/状态徽章正常)
|
|
|
+
|
|
|
+- [ ] **步骤 1:`loadRecommendation` 用 `res.data.*`**
|
|
|
+
|
|
|
+第 137-147 行替换为:
|
|
|
+
|
|
|
+```js
|
|
|
+loadRecommendation: function() {
|
|
|
+ var self = this
|
|
|
+ var today = self.formatDate(new Date())
|
|
|
+ getDietRecommendation({ date: today }).then(function(res) {
|
|
|
+ var d = res && res.data
|
|
|
+ if (d && d.id) {
|
|
|
+ self.recommendation = d
|
|
|
+ self.parseMenu(d.menu)
|
|
|
+ self.summary = d.nutritionSummary ? (typeof d.nutritionSummary === 'string' ? JSON.parse(d.nutritionSummary) : d.nutritionSummary) : {}
|
|
|
+ } else {
|
|
|
+ self.recommendation = null
|
|
|
+ self.meals = []
|
|
|
+ self.summary = {}
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:`generateRecipe` 用 `res.data.id` + 传 `selected_foods`(若有)**
|
|
|
+
|
|
|
+第 175-191 行替换为:
|
|
|
+
|
|
|
+```js
|
|
|
+generateRecipe: function() {
|
|
|
+ var self = this
|
|
|
+ self.loading = true
|
|
|
+ generateDietRecommendation({
|
|
|
+ date: self.formatDate(new Date()),
|
|
|
+ meal_type: 'all'
|
|
|
+ }).then(function(res) {
|
|
|
+ self.loading = false
|
|
|
+ if (res && res.data && res.data.id) {
|
|
|
+ uni.showToast({ title: '食谱生成成功', icon: 'success' })
|
|
|
+ self.loadRecommendation()
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ }).catch(function() {
|
|
|
+ self.loading = false
|
|
|
+ uni.showToast({ title: '生成失败', icon: 'none' })
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:`parseMenu` 兼容后端归一化结构(含旧结构兜底)**
|
|
|
+
|
|
|
+第 148-174 行,`parseMenu` 内 `ing.amount` 已与后端归一化输出一致;为兼容后端可能存的旧数据,在 ingredient 解析处加兜底:
|
|
|
+
|
|
|
+```js
|
|
|
+parseMenu: function(menuJson) {
|
|
|
+ try {
|
|
|
+ var menu = (typeof menuJson === 'string') ? JSON.parse(menuJson || '{}') : (menuJson || {})
|
|
|
+ this.meals = []
|
|
|
+ var mealMap = {
|
|
|
+ 'breakfast': { name: '早餐', icon: '🌅' },
|
|
|
+ 'lunch': { name: '午餐', icon: '☀️' },
|
|
|
+ 'dinner': { name: '晚餐', icon: '🌙' }
|
|
|
+ }
|
|
|
+ if (menu.meals) {
|
|
|
+ var self = this
|
|
|
+ menu.meals.forEach(function(meal) {
|
|
|
+ var config = mealMap[meal.type] || { name: meal.name || meal.type, icon: '🍽️' }
|
|
|
+ var dishes = (meal.dishes || []).map(function(dish) {
|
|
|
+ var ing = (dish.ingredients || []).map(function(item) {
|
|
|
+ return { name: item.name, amount: item.amount || item.grams || 0 }
|
|
|
+ })
|
|
|
+ return {
|
|
|
+ name: dish.name,
|
|
|
+ calories: dish.calories || (dish.nutrition && dish.nutrition.calories) || 0,
|
|
|
+ ingredients: ing,
|
|
|
+ method: dish.method || dish.cooking_method || ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+ self.meals.push({
|
|
|
+ name: config.name,
|
|
|
+ icon: config.icon,
|
|
|
+ type: meal.type,
|
|
|
+ participants: meal.participants || 1,
|
|
|
+ dishes: dishes
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('解析菜单失败', e)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 说明:模板中 `dish.calories`/`ing.amount`/`dish.method` 字段名保持不变(后端归一化已对齐),此步骤仅加旧结构兜底 + 支持 `nutritionSummary` 为 JSON 字符串的情况。
|
|
|
+
|
|
|
+- [ ] **步骤 4:前端校验**
|
|
|
+
|
|
|
+Run(`cfc-frontend/` 下):
|
|
|
+```bash
|
|
|
+node -e "
|
|
|
+const fs=require('fs');
|
|
|
+const s=fs.readFileSync('pages/diet/recommendation.vue','utf8');
|
|
|
+const m=s.match(/<script>([\s\S]*?)<\/script>/);
|
|
|
+new Function(m[1].replace(/import\s[^;]+;/g,'').replace(/export\s+default/,'return'));
|
|
|
+console.log('script OK');
|
|
|
+"
|
|
|
+grep -nE '\?\.|display:\s*grid|:key="[^"]*(\|\||&&|\+)' pages/diet/recommendation.vue
|
|
|
+```
|
|
|
+Expected: `script OK` + grep 无输出
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/diet/recommendation.vue
|
|
|
+git commit -m "fix(diet): 食谱推荐页 res.data 取值修复+菜单解析兼容"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: API 文档同步 + 全量复核
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/superpowers/api/API_REFERENCE.md`
|
|
|
+
|
|
|
+- [ ] **步骤 1:API_REFERENCE 新增搜索接口记录**
|
|
|
+
|
|
|
+在 `docs/superpowers/api/API_REFERENCE.md` 第 353 行附近的 `### 4.11 饮食推荐(/api/diet/*)` 表格中追加:
|
|
|
+
|
|
|
+```markdown
|
|
|
+| `POST /api/diet/ingredients/search` | 搜索食材(keyword) |
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:全量编译 + 前端校验**
|
|
|
+
|
|
|
+Run:
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+cd cfc-frontend && node -e "..." pages/diet/index.vue pages/diet/recommendation.vue
|
|
|
+```
|
|
|
+Expected: BUILD SUCCESS + script OK × 2 + grep 无输出
|
|
|
+
|
|
|
+- [ ] **步骤 3:git 复核 staged 范围**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/api/API_REFERENCE.md
|
|
|
+git diff --cached --stat
|
|
|
+```
|
|
|
+Expected: 仅包含目标文件
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "docs(diet): API_REFERENCE 记录 /api/diet/ingredients/search"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检记录
|
|
|
+
|
|
|
+- [ ] **规格覆盖度**:spec §4.2(食材推荐流程:换一批/添加/生成)→ Task 1+3;§4.2 confirm→generate(食材确认+生成)→ Task 2;§6.1 接口 → Task 1+2;§7.3 首页布局按钮 → Task 3+4
|
|
|
+- [ ] **占位符扫描**:无 TODO/待定;每个代码步骤含实际代码
|
|
|
+- [ ] **类型一致性**:`res.data.id` / `res.data.menu` / `res.data.nutritionSummary` 前后端一致;后端 `normalizeMenu` 输出 `dish.calories/ingredients[].amount/method` 与前端 `parseMenu` 期望一致
|
|
|
+- [ ] **LangGraph 不改**:Python `/api/v1/menu/generate` 已实现,仅 Java 端消费其输出
|