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(仅样式,本次不改视觉)@PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMapping@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 APIrequest 包装器 resolve 的是整个 Result 对象 {code, message, data}——所有业务字段在 res.data.* 下{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
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java(generateIngredientList 签名)cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.javacfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.javaInterfaces:
IngredientRecommendation(foodId/name/reason/score/category)、FoodService.listAll 或 FoodMapperProduces:
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,可让每次换一批取到不同起始位置的食材组合。
DietIngredientService.refreshIngredients 传 seedDietIngredientService.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()) 即可。)
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"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.javaInterfaces:
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.ObjectMapperProduces: 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;
}
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)。
在 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 单例按类型注入,无命名冲突。
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菜单并聚合营养"
Files:
cfc-frontend/utils/api.js(新增 searchDietFoods)cfc-frontend/pages/diet/index.vueInterfaces:
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 })
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' })
}
})
}
index.vue selectedIngredients 同步loadIngredients(第 219-244 行)和 refreshIngredients(第 245-256 行)成功分支里,在 self.ingredients = res.data.ingredients 后追加:
self.selectedIngredients = self.ingredients
同时 removeIngredient 成功回调里 self.loadIngredients() 已会重新同步;addFood 成功回调 self.loadIngredients() 同理。
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与搜索返回字段一致,无需改模板。
index.vue import 增加 searchDietFoods第 161 行 import 行追加 searchDietFoods:
import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig, searchDietFoods } from '@/utils/api.js'
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同步"
Files:
cfc-frontend/pages/diet/recommendation.vueInterfaces:
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 = {}
}
})
}
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' })
})
}
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 字符串的情况。
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 取值修复+菜单解析兼容"
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) |
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"
res.data.id / res.data.menu / res.data.nutritionSummary 前后端一致;后端 normalizeMenu 输出 dish.calories/ingredients[].amount/method 与前端 parseMenu 期望一致/api/v1/menu/generate 已实现,仅 Java 端消费其输出