|
|
@@ -0,0 +1,3784 @@
|
|
|
+# 减脂专题小程序 实现计划
|
|
|
+
|
|
|
+> **面向 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 张表定义**
|
|
|
+
|
|
|
+```sql
|
|
|
+-- ============ 减脂专题 ============
|
|
|
+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() 末尾追加迁移**
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 个实体类**
|
|
|
+
|
|
|
+```java
|
|
|
+// 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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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 接口**
|
|
|
+
|
|
|
+```java
|
|
|
+// 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> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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)**
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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(环境自适应)**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * 减脂营 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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```javascript
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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(精简版请求封装)**
|
|
|
+
|
|
|
+```javascript
|
|
|
+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**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * 时间格式化工具
|
|
|
+ * 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**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 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
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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(列表页)**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 末尾追加:
|
|
|
+
|
|
|
+```markdown
|
|
|
+## 减脂专题(/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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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)
|
|
|
+- 待与用户确认具体方向
|