2026-09-01-diet-recipe-fix.md 33 KB

饮食页面「换一批」&「生成食谱」端到端修复 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 边界操作。

全局校验命令(每个任务提交前必跑)

# 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 行)。改造:

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 行,改为:

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,本类新增注入:

@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 新增搜索接口

    @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

    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 行替换为(保持方法签名):

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 类不冲突,因为是不同实例注入):

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 区新增:

@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

    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 行)新增:

export const searchDietFoods = (keyword) => request('/api/diet/ingredients/search', 'POST', { keyword })
  • 步骤 2:index.vue generateRecipe 取值修复

第 271-286 行,if (res && res.id) 改为:

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 后追加:

self.selectedIngredients = self.ingredients

同时 removeIngredient 成功回调里 self.loadIngredients() 已会重新同步;addFood 成功回调 self.loadIngredients() 同理。

  • 步骤 4:index.vue searchFood 实现 + addFood 修复

第 332-345 行替换为:

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:

import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig, searchDietFoods } from '@/utils/api.js'
  • 步骤 6:前端校验

Run(cfc-frontend/ 下):

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

    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 行替换为:

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 行替换为:

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 解析处加兜底:

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/ 下):

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

    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/*) 表格中追加:

| `POST /api/diet/ingredients/search` | 搜索食材(keyword) |
  • 步骤 2:全量编译 + 前端校验

Run:

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 范围

    git add docs/superpowers/api/API_REFERENCE.md
    git diff --cached --stat
    

Expected: 仅包含目标文件

  • [ ] 步骤 4:Commit

    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 端消费其输出