2026-10-09-fatloss-miniprogram-plan.md 107 KB

减脂专题小程序 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 开发一个独立的「减脂专题」微信小程序(xiaofat-miniprogram/),共享主系统用户体系与健康数据,提供体重/体脂追踪、饮食记录、运动打卡、AI 个性化建议四大核心功能。

架构: 新 uni-app Vue 2 项目,通过主系统 /api/auth/* 认证接口共享 OpenID 用户体系,复用主系统 cfc-backend(9082)健康数据接口,通过 AiGateway 调用 cfc-langgraph(9000)AI 服务。专题特有数据(体重/体脂/饮食/运动/目标)新增表并存主库。

技术栈: uni-app Vue 2(Options API)、微信小程序、Spring Boot 2.7.18 后端扩展、MyBatis-Plus、LangGraph Python AI 服务。


文件结构

xiaofat-miniprogram/                    # 新小程序前端(uni-app Vue 2)
├─ manifest.json                        # 独立 AppID
├─ pages.json                           # TabBar 5 个:首页/体重/饮食/运动/AI
├─ App.vue                              # 全局生命周期,登录态检查
├─ main.js                              # Vue 实例入口
├─ config.js                            # API baseUrl 环境自适应
├─ utils/
│   ├─ api.js                           # 请求封装(从主项目 api.js 精简)
│   └─ format.js                        # 时间格式化 parseDate
├─ store/
│   ├─ index.js                         # Vuex 根 store
│   └─ modules/
│       └─ user.js                      # 用户/登录态/角色
├─ pages/
│   ├─ index/index.vue                  # 首页(数据概览 + 快捷入口)
│   ├─ weight/index.vue                 # 体重/体脂记录列表
│   ├─ weight/create.vue                # 新增体重/体脂记录
│   ├─ weight/chart.vue                 # 体重趋势图表
│   ├─ diet/index.vue                   # 饮食记录
│   ├─ diet/create.vue                  # 新增饮食记录
│   ├─ diet/food-query.vue              # 食物热量查询
│   ├─ exercise/index.vue               # 运动打卡列表
│   ├─ exercise/create.vue              # 新增运动记录
│   ├─ ai-coach/index.vue               # AI 教练对话
│   └─ profile/index.vue                # 个人设置 + 目标设定
└─ components/
    ├─ weight-card.vue                  # 体重展示卡片
    ├─ progress-ring.vue                # 环形进度
    ├─ date-picker.vue                  # 日期选择
    └─ ai-chat-bubble.vue               # AI 聊天气泡

cfc-backend/src/main/java/com/etotem/cfc/   # 后端扩展(减脂专题)
├─ controller/fatloss/
│   ├─ FatWeightController.java         # 体重/体脂 CRUD
│   ├─ FatDietController.java           # 饮食记录 CRUD
│   ├─ FatExerciseController.java       # 运动记录 CRUD
│   └─ FatGoalController.java           # 目标管理
├─ entity/
│   ├─ FatWeightRecord.java             # 体重体脂记录实体
│   ├─ FatDietRecord.java               # 饮食记录实体
│   ├─ FatExerciseRecord.java           # 运动记录实体
│   └─ FatGoal.java                     # 减脂目标实体
├─ mapper/
│   ├─ FatWeightRecordMapper.java
│   ├─ FatDietRecordMapper.java
│   ├─ FatExerciseRecordMapper.java
│   └─ FatGoalMapper.java
└─ service/
    ├─ FatWeightService.java
    ├─ FatDietService.java
    ├─ FatExerciseService.java
    └─ FatGoalService.java

cfc-backend/src/main/resources/
├─ schema.sql                           # 新增 4 张表定义
└─ config/DatabaseInitializer.java      # 新增迁移 N+1 ~ N+4

任务分解

任务 1:后端 - 建表迁移(4 张新表)

文件:

  • 修改:cfc-backend/src/main/resources/schema.sql
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

  • [ ] 步骤 1:在 schema.sql 末尾追加 4 张表定义

    -- ============ 减脂专题 ============
    CREATE TABLE IF NOT EXISTS fat_weight_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
    weight DECIMAL(5,2) NOT NULL COMMENT '体重(kg)',
    body_fat_percent DECIMAL(4,2) DEFAULT NULL COMMENT '体脂率(%)',
    waist_circumference DECIMAL(4,2) DEFAULT NULL COMMENT '腰围(cm)',
    measure_date DATE NOT NULL COMMENT '测量日期',
    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_fat_weight_member (member_id, measure_date)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-体重记录';
    
    CREATE TABLE IF NOT EXISTS fat_diet_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
    meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack',
    food_name VARCHAR(255) NOT NULL COMMENT '食物名称',
    food_weight DECIMAL(6,2) DEFAULT NULL COMMENT '食物重量(克)',
    calories INT DEFAULT 0 COMMENT '热量(kcal)',
    protein DECIMAL(6,2) DEFAULT 0 COMMENT '蛋白质(g)',
    fat DECIMAL(6,2) DEFAULT 0 COMMENT '脂肪(g)',
    carbs DECIMAL(6,2) DEFAULT 0 COMMENT '碳水(g)',
    eat_date DATE NOT NULL COMMENT '进餐日期',
    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_fat_diet_member (member_id, eat_date)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-饮食记录';
    
    CREATE TABLE IF NOT EXISTS fat_exercise_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
    exercise_type VARCHAR(50) NOT NULL COMMENT '运动类型',
    duration_min INT DEFAULT 0 COMMENT '时长(分钟)',
    distance_km DECIMAL(5,2) DEFAULT NULL COMMENT '距离(km)',
    calories_burned INT DEFAULT 0 COMMENT '消耗热量(kcal)',
    exercise_date DATE NOT NULL COMMENT '运动日期',
    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_fat_exercise_member (member_id, exercise_date)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-运动记录';
    
    CREATE TABLE IF NOT EXISTS fat_goal (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
    target_weight DECIMAL(5,2) NOT NULL COMMENT '目标体重(kg)',
    start_weight DECIMAL(5,2) NOT NULL COMMENT '初始体重(kg)',
    target_body_fat DECIMAL(4,2) DEFAULT NULL COMMENT '目标体脂率(%)',
    target_date DATE DEFAULT NULL COMMENT '目标日期',
    status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/abandoned',
    start_date DATE NOT NULL COMMENT '开始日期',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_fat_goal_member (member_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-目标';
    
  • [ ] 步骤 2:在 DatabaseInitializer.runMigrations() 末尾追加迁移

    // 迁移N+1: 创建减脂专题体重记录表
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_weight_record (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "member_id BIGINT NOT NULL, " +
            "weight DECIMAL(5,2) NOT NULL, " +
            "body_fat_percent DECIMAL(4,2) DEFAULT NULL, " +
            "waist_circumference DECIMAL(4,2) DEFAULT NULL, " +
            "measure_date DATE NOT NULL, " +
            "remark VARCHAR(255) DEFAULT NULL, " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "INDEX idx_fat_weight_member (member_id, measure_date)) " +
            "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-体重记录'");
    log.info("已创建fat_weight_record表");
    } catch (Exception e) {
    // 表已存在,忽略错误
    }
    
    // 迁移N+2: 创建减脂专题饮食记录表
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_diet_record (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "member_id BIGINT NOT NULL, " +
            "meal_type VARCHAR(20) NOT NULL, " +
            "food_name VARCHAR(255) NOT NULL, " +
            "food_weight DECIMAL(6,2) DEFAULT NULL, " +
            "calories INT DEFAULT 0, " +
            "protein DECIMAL(6,2) DEFAULT 0, " +
            "fat DECIMAL(6,2) DEFAULT 0, " +
            "carbs DECIMAL(6,2) DEFAULT 0, " +
            "eat_date DATE NOT NULL, " +
            "remark VARCHAR(255) DEFAULT NULL, " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "INDEX idx_fat_diet_member (member_id, eat_date)) " +
            "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-饮食记录'");
    log.info("已创建fat_diet_record表");
    } catch (Exception e) {
    // 表已存在,忽略错误
    }
    
    // 迁移N+3: 创建减脂专题运动记录表
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_exercise_record (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "member_id BIGINT NOT NULL, " +
            "exercise_type VARCHAR(50) NOT NULL, " +
            "duration_min INT DEFAULT 0, " +
            "distance_km DECIMAL(5,2) DEFAULT NULL, " +
            "calories_burned INT DEFAULT 0, " +
            "exercise_date DATE NOT NULL, " +
            "remark VARCHAR(255) DEFAULT NULL, " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "INDEX idx_fat_exercise_member (member_id, exercise_date)) " +
            "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-运动记录'");
    log.info("已创建fat_exercise_record表");
    } catch (Exception e) {
    // 表已存在,忽略错误
    }
    
    // 迁移N+4: 创建减脂专题目标表
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_goal (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "member_id BIGINT NOT NULL, " +
            "target_weight DECIMAL(5,2) NOT NULL, " +
            "start_weight DECIMAL(5,2) NOT NULL, " +
            "target_body_fat DECIMAL(4,2) DEFAULT NULL, " +
            "target_date DATE DEFAULT NULL, " +
            "status VARCHAR(20) DEFAULT 'active', " +
            "start_date DATE NOT NULL, " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
            "INDEX idx_fat_goal_member (member_id)) " +
            "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-目标'");
    log.info("已创建fat_goal表");
    } catch (Exception e) {
    // 表已存在,忽略错误
    }
    
  • [ ] 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git commit -m "feat(fatloss): add 4 fat-loss tables migration"
    

任务 2:后端 - 实体类与 Mapper

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/FatWeightRecord.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/FatDietRecord.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/FatExerciseRecord.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/FatGoal.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/FatWeightRecordMapper.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/FatDietRecordMapper.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/FatExerciseRecordMapper.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/FatGoalMapper.java

  • [ ] 步骤 1:创建 4 个实体类

    // FatWeightRecord.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.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("fat_weight_record")
    public class FatWeightRecord {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long memberId;
    private BigDecimal weight;
    private BigDecimal bodyFatPercent;
    private BigDecimal waistCircumference;
    private Date measureDate;
    private String remark;
    private Date createdAt;
    }
    
    // FatDietRecord.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.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("fat_diet_record")
    public class FatDietRecord {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long memberId;
    private String mealType;
    private String foodName;
    private BigDecimal foodWeight;
    private Integer calories;
    private BigDecimal protein;
    private BigDecimal fat;
    private BigDecimal carbs;
    private Date eatDate;
    private String remark;
    private Date createdAt;
    }
    
    // FatExerciseRecord.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.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("fat_exercise_record")
    public class FatExerciseRecord {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long memberId;
    private String exerciseType;
    private Integer durationMin;
    private BigDecimal distanceKm;
    private Integer caloriesBurned;
    private Date exerciseDate;
    private String remark;
    private Date createdAt;
    }
    
    // FatGoal.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.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("fat_goal")
    public class FatGoal {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long memberId;
    private BigDecimal targetWeight;
    private BigDecimal startWeight;
    private BigDecimal targetBodyFat;
    private Date targetDate;
    private String status;
    private Date startDate;
    private Date createdAt;
    private Date updatedAt;
    }
    
  • [ ] 步骤 2:创建 4 个 Mapper 接口

    // FatWeightRecordMapper.java
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.FatWeightRecord;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface FatWeightRecordMapper extends BaseMapper<FatWeightRecord> {
    }
    
    // FatDietRecordMapper.java
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.FatDietRecord;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface FatDietRecordMapper extends BaseMapper<FatDietRecord> {
    }
    
    // FatExerciseRecordMapper.java
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.FatExerciseRecord;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface FatExerciseRecordMapper extends BaseMapper<FatExerciseRecord> {
    }
    
    // FatGoalMapper.java
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.FatGoal;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface FatGoalMapper extends BaseMapper<FatGoal> {
    }
    
  • [ ] 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/Fat*.java cfc-backend/src/main/java/com/etotem/cfc/mapper/Fat*Mapper.java
    git commit -m "feat(fatloss): add entities and mappers"
    

任务 3:后端 - Service 层

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/service/FatWeightService.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/service/FatDietService.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/service/FatExerciseService.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/service/FatGoalService.java

  • [ ] 步骤 1:创建 FatWeightService

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.FatWeightRecord;
    import com.etotem.cfc.mapper.FatWeightRecordMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    import java.util.List;
    
    @Service
    public class FatWeightService {
    
    @Resource
    private FatWeightRecordMapper fatWeightRecordMapper;
    
    public List<FatWeightRecord> list(Long memberId, Date startDate, Date endDate) {
        LambdaQueryWrapper<FatWeightRecord> wrapper = new LambdaQueryWrapper<FatWeightRecord>()
                .eq(FatWeightRecord::getMemberId, memberId)
                .orderByDesc(FatWeightRecord::getMeasureDate);
        if (startDate != null) {
            wrapper.ge(FatWeightRecord::getMeasureDate, startDate);
        }
        if (endDate != null) {
            wrapper.le(FatWeightRecord::getMeasureDate, endDate);
        }
        return fatWeightRecordMapper.selectList(wrapper);
    }
    
    public FatWeightRecord create(FatWeightRecord record) {
        record.setCreatedAt(new Date());
        fatWeightRecordMapper.insert(record);
        return record;
    }
    
    public FatWeightRecord update(FatWeightRecord record) {
        fatWeightRecordMapper.updateById(record);
        return record;
    }
    
    public boolean delete(Long id) {
        return fatWeightRecordMapper.deleteById(id) > 0;
    }
    }
    
  • [ ] 步骤 2:创建 FatDietService

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.FatDietRecord;
    import com.etotem.cfc.mapper.FatDietRecordMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    import java.util.List;
    
    @Service
    public class FatDietService {
    
    @Resource
    private FatDietRecordMapper fatDietRecordMapper;
    
    public List<FatDietRecord> list(Long memberId, Date startDate, Date endDate) {
        LambdaQueryWrapper<FatDietRecord> wrapper = new LambdaQueryWrapper<FatDietRecord>()
                .eq(FatDietRecord::getMemberId, memberId)
                .orderByDesc(FatDietRecord::getEatDate);
        if (startDate != null) {
            wrapper.ge(FatDietRecord::getEatDate, startDate);
        }
        if (endDate != null) {
            wrapper.le(FatDietRecord::getEatDate, endDate);
        }
        return fatDietRecordMapper.selectList(wrapper);
    }
    
    public FatDietRecord create(FatDietRecord record) {
        record.setCreatedAt(new Date());
        fatDietRecordMapper.insert(record);
        return record;
    }
    
    public boolean delete(Long id) {
        return fatDietRecordMapper.deleteById(id) > 0;
    }
    
    /** 统计指定日期总热量 */
    public int sumCalories(Long memberId, Date date) {
        List<FatDietRecord> records = fatDietRecordMapper.selectList(
                new LambdaQueryWrapper<FatDietRecord>()
                        .eq(FatDietRecord::getMemberId, memberId)
                        .eq(FatDietRecord::getEatDate, date));
        int total = 0;
        for (FatDietRecord r : records) {
            if (r.getCalories() != null) total += r.getCalories();
        }
        return total;
    }
    }
    
  • [ ] 步骤 3:创建 FatExerciseService

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.FatExerciseRecord;
    import com.etotem.cfc.mapper.FatExerciseRecordMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    import java.util.List;
    
    @Service
    public class FatExerciseService {
    
    @Resource
    private FatExerciseRecordMapper fatExerciseRecordMapper;
    
    public List<FatExerciseRecord> list(Long memberId, Date startDate, Date endDate) {
        LambdaQueryWrapper<FatExerciseRecord> wrapper = new LambdaQueryWrapper<FatExerciseRecord>()
                .eq(FatExerciseRecord::getMemberId, memberId)
                .orderByDesc(FatExerciseRecord::getExerciseDate);
        if (startDate != null) {
            wrapper.ge(FatExerciseRecord::getExerciseDate, startDate);
        }
        if (endDate != null) {
            wrapper.le(FatExerciseRecord::getExerciseDate, endDate);
        }
        return fatExerciseRecordMapper.selectList(wrapper);
    }
    
    public FatExerciseRecord create(FatExerciseRecord record) {
        record.setCreatedAt(new Date());
        fatExerciseRecordMapper.insert(record);
        return record;
    }
    
    public boolean delete(Long id) {
        return fatExerciseRecordMapper.deleteById(id) > 0;
    }
    }
    
  • [ ] 步骤 4:创建 FatGoalService

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.FatGoal;
    import com.etotem.cfc.mapper.FatGoalMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    
    @Service
    public class FatGoalService {
    
    @Resource
    private FatGoalMapper fatGoalMapper;
    
    public FatGoal getActive(Long memberId) {
        return fatGoalMapper.selectOne(
                new LambdaQueryWrapper<FatGoal>()
                        .eq(FatGoal::getMemberId, memberId)
                        .eq(FatGoal::getStatus, "active")
                        .orderByDesc(FatGoal::getCreatedAt)
                        .last("LIMIT 1"));
    }
    
    public FatGoal create(FatGoal goal) {
        goal.setStatus("active");
        goal.setCreatedAt(new Date());
        goal.setUpdatedAt(new Date());
        fatGoalMapper.insert(goal);
        return goal;
    }
    
    public FatGoal update(FatGoal goal) {
        goal.setUpdatedAt(new Date());
        fatGoalMapper.updateById(goal);
        return goal;
    }
    
    /** 完成目标 */
    public FatGoal complete(Long id) {
        FatGoal goal = fatGoalMapper.selectById(id);
        if (goal != null) {
            goal.setStatus("completed");
            goal.setUpdatedAt(new Date());
            fatGoalMapper.updateById(goal);
        }
        return goal;
    }
    
    /** 放弃目标 */
    public FatGoal abandon(Long id) {
        FatGoal goal = fatGoalMapper.selectById(id);
        if (goal != null) {
            goal.setStatus("abandoned");
            goal.setUpdatedAt(new Date());
            fatGoalMapper.updateById(goal);
        }
        return goal;
    }
    }
    
  • [ ] 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/Fat*Service.java
    git commit -m "feat(fatloss): add service layer"
    

任务 4:后端 - Controller 层

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatWeightController.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatDietController.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatExerciseController.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatGoalController.java

  • [ ] 步骤 1:创建 FatWeightController

    package com.etotem.cfc.controller.fatloss;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.entity.FatWeightRecord;
    import com.etotem.cfc.service.FatWeightService;
    import com.etotem.cfc.util.ParamUtils;
    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.Date;
    import java.util.List;
    import java.util.Map;
    
    @Tag(name = "减脂-体重管理")
    @RestController
    @RequestMapping("/api/fatloss/weight")
    public class FatWeightController {
    
    @Resource
    private FatWeightService fatWeightService;
    
    @Operation(summary = "获取体重记录列表")
    @PostMapping("/list")
    public Result<List<FatWeightRecord>> list(@RequestBody(required = false) Map<String, Object> params,
                                              @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
        if (memberId == null) return Result.error("memberId不能为空");
        Date startDate = ParamUtils.getDate(params == null ? null : params.get("startDate"));
        Date endDate = ParamUtils.getDate(params == null ? null : params.get("endDate"));
        return Result.success(fatWeightService.list(memberId, startDate, endDate));
    }
    
    @Operation(summary = "创建体重记录")
    @PostMapping("/create")
    public Result<FatWeightRecord> create(@RequestBody FatWeightRecord record,
                                          @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        if (record.getMemberId() == null) record.setMemberId(currentMemberId);
        if (record.getMemberId() == null) return Result.error("memberId不能为空");
        if (record.getWeight() == null) return Result.error("体重不能为空");
        if (record.getMeasureDate() == null) return Result.error("测量日期不能为空");
        return Result.success(fatWeightService.create(record));
    }
    
    @Operation(summary = "更新体重记录")
    @PostMapping("/update")
    public Result<FatWeightRecord> update(@RequestBody FatWeightRecord record) {
        if (record.getId() == null) return Result.error("id不能为空");
        return Result.success(fatWeightService.update(record));
    }
    
    @Operation(summary = "删除体重记录")
    @PostMapping("/delete")
    public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
        Long id = ParamUtils.getLong(params.get("id"));
        if (id == null) return Result.error("id不能为空");
        return Result.success(fatWeightService.delete(id));
    }
    }
    
  • [ ] 步骤 2:创建 FatDietController

    package com.etotem.cfc.controller.fatloss;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.entity.FatDietRecord;
    import com.etotem.cfc.service.FatDietService;
    import com.etotem.cfc.util.ParamUtils;
    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.Date;
    import java.util.List;
    import java.util.Map;
    
    @Tag(name = "减脂-饮食记录")
    @RestController
    @RequestMapping("/api/fatloss/diet")
    public class FatDietController {
    
    @Resource
    private FatDietService fatDietService;
    
    @Operation(summary = "获取饮食记录列表")
    @PostMapping("/list")
    public Result<List<FatDietRecord>> list(@RequestBody(required = false) Map<String, Object> params,
                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
        if (memberId == null) return Result.error("memberId不能为空");
        Date startDate = ParamUtils.getDate(params == null ? null : params.get("startDate"));
        Date endDate = ParamUtils.getDate(params == null ? null : params.get("endDate"));
        return Result.success(fatDietService.list(memberId, startDate, endDate));
    }
    
    @Operation(summary = "创建饮食记录")
    @PostMapping("/create")
    public Result<FatDietRecord> create(@RequestBody FatDietRecord record,
                                        @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        if (record.getMemberId() == null) record.setMemberId(currentMemberId);
        if (record.getMemberId() == null) return Result.error("memberId不能为空");
        if (record.getEatDate() == null) return Result.error("进餐日期不能为空");
        if (record.getFoodName() == null || record.getFoodName().isEmpty()) return Result.error("食物名称不能为空");
        return Result.success(fatDietService.create(record));
    }
    
    @Operation(summary = "删除饮食记录")
    @PostMapping("/delete")
    public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
        Long id = ParamUtils.getLong(params.get("id"));
        if (id == null) return Result.error("id不能为空");
        return Result.success(fatDietService.delete(id));
    }
    
    @Operation(summary = "获取当日总热量")
    @PostMapping("/calories-summary")
    public Result<Integer> caloriesSummary(@RequestBody(required = false) Map<String, Object> params,
                                           @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
        if (memberId == null) return Result.error("memberId不能为空");
        Date date = ParamUtils.getDate(params == null ? null : params.get("date"));
        if (date == null) date = new Date();
        return Result.success(fatDietService.sumCalories(memberId, date));
    }
    }
    
  • [ ] 步骤 3:创建 FatExerciseController

    package com.etotem.cfc.controller.fatloss;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.entity.FatExerciseRecord;
    import com.etotem.cfc.service.FatExerciseService;
    import com.etotem.cfc.util.ParamUtils;
    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.Date;
    import java.util.List;
    import java.util.Map;
    
    @Tag(name = "减脂-运动记录")
    @RestController
    @RequestMapping("/api/fatloss/exercise")
    public class FatExerciseController {
    
    @Resource
    private FatExerciseService fatExerciseService;
    
    @Operation(summary = "获取运动记录列表")
    @PostMapping("/list")
    public Result<List<FatExerciseRecord>> list(@RequestBody(required = false) Map<String, Object> params,
                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
        if (memberId == null) return Result.error("memberId不能为空");
        Date startDate = ParamUtils.getDate(params == null ? null : params.get("startDate"));
        Date endDate = ParamUtils.getDate(params == null ? null : params.get("endDate"));
        return Result.success(fatExerciseService.list(memberId, startDate, endDate));
    }
    
    @Operation(summary = "创建运动记录")
    @PostMapping("/create")
    public Result<FatExerciseRecord> create(@RequestBody FatExerciseRecord record,
                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        if (record.getMemberId() == null) record.setMemberId(currentMemberId);
        if (record.getMemberId() == null) return Result.error("memberId不能为空");
        if (record.getExerciseDate() == null) return Result.error("运动日期不能为空");
        if (record.getExerciseType() == null || record.getExerciseType().isEmpty()) return Result.error("运动类型不能为空");
        return Result.success(fatExerciseService.create(record));
    }
    
    @Operation(summary = "删除运动记录")
    @PostMapping("/delete")
    public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
        Long id = ParamUtils.getLong(params.get("id"));
        if (id == null) return Result.error("id不能为空");
        return Result.success(fatExerciseService.delete(id));
    }
    }
    
  • [ ] 步骤 4:创建 FatGoalController

    package com.etotem.cfc.controller.fatloss;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.entity.FatGoal;
    import com.etotem.cfc.service.FatGoalService;
    import com.etotem.cfc.util.ParamUtils;
    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.Map;
    
    @Tag(name = "减脂-目标管理")
    @RestController
    @RequestMapping("/api/fatloss/goal")
    public class FatGoalController {
    
    @Resource
    private FatGoalService fatGoalService;
    
    @Operation(summary = "获取当前减脂目标")
    @PostMapping("/active")
    public Result<FatGoal> active(@RequestBody(required = false) Map<String, Object> params,
                                  @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
        if (memberId == null) return Result.error("memberId不能为空");
        return Result.success(fatGoalService.getActive(memberId));
    }
    
    @Operation(summary = "创建减脂目标")
    @PostMapping("/create")
    public Result<FatGoal> create(@RequestBody FatGoal goal,
                                  @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
        if (goal.getMemberId() == null) goal.setMemberId(currentMemberId);
        if (goal.getMemberId() == null) return Result.error("memberId不能为空");
        if (goal.getTargetWeight() == null) return Result.error("目标体重不能为空");
        if (goal.getStartWeight() == null) return Result.error("初始体重不能为空");
        if (goal.getStartDate() == null) return Result.error("开始日期不能为空");
        return Result.success(fatGoalService.create(goal));
    }
    
    @Operation(summary = "更新减脂目标")
    @PostMapping("/update")
    public Result<FatGoal> update(@RequestBody FatGoal goal) {
        if (goal.getId() == null) return Result.error("id不能为空");
        return Result.success(fatGoalService.update(goal));
    }
    
    @Operation(summary = "完成减脂目标")
    @PostMapping("/complete")
    public Result<FatGoal> complete(@RequestBody Map<String, Object> params) {
        Long id = ParamUtils.getLong(params.get("id"));
        if (id == null) return Result.error("id不能为空");
        return Result.success(fatGoalService.complete(id));
    }
    
    @Operation(summary = "放弃减脂目标")
    @PostMapping("/abandon")
    public Result<FatGoal> abandon(@RequestBody Map<String, Object> params) {
        Long id = ParamUtils.getLong(params.get("id"));
        if (id == null) return Result.error("id不能为空");
        return Result.success(fatGoalService.abandon(id));
    }
    }
    
  • [ ] 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • 步骤 6:检查路由是否重复

运行:grep -rn '@Mapping' cfc-backend/src/.../controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep fatloss 预期:输出 /api/fatloss/weight/*、/api/fatloss/diet/*、/api/fatloss/exercise/*、/api/fatloss/goal/*,无重复

  • [ ] 步骤 7:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/
    git commit -m "feat(fatloss): add controllers for weight/diet/exercise/goal"
    

任务 5:后端 - JwtInterceptor 白名单确认

文件:

  • 修改:确认 cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java 已拦截 /api/**

  • [ ] 步骤 1:确认 JWT 拦截覆盖新接口

运行:grep -n "includePathPatterns\|addPathPatterns" cfc-backend/src/main/java/com/etotem/cfc/config/*Interceptor*.java 预期:addPathPatterns("/api/**") 已存在,新接口自动受保护(/api/fatloss/* 需要登录)

  • 步骤 2:记录确认结果(无需代码变更)

如果拦截器配置已覆盖 /api/** 且 8 个公开路径不包含 /api/fatloss,则无需修改。


任务 6:前端 - 项目脚手架

文件:

  • 创建:xiaofat-miniprogram/manifest.json
  • 创建:xiaofat-miniprogram/pages.json
  • 创建:xiaofat-miniprogram/App.vue
  • 创建:xiaofat-miniprogram/main.js
  • 创建:xiaofat-miniprogram/config.js

  • [ ] 步骤 1:创建 manifest.json

    {
    "name": "减脂营",
    "appid": "__UNI__FATLOSS",
    "description": "科学减脂,从记录开始",
    "versionName": "1.0.0",
    "versionCode": "100",
    "transformPx": false,
    "mp-weixin": {
    "appid": "待注册新AppID",
    "setting": {
      "urlCheck": false,
      "es6": true,
      "postcss": true,
      "minified": true
    },
    "usingComponents": true,
    "permission": {}
    },
    "vueVersion": "2"
    }
    
  • [ ] 步骤 2:创建 pages.json(5 个 TabBar)

    {
    "pages": [
    {
      "path": "pages/index/index",
      "style": { "navigationBarTitleText": "减脂营" }
    },
    {
      "path": "pages/weight/index",
      "style": { "navigationBarTitleText": "体重" }
    },
    {
      "path": "pages/diet/index",
      "style": { "navigationBarTitleText": "饮食" }
    },
    {
      "path": "pages/exercise/index",
      "style": { "navigationBarTitleText": "运动" }
    },
    {
      "path": "pages/ai-coach/index",
      "style": { "navigationBarTitleText": "AI教练" }
    }
    ],
    "subPackages": [
    {
      "root": "pages/weight",
      "pages": [
        { "path": "create", "style": { "navigationBarTitleText": "记录体重" } },
        { "path": "chart", "style": { "navigationBarTitleText": "体重趋势" } }
      ]
    },
    {
      "root": "pages/diet",
      "pages": [
        { "path": "create", "style": { "navigationBarTitleText": "记录饮食" } },
        { "path": "food-query", "style": { "navigationBarTitleText": "食物热量查询" } }
      ]
    },
    {
      "root": "pages/exercise",
      "pages": [
        { "path": "create", "style": { "navigationBarTitleText": "记录运动" } }
      ]
    },
    {
      "root": "pages/profile",
      "pages": [
        { "path": "index", "style": { "navigationBarTitleText": "我的" } }
      ]
    }
    ],
    "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "减脂营",
    "navigationBarBackgroundColor": "#FFFFFF",
    "backgroundColor": "#F8F8F8"
    },
    "tabBar": {
    "color": "#7A7E83",
    "selectedColor": "#F97316",
    "borderStyle": "black",
    "backgroundColor": "#FFFFFF",
    "list": [
      { "pagePath": "pages/index/index", "text": "首页" },
      { "pagePath": "pages/weight/index", "text": "体重" },
      { "pagePath": "pages/diet/index", "text": "饮食" },
      { "pagePath": "pages/exercise/index", "text": "运动" },
      { "pagePath": "pages/ai-coach/index", "text": "AI教练" }
    ]
    },
    "easycom": {
    "autoscan": true,
    "custom": {
      "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
    }
    }
    }
    
  • [ ] 步骤 3:创建 config.js(环境自适应)

    /**
    * 减脂营 API 配置
    * 环境自动检测(同主项目 config.js 逻辑)
    */
    let API_BASE_URL = 'https://cfc.etotem.com.cn'
    
    try {
    const accountInfo = uni.getAccountInfoSync()
    const env = accountInfo && accountInfo.miniProgram && accountInfo.miniProgram.envVersion
    if (env) {
    switch (env) {
      case 'develop':
        API_BASE_URL = 'https://cfc.etotem.com.cn'
        break
      case 'trial':
        API_BASE_URL = 'https://cfc.etotem.com.cn'
        break
      case 'release':
        API_BASE_URL = 'https://cfc.etotem.com.cn'
        break
    }
    }
    } catch (e) {
    // 非小程序环境使用默认地址
    }
    
    export default {
    API_BASE_URL,
    api(path) {
    return API_BASE_URL + path
    }
    }
    
  • [ ] 步骤 4:创建 App.vue

    <script>
    import { silentLogin } from './utils/api.js'
    
    export default {
    onLaunch: function() {
    var cachedOpenid = uni.getStorageSync('openid')
    if (!cachedOpenid) {
      // 首次启动,静默登录获取 openid
      uni.login({
        provider: 'weixin',
        success: function(loginRes) {
          if (loginRes && loginRes.code) {
            silentLogin(loginRes.code).then(function(res) {
              if (res && res.data && res.data.token) {
                uni.setStorageSync('token', res.data.token)
                uni.setStorageSync('openid', res.data.openid)
                uni.setStorageSync('userId', res.data.userId)
                uni.setStorageSync('userInfo', {
                  nickname: res.data.nickname,
                  userId: res.data.userId,
                  familyId: res.data.familyId
                })
              }
            }).catch(function() {
              console.warn('[减脂营] 静默登录失败,用户需手动登录')
            })
          }
        }
      })
    }
    }
    }
    </script>
    
    <style>
    /* 全局样式 */
    page {
    background-color: #F8F8F8;
    font-size: 28rpx;
    color: #333;
    }
    </style>
    
  • [ ] 步骤 5:创建 main.js

    import Vue from 'vue'
    import App from './App'
    import store from './store'
    
    Vue.config.productionTip = false
    Vue.prototype.$store = store
    
    App.mpType = 'app'
    
    const app = new Vue({
    ...App,
    store
    })
    
    app.$mount()
    
  • [ ] 步骤 6:语法校验

运行:node --check xiaofat-miniprogram/config.js && node --check xiaofat-miniprogram/main.js 预期:无输出,exit code 0

  • [ ] 步骤 7:Commit

    git add xiaofat-miniprogram/
    git commit -m "feat(fatloss-miniprogram): init project scaffold"
    

任务 7:前端 - 请求封装与工具函数

文件:

  • 创建:xiaofat-miniprogram/utils/api.js
  • 创建:xiaofat-miniprogram/utils/format.js
  • 创建:xiaofat-miniprogram/store/index.js
  • 创建:xiaofat-miniprogram/store/modules/user.js

  • [ ] 步骤 1:创建 utils/api.js(精简版请求封装)

    import config from '../config.js'
    
    const BASE_URL = config.API_BASE_URL
    var _dedupMap = {}
    var DEDUP_WINDOW_MS = 3000
    
    function _makeDedupKey(method, url, body) {
    var bodyStr = ''
    try { bodyStr = JSON.stringify(body || {}) } catch (e) { bodyStr = '' }
    return method + '|' + url + '|' + bodyStr
    }
    
    const request = (url, method = 'POST', data = {}, options = {}) => {
    var fullUrl = BASE_URL + url
    const token = uni.getStorageSync('token')
    
    var key = _makeDedupKey(method, fullUrl, data)
    if (!options._skipDedup) {
    var cached = _dedupMap[key]
    if (cached && Date.now() - cached.ts < DEDUP_WINDOW_MS) {
      return cached.promise
    }
    }
    
    var _promise = new Promise((resolve, reject) => {
    uni.request({
      url: fullUrl,
      method: method,
      data: data,
      header: {
        'Content-Type': 'application/json',
        'Authorization': token ? 'Bearer ' + token : ''
      },
      success: function(res) {
        if (res.statusCode === 401) {
          // 登录过期静默续期
          uni.login({
            provider: 'weixin',
            success: function(loginRes) {
              if (loginRes && loginRes.code) {
                silentLogin(loginRes.code).then(function(sr) {
                  if (sr && sr.data && sr.data.token) {
                    uni.setStorageSync('token', sr.data.token)
                    uni.setStorageSync('openid', sr.data.openid)
                    resolve(sr.data)
                  } else {
                    uni.showToast({ title: '登录已过期,请重新进入', icon: 'none' })
                    reject(res.data)
                  }
                })
              }
            }
          })
          return
        }
        if (res.data && res.data.code === 200) {
          resolve(res.data)
        } else {
          var msg = res.data && res.data.message ? res.data.message : '请求失败'
          uni.showToast({ title: msg, icon: 'none' })
          reject(res.data)
        }
      },
      fail: function(err) {
        console.error('[API] 网络错误 ' + method + ' ' + fullUrl, err)
        uni.showToast({ title: '网络请求失败', icon: 'none' })
        reject(err)
      }
    })
    })
    
    if (!options._skipDedup) {
    _dedupMap[key] = { promise: _promise, ts: Date.now() }
    _promise.then(function() {
      setTimeout(function() { delete _dedupMap[key] }, DEDUP_WINDOW_MS)
    }, function() {
      setTimeout(function() { delete _dedupMap[key] }, DEDUP_WINDOW_MS)
    })
    }
    
    return _promise
    }
    
    // ===== 认证 =====
    export const silentLogin = (code, options) => {
    return request('/api/auth/silent-login', 'POST', { code }, options || {})
    }
    
    export const wechatLogin = (code, userInfo) => {
    return request('/api/auth/wechat-login', 'POST', { code, nickname: userInfo.nickname, avatar: userInfo.avatar })
    }
    
    export const getUserInfo = () => {
    return request('/api/user/info', 'POST', {})
    }
    
    // ===== 减脂专题 API =====
    // 体重
    export const getWeightList = (memberId, startDate, endDate) => {
    var data = {}
    if (memberId) data.memberId = memberId
    if (startDate) data.startDate = startDate
    if (endDate) data.endDate = endDate
    return request('/api/fatloss/weight/list', 'POST', data)
    }
    
    export const createWeightRecord = (data) => {
    return request('/api/fatloss/weight/create', 'POST', data)
    }
    
    export const updateWeightRecord = (data) => {
    return request('/api/fatloss/weight/update', 'POST', data)
    }
    
    export const deleteWeightRecord = (id) => {
    return request('/api/fatloss/weight/delete', 'POST', { id: id })
    }
    
    // 饮食
    export const getDietList = (memberId, startDate, endDate) => {
    var data = {}
    if (memberId) data.memberId = memberId
    if (startDate) data.startDate = startDate
    if (endDate) data.endDate = endDate
    return request('/api/fatloss/diet/list', 'POST', data)
    }
    
    export const createDietRecord = (data) => {
    return request('/api/fatloss/diet/create', 'POST', data)
    }
    
    export const deleteDietRecord = (id) => {
    return request('/api/fatloss/diet/delete', 'POST', { id: id })
    }
    
    export const getCaloriesSummary = (date) => {
    var data = {}
    if (date) data.date = date
    return request('/api/fatloss/diet/calories-summary', 'POST', data)
    }
    
    // 运动
    export const getExerciseList = (memberId, startDate, endDate) => {
    var data = {}
    if (memberId) data.memberId = memberId
    if (startDate) data.startDate = startDate
    if (endDate) data.endDate = endDate
    return request('/api/fatloss/exercise/list', 'POST', data)
    }
    
    export const createExerciseRecord = (data) => {
    return request('/api/fatloss/exercise/create', 'POST', data)
    }
    
    export const deleteExerciseRecord = (id) => {
    return request('/api/fatloss/exercise/delete', 'POST', { id: id })
    }
    
    // 目标
    export const getActiveGoal = () => {
    return request('/api/fatloss/goal/active', 'POST', {})
    }
    
    export const createGoal = (data) => {
    return request('/api/fatloss/goal/create', 'POST', data)
    }
    
    export const updateGoal = (data) => {
    return request('/api/fatloss/goal/update', 'POST', data)
    }
    
    export const completeGoal = (id) => {
    return request('/api/fatloss/goal/complete', 'POST', { id: id })
    }
    
    export const abandonGoal = (id) => {
    return request('/api/fatloss/goal/abandon', 'POST', { id: id })
    }
    
    // ===== AI 教练(走主系统 AiGateway) =====
    export const aiCoachSend = (data) => {
    return request('/api/ai/health-coach/send', 'POST', data)
    }
    
  • [ ] 步骤 2:创建 utils/format.js

    /**
    * 时间格式化工具
    * iOS 安全:统一用 parseDate 解析后手动拼格式
    */
    
    export function parseDate(str) {
    if (!str) return null
    if (str instanceof Date) return str
    var normalized = String(str)
    .replace(' ', 'T')
    .replace('CST', '')
    .trim()
    var d = new Date(normalized)
    return isNaN(d.getTime()) ? null : d
    }
    
    export function formatDateTime(str) {
    var d = parseDate(str)
    if (!d) return ''
    function pad(n) { return n < 10 ? '0' + n : '' + n }
    return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
    ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
    }
    
    export function formatDate(str) {
    var d = parseDate(str)
    if (!d) return ''
    function pad(n) { return n < 10 ? '0' + n : '' + n }
    return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
    }
    
    export function today() {
    var d = new Date()
    function pad(n) { return n < 10 ? '0' + n : '' + n }
    return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
    }
    
  • [ ] 步骤 3:创建 store/index.js 与 store/modules/user.js

    // store/index.js
    import Vue from 'vue'
    import Vuex from 'vuex'
    import user from './modules/user'
    
    Vue.use(Vuex)
    
    export default new Vuex.Store({
    modules: {
    user: user
    }
    })
    
    // store/modules/user.js
    export default {
    state: {
    token: '',
    userId: null,
    memberId: null,
    nickname: '',
    familyId: null,
    openid: '',
    isLoggedIn: false
    },
    mutations: {
    setToken(state, token) {
      state.token = token
      uni.setStorageSync('token', token)
    },
    setUserInfo(state, info) {
      state.userId = info.userId
      state.memberId = info.memberId
      state.nickname = info.nickname
      state.familyId = info.familyId
      state.openid = info.openid
      state.isLoggedIn = !!info.userId
    },
    logout(state) {
      state.token = ''
      state.userId = null
      state.memberId = null
      state.nickname = ''
      state.familyId = null
      state.openid = ''
      state.isLoggedIn = false
      uni.removeStorageSync('token')
      uni.removeStorageSync('openid')
      uni.removeStorageSync('userId')
    }
    },
    actions: {
    initFromStorage({ commit }) {
      var token = uni.getStorageSync('token')
      var openid = uni.getStorageSync('openid')
      var userId = uni.getStorageSync('userId')
      if (token) {
        commit('setToken', token)
      }
      if (userId) {
        var userInfo = uni.getStorageSync('userInfo') || {}
        commit('setUserInfo', {
          userId: userId,
          memberId: userInfo.memberId,
          nickname: userInfo.nickname,
          familyId: userInfo.familyId,
          openid: openid
        })
      }
    }
    }
    }
    
  • [ ] 步骤 4:语法校验

运行:node --check xiaofat-miniprogram/utils/api.js && node --check xiaofat-miniprogram/utils/format.js && node --check xiaofat-miniprogram/store/index.js && node --check xiaofat-miniprogram/store/modules/user.js 预期:无输出,exit code 0

  • [ ] 步骤 5:Commit

    git add xiaofat-miniprogram/utils/ xiaofat-miniprogram/store/
    git commit -m "feat(fatloss-miniprogram): add api wrappers and store"
    

任务 8:前端 - 首页(数据概览 + 快捷入口)

文件:

  • 创建:xiaofat-miniprogram/pages/index/index.vue

  • [ ] 步骤 1:创建 index.vue

    <template>
    <view class="page">
    <!-- 未登录引导 -->
    <view v-if="!isLoggedIn" class="login-guide">
      <view class="logo">🏋️</view>
      <text class="title">科学减脂,从记录开始</text>
      <text class="subtitle">记录体重、饮食、运动,AI 为您定制专属方案</text>
      <button class="btn-primary" @click="handleLogin">微信一键登录</button>
    </view>
    
    <!-- 已登录主界面 -->
    <view v-else>
      <!-- 今日概览卡片 -->
      <view class="overview-card">
        <view class="overview-header">
          <text class="overview-title">今日概览</text>
          <text class="overview-date">{{ todayStr }}</text>
        </view>
        <view class="overview-grid">
          <view class="overview-item" @click="goPage('/pages/weight/index')">
            <text class="ov-value">{{ todayCalories }}</text>
            <text class="ov-label">摄入热量(kcal)</text>
          </view>
          <view class="overview-item" @click="goPage('/pages/exercise/index')">
            <text class="ov-value">{{ todayBurned }}</text>
            <text class="ov-label">消耗热量(kcal)</text>
          </view>
          <view class="overview-item">
            <text class="ov-value">{{ todayWeight }}</text>
            <text class="ov-label">最新体重(kg)</text>
          </view>
        </view>
        <view class="net-balance">
          热量缺口:{{ netCalories }} kcal
        </view>
      </view>
    
      <!-- 目标进度 -->
      <view class="goal-card" v-if="goal">
        <text class="goal-title">当前目标</text>
        <text class="goal-desc">从 {{ goal.startWeight }}kg → {{ goal.targetWeight }}kg</text>
        <view class="progress-bar">
          <view class="progress-fill" :style="{ width: goalProgress + '%' }"></view>
        </view>
        <text class="goal-progress-text">{{ goalProgress }}%</text>
      </view>
      <view class="goal-card" v-else @click="goPage('/pages/profile/index')">
        <text class="goal-title">还没有减脂目标?</text>
        <text class="goal-desc">点击设置目标,开启减脂之旅 →</text>
      </view>
    
      <!-- 快捷操作 -->
      <view class="quick-actions">
        <view class="action-btn" @click="goPage('/pages/weight/create')">记录体重</view>
        <view class="action-btn" @click="goPage('/pages/diet/create')">记录饮食</view>
        <view class="action-btn" @click="goPage('/pages/exercise/create')">记录运动</view>
      </view>
    
      <!-- AI 教练入口 -->
      <view class="ai-entry" @click="goPage('/pages/ai-coach/index')">
        <text class="ai-icon">🤖</text>
        <view class="ai-info">
          <text class="ai-title">AI 减脂教练</text>
          <text class="ai-subtitle">随时提问,获得个性化建议</text>
        </view>
        <text class="ai-arrow">→</text>
      </view>
    </view>
    </view>
    </template>
    
    <script>
    import { getCaloriesSummary, getWeightList, getExerciseList, getActiveGoal } from '../../utils/api.js'
    import { formatDate, today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      isLoggedIn: false,
      todayStr: formatDate(new Date()),
      todayCalories: 0,
      todayBurned: 0,
      todayWeight: '--',
      netCalories: 0,
      goal: null,
      goalProgress: 0
    }
    },
    onShow() {
    this.isLoggedIn = !!uni.getStorageSync('token')
    if (this.isLoggedIn) {
      this.loadOverview()
    }
    },
    methods: {
    loadOverview() {
      var self = this
      var todayStr = today()
      // 当日热量摄入
      getCaloriesSummary(todayStr).then(function(res) {
        self.todayCalories = res.data || 0
      }).catch(function() {})
      // 当日运动消耗
      getExerciseList(null, todayStr, todayStr).then(function(res) {
        var records = res.data || []
        var burned = 0
        records.forEach(function(r) {
          burned += (r.caloriesBurned || 0)
        })
        self.todayBurned = burned
      }).catch(function() {})
      // 最新体重
      getWeightList(null, null, null).then(function(res) {
        var records = res.data || []
        if (records.length > 0) {
          self.todayWeight = records[0].weight
        }
      }).catch(function() {})
      // 当前目标
      getActiveGoal().then(function(res) {
        var goal = res.data
        if (goal) {
          self.goal = goal
          var start = parseFloat(goal.startWeight)
          var target = parseFloat(goal.targetWeight)
          var diff = start - target
          if (diff > 0) {
            // 需要计算当前体重
            getWeightList(null, null, null).then(function(wres) {
              var records = wres.data || []
              if (records.length > 0) {
                var current = parseFloat(records[0].weight)
                var progress = Math.min(100, Math.max(0, (start - current) / diff * 100))
                self.goalProgress = Math.round(progress)
              }
            })
          } else {
            self.goalProgress = 100
          }
        }
      }).catch(function() {})
    },
    handleLogin() {
      var self = this
      uni.login({
        provider: 'weixin',
        success: function(loginRes) {
          if (loginRes && loginRes.code) {
            // 调用主系统静默登录(首次会创建账号)
            uni.request({
              url: 'https://cfc.etotem.com.cn/api/auth/silent-login',
              method: 'POST',
              data: { code: loginRes.code },
              header: { 'Content-Type': 'application/json' },
              success: function(res) {
                if (res.data && res.data.code === 200 && res.data.data) {
                  var d = res.data.data
                  uni.setStorageSync('token', d.token)
                  uni.setStorageSync('openid', d.openid)
                  uni.setStorageSync('userId', d.userId)
                  uni.setStorageSync('userInfo', {
                    nickname: d.nickname,
                    userId: d.userId,
                    familyId: d.familyId
                  })
                  self.$store.commit('setToken', d.token)
                  self.$store.commit('setUserInfo', {
                    userId: d.userId,
                    nickname: d.nickname,
                    familyId: d.familyId,
                    openid: d.openid
                  })
                  self.isLoggedIn = true
                  self.loadOverview()
                } else {
                  uni.showToast({ title: '登录失败', icon: 'none' })
                }
              },
              fail: function() {
                uni.showToast({ title: '网络请求失败', icon: 'none' })
              }
            })
          }
        }
      })
    },
    goPage(url) {
      uni.navigateTo({ url: url })
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    box-sizing: border-box;
    }
    .login-guide {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-top: 200rpx;
    }
    .logo {
    font-size: 120rpx;
    margin-bottom: 30rpx;
    }
    .title {
    font-size: 40rpx;
    font-weight: bold;
    margin-bottom: 20rpx;
    }
    .subtitle {
    font-size: 26rpx;
    color: #999;
    text-align: center;
    margin-bottom: 60rpx;
    padding: 0 60rpx;
    }
    .btn-primary {
    background: #F97316;
    color: #fff;
    border-radius: 44rpx;
    padding: 20rpx 80rpx;
    font-size: 32rpx;
    }
    .overview-card {
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 30rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
    }
    .overview-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 24rpx;
    }
    .overview-title {
    font-size: 32rpx;
    font-weight: bold;
    }
    .overview-date {
    font-size: 24rpx;
    color: #999;
    }
    .overview-grid {
    display: flex;
    justify-content: space-between;
    }
    .overview-item {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    }
    .ov-value {
    font-size: 40rpx;
    font-weight: bold;
    color: #F97316;
    }
    .ov-label {
    font-size: 22rpx;
    color: #999;
    margin-top: 8rpx;
    }
    .net-balance {
    margin-top: 24rpx;
    padding-top: 20rpx;
    border-top: 1rpx solid #f0f0f0;
    font-size: 26rpx;
    color: #666;
    text-align: center;
    }
    .goal-card {
    background: linear-gradient(135deg, #FF8C42, #F97316);
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 30rpx;
    color: #fff;
    }
    .goal-title {
    font-size: 32rpx;
    font-weight: bold;
    display: block;
    margin-bottom: 12rpx;
    }
    .goal-desc {
    font-size: 26rpx;
    display: block;
    margin-bottom: 20rpx;
    }
    .progress-bar {
    height: 16rpx;
    background: rgba(255, 255, 255, 0.3);
    border-radius: 8rpx;
    overflow: hidden;
    margin-bottom: 12rpx;
    }
    .progress-fill {
    height: 100%;
    background: #fff;
    border-radius: 8rpx;
    }
    .goal-progress-text {
    font-size: 24rpx;
    opacity: 0.9;
    }
    .quick-actions {
    display: flex;
    justify-content: space-between;
    margin-bottom: 30rpx;
    }
    .action-btn {
    flex: 1;
    margin: 0 10rpx;
    background: #fff;
    border-radius: 12rpx;
    padding: 24rpx 0;
    text-align: center;
    font-size: 26rpx;
    color: #333;
    border: 1rpx solid #eee;
    }
    .action-btn:first-child {
    margin-left: 0;
    }
    .action-btn:last-child {
    margin-right: 0;
    }
    .ai-entry {
    display: flex;
    align-items: center;
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 30rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
    }
    .ai-icon {
    font-size: 60rpx;
    margin-right: 20rpx;
    }
    .ai-info {
    flex: 1;
    display: flex;
    flex-direction: column;
    }
    .ai-title {
    font-size: 30rpx;
    font-weight: bold;
    }
    .ai-subtitle {
    font-size: 24rpx;
    color: #999;
    margin-top: 6rpx;
    }
    .ai-arrow {
    font-size: 32rpx;
    color: #ccc;
    }
    </style>
    
  • [ ] 步骤 2:校验 Vue 文件脚本块语法

运行:node -e "const fs=require('fs');const s=fs.readFileSync('xiaofat-miniprogram/pages/index/index.vue','utf8');const m=s.match(/<script>([\s\S]*?)<\/script>/);if(m){new Function(m[1].replace(/export default/g,'return'))}console.log('OK')" 预期:输出 OK

  • [ ] 步骤 3:Commit

    git add xiaofat-miniprogram/pages/index/index.vue
    git commit -m "feat(fatloss-miniprogram): add home page"
    

任务 9:前端 - 体重记录页(列表 + 创建 + 图表)

文件:

  • 创建:xiaofat-miniprogram/pages/weight/index.vue
  • 创建:xiaofat-miniprogram/pages/weight/create.vue
  • 创建:xiaofat-miniprogram/pages/weight/chart.vue

  • [ ] 步骤 1:创建 weight/index.vue(列表页)

    <template>
    <view class="page">
    <view class="summary-header">
      <view class="summary-item">
        <text class="s-value">{{ latestWeight }}</text>
        <text class="s-label">最新体重(kg)</text>
      </view>
      <view class="summary-item">
        <text class="s-value">{{ latestBodyFat }}</text>
        <text class="s-label">体脂率(%)</text>
      </view>
      <view class="summary-item">
        <text class="s-value">{{ totalRecords }}</text>
        <text class="s-label">记录次数</text>
      </view>
    </view>
    
    <view class="list-header">
      <text class="list-title">历史记录</text>
      <text class="chart-link" @click="goChart">查看趋势</text>
    </view>
    
    <view v-if="records.length === 0" class="empty">
      <text>还没有体重记录,点击下方按钮开始记录</text>
    </view>
    
    <view v-else class="record-list">
      <view class="record-item" v-for="(item, index) in records" :key="'w' + index">
        <view class="record-info">
          <text class="record-date">{{ item.measureDate }}</text>
          <text class="record-remark">{{ item.remark || '' }}</text>
        </view>
        <view class="record-values">
          <text class="record-weight">{{ item.weight }} kg</text>
          <text v-if="item.bodyFatPercent" class="record-fat">{{ item.bodyFatPercent }}%</text>
        </view>
        <text class="record-delete" @click="deleteRecord(item.id)">删除</text>
      </view>
    </view>
    
    <button class="btn-primary fixed-bottom" @click="goCreate">+ 记录体重</button>
    </view>
    </template>
    
    <script>
    import { getWeightList, deleteWeightRecord } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      records: [],
      latestWeight: '--',
      latestBodyFat: '--',
      totalRecords: 0
    }
    },
    onShow() {
    this.loadData()
    },
    methods: {
    loadData() {
      var self = this
      getWeightList(null, null, null).then(function(res) {
        self.records = res.data || []
        if (self.records.length > 0) {
          self.latestWeight = self.records[0].weight
          self.latestBodyFat = self.records[0].bodyFatPercent || '--'
        }
        self.totalRecords = self.records.length
      }).catch(function() {})
    },
    deleteRecord(id) {
      var self = this
      uni.showModal({
        title: '确认删除',
        content: '确定删除这条体重记录吗?',
        success: function(res) {
          if (res.confirm) {
            deleteWeightRecord(id).then(function() {
              uni.showToast({ title: '已删除', icon: 'success' })
              self.loadData()
            }).catch(function() {})
          }
        }
      })
    },
    goCreate() {
      uni.navigateTo({ url: '/pages/weight/create' })
    },
    goChart() {
      uni.navigateTo({ url: '/pages/weight/chart' })
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    padding-bottom: 140rpx;
    box-sizing: border-box;
    }
    .summary-header {
    display: flex;
    justify-content: space-around;
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx 0;
    margin-bottom: 30rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
    }
    .summary-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    }
    .s-value {
    font-size: 40rpx;
    font-weight: bold;
    color: #F97316;
    }
    .s-label {
    font-size: 22rpx;
    color: #999;
    margin-top: 8rpx;
    }
    .list-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20rpx;
    }
    .list-title {
    font-size: 30rpx;
    font-weight: bold;
    }
    .chart-link {
    font-size: 26rpx;
    color: #F97316;
    }
    .empty {
    text-align: center;
    color: #999;
    font-size: 26rpx;
    padding: 80rpx 0;
    }
    .record-list {
    background: #fff;
    border-radius: 16rpx;
    overflow: hidden;
    }
    .record-item {
    display: flex;
    align-items: center;
    padding: 24rpx 30rpx;
    border-bottom: 1rpx solid #f5f5f5;
    }
    .record-info {
    flex: 1;
    display: flex;
    flex-direction: column;
    }
    .record-date {
    font-size: 26rpx;
    color: #333;
    }
    .record-remark {
    font-size: 22rpx;
    color: #999;
    margin-top: 4rpx;
    }
    .record-values {
    display: flex;
    align-items: baseline;
    margin-right: 20rpx;
    }
    .record-weight {
    font-size: 30rpx;
    font-weight: bold;
    }
    .record-fat {
    font-size: 24rpx;
    color: #999;
    margin-left: 12rpx;
    }
    .record-delete {
    font-size: 24rpx;
    color: #ff4d4f;
    padding: 10rpx;
    }
    .fixed-bottom {
    position: fixed;
    bottom: 40rpx;
    left: 30rpx;
    right: 30rpx;
    background: #F97316;
    color: #fff;
    border-radius: 44rpx;
    }
    </style>
    
  • [ ] 步骤 2:创建 weight/create.vue

    <template>
    <view class="page">
    <view class="form-card">
      <view class="form-item">
        <text class="label">体重 (kg) *</text>
        <input class="input" type="digit" v-model="form.weight" placeholder="请输入体重" />
      </view>
      <view class="form-item">
        <text class="label">体脂率 (%)</text>
        <input class="input" type="digit" v-model="form.bodyFatPercent" placeholder="可选" />
      </view>
      <view class="form-item">
        <text class="label">腰围 (cm)</text>
        <input class="input" type="digit" v-model="form.waistCircumference" placeholder="可选" />
      </view>
      <view class="form-item">
        <text class="label">测量日期 *</text>
        <picker mode="date" :value="form.measureDate" @change="onDateChange">
          <view class="picker-value">{{ form.measureDate || '选择日期' }}</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">备注</text>
        <input class="input" v-model="form.remark" placeholder="可选" />
      </view>
    </view>
    <button class="btn-primary" @click="submit">保存记录</button>
    </view>
    </template>
    
    <script>
    import { createWeightRecord } from '../../utils/api.js'
    import { today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      form: {
        weight: '',
        bodyFatPercent: '',
        waistCircumference: '',
        measureDate: today(),
        remark: ''
      }
    }
    },
    methods: {
    onDateChange(e) {
      this.form.measureDate = e.detail.value
    },
    submit() {
      var self = this
      if (!this.form.weight) {
        uni.showToast({ title: '请输入体重', icon: 'none' })
        return
      }
      var data = {
        weight: parseFloat(this.form.weight),
        bodyFatPercent: this.form.bodyFatPercent ? parseFloat(this.form.bodyFatPercent) : null,
        waistCircumference: this.form.waistCircumference ? parseFloat(this.form.waistCircumference) : null,
        measureDate: this.form.measureDate,
        remark: this.form.remark
      }
      createWeightRecord(data).then(function() {
        uni.showToast({ title: '记录成功', icon: 'success' })
        setTimeout(function() {
          uni.navigateBack()
        }, 1000)
      }).catch(function() {})
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    box-sizing: border-box;
    }
    .form-card {
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 40rpx;
    }
    .form-item {
    margin-bottom: 30rpx;
    }
    .label {
    display: block;
    font-size: 28rpx;
    color: #333;
    margin-bottom: 12rpx;
    }
    .input {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 16rpx 20rpx;
    font-size: 28rpx;
    }
    .picker-value {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 16rpx 20rpx;
    font-size: 28rpx;
    color: #333;
    }
    .btn-primary {
    background: #F97316;
    color: #fff;
    border-radius: 44rpx;
    }
    </style>
    
  • [ ] 步骤 3:创建 weight/chart.vue(趋势图 - 使用 Canvas)

    <template>
    <view class="page">
    <view class="chart-container">
      <canvas canvas-id="weightChart" id="weightChart" class="chart-canvas"></canvas>
    </view>
    <view v-if="records.length === 0" class="empty">
      <text>暂无数据,请先记录体重</text>
    </view>
    </view>
    </template>
    
    <script>
    import { getWeightList } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      records: []
    }
    },
    onReady() {
    this.loadData()
    },
    methods: {
    loadData() {
      var self = this
      getWeightList(null, null, null).then(function(res) {
        self.records = res.data || []
        // 按日期升序
        self.records.reverse()
        if (self.records.length > 1) {
          self.drawChart()
        }
      }).catch(function() {})
    },
    drawChart() {
      var ctx = uni.createCanvasContext('weightChart', this)
      var data = this.records
      var width = 340
      var height = 300
      var padding = 30
    
      // 计算数据范围
      var weights = data.map(function(r) { return parseFloat(r.weight) })
      var min = Math.min.apply(null, weights)
      var max = Math.max.apply(null, weights)
      var range = (max - min) || 1
      // 上下留10%空间
      min = min - range * 0.1
      max = max + range * 0.1
      range = (max - min) || 1
    
      var chartW = width - padding * 2
      var chartH = height - padding * 2
    
      // 绘制网格和折线
      ctx.setStrokeStyle('#eee')
      ctx.setLineWidth(1)
      for (var i = 0; i <= 4; i++) {
        var y = padding + chartH * i / 4
        ctx.beginPath()
        ctx.moveTo(padding, y)
        ctx.lineTo(width - padding, y)
        ctx.stroke()
      }
    
      // 绘制折线
      ctx.setStrokeStyle('#F97316')
      ctx.setLineWidth(2)
      ctx.beginPath()
      var stepX = data.length > 1 ? chartW / (data.length - 1) : 0
      for (var j = 0; j < data.length; j++) {
        var x = padding + j * stepX
        var value = parseFloat(data[j].weight)
        var y2 = padding + chartH * (1 - (value - min) / range)
        if (j === 0) {
          ctx.moveTo(x, y2)
        } else {
          ctx.lineTo(x, y2)
        }
      }
      ctx.stroke()
    
      // 绘制数据点
      ctx.setFillStyle('#F97316')
      for (var k = 0; k < data.length; k++) {
        var x2 = padding + k * stepX
        var y3 = padding + chartH * (1 - (parseFloat(data[k].weight) - min) / range)
        ctx.beginPath()
        ctx.arc(x2, y3, 3, 0, Math.PI * 2)
        ctx.fill()
      }
    
      // 标签:首尾日期
      if (data.length > 0) {
        ctx.setFillStyle('#999')
        ctx.setFontSize(10)
        ctx.fillText(data[0].measureDate, padding, height - 5)
        if (data.length > 1) {
          ctx.fillText(data[data.length - 1].measureDate, width - padding - 50, height - 5)
        }
        // 最大最小值标注
        ctx.fillText('max ' + Math.max.apply(null, weights), padding + 5, padding + 10)
        ctx.fillText('min ' + Math.min.apply(null, weights), padding + 5, padding + chartH - 5)
      }
    
      ctx.draw()
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    box-sizing: border-box;
    }
    .chart-container {
    background: #fff;
    border-radius: 16rpx;
    padding: 20rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
    }
    .chart-canvas {
    width: 680rpx;
    height: 600rpx;
    }
    .empty {
    text-align: center;
    color: #999;
    font-size: 26rpx;
    padding: 80rpx 0;
    }
    </style>
    
  • [ ] 步骤 4:校验 Vue 文件语法 + Commit

    git add xiaofat-miniprogram/pages/weight/
    git commit -m "feat(fatloss-miniprogram): add weight tracking pages"
    

任务 10:前端 - 饮食记录页

文件:

  • 创建:xiaofat-miniprogram/pages/diet/index.vue
  • 创建:xiaofat-miniprogram/pages/diet/create.vue

  • [ ] 步骤 1:创建 diet/index.vue

    <template>
    <view class="page">
    <!-- 今日热量概览 -->
    <view class="calories-card">
      <text class="calories-title">今日摄入</text>
      <text class="calories-value">{{ todayCalories }} kcal</text>
      <text class="calories-target">目标:{{ dailyTarget }} kcal</text>
      <view class="calories-bar">
        <view class="calories-fill" :style="{ width: caloriesPercent + '%' }"></view>
      </view>
    </view>
    
    <view class="diet-list">
      <view v-for="mealGroup in mealGroups" :key="mealGroup.type" class="meal-section">
        <view class="meal-header">
          <text class="meal-name">{{ mealGroup.label }}</text>
          <text class="meal-calories">{{ mealGroup.total }} kcal</text>
        </view>
        <view v-for="(item, index) in mealGroup.items" :key="'m' + index" class="meal-item">
          <view class="meal-info">
            <text class="meal-food">{{ item.foodName }}</text>
            <text class="meal-gram">{{ item.foodWeight ? item.foodWeight + 'g' : '' }}</text>
          </view>
          <text class="meal-item-calories">{{ item.calories }} kcal</text>
          <text class="meal-delete" @click="deleteRecord(item.id)">删除</text>
        </view>
        <view v-if="mealGroup.items.length === 0" class="meal-empty">暂无记录</view>
      </view>
    </view>
    
    <button class="btn-primary fixed-bottom" @click="goCreate">+ 记录饮食</button>
    </view>
    </template>
    
    <script>
    import { getDietList, deleteDietRecord, getCaloriesSummary } from '../../utils/api.js'
    import { today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      records: [],
      todayCalories: 0,
      dailyTarget: 1500,
      mealGroups: [
        { type: 'breakfast', label: '早餐', items: [], total: 0 },
        { type: 'lunch', label: '午餐', items: [], total: 0 },
        { type: 'dinner', label: '晚餐', items: [], total: 0 },
        { type: 'snack', label: '加餐', items: [], total: 0 }
      ]
    }
    },
    onShow() {
    this.loadData()
    },
    computed: {
    caloriesPercent() {
      var pct = this.todayCalories / this.dailyTarget * 100
      return Math.min(100, pct)
    }
    },
    methods: {
    loadData() {
      var self = this
      var todayStr = today()
      getDietList(null, todayStr, todayStr).then(function(res) {
        self.records = res.data || []
        // 按餐次分组
        self.mealGroups.forEach(function(g) {
          g.items = self.records.filter(function(r) { return r.mealType === g.type })
          g.total = g.items.reduce(function(sum, r) { return sum + (r.calories || 0) }, 0)
        })
      }).catch(function() {})
      getCaloriesSummary(todayStr).then(function(res) {
        self.todayCalories = res.data || 0
      }).catch(function() {})
    },
    deleteRecord(id) {
      var self = this
      uni.showModal({
        title: '确认删除',
        content: '确定删除这条饮食记录吗?',
        success: function(res) {
          if (res.confirm) {
            deleteDietRecord(id).then(function() {
              uni.showToast({ title: '已删除', icon: 'success' })
              self.loadData()
            }).catch(function() {})
          }
        }
      })
    },
    goCreate() {
      uni.navigateTo({ url: '/pages/diet/create' })
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    padding-bottom: 140rpx;
    box-sizing: border-box;
    }
    .calories-card {
    background: linear-gradient(135deg, #FF8C42, #F97316);
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 30rpx;
    color: #fff;
    }
    .calories-title {
    display: block;
    font-size: 26rpx;
    opacity: 0.9;
    }
    .calories-value {
    display: block;
    font-size: 56rpx;
    font-weight: bold;
    margin: 10rpx 0;
    }
    .calories-target {
    display: block;
    font-size: 24rpx;
    opacity: 0.8;
    margin-bottom: 16rpx;
    }
    .calories-bar {
    height: 12rpx;
    background: rgba(255, 255, 255, 0.3);
    border-radius: 6rpx;
    overflow: hidden;
    }
    .calories-fill {
    height: 100%;
    background: #fff;
    border-radius: 6rpx;
    }
    .diet-list {
    background: #fff;
    border-radius: 16rpx;
    padding: 20rpx 30rpx;
    }
    .meal-section {
    margin-bottom: 30rpx;
    }
    .meal-header {
    display: flex;
    justify-content: space-between;
    padding-bottom: 16rpx;
    border-bottom: 1rpx solid #f0f0f0;
    margin-bottom: 12rpx;
    }
    .meal-name {
    font-size: 30rpx;
    font-weight: bold;
    }
    .meal-calories {
    font-size: 26rpx;
    color: #F97316;
    }
    .meal-item {
    display: flex;
    align-items: center;
    padding: 16rpx 0;
    }
    .meal-info {
    flex: 1;
    display: flex;
    flex-direction: column;
    }
    .meal-food {
    font-size: 26rpx;
    }
    .meal-gram {
    font-size: 22rpx;
    color: #999;
    }
    .meal-item-calories {
    font-size: 26rpx;
    color: #666;
    margin-right: 20rpx;
    }
    .meal-delete {
    font-size: 24rpx;
    color: #ff4d4f;
    }
    .meal-empty {
    text-align: center;
    color: #ccc;
    font-size: 24rpx;
    padding: 16rpx 0;
    }
    .fixed-bottom {
    position: fixed;
    bottom: 40rpx;
    left: 30rpx;
    right: 30rpx;
    background: #F97316;
    color: #fff;
    border-radius: 44rpx;
    }
    </style>
    
  • [ ] 步骤 2:创建 diet/create.vue

    <template>
    <view class="page">
    <view class="form-card">
      <view class="form-item">
        <text class="label">餐次 *</text>
        <picker :range="mealTypeOptions" range-key="label" @change="onMealTypeChange">
          <view class="picker-value">{{ mealTypeLabel }}</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">食物名称 *</text>
        <input class="input" v-model="form.foodName" placeholder="如:鸡胸肉" />
      </view>
      <view class="form-item">
        <text class="label">食物重量 (克)</text>
        <input class="input" type="digit" v-model="form.foodWeight" placeholder="可选" />
      </view>
      <view class="form-item">
        <text class="label">热量 (kcal) *</text>
        <input class="input" type="number" v-model="form.calories" placeholder="输入热量" />
      </view>
      <view class="form-item">
        <text class="label">日期 *</text>
        <picker mode="date" :value="form.eatDate" @change="onDateChange">
          <view class="picker-value">{{ form.eatDate || '选择日期' }}</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">备注</text>
        <input class="input" v-model="form.remark" placeholder="可选" />
      </view>
    </view>
    <button class="btn-primary" @click="submit">保存记录</button>
    </view>
    </template>
    
    <script>
    import { createDietRecord } from '../../utils/api.js'
    import { today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      mealTypeOptions: [
        { type: 'breakfast', label: '早餐' },
        { type: 'lunch', label: '午餐' },
        { type: 'dinner', label: '晚餐' },
        { type: 'snack', label: '加餐' }
      ],
      mealTypeLabel: '早餐',
      form: {
        mealType: 'breakfast',
        foodName: '',
        foodWeight: '',
        calories: '',
        eatDate: today(),
        remark: ''
      }
    }
    },
    methods: {
    onMealTypeChange(e) {
      var idx = e.detail.value
      this.mealTypeLabel = this.mealTypeOptions[idx].label
      this.form.mealType = this.mealTypeOptions[idx].type
    },
    onDateChange(e) {
      this.form.eatDate = e.detail.value
    },
    submit() {
      var self = this
      if (!this.form.foodName) {
        uni.showToast({ title: '请输入食物名称', icon: 'none' })
        return
      }
      if (!this.form.calories) {
        uni.showToast({ title: '请输入热量', icon: 'none' })
        return
      }
      var data = {
        mealType: this.form.mealType,
        foodName: this.form.foodName,
        foodWeight: this.form.foodWeight ? parseFloat(this.form.foodWeight) : null,
        calories: parseInt(this.form.calories),
        eatDate: this.form.eatDate,
        remark: this.form.remark
      }
      createDietRecord(data).then(function() {
        uni.showToast({ title: '记录成功', icon: 'success' })
        setTimeout(function() {
          uni.navigateBack()
        }, 1000)
      }).catch(function() {})
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    box-sizing: border-box;
    }
    .form-card {
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 40rpx;
    }
    .form-item {
    margin-bottom: 30rpx;
    }
    .label {
    display: block;
    font-size: 28rpx;
    color: #333;
    margin-bottom: 12rpx;
    }
    .input {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 16rpx 20rpx;
    font-size: 28rpx;
    }
    .picker-value {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 16rpx 20rpx;
    font-size: 28rpx;
    color: #333;
    }
    .btn-primary {
    background: #F97316;
    color: #fff;
    border-radius: 44rpx;
    }
    </style>
    
  • [ ] 步骤 3:校验 + Commit

    git add xiaofat-miniprogram/pages/diet/
    git commit -m "feat(fatloss-miniprogram): add diet tracking pages"
    

任务 11:前端 - 运动记录页

文件:

  • 创建:xiaofat-miniprogram/pages/exercise/index.vue
  • 创建:xiaofat-miniprogram/pages/exercise/create.vue

  • [ ] 步骤 1:创建 exercise/index.vue

    <template>
    <view class="page">
    <view class="summary-card">
      <view class="summary-item">
        <text class="s-value">{{ totalSessions }}</text>
        <text class="s-label">本周运动次数</text>
      </view>
      <view class="summary-item">
        <text class="s-value">{{ totalMinutes }}</text>
        <text class="s-label">本周运动时长(分)</text>
      </view>
      <view class="summary-item">
        <text class="s-value">{{ totalBurned }}</text>
        <text class="s-label">本周消耗(kcal)</text>
      </view>
    </view>
    
    <view v-if="records.length === 0" class="empty">
      <text>还没有运动记录,点击下方按钮开始打卡</text>
    </view>
    
    <view v-else class="record-list">
      <view class="record-item" v-for="(item, index) in records" :key="'e' + index">
        <view class="record-info">
          <text class="record-date">{{ item.exerciseDate }}</text>
          <text class="record-type">{{ item.exerciseType }}</text>
        </view>
        <view class="record-values">
          <text class="record-duration">{{ item.durationMin }} 分</text>
          <text v-if="item.distanceKm" class="record-distance">{{ item.distanceKm }} km</text>
          <text class="record-burned">{{ item.caloriesBurned }} kcal</text>
        </view>
        <text class="record-delete" @click="deleteRecord(item.id)">删除</text>
      </view>
    </view>
    
    <button class="btn-primary fixed-bottom" @click="goCreate">+ 运动打卡</button>
    </view>
    </template>
    
    <script>
    import { getExerciseList, deleteExerciseRecord } from '../../utils/api.js'
    import { today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      records: [],
      totalSessions: 0,
      totalMinutes: 0,
      totalBurned: 0
    }
    },
    onShow() {
    this.loadData()
    },
    methods: {
    loadData() {
      var self = this
      getExerciseList(null, null, null).then(function(res) {
        self.records = res.data || []
        var burned = 0
        var minutes = 0
        self.records.forEach(function(r) {
          burned += (r.caloriesBurned || 0)
          minutes += (r.durationMin || 0)
        })
        self.totalSessions = self.records.length
        self.totalMinutes = minutes
        self.totalBurned = burned
      }).catch(function() {})
    },
    deleteRecord(id) {
      var self = this
      uni.showModal({
        title: '确认删除',
        content: '确定删除这条运动记录吗?',
        success: function(res) {
          if (res.confirm) {
            deleteExerciseRecord(id).then(function() {
              uni.showToast({ title: '已删除', icon: 'success' })
              self.loadData()
            }).catch(function() {})
          }
        }
      })
    },
    goCreate() {
      uni.navigateTo({ url: '/pages/exercise/create' })
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    padding-bottom: 140rpx;
    box-sizing: border-box;
    }
    .summary-card {
    display: flex;
    justify-content: space-around;
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx 0;
    margin-bottom: 30rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
    }
    .summary-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    }
    .s-value {
    font-size: 36rpx;
    font-weight: bold;
    color: #10B981;
    }
    .s-label {
    font-size: 22rpx;
    color: #999;
    margin-top: 8rpx;
    }
    .empty {
    text-align: center;
    color: #999;
    font-size: 26rpx;
    padding: 80rpx 0;
    }
    .record-list {
    background: #fff;
    border-radius: 16rpx;
    overflow: hidden;
    }
    .record-item {
    display: flex;
    align-items: center;
    padding: 24rpx 30rpx;
    border-bottom: 1rpx solid #f5f5f5;
    }
    .record-info {
    flex: 1;
    display: flex;
    flex-direction: column;
    }
    .record-date {
    font-size: 26rpx;
    color: #333;
    }
    .record-type {
    font-size: 22rpx;
    color: #10B981;
    margin-top: 4rpx;
    }
    .record-values {
    display: flex;
    align-items: center;
    margin-right: 20rpx;
    }
    .record-duration {
    font-size: 26rpx;
    font-weight: bold;
    }
    .record-distance {
    font-size: 22rpx;
    color: #999;
    margin-left: 10rpx;
    }
    .record-burned {
    font-size: 24rpx;
    color: #F97316;
    margin-left: 16rpx;
    }
    .record-delete {
    font-size: 24rpx;
    color: #ff4d4f;
    padding: 10rpx;
    }
    .fixed-bottom {
    position: fixed;
    bottom: 40rpx;
    left: 30rpx;
    right: 30rpx;
    background: #10B981;
    color: #fff;
    border-radius: 44rpx;
    }
    </style>
    
  • [ ] 步骤 2:创建 exercise/create.vue

    <template>
    <view class="page">
    <view class="form-card">
      <view class="form-item">
        <text class="label">运动类型 *</text>
        <picker :range="exerciseTypes" @change="onTypeChange">
          <view class="picker-value">{{ form.exerciseType || '选择运动类型' }}</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">时长 (分钟) *</text>
        <input class="input" type="number" v-model="form.durationMin" placeholder="请输入时长" />
      </view>
      <view class="form-item">
        <text class="label">距离 (km)</text>
        <input class="input" type="digit" v-model="form.distanceKm" placeholder="可选" />
      </view>
      <view class="form-item">
        <text class="label">消耗热量 (kcal)</text>
        <input class="input" type="number" v-model="form.caloriesBurned" placeholder="可选" />
      </view>
      <view class="form-item">
        <text class="label">日期 *</text>
        <picker mode="date" :value="form.exerciseDate" @change="onDateChange">
          <view class="picker-value">{{ form.exerciseDate || '选择日期' }}</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">备注</text>
        <input class="input" v-model="form.remark" placeholder="可选" />
      </view>
    </view>
    <button class="btn-primary" @click="submit">保存记录</button>
    </view>
    </template>
    
    <script>
    import { createExerciseRecord } from '../../utils/api.js'
    import { today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      exerciseTypes: ['跑步', '快走', '游泳', '骑行', '健身', '瑜伽', '跳绳', '其他'],
      form: {
        exerciseType: '',
        durationMin: '',
        distanceKm: '',
        caloriesBurned: '',
        exerciseDate: today(),
        remark: ''
      }
    }
    },
    methods: {
    onTypeChange(e) {
      this.form.exerciseType = this.exerciseTypes[e.detail.value]
    },
    onDateChange(e) {
      this.form.exerciseDate = e.detail.value
    },
    submit() {
      var self = this
      if (!this.form.exerciseType) {
        uni.showToast({ title: '请选择运动类型', icon: 'none' })
        return
      }
      if (!this.form.durationMin) {
        uni.showToast({ title: '请输入运动时长', icon: 'none' })
        return
      }
      var data = {
        exerciseType: this.form.exerciseType,
        durationMin: parseInt(this.form.durationMin),
        distanceKm: this.form.distanceKm ? parseFloat(this.form.distanceKm) : null,
        caloriesBurned: this.form.caloriesBurned ? parseInt(this.form.caloriesBurned) : 0,
        exerciseDate: this.form.exerciseDate,
        remark: this.form.remark
      }
      createExerciseRecord(data).then(function() {
        uni.showToast({ title: '打卡成功', icon: 'success' })
        setTimeout(function() {
          uni.navigateBack()
        }, 1000)
      }).catch(function() {})
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    box-sizing: border-box;
    }
    .form-card {
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 40rpx;
    }
    .form-item {
    margin-bottom: 30rpx;
    }
    .label {
    display: block;
    font-size: 28rpx;
    color: #333;
    margin-bottom: 12rpx;
    }
    .input {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 16rpx 20rpx;
    font-size: 28rpx;
    }
    .picker-value {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 16rpx 20rpx;
    font-size: 28rpx;
    color: #333;
    }
    .btn-primary {
    background: #10B981;
    color: #fff;
    border-radius: 44rpx;
    }
    </style>
    
  • [ ] 步骤 3:校验 + Commit

    git add xiaofat-miniprogram/pages/exercise/
    git commit -m "feat(fatloss-miniprogram): add exercise tracking pages"
    

任务 12:前端 - AI 教练对话页

文件:

  • 创建:xiaofat-miniprogram/pages/ai-coach/index.vue

  • [ ] 步骤 1:创建 ai-coach/index.vue

    <template>
    <view class="chat-page">
    <!-- 聊天消息列表 -->
    <scroll-view scroll-y class="chat-list" :scroll-into-view="scrollIntoView">
      <view v-for="(msg, index) in messages" :key="'m' + index" class="msg-row" :class="msg.role === 'user' ? 'row-user' : 'row-ai'">
        <view class="msg-bubble" :class="msg.role === 'user' ? 'bubble-user' : 'bubble-ai'">
          <text class="msg-text">{{ msg.content }}</text>
        </view>
      </view>
      <view v-if="thinking" class="msg-row row-ai">
        <view class="msg-bubble bubble-ai thinking">
          <text>正在思考...</text>
        </view>
      </view>
      <view id="chat-bottom" style="height: 10px;"></view>
    </scroll-view>
    
    <!-- 快捷提问 -->
    <scroll-view scroll-x class="quick-questions">
      <view class="quick-item" v-for="(q, index) in quickQuestions" :key="'q' + index" @click="askQuick(q)">
        <text>{{ q }}</text>
      </view>
    </scroll-view>
    
    <!-- 输入栏 -->
    <view class="input-bar">
      <input class="chat-input" v-model="inputText" placeholder="问AI教练任何减脂问题" confirm-type="send" @confirm="sendMessage" />
      <button class="send-btn" @click="sendMessage" :disabled="!inputText">发送</button>
    </view>
    </view>
    </template>
    
    <script>
    import { aiCoachSend } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      messages: [
        { role: 'ai', content: '你好!我是你的 AI 减脂教练。我可以帮你分析体重数据、制定饮食计划、提供运动建议。有什么想问的吗?' }
      ],
      inputText: '',
      thinking: false,
      scrollIntoView: 'chat-bottom',
      quickQuestions: ['今天吃多少卡路里合适?', '我的体重为什么不降?', '推荐减脂早餐', '怎么安排运动?']
    }
    },
    onShow() {
    this.scrollToBottom()
    },
    methods: {
    askQuick(q) {
      this.inputText = q
      this.sendMessage()
    },
    sendMessage() {
      var self = this
      var text = this.inputText.trim()
      if (!text || this.thinking) return
      this.messages.push({ role: 'user', content: text })
      this.inputText = ''
      this.thinking = true
      this.scrollToBottom()
    
      aiCoachSend({ message: text }).then(function(res) {
        self.thinking = false
        var answer = res.data && res.data.reply
          ? res.data.reply
          : (res.data && res.data.content
            ? res.data.content
            : '抱歉,我暂时无法回答这个问题。')
        self.messages.push({ role: 'ai', content: answer })
        self.scrollToBottom()
      }).catch(function() {
        self.thinking = false
        self.messages.push({ role: 'ai', content: '网络异常,请稍后再试。' })
        self.scrollToBottom()
      })
    },
    scrollToBottom() {
      var self = this
      setTimeout(function() {
        self.scrollIntoView = 'chat-bottom'
      }, 100)
    }
    }
    }
    </script>
    
    <style scoped>
    .chat-page {
    display: flex;
    flex-direction: column;
    height: 100vh;
    }
    .chat-list {
    flex: 1;
    padding: 20rpx;
    box-sizing: border-box;
    }
    .msg-row {
    display: flex;
    margin-bottom: 20rpx;
    }
    .row-user {
    justify-content: flex-end;
    }
    .row-ai {
    justify-content: flex-start;
    }
    .msg-bubble {
    max-width: 70%;
    padding: 20rpx 24rpx;
    border-radius: 16rpx;
    font-size: 28rpx;
    line-height: 1.5;
    }
    .bubble-user {
    background: #F97316;
    color: #fff;
    border-top-right-radius: 4rpx;
    }
    .bubble-ai {
    background: #fff;
    color: #333;
    border-top-left-radius: 4rpx;
    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
    }
    .thinking {
    color: #999;
    }
    .quick-questions {
    white-space: nowrap;
    padding: 16rpx 20rpx;
    background: #fff;
    }
    .quick-item {
    display: inline-block;
    background: #FFF7ED;
    color: #F97316;
    border: 1rpx solid #FFE4CC;
    border-radius: 24rpx;
    padding: 10rpx 24rpx;
    font-size: 24rpx;
    margin-right: 16rpx;
    }
    .input-bar {
    display: flex;
    align-items: center;
    padding: 16rpx 20rpx;
    background: #fff;
    border-top: 1rpx solid #eee;
    }
    .chat-input {
    flex: 1;
    border: 1rpx solid #e5e5e5;
    border-radius: 36rpx;
    padding: 14rpx 24rpx;
    font-size: 28rpx;
    margin-right: 16rpx;
    }
    .send-btn {
    background: #F97316;
    color: #fff;
    border-radius: 36rpx;
    font-size: 26rpx;
    padding: 0 30rpx;
    line-height: 64rpx;
    height: 64rpx;
    }
    .send-btn[disabled] {
    background: #ccc;
    }
    </style>
    
  • [ ] 步骤 2:校验 + Commit

    git add xiaofat-miniprogram/pages/ai-coach/
    git commit -m "feat(fatloss-miniprogram): add AI coach chat page"
    

任务 13:前端 - 个人页(目标设定 + 个人信息)

文件:

  • 创建:xiaofat-miniprogram/pages/profile/index.vue

  • [ ] 步骤 1:创建 profile/index.vue

    <template>
    <view class="page">
    <!-- 用户信息 -->
    <view class="user-card">
      <view class="avatar">{{ nickname ? nickname.slice(0, 1) : '减' }}</view>
      <view class="user-info">
        <text class="user-name">{{ nickname || '减脂用户' }}</text>
        <text class="user-days">累计打卡 {{ totalDays }} 天</text>
      </view>
    </view>
    
    <!-- 目标设置 -->
    <view class="section">
      <text class="section-title">减脂目标</text>
      <view v-if="goal" class="goal-detail">
        <view class="goal-row">
          <text class="goal-label">初始体重</text>
          <text class="goal-value">{{ goal.startWeight }} kg</text>
        </view>
        <view class="goal-row">
          <text class="goal-label">目标体重</text>
          <text class="goal-value">{{ goal.targetWeight }} kg</text>
        </view>
        <view class="goal-row" v-if="goal.targetBodyFat">
          <text class="goal-label">目标体脂率</text>
          <text class="goal-value">{{ goal.targetBodyFat }}%</text>
        </view>
        <view class="goal-row" v-if="goal.targetDate">
          <text class="goal-label">目标日期</text>
          <text class="goal-value">{{ goal.targetDate }}</text>
        </view>
        <view class="goal-actions">
          <button class="btn-sm btn-complete" @click="completeGoal">达成目标</button>
          <button class="btn-sm btn-abandon" @click="abandonGoal">放弃目标</button>
        </view>
      </view>
      <view v-else class="goal-empty">
        <text>还没有设定减脂目标</text>
        <button class="btn-primary btn-small" @click="showGoalForm = true">立即设定</button>
      </view>
    </view>
    
    <!-- 设定目标表单(弹层) -->
    <view v-if="showGoalForm" class="modal-mask" @click="showGoalForm = false">
      <view class="modal-content" @click.stop>
        <text class="modal-title">设定减脂目标</text>
        <view class="form-item">
          <text class="label">初始体重 (kg) *</text>
          <input class="input" type="digit" v-model="goalForm.startWeight" placeholder="当前体重" />
        </view>
        <view class="form-item">
          <text class="label">目标体重 (kg) *</text>
          <input class="input" type="digit" v-model="goalForm.targetWeight" placeholder="目标体重" />
        </view>
        <view class="form-item">
          <text class="label">目标体脂率 (%)</text>
          <input class="input" type="digit" v-model="goalForm.targetBodyFat" placeholder="可选" />
        </view>
        <view class="form-item">
          <text class="label">目标日期</text>
          <picker mode="date" :value="goalForm.targetDate" @change="onTargetDateChange">
            <view class="picker-value">{{ goalForm.targetDate || '选择日期' }}</view>
          </picker>
        </view>
        <view class="modal-actions">
          <button class="btn-sm btn-cancel" @click="showGoalForm = false">取消</button>
          <button class="btn-sm btn-save" @click="submitGoal">保存目标</button>
        </view>
      </view>
    </view>
    
    <!-- 其他设置 -->
    <view class="section">
      <text class="section-title">其他</text>
      <view class="setting-item" @click="logout">
        <text class="setting-text">退出登录</text>
      </view>
    </view>
    </view>
    </template>
    
    <script>
    import { getActiveGoal, createGoal, completeGoal, abandonGoal } from '../../utils/api.js'
    import { today } from '../../utils/format.js'
    
    export default {
    data() {
    return {
      nickname: uni.getStorageSync('userInfo') ? uni.getStorageSync('userInfo').nickname : '',
      totalDays: 0,
      goal: null,
      showGoalForm: false,
      goalForm: {
        startWeight: '',
        targetWeight: '',
        targetBodyFat: '',
        targetDate: ''
      }
    }
    },
    onShow() {
    this.loadData()
    },
    methods: {
    loadData() {
      var self = this
      getActiveGoal().then(function(res) {
        self.goal = res.data || null
        if (self.goal) {
          self.goalForm.startWeight = self.goal.startWeight
          self.goalForm.targetWeight = self.goal.targetWeight
          self.goalForm.targetBodyFat = self.goal.targetBodyFat || ''
          self.goalForm.targetDate = self.goal.targetDate || ''
        }
      }).catch(function() {})
    },
    onTargetDateChange(e) {
      this.goalForm.targetDate = e.detail.value
    },
    submitGoal() {
      var self = this
      if (!this.goalForm.startWeight || !this.goalForm.targetWeight) {
        uni.showToast({ title: '请填写体重', icon: 'none' })
        return
      }
      var data = {
        startWeight: parseFloat(this.goalForm.startWeight),
        targetWeight: parseFloat(this.goalForm.targetWeight),
        targetBodyFat: this.goalForm.targetBodyFat ? parseFloat(this.goalForm.targetBodyFat) : null,
        targetDate: this.goalForm.targetDate || null,
        startDate: today()
      }
      var promise = this.goal
        ? createGoal(data)  // 简化:实现上应改为 update
        : createGoal(data)
      promise.then(function() {
        uni.showToast({ title: '目标已保存', icon: 'success' })
        self.showGoalForm = false
        self.loadData()
      }).catch(function() {})
    },
    completeGoal() {
      var self = this
      if (this.goal) {
        completeGoal(this.goal.id).then(function() {
          uni.showToast({ title: '恭喜达成目标!', icon: 'success' })
          self.loadData()
        }).catch(function() {})
      }
    },
    abandonGoal() {
      var self = this
      if (this.goal) {
        uni.showModal({
          title: '放弃目标',
          content: '确定要放弃当前的减脂目标吗?',
          success: function(res) {
            if (res.confirm) {
              abandonGoal(self.goal.id).then(function() {
                uni.showToast({ title: '已放弃', icon: 'none' })
                self.loadData()
              }).catch(function() {})
            }
          }
        })
      }
    },
    logout() {
      var self = this
      uni.showModal({
        title: '退出登录',
        content: '确定退出当前账号吗?',
        success: function(res) {
          if (res.confirm) {
            uni.removeStorageSync('token')
            uni.removeStorageSync('openid')
            uni.removeStorageSync('userId')
            uni.removeStorageSync('userInfo')
            self.$store.commit('logout')
            uni.reLaunch({ url: '/pages/index/index' })
          }
        }
      })
    }
    }
    }
    </script>
    
    <style scoped>
    .page {
    min-height: 100vh;
    padding: 30rpx;
    box-sizing: border-box;
    }
    .user-card {
    display: flex;
    align-items: center;
    background: linear-gradient(135deg, #FF8C42, #F97316);
    border-radius: 16rpx;
    padding: 40rpx 30rpx;
    margin-bottom: 30rpx;
    color: #fff;
    }
    .avatar {
    width: 100rpx;
    height: 100rpx;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.3);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 44rpx;
    margin-right: 24rpx;
    }
    .user-info {
    display: flex;
    flex-direction: column;
    }
    .user-name {
    font-size: 36rpx;
    font-weight: bold;
    }
    .user-days {
    font-size: 24rpx;
    opacity: 0.8;
    margin-top: 8rpx;
    }
    .section {
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 30rpx;
    }
    .section-title {
    display: block;
    font-size: 30rpx;
    font-weight: bold;
    margin-bottom: 20rpx;
    }
    .goal-row {
    display: flex;
    justify-content: space-between;
    padding: 14rpx 0;
    border-bottom: 1rpx solid #f5f5f5;
    }
    .goal-label {
    font-size: 26rpx;
    color: #666;
    }
    .goal-value {
    font-size: 26rpx;
    font-weight: bold;
    }
    .goal-actions {
    display: flex;
    margin-top: 20rpx;
    }
    .btn-sm {
    font-size: 24rpx;
    border-radius: 32rpx;
    padding: 0 30rpx;
    height: 56rpx;
    line-height: 56rpx;
    margin-right: 20rpx;
    }
    .btn-complete {
    background: #10B981;
    color: #fff;
    }
    .btn-abandon {
    background: #fff;
    color: #666;
    border: 1rpx solid #ddd;
    }
    .goal-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 30rpx 0;
    color: #999;
    font-size: 26rpx;
    }
    .btn-small {
    margin-top: 20rpx;
    background: #F97316;
    color: #fff;
    border-radius: 32rpx;
    font-size: 26rpx;
    padding: 0 40rpx;
    height: 60rpx;
    line-height: 60rpx;
    }
    .modal-mask {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 100;
    }
    .modal-content {
    width: 80%;
    background: #fff;
    border-radius: 16rpx;
    padding: 40rpx 30rpx;
    }
    .modal-title {
    display: block;
    font-size: 32rpx;
    font-weight: bold;
    text-align: center;
    margin-bottom: 30rpx;
    }
    .form-item {
    margin-bottom: 24rpx;
    }
    .label {
    display: block;
    font-size: 26rpx;
    color: #333;
    margin-bottom: 10rpx;
    }
    .input {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 14rpx 18rpx;
    font-size: 26rpx;
    }
    .picker-value {
    border: 1rpx solid #e5e5e5;
    border-radius: 8rpx;
    padding: 14rpx 18rpx;
    font-size: 26rpx;
    color: #333;
    }
    .modal-actions {
    display: flex;
    justify-content: flex-end;
    margin-top: 30rpx;
    }
    .btn-cancel {
    background: #fff;
    color: #666;
    border: 1rpx solid #ddd;
    }
    .btn-save {
    background: #F97316;
    color: #fff;
    }
    .setting-item {
    padding: 20rpx 0;
    border-bottom: 1rpx solid #f5f5f5;
    }
    .setting-text {
    font-size: 28rpx;
    color: #ff4d4f;
    }
    </style>
    
  • [ ] 步骤 2:校验 + Commit

    git add xiaofat-miniprogram/pages/profile/
    git commit -m "feat(fatloss-miniprogram): add profile and goal page"
    

任务 14:端到端验证

  • 步骤 1:后端编译

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • 步骤 2:前端语法校验

运行:find xiaofat-miniprogram -name "*.js" | xargs -I{} node --check {} 预期:所有文件无语法错误

  • 步骤 3:API 路由重复检查

运行:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep fatloss 预期:/api/fatloss/weight/list 等 14 个路由,每个唯一

  • 步骤 4:检查 API_REFERENCE.md 是否需要同步

运行:grep -c "fatloss" docs/superpowers/api/API_REFERENCE.md 预期:0(需要追加新接口文档)

  • 步骤 5:在 API_REFERENCE.md 追加减脂专题接口文档

在 docs/superpowers/api/API_REFERENCE.md 末尾追加:

## 减脂专题(/api/fatloss/*)

| 模块 | 端点 | 说明 |
|------|------|------|
| 体重 | `POST /api/fatloss/weight/list` | 体重记录列表(memberId/startDate/endDate) |
| 体重 | `POST /api/fatloss/weight/create` | 创建体重记录 |
| 体重 | `POST /api/fatloss/weight/update` | 更新体重记录 |
| 体重 | `POST /api/fatloss/weight/delete` | 删除体重记录 |
| 饮食 | `POST /api/fatloss/diet/list` | 饮食记录列表 |
| 饮食 | `POST /api/fatloss/diet/create` | 创建饮食记录 |
| 饮食 | `POST /api/fatloss/diet/delete` | 删除饮食记录 |
| 饮食 | `POST /api/fatloss/diet/calories-summary` | 当日总热量 |
| 运动 | `POST /api/fatloss/exercise/list` | 运动记录列表 |
| 运动 | `POST /api/fatloss/exercise/create` | 创建运动记录 |
| 运动 | `POST /api/fatloss/exercise/delete` | 删除运动记录 |
| 目标 | `POST /api/fatloss/goal/active` | 获取当前目标 |
| 目标 | `POST /api/fatloss/goal/create` | 创建目标 |
| 目标 | `POST /api/fatloss/goal/update` | 更新目标 |
| 目标 | `POST /api/fatloss/goal/complete` | 完成目标 |
| 目标 | `POST /api/fatloss/goal/abandon` | 放弃目标 |
  • [ ] 步骤 6:最终 Commit

    git add docs/superpowers/api/API_REFERENCE.md
    git commit -m "docs(api): add fatloss API reference"
    

后续专题扩展(需求确认后再启用)

以下专题在本计划(减脂 MVP)完成后进入规划,复用减脂专题已验证的基础设施 (登录流程、请求封装、AI 接入、数据表模式)。

睡眠专题(Phase 2)

  • 复用主系统 HealthSleepRecord 表(睡眠打卡)
  • 新增:睡眠周报分析、助眠建议、作息计划表
  • AI:LangGraph 睡眠分析 graph

心理健康专题(Phase 2)

  • 复用主系统冥想、情绪打卡模块
  • 新增:情绪日记、减压课程、量表测评
  • AI:LangGraph 情绪分析 graph

家庭教育专题(Phase 3)

  • 复用主系统任务、心愿、成长档案能力
  • 新增:亲子活动推荐、成长里程碑、
  • AI:LangGraph 教育建议 graph

专题 5(Phase 3)

  • 待与用户确认具体方向