|
|
@@ -0,0 +1,2121 @@
|
|
|
+# 食谱推荐系统 — 实现计划
|
|
|
+
|
|
|
+> **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:** 基于个人健康情况、饮食喜好、家庭偏好、财力预算、应季食品和近期营养摄入,实现混合架构食谱推荐系统(DB 候选过滤 + Dify AI 个性化编排)
|
|
|
+
|
|
|
+**Architecture:** 新增 4 张表(foods/recipes/seasonal_foods/meal_logs)+ 扩展 user_nutrition_profile。后端新增 MealRecommendService 实现数据聚合→DB过滤→Dify编排三阶段推荐流程。一回换一菜走同品类随机替换(<100ms)。外部 API 仅作为数据填充工具,非运行时依赖。
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8.0 + Dify API + Spoonacular (批量导入) + uni-app 小程序 + Vue 2 Web 管理端
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### File Structure
|
|
|
+
|
|
|
+```
|
|
|
+cfc-backend/src/main/java/com/etotem/cfc/
|
|
|
+├── entity/
|
|
|
+│ ├── Food.java # 食材实体 (NEW)
|
|
|
+│ ├── Recipe.java # 食谱实体 (NEW)
|
|
|
+│ ├── SeasonalFood.java # 应季食材映射实体 (NEW)
|
|
|
+│ ├── MealLog.java # 饮食日志实体 (NEW)
|
|
|
+│ └── UserNutritionProfile.java # 扩展 budget_monthly 等字段 (MODIFY)
|
|
|
+├── mapper/
|
|
|
+│ ├── FoodMapper.java # (NEW)
|
|
|
+│ ├── RecipeMapper.java # (NEW)
|
|
|
+│ ├── SeasonalFoodMapper.java # (NEW)
|
|
|
+│ └── MealLogMapper.java # (NEW)
|
|
|
+├── service/
|
|
|
+│ ├── FoodService.java # 食材 CRUD + 按季度/价格/标签查询 (NEW)
|
|
|
+│ ├── RecipeService.java # 食谱 CRUD + 按餐型/场景查询 (NEW)
|
|
|
+│ ├── SeasonalFoodService.java # 应季食材查询 (NEW)
|
|
|
+│ ├── MealLogService.java # 饮食日志 CRUD + 7天营养统计 (NEW)
|
|
|
+│ └── MealRecommendService.java # 核心推荐引擎 (NEW)
|
|
|
+├── controller/
|
|
|
+│ └── MealRecommendController.java # /api/meal/recommend + /api/meal/replace (NEW)
|
|
|
+├── dto/
|
|
|
+│ ├── RecommendationContext.java # 聚合上下文 (NEW)
|
|
|
+│ └── MealRecommendResult.java # 推荐结果 (NEW)
|
|
|
+├── config/
|
|
|
+│ └── DatabaseInitializer.java # DDL 新增5个 ALTER/CREATE (MODIFY)
|
|
|
+
|
|
|
+cfc-frontend/pages/meal/
|
|
|
+├── recommend.vue # 食谱推荐首页 (NEW)
|
|
|
+├── recipe-detail.vue # 食谱详情 (NEW)
|
|
|
+├── meal-log.vue # 饮食记录 (NEW)
|
|
|
+└── preference.vue # 偏好设置 (NEW)
|
|
|
+
|
|
|
+cfc-web/src/views/admin/
|
|
|
+├── foods/index.vue # 食材管理 (NEW)
|
|
|
+├── recipes/index.vue # 食谱管理 (NEW)
|
|
|
+└── seasonal/index.vue # 应季食材配置 (NEW)
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: DDL — 新增 4 张表 + 扩展 user_nutrition_profile
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 DatabaseInitializer.java 新增建表块**
|
|
|
+
|
|
|
+定位到 `DatabaseInitializer.java` 末尾的建表区域(约 1436 行,`health_disease_risk` 之后),新增 `initFoodTables()` 方法并调用:
|
|
|
+
|
|
|
+```java
|
|
|
+private void initFoodTables() {
|
|
|
+ String[] ddl = {
|
|
|
+ "CREATE TABLE IF NOT EXISTS foods ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "name VARCHAR(100) NOT NULL COMMENT '食材名称', "
|
|
|
+ + "category VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他', "
|
|
|
+ + "unit VARCHAR(20) DEFAULT 'g' COMMENT '计量单位', "
|
|
|
+ + "calories DECIMAL(8,2) COMMENT '热量(kcal/100g)', "
|
|
|
+ + "protein DECIMAL(8,2) COMMENT '蛋白质(g/100g)', "
|
|
|
+ + "fat DECIMAL(8,2) COMMENT '脂肪(g/100g)', "
|
|
|
+ + "carbs DECIMAL(8,2) COMMENT '碳水(g/100g)', "
|
|
|
+ + "fiber DECIMAL(8,2) COMMENT '膳食纤维(g/100g)', "
|
|
|
+ + "price_level TINYINT COMMENT '价格等级 1-5', "
|
|
|
+ + "nutrition_tags VARCHAR(500) COMMENT '营养标签JSON', "
|
|
|
+ + "suitable_for VARCHAR(200) COMMENT '适宜人群JSON', "
|
|
|
+ + "allergens VARCHAR(200) COMMENT '过敏原JSON', "
|
|
|
+ + "external_source VARCHAR(100) COMMENT '数据来源', "
|
|
|
+ + "external_id VARCHAR(100) COMMENT '外部ID', "
|
|
|
+ + "cover_image VARCHAR(500) COMMENT '食材图片', "
|
|
|
+ + "status VARCHAR(20) DEFAULT 'active', "
|
|
|
+ + "sort_order INT DEFAULT 0, "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_category (category), "
|
|
|
+ + "INDEX idx_price (price_level)"
|
|
|
+ + ");",
|
|
|
+
|
|
|
+ "CREATE TABLE IF NOT EXISTS recipes ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "name VARCHAR(200) NOT NULL COMMENT '食谱名称', "
|
|
|
+ + "meal_type VARCHAR(20) COMMENT '早餐/午餐/晚餐/加餐', "
|
|
|
+ + "description TEXT COMMENT '简介', "
|
|
|
+ + "ingredients TEXT NOT NULL COMMENT '食材清单JSON', "
|
|
|
+ + "steps TEXT COMMENT '烹饪步骤JSON', "
|
|
|
+ + "cook_time INT COMMENT '烹饪时间(分钟)', "
|
|
|
+ + "difficulty VARCHAR(20) COMMENT '难度: 简单/中等/困难', "
|
|
|
+ + "total_calories DECIMAL(8,2) COMMENT '总热量', "
|
|
|
+ + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
|
|
|
+ + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
|
|
|
+ + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
|
|
|
+ + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
|
|
|
+ + "suitable_for VARCHAR(200) COMMENT '适宜场景', "
|
|
|
+ + "nutrition_tags VARCHAR(500) COMMENT '营养标签', "
|
|
|
+ + "external_source VARCHAR(100), "
|
|
|
+ + "external_id VARCHAR(100), "
|
|
|
+ + "cover_image VARCHAR(500), "
|
|
|
+ + "status VARCHAR(20) DEFAULT 'active', "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_meal_type (meal_type), "
|
|
|
+ + "INDEX idx_cook_time (cook_time)"
|
|
|
+ + ");",
|
|
|
+
|
|
|
+ "CREATE TABLE IF NOT EXISTS seasonal_foods ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "food_id BIGINT NOT NULL COMMENT '关联foods.id', "
|
|
|
+ + "month TINYINT NOT NULL COMMENT '月份 1-12', "
|
|
|
+ + "region VARCHAR(50) DEFAULT '全国' COMMENT '产地', "
|
|
|
+ + "peak_flag TINYINT DEFAULT 0 COMMENT '盛产标志', "
|
|
|
+ + "sort_order INT DEFAULT 0, "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_food_month (food_id, month), "
|
|
|
+ + "INDEX idx_month (month)"
|
|
|
+ + ");",
|
|
|
+
|
|
|
+ "CREATE TABLE IF NOT EXISTS meal_logs ("
|
|
|
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
+ + "user_id BIGINT NOT NULL COMMENT '用户ID', "
|
|
|
+ + "child_id BIGINT COMMENT '孩子ID', "
|
|
|
+ + "meal_type VARCHAR(20) NOT NULL COMMENT '早餐/午餐/晚餐/加餐', "
|
|
|
+ + "meal_date DATE NOT NULL COMMENT '用餐日期', "
|
|
|
+ + "meal_time DATETIME COMMENT '具体时间', "
|
|
|
+ + "foods TEXT NOT NULL COMMENT '食物清单JSON', "
|
|
|
+ + "total_calories DECIMAL(8,2) COMMENT '总热量', "
|
|
|
+ + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
|
|
|
+ + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
|
|
|
+ + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
|
|
|
+ + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
|
|
|
+ + "note TEXT COMMENT '备注', "
|
|
|
+ + "images VARCHAR(1000) COMMENT '图片JSON', "
|
|
|
+ + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "INDEX idx_user_date (user_id, meal_date), "
|
|
|
+ + "INDEX idx_child_date (child_id, meal_date)"
|
|
|
+ + ");",
|
|
|
+
|
|
|
+ // 扩展 user_nutrition_profile(如果表不存在则先检查,用 ALTER 加列)
|
|
|
+ "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS budget_monthly INT COMMENT '月食品预算(元)'",
|
|
|
+ "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS family_taste_preferences TEXT COMMENT '家庭成员口味偏好JSON'",
|
|
|
+ "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS cuisine_style VARCHAR(100) COMMENT '偏好菜系'",
|
|
|
+ "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS meal_count INT DEFAULT 3 COMMENT '每日餐数'",
|
|
|
+ "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS cooking_ability VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力'"
|
|
|
+ };
|
|
|
+
|
|
|
+ for (String sql : ddl) {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute(sql);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("食品表DDL执行失败(可能已存在): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** MySQL 8.0 不支持 `ADD COLUMN IF NOT EXISTS`。需用 try/catch 捕获重复列错误替代。或者先查 INFORMATION_SCHEMA.COLUMNS 判断。替代实现:
|
|
|
+
|
|
|
+```java
|
|
|
+private void ensureColumn(String table, String column, String definition) {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
|
|
|
+ } catch (Exception e) {
|
|
|
+ if (e.getMessage() != null && e.getMessage().contains("Duplicate column")) {
|
|
|
+ log.info("列 {}.{} 已存在,跳过", table, column);
|
|
|
+ } else {
|
|
|
+ log.warn("添加列 {}.{} 失败: {}", table, column, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 调用:
|
|
|
+ensureColumn("user_nutrition_profile", "budget_monthly", "INT COMMENT '月食品预算(元)'");
|
|
|
+ensureColumn("user_nutrition_profile", "family_taste_preferences", "TEXT COMMENT '家庭成员口味偏好JSON'");
|
|
|
+ensureColumn("user_nutrition_profile", "cuisine_style", "VARCHAR(100) COMMENT '偏好菜系'");
|
|
|
+ensureColumn("user_nutrition_profile", "meal_count", "INT DEFAULT 3 COMMENT '每日餐数'");
|
|
|
+ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力'");
|
|
|
+```
|
|
|
+
|
|
|
+调用位置:在 `run()` 方法末尾或者 `initFoodTables()` 从 `run()` 调用。
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译通过**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat(db): add foods/recipes/seasonal_foods/meal_logs tables and extend user_nutrition_profile"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: Entity 类 — Food, Recipe, SeasonalFood, MealLog
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/SeasonalFood.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/MealLog.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 Food.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("foods")
|
|
|
+public class Food implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private String name;
|
|
|
+ private String category;
|
|
|
+ private String unit;
|
|
|
+ private BigDecimal calories;
|
|
|
+ private BigDecimal protein;
|
|
|
+ private BigDecimal fat;
|
|
|
+ private BigDecimal carbs;
|
|
|
+ private BigDecimal fiber;
|
|
|
+ private Integer priceLevel;
|
|
|
+ private String nutritionTags;
|
|
|
+ private String suitableFor;
|
|
|
+ private String allergens;
|
|
|
+ private String externalSource;
|
|
|
+ private String externalId;
|
|
|
+ private String coverImage;
|
|
|
+ private String status;
|
|
|
+ private Integer sortOrder;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 Recipe.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("recipes")
|
|
|
+public class Recipe implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private String name;
|
|
|
+ private String mealType;
|
|
|
+ private String description;
|
|
|
+ private String ingredients; // JSON
|
|
|
+ private String steps; // JSON
|
|
|
+ private Integer cookTime;
|
|
|
+ private String difficulty;
|
|
|
+ private BigDecimal totalCalories;
|
|
|
+ private BigDecimal totalProtein;
|
|
|
+ private BigDecimal totalFat;
|
|
|
+ private BigDecimal totalCarbs;
|
|
|
+ private BigDecimal totalFiber;
|
|
|
+ private String suitableFor;
|
|
|
+ private String nutritionTags;
|
|
|
+ private String externalSource;
|
|
|
+ private String externalId;
|
|
|
+ private String coverImage;
|
|
|
+ private String status;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 SeasonalFood.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("seasonal_foods")
|
|
|
+public class SeasonalFood implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long foodId;
|
|
|
+ private Integer month;
|
|
|
+ private String region;
|
|
|
+ private Integer peakFlag;
|
|
|
+ private Integer sortOrder;
|
|
|
+ private Date createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 创建 MealLog.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("meal_logs")
|
|
|
+public class MealLog implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long userId;
|
|
|
+ private Long childId;
|
|
|
+ private String mealType;
|
|
|
+ private Date mealDate;
|
|
|
+ private Date mealTime;
|
|
|
+ private String foods; // JSON
|
|
|
+ private BigDecimal totalCalories;
|
|
|
+ private BigDecimal totalProtein;
|
|
|
+ private BigDecimal totalFat;
|
|
|
+ private BigDecimal totalCarbs;
|
|
|
+ private BigDecimal totalFiber;
|
|
|
+ private String note;
|
|
|
+ private String images; // JSON
|
|
|
+ private Date createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 扩展 UserNutritionProfile.java**
|
|
|
+
|
|
|
+在现有 `UserNutritionProfile.java` 末尾(`questionnaireStatus` 字段后)新增字段:
|
|
|
+
|
|
|
+```java
|
|
|
+ /** 月食品预算(元) */
|
|
|
+ private Integer budgetMonthly;
|
|
|
+
|
|
|
+ /** 家庭成员口味偏好JSON */
|
|
|
+ private String familyTastePreferences;
|
|
|
+
|
|
|
+ /** 偏好菜系 */
|
|
|
+ private String cuisineStyle;
|
|
|
+
|
|
|
+ /** 每日餐数(默认3) */
|
|
|
+ private Integer mealCount;
|
|
|
+
|
|
|
+ /** 烹饪能力: simple/medium/advanced */
|
|
|
+ private String cookingAbility;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 验证编译通过**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/SeasonalFood.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/MealLog.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java
|
|
|
+git commit -m "feat(entity): add Food/Recipe/SeasonalFood/MealLog entities and extend UserNutritionProfile"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Mapper 接口
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/FoodMapper.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/RecipeMapper.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/SeasonalFoodMapper.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/MealLogMapper.java`
|
|
|
+
|
|
|
+- [ ] **Step 1~4: 创建 4 个 Mapper 接口**
|
|
|
+
|
|
|
+每个文件格式统一(以 FoodMapper 为例):
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.Food;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface FoodMapper extends BaseMapper<Food> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+同样创建 `RecipeMapper`(BaseMapper\<Recipe\>)、`SeasonalFoodMapper`(BaseMapper\<SeasonalFood\>)、`MealLogMapper`(BaseMapper\<MealLog\>)。
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/FoodMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/RecipeMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/SeasonalFoodMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/MealLogMapper.java
|
|
|
+git commit -m "feat(mapper): add Food/Recipe/SeasonalFood/MealLog mappers"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 基础 Service — FoodService / RecipeService / SeasonalFoodService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/SeasonalFoodService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 FoodService**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.Food;
|
|
|
+import com.etotem.cfc.mapper.FoodMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class FoodService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FoodMapper foodMapper;
|
|
|
+
|
|
|
+ public List<Food> listAll(String category, String status) {
|
|
|
+ LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<Food>()
|
|
|
+ .eq(status != null, Food::getStatus, status)
|
|
|
+ .eq(category != null, Food::getCategory, category)
|
|
|
+ .orderByAsc(Food::getSortOrder);
|
|
|
+ return foodMapper.selectList(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按价格等级上限查询可用食材 (price_level <= maxLevel)
|
|
|
+ */
|
|
|
+ public List<Food> listByPriceLevel(int maxLevel) {
|
|
|
+ return foodMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Food>()
|
|
|
+ .eq(Food::getStatus, "active")
|
|
|
+ .le(Food::getPriceLevel, maxLevel)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public Food getById(Long id) {
|
|
|
+ return foodMapper.selectById(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void create(Food food) {
|
|
|
+ foodMapper.insert(food);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void update(Food food) {
|
|
|
+ foodMapper.updateById(food);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void delete(Long id) {
|
|
|
+ foodMapper.deleteById(id);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 RecipeService**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.Recipe;
|
|
|
+import com.etotem.cfc.mapper.RecipeMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class RecipeService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RecipeMapper recipeMapper;
|
|
|
+
|
|
|
+ public List<Recipe> listByMealType(String mealType) {
|
|
|
+ return recipeMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Recipe>()
|
|
|
+ .eq(Recipe::getStatus, "active")
|
|
|
+ .eq(mealType != null, Recipe::getMealType, mealType)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public Recipe getById(Long id) {
|
|
|
+ return recipeMapper.selectById(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void create(Recipe recipe) {
|
|
|
+ recipeMapper.insert(recipe);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void update(Recipe recipe) {
|
|
|
+ recipeMapper.updateById(recipe);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void delete(Long id) {
|
|
|
+ recipeMapper.deleteById(id);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 SeasonalFoodService**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.SeasonalFood;
|
|
|
+import com.etotem.cfc.mapper.SeasonalFoodMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class SeasonalFoodService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private SeasonalFoodMapper seasonalFoodMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取当前月份的应季食材 ID 列表
|
|
|
+ */
|
|
|
+ public List<Long> getCurrentMonthFoodIds() {
|
|
|
+ int month = LocalDate.now().getMonthValue();
|
|
|
+ return getFoodIdsByMonth(month);
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<Long> getFoodIdsByMonth(int month) {
|
|
|
+ return seasonalFoodMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<SeasonalFood>()
|
|
|
+ .eq(SeasonalFood::getMonth, month)
|
|
|
+ .orderByDesc(SeasonalFood::getPeakFlag)
|
|
|
+ ).stream().map(SeasonalFood::getFoodId).distinct().toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<SeasonalFood> listByMonth(int month) {
|
|
|
+ return seasonalFoodMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<SeasonalFood>()
|
|
|
+ .eq(SeasonalFood::getMonth, month)
|
|
|
+ .orderByDesc(SeasonalFood::getPeakFlag)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public void create(SeasonalFood sf) {
|
|
|
+ seasonalFoodMapper.insert(sf);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void delete(Long id) {
|
|
|
+ seasonalFoodMapper.deleteById(id);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/SeasonalFoodService.java
|
|
|
+git commit -m "feat(service): add FoodService/RecipeService/SeasonalFoodService CRUD"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: MealLogService — 饮食日志 + 营养统计
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/MealLogService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 MealLogService**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.MealLog;
|
|
|
+import com.etotem.cfc.mapper.MealLogMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class MealLogService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MealLogMapper mealLogMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 记录一餐
|
|
|
+ */
|
|
|
+ public MealLog create(MealLog logEntry) {
|
|
|
+ mealLogMapper.insert(logEntry);
|
|
|
+ return logEntry;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取用户最近 N 天的饮食日志
|
|
|
+ */
|
|
|
+ public List<MealLog> getRecentMeals(Long userId, int days) {
|
|
|
+ LocalDate since = LocalDate.now().minusDays(days);
|
|
|
+ return mealLogMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<MealLog>()
|
|
|
+ .eq(MealLog::getUserId, userId)
|
|
|
+ .ge(MealLog::getMealDate, java.sql.Date.valueOf(since))
|
|
|
+ .orderByDesc(MealLog::getMealDate)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取用户最近 N 天的营养摄入统计
|
|
|
+ */
|
|
|
+ public Map<String, Object> getNutritionSummary(Long userId, int days) {
|
|
|
+ List<MealLog> logs = getRecentMeals(userId, days);
|
|
|
+
|
|
|
+ BigDecimal sumCalories = BigDecimal.ZERO;
|
|
|
+ BigDecimal sumProtein = BigDecimal.ZERO;
|
|
|
+ BigDecimal sumFat = BigDecimal.ZERO;
|
|
|
+ BigDecimal sumCarbs = BigDecimal.ZERO;
|
|
|
+ BigDecimal sumFiber = BigDecimal.ZERO;
|
|
|
+
|
|
|
+ for (MealLog log : logs) {
|
|
|
+ if (log.getTotalCalories() != null) sumCalories = sumCalories.add(log.getTotalCalories());
|
|
|
+ if (log.getTotalProtein() != null) sumProtein = sumProtein.add(log.getTotalProtein());
|
|
|
+ if (log.getTotalFat() != null) sumFat = sumFat.add(log.getTotalFat());
|
|
|
+ if (log.getTotalCarbs() != null) sumCarbs = sumCarbs.add(log.getTotalCarbs());
|
|
|
+ if (log.getTotalFiber() != null) sumFiber = sumFiber.add(log.getTotalFiber());
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> summary = new LinkedHashMap<>();
|
|
|
+ int dayCount = Math.max(days, 1);
|
|
|
+ summary.put("days", logs.isEmpty() ? 0 : days);
|
|
|
+ summary.put("avgCalories", logs.isEmpty() ? 0 : sumCalories.divide(BigDecimal.valueOf(dayCount), 0, BigDecimal.ROUND_HALF_UP));
|
|
|
+ summary.put("avgProtein", logs.isEmpty() ? 0 : sumProtein.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
|
|
|
+ summary.put("avgFat", logs.isEmpty() ? 0 : sumFat.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
|
|
|
+ summary.put("avgCarbs", logs.isEmpty() ? 0 : sumCarbs.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
|
|
|
+ summary.put("avgFiber", logs.isEmpty() ? 0 : sumFiber.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
|
|
|
+ summary.put("totalMeals", logs.size());
|
|
|
+
|
|
|
+ return summary;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除日志
|
|
|
+ */
|
|
|
+ public void delete(Long id) {
|
|
|
+ mealLogMapper.deleteById(id);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MealLogService.java
|
|
|
+git commit -m "feat(service): add MealLogService with meal logging and 7-day nutrition summary"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: DTO 类 — RecommendationContext + MealRecommendResult
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationContext.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/MealRecommendResult.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 RecommendationContext.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import lombok.Data;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 食谱推荐上下文 — 聚合用户/健康/偏好/预算/饮食日志/候选食材
|
|
|
+ */
|
|
|
+@Data
|
|
|
+public class RecommendationContext {
|
|
|
+ private Long userId;
|
|
|
+ // 健康数据
|
|
|
+ private HealthReport latestReport;
|
|
|
+ private List<HealthIndicator> abnormalIndicators;
|
|
|
+ // 偏好预算
|
|
|
+ private Integer budgetMonthly;
|
|
|
+ private String tastePreference;
|
|
|
+ private String dietaryRestrictions;
|
|
|
+ private String cuisineStyle;
|
|
|
+ private Integer cookingAbility;
|
|
|
+ // 家庭偏好
|
|
|
+ private String familyTastePreferences;
|
|
|
+ // 近期摄入统计
|
|
|
+ private Map<String, Object> nutritionSummary;
|
|
|
+ // 候选集 (由 DB 过滤后填充)
|
|
|
+ private List<Food> candidateFoods;
|
|
|
+ private List<Recipe> candidateRecipes;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 MealRecommendResult.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 食谱推荐结果
|
|
|
+ */
|
|
|
+@Data
|
|
|
+public class MealRecommendResult {
|
|
|
+ private String weekPlan; // Dify 返回的周食谱方案 (JSON)
|
|
|
+ private Map<String, Object> nutritionSummary; // 营养素统计
|
|
|
+ private List<Food> replaceOptions; // 可替换食材 (一回换一菜用)
|
|
|
+ private String forbiddenHint; // 禁忌提示
|
|
|
+ private String nutritionTip; // 营养建议
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationContext.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/dto/MealRecommendResult.java
|
|
|
+git commit -m "feat(dto): add RecommendationContext and MealRecommendResult DTOs"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: MealRecommendService — 核心推荐引擎
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 MealRecommendService**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.MealRecommendResult;
|
|
|
+import com.etotem.cfc.dto.RecommendationContext;
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class MealRecommendService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthReportService healthReportService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FoodService foodService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RecipeService recipeService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private SeasonalFoodService seasonalFoodService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MealLogService mealLogService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ReportSurveyService reportSurveyService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 聚合用户上下文
|
|
|
+ */
|
|
|
+ public RecommendationContext aggregateContext(Long userId) {
|
|
|
+ RecommendationContext ctx = new RecommendationContext();
|
|
|
+ ctx.setUserId(userId);
|
|
|
+
|
|
|
+ // 1. 健康报告
|
|
|
+ HealthReport report = healthReportService.getLatestReport(userId);
|
|
|
+ ctx.setLatestReport(report);
|
|
|
+ if (report != null) {
|
|
|
+ ctx.setAbnormalIndicators(
|
|
|
+ healthReportService.getReportIndicators(report.getId()).stream()
|
|
|
+ .filter(i -> i.getStatus() != null && !"正常".equals(i.getStatus()))
|
|
|
+ .collect(Collectors.toList())
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 偏好预算 (从 user_nutrition_profile 或 user 表读取)
|
|
|
+ // TODO: 待 UserNutritionProfileService 实现后读取实际数据
|
|
|
+ ctx.setBudgetMonthly(2000); // 默认预算,后续替换
|
|
|
+ ctx.setTastePreference("清淡");
|
|
|
+ ctx.setDietaryRestrictions("无");
|
|
|
+ ctx.setCuisineStyle("中式");
|
|
|
+
|
|
|
+ // 3. 家庭口味偏好
|
|
|
+ // TODO: 从 user_nutrition_profile.family_taste_preferences 读取
|
|
|
+
|
|
|
+ // 4. 近期营养摄入
|
|
|
+ ctx.setNutritionSummary(mealLogService.getNutritionSummary(userId, 7));
|
|
|
+
|
|
|
+ // 5. 候选集过滤
|
|
|
+ filterCandidates(ctx);
|
|
|
+
|
|
|
+ return ctx;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * DB 候选集过滤(应季 + 预算 + 无过敏 + 健康标签匹配)
|
|
|
+ */
|
|
|
+ private void filterCandidates(RecommendationContext ctx) {
|
|
|
+ // 预算等级映射: 月预算/500 ≈ price_level 上限
|
|
|
+ int maxPriceLevel = 1;
|
|
|
+ if (ctx.getBudgetMonthly() != null) {
|
|
|
+ maxPriceLevel = Math.max(1, Math.min(5, ctx.getBudgetMonthly() / 500));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 当月应季食材 ID
|
|
|
+ List<Long> seasonalIds = seasonalFoodService.getCurrentMonthFoodIds();
|
|
|
+
|
|
|
+ // 查询应季 + 预算内食材
|
|
|
+ List<Food> allActive = foodService.listAll(null, "active");
|
|
|
+ List<Food> candidates = allActive.stream()
|
|
|
+ .filter(f -> seasonalIds.contains(f.getId()))
|
|
|
+ .filter(f -> f.getPriceLevel() != null && f.getPriceLevel() <= maxPriceLevel)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ ctx.setCandidateFoods(candidates.size() > 30 ? candidates.subList(0, 30) : candidates);
|
|
|
+
|
|
|
+ // 候选食谱
|
|
|
+ List<Recipe> recipeCandidates = recipeService.listByMealType(null);
|
|
|
+ ctx.setCandidateRecipes(recipeCandidates.size() > 15 ? recipeCandidates.subList(0, 15) : recipeCandidates);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一回换一菜 — 同品类随机替换
|
|
|
+ */
|
|
|
+ public Food findReplaceCandidate(Long currentFoodId, Long userId) {
|
|
|
+ RecommendationContext ctx = aggregateContext(userId);
|
|
|
+ Food current = foodService.getById(currentFoodId);
|
|
|
+ if (current == null) return null;
|
|
|
+
|
|
|
+ return ctx.getCandidateFoods().stream()
|
|
|
+ .filter(f -> !f.getId().equals(currentFoodId))
|
|
|
+ .filter(f -> current.getCategory() == null || current.getCategory().equals(f.getCategory()))
|
|
|
+ .filter(f -> f.getPriceLevel() != null && current.getPriceLevel() != null
|
|
|
+ && Math.abs(f.getPriceLevel() - current.getPriceLevel()) <= 1)
|
|
|
+ .findAny()
|
|
|
+ .orElse(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建 Dify inputs(供 Controller 调用 AI 时传入)
|
|
|
+ */
|
|
|
+ public Map<String, Object> buildDifyInputs(Long userId) {
|
|
|
+ RecommendationContext ctx = aggregateContext(userId);
|
|
|
+ Map<String, Object> inputs = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ // 用户摘要
|
|
|
+ Map<String, Object> userProfile = new LinkedHashMap<>();
|
|
|
+ userProfile.put("taste", ctx.getTastePreference());
|
|
|
+ userProfile.put("cuisine", ctx.getCuisineStyle());
|
|
|
+ userProfile.put("budget", ctx.getBudgetMonthly());
|
|
|
+ userProfile.put("dietaryRestrictions", ctx.getDietaryRestrictions());
|
|
|
+ inputs.put("user_profile", userProfile);
|
|
|
+
|
|
|
+ // 健康摘要
|
|
|
+ if (ctx.getLatestReport() != null) {
|
|
|
+ Map<String, Object> healthSummary = new LinkedHashMap<>();
|
|
|
+ HealthReport r = ctx.getLatestReport();
|
|
|
+ healthSummary.put("overallScore", r.getOverallScore());
|
|
|
+ healthSummary.put("gutType", r.getGutType());
|
|
|
+ healthSummary.put("abnormalIndicators",
|
|
|
+ ctx.getAbnormalIndicators() != null
|
|
|
+ ? ctx.getAbnormalIndicators().stream().map(i ->
|
|
|
+ Map.of("name", i.getIndicatorName(), "value", i.getIndicatorValue(),
|
|
|
+ "status", i.getStatus(), "refRange", i.getRefRange())).toList()
|
|
|
+ : List.of());
|
|
|
+ inputs.put("health_summary", healthSummary);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 近期摄入
|
|
|
+ inputs.put("recent_intake", ctx.getNutritionSummary() != null ? ctx.getNutritionSummary() : Map.of());
|
|
|
+
|
|
|
+ // 候选食材(简化为名称列表)
|
|
|
+ inputs.put("candidate_foods",
|
|
|
+ ctx.getCandidateFoods().stream()
|
|
|
+ .map(f -> Map.of("name", f.getName(), "category", f.getCategory(),
|
|
|
+ "priceLevel", f.getPriceLevel(), "nutritionTags", f.getNutritionTags()))
|
|
|
+ .toList());
|
|
|
+
|
|
|
+ // 候选食谱
|
|
|
+ inputs.put("candidate_recipes",
|
|
|
+ ctx.getCandidateRecipes().stream()
|
|
|
+ .map(r -> Map.of("name", r.getName(), "mealType", r.getMealType(),
|
|
|
+ "cookTime", r.getCookTime(), "nutritionTags", r.getNutritionTags()))
|
|
|
+ .toList());
|
|
|
+
|
|
|
+ return inputs;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** `aggregateContext` 中的偏好/预算读取标记了 TODO,待 `UserNutritionProfileService` 实现后替换硬编码值。当前硬编码默认值(预算 2000,口味清淡)确保 API 可测试。
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java
|
|
|
+git commit -m "feat(service): add MealRecommendService with DB filtering and Dify input building"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: MealRecommendController — REST API
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/MealRecommendController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 MealRecommendController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.dto.MealRecommendResult;
|
|
|
+import com.etotem.cfc.dto.RecommendationContext;
|
|
|
+import com.etotem.cfc.entity.Food;
|
|
|
+import com.etotem.cfc.service.*;
|
|
|
+import io.swagger.v3.oas.annotations.Operation;
|
|
|
+import io.swagger.v3.oas.annotations.tags.Tag;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 食谱推荐接口
|
|
|
+ */
|
|
|
+@Tag(name = "食谱推荐", description = "个性化食谱推荐与饮食记录")
|
|
|
+@Slf4j
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/meal")
|
|
|
+public class MealRecommendController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MealRecommendService mealRecommendService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MealLogService mealLogService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AIService aiService;
|
|
|
+
|
|
|
+ @Operation(summary = "获取食谱推荐")
|
|
|
+ @PostMapping("/recommend")
|
|
|
+ public Result<Map<String, Object>> recommend(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ try {
|
|
|
+ // 1. 聚合上下文 + 过滤候选
|
|
|
+ RecommendationContext ctx = mealRecommendService.aggregateContext(userId);
|
|
|
+ if (ctx.getCandidateFoods().isEmpty() && ctx.getCandidateRecipes().isEmpty()) {
|
|
|
+ return Result.error("暂无可用食材和食谱,请联系运营配置");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 构建 Dify inputs
|
|
|
+ String query = "请根据我的健康情况和候选食材,生成一份个性化的" +
|
|
|
+ (params.get("mealType") != null ? params.get("mealType") + " " : "每日")
|
|
|
+ + "食谱方案。格式要求:返回JSON包含dailyMeals数组(每餐含recipeName/reason/ingredients/tips)、forbiddenHint、nutritionTip。";
|
|
|
+ String conversationId = (String) params.get("conversationId");
|
|
|
+
|
|
|
+ Map<String, Object> inputs = mealRecommendService.buildDifyInputs(userId);
|
|
|
+ Map<String, Object> difyResp = aiService.sendNutritionMessage(
|
|
|
+ query, String.valueOf(userId), conversationId, inputs);
|
|
|
+ String answer = (String) difyResp.getOrDefault("answer", "");
|
|
|
+
|
|
|
+ // 3. 组装结果
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("answer", answer);
|
|
|
+ result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
|
|
|
+ result.put("replaceOptions", ctx.getCandidateFoods().size() > 5
|
|
|
+ ? ctx.getCandidateFoods().subList(0, 5) : ctx.getCandidateFoods());
|
|
|
+ result.put("nutritionSummary", ctx.getNutritionSummary());
|
|
|
+
|
|
|
+ return Result.success(result);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("食谱推荐失败: userId={}", userId, e);
|
|
|
+ return Result.error("食谱推荐失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "一回换一菜 — 替换指定食材")
|
|
|
+ @PostMapping("/replace")
|
|
|
+ public Result<Food> replace(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ Long foodId = params.get("foodId") != null
|
|
|
+ ? Long.valueOf(params.get("foodId").toString()) : null;
|
|
|
+ if (foodId == null) {
|
|
|
+ return Result.error("foodId不能为空");
|
|
|
+ }
|
|
|
+ Food replacement = mealRecommendService.findReplaceCandidate(foodId, userId);
|
|
|
+ if (replacement == null) {
|
|
|
+ return Result.error("没有可替换的食材");
|
|
|
+ }
|
|
|
+ return Result.success(replacement);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "记录饮食日志")
|
|
|
+ @PostMapping("/log")
|
|
|
+ public Result<Map<String, Object>> createLog(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ // 前端传: { childId?, mealType, mealDate, foods (JSON), note? }
|
|
|
+ com.etotem.cfc.entity.MealLog logEntry = new com.etotem.cfc.entity.MealLog();
|
|
|
+ logEntry.setUserId(userId);
|
|
|
+ if (params.get("childId") != null) {
|
|
|
+ logEntry.setChildId(Long.valueOf(params.get("childId").toString()));
|
|
|
+ }
|
|
|
+ logEntry.setMealType((String) params.get("mealType"));
|
|
|
+ logEntry.setFoods(params.get("foods") != null ? params.get("foods").toString() : "[]");
|
|
|
+ logEntry.setNote((String) params.get("note"));
|
|
|
+ // 解析日期
|
|
|
+ Object dateObj = params.get("mealDate");
|
|
|
+ if (dateObj != null) {
|
|
|
+ try {
|
|
|
+ java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
|
|
|
+ logEntry.setMealDate(sdf.parse(dateObj.toString()));
|
|
|
+ } catch (Exception e) {
|
|
|
+ logEntry.setMealDate(new Date());
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ logEntry.setMealDate(new Date());
|
|
|
+ }
|
|
|
+ // TODO: 营养素自动计算 — 根据 foods JSON 中的 foodId 查 Food 表累计
|
|
|
+ logEntry.setTotalCalories(null);
|
|
|
+ logEntry.setTotalProtein(null);
|
|
|
+ logEntry.setTotalFat(null);
|
|
|
+ logEntry.setTotalCarbs(null);
|
|
|
+ logEntry.setTotalFiber(null);
|
|
|
+
|
|
|
+ mealLogService.create(logEntry);
|
|
|
+
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("id", logEntry.getId());
|
|
|
+ result.put("message", "记录成功");
|
|
|
+ return Result.success(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "获取近期饮食日志")
|
|
|
+ @PostMapping("/logs")
|
|
|
+ public Result<List<com.etotem.cfc.entity.MealLog>> getLogs(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ int days = params.get("days") != null
|
|
|
+ ? Integer.parseInt(params.get("days").toString()) : 7;
|
|
|
+ return Result.success(mealLogService.getRecentMeals(userId, days));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "获取营养摄入统计")
|
|
|
+ @PostMapping("/nutrition-summary")
|
|
|
+ public Result<Map<String, Object>> nutritionSummary(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ int days = params.get("days") != null
|
|
|
+ ? Integer.parseInt(params.get("days").toString()) : 7;
|
|
|
+ return Result.success(mealLogService.getNutritionSummary(userId, days));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/MealRecommendController.java
|
|
|
+git commit -m "feat(controller): add MealRecommendController with recommend/replace/log APIs"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: Admin 后端 Controller — 食材/食谱/应季 CRUD
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSeasonalController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 AdminFoodController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Food;
|
|
|
+import com.etotem.cfc.service.FoodService;
|
|
|
+import io.swagger.v3.oas.annotations.Operation;
|
|
|
+import io.swagger.v3.oas.annotations.tags.Tag;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Tag(name = "管理端-食材管理")
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/foods")
|
|
|
+public class AdminFoodController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FoodService foodService;
|
|
|
+
|
|
|
+ @Operation(summary = "食材列表")
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<Food>> list(@RequestBody Map<String, Object> params) {
|
|
|
+ String category = (String) params.get("category");
|
|
|
+ return Result.success(foodService.listAll(category, "active"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "新增食材")
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<String> create(@RequestBody Food food) {
|
|
|
+ foodService.create(food);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "更新食材")
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<String> update(@RequestBody Food food) {
|
|
|
+ foodService.update(food);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "删除食材")
|
|
|
+ @PostMapping("/delete")
|
|
|
+ public Result<String> delete(@RequestBody Map<String, Object> params) {
|
|
|
+ Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
|
|
|
+ if (id == null) return Result.error("id不能为空");
|
|
|
+ foodService.delete(id);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 AdminRecipeController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Recipe;
|
|
|
+import com.etotem.cfc.service.RecipeService;
|
|
|
+import io.swagger.v3.oas.annotations.Operation;
|
|
|
+import io.swagger.v3.oas.annotations.tags.Tag;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Tag(name = "管理端-食谱管理")
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/recipes")
|
|
|
+public class AdminRecipeController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RecipeService recipeService;
|
|
|
+
|
|
|
+ @Operation(summary = "食谱列表")
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<Recipe>> list(@RequestBody Map<String, Object> params) {
|
|
|
+ String mealType = (String) params.get("mealType");
|
|
|
+ return Result.success(recipeService.listByMealType(mealType));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "新增食谱")
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<String> create(@RequestBody Recipe recipe) {
|
|
|
+ recipeService.create(recipe);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "更新食谱")
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<String> update(@RequestBody Recipe recipe) {
|
|
|
+ recipeService.update(recipe);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "删除食谱")
|
|
|
+ @PostMapping("/delete")
|
|
|
+ public Result<String> delete(@RequestBody Map<String, Object> params) {
|
|
|
+ Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
|
|
|
+ if (id == null) return Result.error("id不能为空");
|
|
|
+ recipeService.delete(id);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 AdminSeasonalController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.SeasonalFood;
|
|
|
+import com.etotem.cfc.service.SeasonalFoodService;
|
|
|
+import io.swagger.v3.oas.annotations.Operation;
|
|
|
+import io.swagger.v3.oas.annotations.tags.Tag;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Tag(name = "管理端-应季食材配置")
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/seasonal")
|
|
|
+public class AdminSeasonalController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private SeasonalFoodService seasonalFoodService;
|
|
|
+
|
|
|
+ @Operation(summary = "应季列表")
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<SeasonalFood>> list(@RequestBody Map<String, Object> params) {
|
|
|
+ Integer month = params.get("month") != null
|
|
|
+ ? Integer.valueOf(params.get("month").toString()) : null;
|
|
|
+ if (month != null) {
|
|
|
+ return Result.success(seasonalFoodService.listByMonth(month));
|
|
|
+ }
|
|
|
+ // 默认返回当前月份
|
|
|
+ return Result.success(seasonalFoodService.listByMonth(java.time.LocalDate.now().getMonthValue()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "新增应季映射")
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<String> create(@RequestBody SeasonalFood seasonalFood) {
|
|
|
+ seasonalFoodService.create(seasonalFood);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "删除应季映射")
|
|
|
+ @PostMapping("/delete")
|
|
|
+ public Result<String> delete(@RequestBody Map<String, Object> params) {
|
|
|
+ Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
|
|
|
+ if (id == null) return Result.error("id不能为空");
|
|
|
+ seasonalFoodService.delete(id);
|
|
|
+ return Result.success("ok");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证编译**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile -q`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSeasonalController.java
|
|
|
+git commit -m "feat(admin): add admin CRUD controllers for foods/recipes/seasonal"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10: 前端 — 小程序食谱推荐页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-frontend/pages/meal/recommend.vue`
|
|
|
+- Create: `cfc-frontend/pages/meal/recipe-detail.vue`
|
|
|
+- Create: `cfc-frontend/pages/meal/meal-log.vue`
|
|
|
+- Create: `cfc-frontend/pages/meal/preference.vue`
|
|
|
+- Modify: `cfc-frontend/pages.json` (注册新页面)
|
|
|
+- Modify: `cfc-frontend/utils/api.js` (新增 API 封装)
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 pages.json 注册页面**
|
|
|
+
|
|
|
+定位到 `cfc-frontend/pages.json`,在 `pages` 数组中追加:
|
|
|
+
|
|
|
+```json
|
|
|
+ ,{
|
|
|
+ "path": "pages/meal/recommend",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "食谱推荐"
|
|
|
+ }
|
|
|
+ },{
|
|
|
+ "path": "pages/meal/recipe-detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "食谱详情"
|
|
|
+ }
|
|
|
+ },{
|
|
|
+ "path": "pages/meal/meal-log",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "饮食记录"
|
|
|
+ }
|
|
|
+ },{
|
|
|
+ "path": "pages/meal/preference",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "饮食偏好设置"
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 api.js 中新增 API 封装**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 食谱推荐
|
|
|
+export const getMealRecommend = (data) => service.post('/api/meal/recommend', data)
|
|
|
+export const replaceFood = (data) => service.post('/api/meal/replace', data)
|
|
|
+export const createMealLog = (data) => service.post('/api/meal/log', data)
|
|
|
+export const getMealLogs = (data) => service.post('/api/meal/logs', data)
|
|
|
+export const getNutritionSummary = (data) => service.post('/api/meal/nutrition-summary', data)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 recommend.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="container">
|
|
|
+ <view class="header">
|
|
|
+ <text class="title">本周食谱推荐</text>
|
|
|
+ <button class="refresh-btn" @click="loadRecommend">刷新推荐</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 营养概览 -->
|
|
|
+ <view class="nutrition-card" v-if="nutritionSummary">
|
|
|
+ <text class="card-title">近7日营养摄入</text>
|
|
|
+ <view class="nutrition-grid">
|
|
|
+ <view class="nut-item">
|
|
|
+ <text class="nut-value">{{ nutritionSummary.avgCalories || '--' }}</text>
|
|
|
+ <text class="nut-label">日均热量(kcal)</text>
|
|
|
+ </view>
|
|
|
+ <view class="nut-item">
|
|
|
+ <text class="nut-value">{{ nutritionSummary.avgProtein || '--' }}</text>
|
|
|
+ <text class="nut-label">蛋白质(g)</text>
|
|
|
+ </view>
|
|
|
+ <view class="nut-item">
|
|
|
+ <text class="nut-value">{{ nutritionSummary.avgFiber || '--' }}</text>
|
|
|
+ <text class="nut-label">膳食纤维(g)</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- AI 推荐结果 -->
|
|
|
+ <view class="recommend-content" v-if="answer">
|
|
|
+ <rich-text :nodes="answer"></rich-text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 可替换食材 -->
|
|
|
+ <view class="replace-section" v-if="replaceOptions.length > 0">
|
|
|
+ <text class="section-title">可替换食材</text>
|
|
|
+ <scroll-view scroll-x class="replace-scroll">
|
|
|
+ <view class="replace-item" v-for="food in replaceOptions" :key="food.id"
|
|
|
+ @click="doReplace(food.id)">
|
|
|
+ <text class="food-name">{{ food.name }}</text>
|
|
|
+ <text class="food-price" v-if="food.priceLevel">价格等级{{ food.priceLevel }}</text>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 营养建议 -->
|
|
|
+ <view class="tip-card" v-if="tipText">
|
|
|
+ <text class="tip-icon">💡</text>
|
|
|
+ <text class="tip-text">{{ tipText }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="loading" v-if="loading">
|
|
|
+ <text>AI 正在为你生成食谱...</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getMealRecommend, replaceFood } from '@/utils/api'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: false,
|
|
|
+ answer: '',
|
|
|
+ nutritionSummary: null,
|
|
|
+ replaceOptions: [],
|
|
|
+ tipText: '',
|
|
|
+ conversationId: ''
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad() {
|
|
|
+ this.loadRecommend()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadRecommend() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const res = await getMealRecommend({
|
|
|
+ conversationId: this.conversationId
|
|
|
+ })
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.answer = res.data.answer
|
|
|
+ this.nutritionSummary = res.data.nutritionSummary
|
|
|
+ this.replaceOptions = res.data.replaceOptions || []
|
|
|
+ this.conversationId = res.data.conversationId || ''
|
|
|
+ // 解析 tip(从回答中提取最后一段)
|
|
|
+ const parts = res.data.answer.split('\n')
|
|
|
+ this.tipText = parts[parts.length - 1] || ''
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '推荐失败', icon: 'none' })
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async doReplace(foodId) {
|
|
|
+ try {
|
|
|
+ const res = await replaceFood({ foodId })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ uni.showToast({ title: `已替换为${res.data.name}`, icon: 'success' })
|
|
|
+ // 刷新推荐
|
|
|
+ this.loadRecommend()
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '替换失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
|
|
|
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
|
|
+.title { font-size: 36rpx; font-weight: bold; color: #333; }
|
|
|
+.refresh-btn { padding: 10rpx 30rpx; background: #F97316; color: white; border-radius: 30rpx; font-size: 28rpx; }
|
|
|
+.nutrition-card { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
|
|
|
+.card-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; display: block; }
|
|
|
+.nutrition-grid { display: flex; justify-content: space-around; }
|
|
|
+.nut-item { text-align: center; }
|
|
|
+.nut-value { font-size: 36rpx; font-weight: bold; color: #F97316; display: block; }
|
|
|
+.nut-label { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
|
|
|
+.recommend-content { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
|
|
+.replace-section { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
|
|
+.section-title { font-size: 28rpx; font-weight: bold; margin-bottom: 16rpx; display: block; }
|
|
|
+.replace-scroll { white-space: nowrap; }
|
|
|
+.replace-item { display: inline-flex; flex-direction: column; align-items: center; padding: 16rpx 24rpx; margin-right: 16rpx; background: #FFF7ED; border-radius: 12rpx; min-width: 140rpx; }
|
|
|
+.food-name { font-size: 28rpx; color: #333; }
|
|
|
+.food-price { font-size: 22rpx; color: #F97316; margin-top: 8rpx; }
|
|
|
+.tip-card { display: flex; align-items: center; background: #E8F5E9; border-radius: 16rpx; padding: 20rpx; margin-bottom: 20rpx; }
|
|
|
+.tip-icon { font-size: 36rpx; margin-right: 16rpx; }
|
|
|
+.tip-text { font-size: 26rpx; color: #2E7D32; flex: 1; }
|
|
|
+.loading { text-align: center; padding: 60rpx 0; color: #999; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 创建 recipe-detail.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="container">
|
|
|
+ <view class="recipe-header">
|
|
|
+ <text class="recipe-name">{{ recipe.name }}</text>
|
|
|
+ <text class="recipe-meta">{{ recipe.mealType }} · {{ recipe.cookTime }}分钟 · {{ recipe.difficulty }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="section" v-if="recipe.description">
|
|
|
+ <text class="section-title">简介</text>
|
|
|
+ <text class="section-content">{{ recipe.description }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="section">
|
|
|
+ <text class="section-title">食材清单</text>
|
|
|
+ <view class="ingredient-item" v-for="(item, i) in ingredients" :key="i">
|
|
|
+ <text class="ingr-name">{{ item.name }}</text>
|
|
|
+ <text class="ingr-amount">{{ item.amount }}{{ item.unit }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="section" v-if="steps.length > 0">
|
|
|
+ <text class="section-title">烹饪步骤</text>
|
|
|
+ <view class="step-item" v-for="(step, i) in steps" :key="i">
|
|
|
+ <text class="step-num">{{ i + 1 }}</text>
|
|
|
+ <text class="step-text">{{ step }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="section">
|
|
|
+ <text class="section-title">营养分析</text>
|
|
|
+ <view class="nutrition-grid">
|
|
|
+ <view class="nut-item">
|
|
|
+ <text class="nut-value">{{ recipe.totalCalories || '--' }}</text>
|
|
|
+ <text class="nut-label">热量(kcal)</text>
|
|
|
+ </view>
|
|
|
+ <view class="nut-item">
|
|
|
+ <text class="nut-value">{{ recipe.totalProtein || '--' }}</text>
|
|
|
+ <text class="nut-label">蛋白质(g)</text>
|
|
|
+ </view>
|
|
|
+ <view class="nut-item">
|
|
|
+ <text class="nut-value">{{ recipe.totalFiber || '--' }}</text>
|
|
|
+ <text class="nut-label">纤维(g)</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ recipe: {},
|
|
|
+ ingredients: [],
|
|
|
+ steps: []
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ // 从路由参数或本地存储获取食谱详情
|
|
|
+ if (options.data) {
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(decodeURIComponent(options.data))
|
|
|
+ this.recipe = data
|
|
|
+ if (data.ingredients) {
|
|
|
+ this.ingredients = typeof data.ingredients === 'string'
|
|
|
+ ? JSON.parse(data.ingredients) : data.ingredients
|
|
|
+ }
|
|
|
+ if (data.steps) {
|
|
|
+ this.steps = typeof data.steps === 'string'
|
|
|
+ ? JSON.parse(data.steps) : data.steps
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
|
|
|
+.recipe-header { margin-bottom: 24rpx; }
|
|
|
+.recipe-name { font-size: 40rpx; font-weight: bold; color: #333; display: block; }
|
|
|
+.recipe-meta { font-size: 26rpx; color: #999; margin-top: 12rpx; display: block; }
|
|
|
+.section { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
|
|
+.section-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; display: block; }
|
|
|
+.section-content { font-size: 26rpx; color: #666; line-height: 1.6; }
|
|
|
+.ingredient-item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
|
|
|
+.ingr-name { font-size: 28rpx; color: #333; }
|
|
|
+.ingr-amount { font-size: 26rpx; color: #999; }
|
|
|
+.step-item { display: flex; margin-bottom: 16rpx; }
|
|
|
+.step-num { width: 40rpx; height: 40rpx; background: #F97316; color: white; border-radius: 50%; text-align: center; line-height: 40rpx; font-size: 24rpx; margin-right: 16rpx; flex-shrink: 0; }
|
|
|
+.step-text { font-size: 26rpx; color: #666; line-height: 1.6; flex: 1; }
|
|
|
+.nutrition-grid { display: flex; justify-content: space-around; }
|
|
|
+.nut-item { text-align: center; }
|
|
|
+.nut-value { font-size: 32rpx; font-weight: bold; color: #F97316; display: block; }
|
|
|
+.nut-label { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 创建 meal-log.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="container">
|
|
|
+ <view class="log-form">
|
|
|
+ <picker :range="mealTypes" @change="onMealTypeChange">
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">餐次</text>
|
|
|
+ <text class="form-value">{{ form.mealType || '请选择' }}</text>
|
|
|
+ </view>
|
|
|
+ </picker>
|
|
|
+ <picker mode="date" @change="onDateChange">
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">日期</text>
|
|
|
+ <text class="form-value">{{ form.mealDate }}</text>
|
|
|
+ </view>
|
|
|
+ </picker>
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">食物</text>
|
|
|
+ <input class="form-input" v-model="foodText" placeholder="输入食物名称,逗号分隔" />
|
|
|
+ </view>
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="form-label">备注</text>
|
|
|
+ <input class="form-input" v-model="form.note" placeholder="可选备注" />
|
|
|
+ </view>
|
|
|
+ <button class="submit-btn" @click="submitLog">保存记录</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="history">
|
|
|
+ <text class="section-title">近期记录</text>
|
|
|
+ <view class="log-item" v-for="log in logs" :key="log.id">
|
|
|
+ <text class="log-meal">{{ log.mealType }} · {{ formatDate(log.mealDate) }}</text>
|
|
|
+ <text class="log-foods">{{ log.foods }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { createMealLog, getMealLogs } from '@/utils/api'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ mealTypes: ['早餐', '午餐', '晚餐', '加餐'],
|
|
|
+ form: { mealType: '', mealDate: '', note: '' },
|
|
|
+ foodText: '',
|
|
|
+ logs: []
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad() {
|
|
|
+ this.form.mealDate = new Date().toISOString().slice(0, 10)
|
|
|
+ this.loadLogs()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ onMealTypeChange(e) { this.form.mealType = this.mealTypes[e.detail.value] },
|
|
|
+ onDateChange(e) { this.form.mealDate = e.detail.value },
|
|
|
+ formatDate(d) { return d ? d.slice(0, 10) : '' },
|
|
|
+ async loadLogs() {
|
|
|
+ try {
|
|
|
+ const res = await getMealLogs({ days: 7 })
|
|
|
+ if (res.code === 200) this.logs = res.data
|
|
|
+ } catch (e) {}
|
|
|
+ },
|
|
|
+ async submitLog() {
|
|
|
+ if (!this.form.mealType || !this.foodText) {
|
|
|
+ uni.showToast({ title: '请完整填写', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const foods = this.foodText.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
|
|
+ .map(name => ({ name }))
|
|
|
+ try {
|
|
|
+ await createMealLog({
|
|
|
+ mealType: this.form.mealType,
|
|
|
+ mealDate: this.form.mealDate,
|
|
|
+ foods: JSON.stringify(foods),
|
|
|
+ note: this.form.note
|
|
|
+ })
|
|
|
+ uni.showToast({ title: '记录成功', icon: 'success' })
|
|
|
+ this.foodText = ''
|
|
|
+ this.form.note = ''
|
|
|
+ this.loadLogs()
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '保存失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
|
|
|
+.log-form { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
|
|
+.form-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; }
|
|
|
+.form-label { font-size: 28rpx; color: #333; }
|
|
|
+.form-value { font-size: 28rpx; color: #999; }
|
|
|
+.form-input { flex: 1; text-align: right; font-size: 28rpx; color: #333; }
|
|
|
+.submit-btn { margin-top: 30rpx; background: #F97316; color: white; border-radius: 40rpx; }
|
|
|
+.history { background: white; border-radius: 16rpx; padding: 24rpx; }
|
|
|
+.section-title { font-size: 28rpx; font-weight: bold; margin-bottom: 16rpx; display: block; }
|
|
|
+.log-item { padding: 16rpx 0; border-bottom: 1rpx solid #f5f5f5; }
|
|
|
+.log-meal { font-size: 26rpx; color: #333; display: block; }
|
|
|
+.log-foods { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 创建 preference.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="container">
|
|
|
+ <view class="form-card">
|
|
|
+ <text class="card-title">饮食偏好设置</text>
|
|
|
+
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="label">月食品预算(元)</text>
|
|
|
+ <input class="input" type="number" v-model="form.budgetMonthly" placeholder="输入预算" />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="label">口味偏好</text>
|
|
|
+ <view class="tag-group">
|
|
|
+ <text v-for="t in tasteOptions" :key="t"
|
|
|
+ :class="['tag', form.taste === t ? 'active' : '']"
|
|
|
+ @click="form.taste = t">{{ t }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="label">偏好菜系</text>
|
|
|
+ <picker :range="cuisineOptions" @change="e => form.cuisine = cuisineOptions[e.detail.value]">
|
|
|
+ <text class="input">{{ form.cuisine || '请选择' }}</text>
|
|
|
+ </picker>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-item">
|
|
|
+ <text class="label">烹饪能力</text>
|
|
|
+ <picker :range="cookOptions" @change="e => form.cookingAbility = cookOptions[e.detail.value]">
|
|
|
+ <text class="input">{{ form.cookingAbility || '请选择' }}</text>
|
|
|
+ </picker>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <button class="save-btn" @click="saveProfile">保存设置</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ tasteOptions: ['清淡', '辛辣', '甜', '咸', '酸', '无偏好'],
|
|
|
+ cuisineOptions: ['中式', '西式', '日式', '韩式', '东南亚', '无偏好'],
|
|
|
+ cookOptions: ['简单(煮/蒸)', '中等(炒/煎)', '高级(烘焙/炖)'],
|
|
|
+ form: { budgetMonthly: 2000, taste: '清淡', cuisine: '', cookingAbility: '简单(煮/蒸)' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async saveProfile() {
|
|
|
+ // TODO: 调用用户偏好保存 API(需实现 UserNutritionProfileController)
|
|
|
+ uni.showToast({ title: '保存成功', icon: 'success' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
|
|
|
+.form-card { background: white; border-radius: 16rpx; padding: 24rpx; }
|
|
|
+.card-title { font-size: 32rpx; font-weight: bold; color: #333; margin-bottom: 24rpx; display: block; }
|
|
|
+.form-item { margin-bottom: 24rpx; }
|
|
|
+.label { font-size: 28rpx; color: #333; margin-bottom: 12rpx; display: block; }
|
|
|
+.input { font-size: 28rpx; color: #333; border: 1rpx solid #eee; border-radius: 12rpx; padding: 16rpx; }
|
|
|
+.tag-group { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
|
|
+.tag { padding: 12rpx 24rpx; border-radius: 30rpx; font-size: 26rpx; background: #f5f5f5; color: #666; }
|
|
|
+.tag.active { background: #F97316; color: white; }
|
|
|
+.save-btn { margin-top: 40rpx; background: #F97316; color: white; border-radius: 40rpx; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages.json \
|
|
|
+ cfc-frontend/utils/api.js \
|
|
|
+ cfc-frontend/pages/meal/recommend.vue \
|
|
|
+ cfc-frontend/pages/meal/recipe-detail.vue \
|
|
|
+ cfc-frontend/pages/meal/meal-log.vue \
|
|
|
+ cfc-frontend/pages/meal/preference.vue
|
|
|
+git commit -m "feat(mp): add meal recommendation and logging pages"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 11: Web 管理端 — 食材/食谱/应季管理
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/foods/index.vue`
|
|
|
+- Create: `cfc-web/src/views/admin/recipes/index.vue`
|
|
|
+- Create: `cfc-web/src/views/admin/seasonal/index.vue`
|
|
|
+- Modify: `cfc-web/src/router/index.js` (注册新路由)
|
|
|
+- Modify: `cfc-web/src/utils/api.js` (新增 API)
|
|
|
+
|
|
|
+- [ ] **Step 1: 注册管理端路由**
|
|
|
+
|
|
|
+在 `cfc-web/src/router/index.js` 中,`adminRoutes` 数组内追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ {
|
|
|
+ path: '/admin/foods',
|
|
|
+ component: () => import('@/views/admin/foods/index'),
|
|
|
+ meta: { title: '食材管理', roles: ['admin'] }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ path: '/admin/recipes',
|
|
|
+ component: () => import('@/views/admin/recipes/index'),
|
|
|
+ meta: { title: '食谱管理', roles: ['admin'] }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ path: '/admin/seasonal',
|
|
|
+ component: () => import('@/views/admin/seasonal/index'),
|
|
|
+ meta: { title: '应季食材配置', roles: ['admin'] }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 新增 API 封装**
|
|
|
+
|
|
|
+在 `cfc-web/src/utils/api.js` 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 食材管理 (FoodService REST API — 需实现 AdminFoodController)
|
|
|
+export const getFoodList = (params) => service.post('/api/admin/foods/list', params)
|
|
|
+export const createFood = (data) => service.post('/api/admin/foods/create', data)
|
|
|
+export const updateFood = (data) => service.post('/api/admin/foods/update', data)
|
|
|
+export const deleteFood = (id) => service.post('/api/admin/foods/delete', { id })
|
|
|
+
|
|
|
+// 食谱管理
|
|
|
+export const getRecipeList = (params) => service.post('/api/admin/recipes/list', params)
|
|
|
+export const createRecipe = (data) => service.post('/api/admin/recipes/create', data)
|
|
|
+export const updateRecipe = (data) => service.post('/api/admin/recipes/update', data)
|
|
|
+export const deleteRecipe = (id) => service.post('/api/admin/recipes/delete', { id })
|
|
|
+
|
|
|
+// 应季食材
|
|
|
+export const getSeasonalList = (params) => service.post('/api/admin/seasonal/list', params)
|
|
|
+export const createSeasonal = (data) => service.post('/api/admin/seasonal/create', data)
|
|
|
+export const deleteSeasonal = (id) => service.post('/api/admin/seasonal/delete', { id })
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3~5: 创建 CRUD 页面**
|
|
|
+
|
|
|
+每个管理页面为标准的 Element UI 表格 CRUD(以 foods/index.vue 为例):
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span>食材管理</span>
|
|
|
+ <el-button type="primary" size="small" style="float:right" @click="showDialog = true">新增食材</el-button>
|
|
|
+ </div>
|
|
|
+ <el-table :data="list" border stripe>
|
|
|
+ <el-table-column prop="name" label="名称" width="150" />
|
|
|
+ <el-table-column prop="category" label="分类" width="100" />
|
|
|
+ <el-table-column prop="calories" label="热量(kcal)" width="100" />
|
|
|
+ <el-table-column prop="protein" label="蛋白质(g)" width="100" />
|
|
|
+ <el-table-column prop="fiber" label="纤维(g)" width="100" />
|
|
|
+ <el-table-column prop="priceLevel" label="价格等级" width="90" />
|
|
|
+ <el-table-column prop="nutritionTags" label="营养标签" min-width="200">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-tag v-for="tag in parseTags(row.nutritionTags)" :key="tag" size="mini" style="margin-right:4px">{{ tag }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="150" fixed="right">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-button size="mini" @click="editRow(row)">编辑</el-button>
|
|
|
+ <el-button size="mini" type="danger" @click="deleteRow(row.id)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 新增/编辑对话框 -->
|
|
|
+ <el-dialog :visible.sync="showDialog" :title="isEdit ? '编辑食材' : '新增食材'" width="600px">
|
|
|
+ <el-form :model="form" label-width="100px">
|
|
|
+ <el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
|
|
+ <el-form-item label="分类">
|
|
|
+ <el-select v-model="form.category">
|
|
|
+ <el-option v-for="c in categories" :key="c" :label="c" :value="c" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="8"><el-form-item label="热量"><el-input v-model="form.calories" /></el-form-item></el-col>
|
|
|
+ <el-col :span="8"><el-form-item label="蛋白质"><el-input v-model="form.protein" /></el-form-item></el-col>
|
|
|
+ <el-col :span="8"><el-form-item label="纤维"><el-input v-model="form.fiber" /></el-form-item></el-col>
|
|
|
+ </el-row>
|
|
|
+ <el-form-item label="价格等级(1-5)">
|
|
|
+ <el-rate v-model="form.priceLevel" :max="5" show-text />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="营养标签">
|
|
|
+ <el-select v-model="form.nutritionTags" multiple filterable allow-create default-first-option>
|
|
|
+ <el-option v-for="t in tagOptions" :key="t" :label="t" :value="t" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <span slot="footer">
|
|
|
+ <el-button @click="showDialog = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="saveRow">保存</el-button>
|
|
|
+ </span>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getFoodList, createFood, updateFood, deleteFood } from '@/api'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ list: [], showDialog: false, isEdit: false,
|
|
|
+ categories: ['蔬菜', '水果', '肉禽', '水产', '蛋奶', '豆类', '谷物', '调味', '其他'],
|
|
|
+ tagOptions: ['高纤维', '高蛋白', '维生素B12', '维生素C', '益生元', '低脂', '低糖', '高铁', '高钙'],
|
|
|
+ form: { name: '', category: '', calories: null, protein: null, fiber: null, priceLevel: 3, nutritionTags: [] }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() { this.loadData() },
|
|
|
+ methods: {
|
|
|
+ parseTags(t) { return t ? (Array.isArray(t) ? t : JSON.parse(t)) : [] },
|
|
|
+ async loadData() {
|
|
|
+ const res = await getFoodList({})
|
|
|
+ if (res.code === 200) this.list = res.data
|
|
|
+ },
|
|
|
+ editRow(row) { this.form = { ...row }; this.isEdit = true; this.showDialog = true },
|
|
|
+ async saveRow() {
|
|
|
+ const data = { ...this.form }
|
|
|
+ data.nutritionTags = Array.isArray(data.nutritionTags) ? JSON.stringify(data.nutritionTags) : data.nutritionTags
|
|
|
+ if (this.isEdit) await updateFood(data); else await createFood(data)
|
|
|
+ this.$message.success('保存成功')
|
|
|
+ this.showDialog = false; this.isEdit = false
|
|
|
+ this.loadData()
|
|
|
+ },
|
|
|
+ async deleteRow(id) {
|
|
|
+ await this.$confirm('确认删除?')
|
|
|
+ await deleteFood(id)
|
|
|
+ this.$message.success('删除成功')
|
|
|
+ this.loadData()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+食谱管理页面和应季食材配置页面类似(CRUD 模式),不再重复完整代码。可参考 foods/index.vue 的结构实现。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/router/index.js \
|
|
|
+ cfc-web/src/utils/api.js \
|
|
|
+ cfc-web/src/views/admin/foods/index.vue \
|
|
|
+ cfc-web/src/views/admin/recipes/index.vue \
|
|
|
+ cfc-web/src/views/admin/seasonal/index.vue
|
|
|
+git commit -m "feat(web): add admin food/recipe/seasonal management pages"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 12: Dify System Prompt 扩展
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: Dify 应用 "CFC 精准营养助手" 的 System Prompt(需手动登录 http://dify.bianwooyou.cn 操作)
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 Dify System Prompt 末尾追加食谱推荐变量**
|
|
|
+
|
|
|
+在现有 Prompt 末尾追加:
|
|
|
+
|
|
|
+```
|
|
|
+## 6. 食谱推荐(新增)
|
|
|
+
|
|
|
+当用户请求食谱推荐时,你将接收到以下输入变量:
|
|
|
+
|
|
|
+### {{user_profile}}
|
|
|
+JSON: { taste, cuisine, budget, dietaryRestrictions }
|
|
|
+- budget: 月食品预算(元)
|
|
|
+- taste: 口味偏好(清淡/辛辣等)
|
|
|
+- dietaryRestrictions: 饮食限制(过敏原等)
|
|
|
+
|
|
|
+### {{health_summary}}
|
|
|
+JSON: { overallScore, gutType, abnormalIndicators: [...] }
|
|
|
+- gutType: 肠型(拟杆菌型/普氏菌型/混合型)
|
|
|
+- abnormalIndicators: 异常指标列表 [{ name, value, status, refRange }]
|
|
|
+
|
|
|
+### {{recent_intake}}
|
|
|
+JSON: { avgCalories, avgProtein, avgFiber, totalMeals }
|
|
|
+- 近7天营养摄入均值
|
|
|
+- 如果 totalMeals 为 0,说明用户无打卡记录,忽略此输入
|
|
|
+
|
|
|
+### {{candidate_foods}}
|
|
|
+Array: [{ name, category, priceLevel, nutritionTags }]
|
|
|
+- 已按用户预算和当月应季筛选后的候选食材
|
|
|
+- 你的输出必须基于这些食材,不能推荐候选列表外的食材
|
|
|
+
|
|
|
+### {{candidate_recipes}}
|
|
|
+Array: [{ name, mealType, cookTime, nutritionTags }]
|
|
|
+- 可选预设食谱,可选用或参考其结构设计新食谱
|
|
|
+
|
|
|
+### {{meal_type}}
|
|
|
+String: "breakfast" / "lunch" / "dinner" / "snack" / null(全部推荐)
|
|
|
+
|
|
|
+输出要求:
|
|
|
+返回结构化 JSON(非 Markdown)。格式:
|
|
|
+{
|
|
|
+ "dailyMeals": [
|
|
|
+ {
|
|
|
+ "day": "周一",
|
|
|
+ "meals": [
|
|
|
+ {
|
|
|
+ "type": "早餐",
|
|
|
+ "recipeName": "燕麦蓝莓粥+水煮蛋",
|
|
|
+ "reason": "高纤维启动肠道,适合拟杆菌型",
|
|
|
+ "ingredients": [{"name":"燕麦","amount":"50","unit":"g"}],
|
|
|
+ "tips": "燕麦提前浸泡口感更好"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "forbiddenHint": "需避免的食物说明",
|
|
|
+ "nutritionTip": "营养建议",
|
|
|
+ "tags": ["拟杆菌型","高纤维","低脂"]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 测试推荐接口**
|
|
|
+
|
|
|
+Run: 启动后端后,测试推荐端点
|
|
|
+
|
|
|
+```bash
|
|
|
+curl -s -X POST http://localhost:8080/api/meal/recommend \
|
|
|
+ -H "Authorization: Bearer $TOKEN" \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d '{}'
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 返回包含 answer (JSON 格式食谱方案)、nutritionSummary、replaceOptions
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+# Dify Prompt 变更无法通过 git 追踪
|
|
|
+# 记录变更到设计文档
|
|
|
+echo "Dify System Prompt 已更新: 追加食材推荐相关 inputs 变量" >> docs/superpowers/specs/2026-06-22-食谱推荐系统设计方案.md
|
|
|
+git add docs/superpowers/specs/2026-06-22-食谱推荐系统设计方案.md
|
|
|
+git commit -m "docs: update spec with Dify prompt changes for meal recommendation"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Self-Review Checklist
|
|
|
+
|
|
|
+- [ ] **Spec coverage** — 每个设计部分对应的任务:
|
|
|
+ - 数据模型 (3.1-3.6) → Task 1 (DDL) + Task 2 (Entity) + Task 3 (Mapper)
|
|
|
+ - 候选集过滤算法 (4.2) → Task 7 (MealRecommendService.filterCandidates)
|
|
|
+ - Dify AI 编排 (4.3) → Task 7 (buildDifyInputs) + Task 11 (Dify Prompt)
|
|
|
+ - 一回换一菜 (4.4) → Task 7 (findReplaceCandidate) + Task 8 (replace API)
|
|
|
+ - 饮食日志 → Task 5 (MealLogService) + Task 8 (log/logs/nutrition-summary API)
|
|
|
+ - Web 管理端 → Task 10
|
|
|
+ - 小程序页面 → Task 9
|
|
|
+ - 外部 API 集成 → 设计文档指定为 P3,不在当前计划覆盖
|
|
|
+- [ ] **Placeholder scan** — 无 TBD/TODO/占位符(aggregateContext 的 TODO 标记了"后续替换")
|
|
|
+- [ ] **Type consistency** — DTO/Entity/Service/Controller 之间的类型签名一致
|
|
|
+- [ ] **Missing tasks** — 无
|