面向 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
文件:
cfc-backend/src/main/resources/schema.sql修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
[x] 步骤 1:在 schema.sql 末尾追加 4 张表定义**
-- ============ 减脂专题 ============
CREATE TABLE IF NOT EXISTS fat_weight_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT NOT NULL COMMENT '家庭成员ID',
weight DECIMAL(5,2) NOT NULL COMMENT '体重(kg)',
body_fat_percent DECIMAL(4,2) DEFAULT NULL COMMENT '体脂率(%)',
waist_circumference DECIMAL(4,2) DEFAULT NULL COMMENT '腰围(cm)',
measure_date DATE NOT NULL COMMENT '测量日期',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_fat_weight_member (member_id, measure_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-体重记录';
CREATE TABLE IF NOT EXISTS fat_diet_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT NOT NULL COMMENT '家庭成员ID',
meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack',
food_name VARCHAR(255) NOT NULL COMMENT '食物名称',
food_weight DECIMAL(6,2) DEFAULT NULL COMMENT '食物重量(克)',
calories INT DEFAULT 0 COMMENT '热量(kcal)',
protein DECIMAL(6,2) DEFAULT 0 COMMENT '蛋白质(g)',
fat DECIMAL(6,2) DEFAULT 0 COMMENT '脂肪(g)',
carbs DECIMAL(6,2) DEFAULT 0 COMMENT '碳水(g)',
eat_date DATE NOT NULL COMMENT '进餐日期',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_fat_diet_member (member_id, eat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-饮食记录';
CREATE TABLE IF NOT EXISTS fat_exercise_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT NOT NULL COMMENT '家庭成员ID',
exercise_type VARCHAR(50) NOT NULL COMMENT '运动类型',
duration_min INT DEFAULT 0 COMMENT '时长(分钟)',
distance_km DECIMAL(5,2) DEFAULT NULL COMMENT '距离(km)',
calories_burned INT DEFAULT 0 COMMENT '消耗热量(kcal)',
exercise_date DATE NOT NULL COMMENT '运动日期',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_fat_exercise_member (member_id, exercise_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-运动记录';
CREATE TABLE IF NOT EXISTS fat_goal (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT NOT NULL COMMENT '家庭成员ID',
target_weight DECIMAL(5,2) NOT NULL COMMENT '目标体重(kg)',
start_weight DECIMAL(5,2) NOT NULL COMMENT '初始体重(kg)',
target_body_fat DECIMAL(4,2) DEFAULT NULL COMMENT '目标体脂率(%)',
target_date DATE DEFAULT NULL COMMENT '目标日期',
status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/abandoned',
start_date DATE NOT NULL COMMENT '开始日期',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_fat_goal_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-目标';
[x] 步骤 2:在 DatabaseInitializer.runMigrations() 末尾追加迁移**
// 迁移N+1: 创建减脂专题体重记录表
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_weight_record (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"member_id BIGINT NOT NULL, " +
"weight DECIMAL(5,2) NOT NULL, " +
"body_fat_percent DECIMAL(4,2) DEFAULT NULL, " +
"waist_circumference DECIMAL(4,2) DEFAULT NULL, " +
"measure_date DATE NOT NULL, " +
"remark VARCHAR(255) DEFAULT NULL, " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_fat_weight_member (member_id, measure_date)) " +
"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-体重记录'");
log.info("已创建fat_weight_record表");
} catch (Exception e) {
// 表已存在,忽略错误
}
// 迁移N+2: 创建减脂专题饮食记录表
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_diet_record (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"member_id BIGINT NOT NULL, " +
"meal_type VARCHAR(20) NOT NULL, " +
"food_name VARCHAR(255) NOT NULL, " +
"food_weight DECIMAL(6,2) DEFAULT NULL, " +
"calories INT DEFAULT 0, " +
"protein DECIMAL(6,2) DEFAULT 0, " +
"fat DECIMAL(6,2) DEFAULT 0, " +
"carbs DECIMAL(6,2) DEFAULT 0, " +
"eat_date DATE NOT NULL, " +
"remark VARCHAR(255) DEFAULT NULL, " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_fat_diet_member (member_id, eat_date)) " +
"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-饮食记录'");
log.info("已创建fat_diet_record表");
} catch (Exception e) {
// 表已存在,忽略错误
}
// 迁移N+3: 创建减脂专题运动记录表
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_exercise_record (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"member_id BIGINT NOT NULL, " +
"exercise_type VARCHAR(50) NOT NULL, " +
"duration_min INT DEFAULT 0, " +
"distance_km DECIMAL(5,2) DEFAULT NULL, " +
"calories_burned INT DEFAULT 0, " +
"exercise_date DATE NOT NULL, " +
"remark VARCHAR(255) DEFAULT NULL, " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_fat_exercise_member (member_id, exercise_date)) " +
"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-运动记录'");
log.info("已创建fat_exercise_record表");
} catch (Exception e) {
// 表已存在,忽略错误
}
// 迁移N+4: 创建减脂专题目标表
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_goal (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"member_id BIGINT NOT NULL, " +
"target_weight DECIMAL(5,2) NOT NULL, " +
"start_weight DECIMAL(5,2) NOT NULL, " +
"target_body_fat DECIMAL(4,2) DEFAULT NULL, " +
"target_date DATE DEFAULT NULL, " +
"status VARCHAR(20) DEFAULT 'active', " +
"start_date DATE NOT NULL, " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
"INDEX idx_fat_goal_member (member_id)) " +
"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂-目标'");
log.info("已创建fat_goal表");
} catch (Exception e) {
// 表已存在,忽略错误
}
[x] 步骤 3:编译验证**
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[x] 步骤 4:Commit**
git add cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
git commit -m "feat(fatloss): add 4 fat-loss tables migration"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/FatWeightRecord.javacfc-backend/src/main/java/com/etotem/cfc/entity/FatDietRecord.javacfc-backend/src/main/java/com/etotem/cfc/entity/FatExerciseRecord.javacfc-backend/src/main/java/com/etotem/cfc/entity/FatGoal.javacfc-backend/src/main/java/com/etotem/cfc/mapper/FatWeightRecordMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/FatDietRecordMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/FatExerciseRecordMapper.java创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/FatGoalMapper.java
[x] 步骤 1:创建 4 个实体类**
// FatWeightRecord.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("fat_weight_record")
public class FatWeightRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private BigDecimal weight;
private BigDecimal bodyFatPercent;
private BigDecimal waistCircumference;
private Date measureDate;
private String remark;
private Date createdAt;
}
// FatDietRecord.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("fat_diet_record")
public class FatDietRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private String mealType;
private String foodName;
private BigDecimal foodWeight;
private Integer calories;
private BigDecimal protein;
private BigDecimal fat;
private BigDecimal carbs;
private Date eatDate;
private String remark;
private Date createdAt;
}
// FatExerciseRecord.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("fat_exercise_record")
public class FatExerciseRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private String exerciseType;
private Integer durationMin;
private BigDecimal distanceKm;
private Integer caloriesBurned;
private Date exerciseDate;
private String remark;
private Date createdAt;
}
// FatGoal.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("fat_goal")
public class FatGoal {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private BigDecimal targetWeight;
private BigDecimal startWeight;
private BigDecimal targetBodyFat;
private Date targetDate;
private String status;
private Date startDate;
private Date createdAt;
private Date updatedAt;
}
[x] 步骤 2:创建 4 个 Mapper 接口**
// FatWeightRecordMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FatWeightRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FatWeightRecordMapper extends BaseMapper<FatWeightRecord> {
}
// FatDietRecordMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FatDietRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FatDietRecordMapper extends BaseMapper<FatDietRecord> {
}
// FatExerciseRecordMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FatExerciseRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FatExerciseRecordMapper extends BaseMapper<FatExerciseRecord> {
}
// FatGoalMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FatGoal;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FatGoalMapper extends BaseMapper<FatGoal> {
}
[x] 步骤 3:编译验证**
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[x] 步骤 4:Commit**
git add cfc-backend/src/main/java/com/etotem/cfc/entity/Fat*.java cfc-backend/src/main/java/com/etotem/cfc/mapper/Fat*Mapper.java
git commit -m "feat(fatloss): add entities and mappers"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/FatWeightService.javacfc-backend/src/main/java/com/etotem/cfc/service/FatDietService.javacfc-backend/src/main/java/com/etotem/cfc/service/FatExerciseService.java创建:cfc-backend/src/main/java/com/etotem/cfc/service/FatGoalService.java
[ ] 步骤 1:创建 FatWeightService
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.FatWeightRecord;
import com.etotem.cfc.mapper.FatWeightRecordMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
@Service
public class FatWeightService {
@Resource
private FatWeightRecordMapper fatWeightRecordMapper;
public List<FatWeightRecord> list(Long memberId, Date startDate, Date endDate) {
LambdaQueryWrapper<FatWeightRecord> wrapper = new LambdaQueryWrapper<FatWeightRecord>()
.eq(FatWeightRecord::getMemberId, memberId)
.orderByDesc(FatWeightRecord::getMeasureDate);
if (startDate != null) {
wrapper.ge(FatWeightRecord::getMeasureDate, startDate);
}
if (endDate != null) {
wrapper.le(FatWeightRecord::getMeasureDate, endDate);
}
return fatWeightRecordMapper.selectList(wrapper);
}
public FatWeightRecord create(FatWeightRecord record) {
record.setCreatedAt(new Date());
fatWeightRecordMapper.insert(record);
return record;
}
public FatWeightRecord update(FatWeightRecord record) {
fatWeightRecordMapper.updateById(record);
return record;
}
public boolean delete(Long id) {
return fatWeightRecordMapper.deleteById(id) > 0;
}
}
[ ] 步骤 2:创建 FatDietService
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.FatDietRecord;
import com.etotem.cfc.mapper.FatDietRecordMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
@Service
public class FatDietService {
@Resource
private FatDietRecordMapper fatDietRecordMapper;
public List<FatDietRecord> list(Long memberId, Date startDate, Date endDate) {
LambdaQueryWrapper<FatDietRecord> wrapper = new LambdaQueryWrapper<FatDietRecord>()
.eq(FatDietRecord::getMemberId, memberId)
.orderByDesc(FatDietRecord::getEatDate);
if (startDate != null) {
wrapper.ge(FatDietRecord::getEatDate, startDate);
}
if (endDate != null) {
wrapper.le(FatDietRecord::getEatDate, endDate);
}
return fatDietRecordMapper.selectList(wrapper);
}
public FatDietRecord create(FatDietRecord record) {
record.setCreatedAt(new Date());
fatDietRecordMapper.insert(record);
return record;
}
public boolean delete(Long id) {
return fatDietRecordMapper.deleteById(id) > 0;
}
/** 统计指定日期总热量 */
public int sumCalories(Long memberId, Date date) {
List<FatDietRecord> records = fatDietRecordMapper.selectList(
new LambdaQueryWrapper<FatDietRecord>()
.eq(FatDietRecord::getMemberId, memberId)
.eq(FatDietRecord::getEatDate, date));
int total = 0;
for (FatDietRecord r : records) {
if (r.getCalories() != null) total += r.getCalories();
}
return total;
}
}
[ ] 步骤 3:创建 FatExerciseService
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.FatExerciseRecord;
import com.etotem.cfc.mapper.FatExerciseRecordMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
@Service
public class FatExerciseService {
@Resource
private FatExerciseRecordMapper fatExerciseRecordMapper;
public List<FatExerciseRecord> list(Long memberId, Date startDate, Date endDate) {
LambdaQueryWrapper<FatExerciseRecord> wrapper = new LambdaQueryWrapper<FatExerciseRecord>()
.eq(FatExerciseRecord::getMemberId, memberId)
.orderByDesc(FatExerciseRecord::getExerciseDate);
if (startDate != null) {
wrapper.ge(FatExerciseRecord::getExerciseDate, startDate);
}
if (endDate != null) {
wrapper.le(FatExerciseRecord::getExerciseDate, endDate);
}
return fatExerciseRecordMapper.selectList(wrapper);
}
public FatExerciseRecord create(FatExerciseRecord record) {
record.setCreatedAt(new Date());
fatExerciseRecordMapper.insert(record);
return record;
}
public boolean delete(Long id) {
return fatExerciseRecordMapper.deleteById(id) > 0;
}
}
[ ] 步骤 4:创建 FatGoalService
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.FatGoal;
import com.etotem.cfc.mapper.FatGoalMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
@Service
public class FatGoalService {
@Resource
private FatGoalMapper fatGoalMapper;
public FatGoal getActive(Long memberId) {
return fatGoalMapper.selectOne(
new LambdaQueryWrapper<FatGoal>()
.eq(FatGoal::getMemberId, memberId)
.eq(FatGoal::getStatus, "active")
.orderByDesc(FatGoal::getCreatedAt)
.last("LIMIT 1"));
}
public FatGoal create(FatGoal goal) {
goal.setStatus("active");
goal.setCreatedAt(new Date());
goal.setUpdatedAt(new Date());
fatGoalMapper.insert(goal);
return goal;
}
public FatGoal update(FatGoal goal) {
goal.setUpdatedAt(new Date());
fatGoalMapper.updateById(goal);
return goal;
}
/** 完成目标 */
public FatGoal complete(Long id) {
FatGoal goal = fatGoalMapper.selectById(id);
if (goal != null) {
goal.setStatus("completed");
goal.setUpdatedAt(new Date());
fatGoalMapper.updateById(goal);
}
return goal;
}
/** 放弃目标 */
public FatGoal abandon(Long id) {
FatGoal goal = fatGoalMapper.selectById(id);
if (goal != null) {
goal.setStatus("abandoned");
goal.setUpdatedAt(new Date());
fatGoalMapper.updateById(goal);
}
return goal;
}
}
[ ] 步骤 5:编译验证
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 6:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/Fat*Service.java
git commit -m "feat(fatloss): add service layer"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatWeightController.javacfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatDietController.javacfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatExerciseController.java创建:cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatGoalController.java
[ ] 步骤 1:创建 FatWeightController
package com.etotem.cfc.controller.fatloss;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FatWeightRecord;
import com.etotem.cfc.service.FatWeightService;
import com.etotem.cfc.util.ParamUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Tag(name = "减脂-体重管理")
@RestController
@RequestMapping("/api/fatloss/weight")
public class FatWeightController {
@Resource
private FatWeightService fatWeightService;
@Operation(summary = "获取体重记录列表")
@PostMapping("/list")
public Result<List<FatWeightRecord>> list(@RequestBody(required = false) Map<String, Object> params,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
if (memberId == null) return Result.error("memberId不能为空");
Date startDate = ParamUtils.getDate(params == null ? null : params.get("startDate"));
Date endDate = ParamUtils.getDate(params == null ? null : params.get("endDate"));
return Result.success(fatWeightService.list(memberId, startDate, endDate));
}
@Operation(summary = "创建体重记录")
@PostMapping("/create")
public Result<FatWeightRecord> create(@RequestBody FatWeightRecord record,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (record.getMemberId() == null) record.setMemberId(currentMemberId);
if (record.getMemberId() == null) return Result.error("memberId不能为空");
if (record.getWeight() == null) return Result.error("体重不能为空");
if (record.getMeasureDate() == null) return Result.error("测量日期不能为空");
return Result.success(fatWeightService.create(record));
}
@Operation(summary = "更新体重记录")
@PostMapping("/update")
public Result<FatWeightRecord> update(@RequestBody FatWeightRecord record) {
if (record.getId() == null) return Result.error("id不能为空");
return Result.success(fatWeightService.update(record));
}
@Operation(summary = "删除体重记录")
@PostMapping("/delete")
public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
Long id = ParamUtils.getLong(params.get("id"));
if (id == null) return Result.error("id不能为空");
return Result.success(fatWeightService.delete(id));
}
}
[ ] 步骤 2:创建 FatDietController
package com.etotem.cfc.controller.fatloss;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FatDietRecord;
import com.etotem.cfc.service.FatDietService;
import com.etotem.cfc.util.ParamUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Tag(name = "减脂-饮食记录")
@RestController
@RequestMapping("/api/fatloss/diet")
public class FatDietController {
@Resource
private FatDietService fatDietService;
@Operation(summary = "获取饮食记录列表")
@PostMapping("/list")
public Result<List<FatDietRecord>> list(@RequestBody(required = false) Map<String, Object> params,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
if (memberId == null) return Result.error("memberId不能为空");
Date startDate = ParamUtils.getDate(params == null ? null : params.get("startDate"));
Date endDate = ParamUtils.getDate(params == null ? null : params.get("endDate"));
return Result.success(fatDietService.list(memberId, startDate, endDate));
}
@Operation(summary = "创建饮食记录")
@PostMapping("/create")
public Result<FatDietRecord> create(@RequestBody FatDietRecord record,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (record.getMemberId() == null) record.setMemberId(currentMemberId);
if (record.getMemberId() == null) return Result.error("memberId不能为空");
if (record.getEatDate() == null) return Result.error("进餐日期不能为空");
if (record.getFoodName() == null || record.getFoodName().isEmpty()) return Result.error("食物名称不能为空");
return Result.success(fatDietService.create(record));
}
@Operation(summary = "删除饮食记录")
@PostMapping("/delete")
public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
Long id = ParamUtils.getLong(params.get("id"));
if (id == null) return Result.error("id不能为空");
return Result.success(fatDietService.delete(id));
}
@Operation(summary = "获取当日总热量")
@PostMapping("/calories-summary")
public Result<Integer> caloriesSummary(@RequestBody(required = false) Map<String, Object> params,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
if (memberId == null) return Result.error("memberId不能为空");
Date date = ParamUtils.getDate(params == null ? null : params.get("date"));
if (date == null) date = new Date();
return Result.success(fatDietService.sumCalories(memberId, date));
}
}
[ ] 步骤 3:创建 FatExerciseController
package com.etotem.cfc.controller.fatloss;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FatExerciseRecord;
import com.etotem.cfc.service.FatExerciseService;
import com.etotem.cfc.util.ParamUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Tag(name = "减脂-运动记录")
@RestController
@RequestMapping("/api/fatloss/exercise")
public class FatExerciseController {
@Resource
private FatExerciseService fatExerciseService;
@Operation(summary = "获取运动记录列表")
@PostMapping("/list")
public Result<List<FatExerciseRecord>> list(@RequestBody(required = false) Map<String, Object> params,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
if (memberId == null) return Result.error("memberId不能为空");
Date startDate = ParamUtils.getDate(params == null ? null : params.get("startDate"));
Date endDate = ParamUtils.getDate(params == null ? null : params.get("endDate"));
return Result.success(fatExerciseService.list(memberId, startDate, endDate));
}
@Operation(summary = "创建运动记录")
@PostMapping("/create")
public Result<FatExerciseRecord> create(@RequestBody FatExerciseRecord record,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (record.getMemberId() == null) record.setMemberId(currentMemberId);
if (record.getMemberId() == null) return Result.error("memberId不能为空");
if (record.getExerciseDate() == null) return Result.error("运动日期不能为空");
if (record.getExerciseType() == null || record.getExerciseType().isEmpty()) return Result.error("运动类型不能为空");
return Result.success(fatExerciseService.create(record));
}
@Operation(summary = "删除运动记录")
@PostMapping("/delete")
public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
Long id = ParamUtils.getLong(params.get("id"));
if (id == null) return Result.error("id不能为空");
return Result.success(fatExerciseService.delete(id));
}
}
[ ] 步骤 4:创建 FatGoalController
package com.etotem.cfc.controller.fatloss;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FatGoal;
import com.etotem.cfc.service.FatGoalService;
import com.etotem.cfc.util.ParamUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Map;
@Tag(name = "减脂-目标管理")
@RestController
@RequestMapping("/api/fatloss/goal")
public class FatGoalController {
@Resource
private FatGoalService fatGoalService;
@Operation(summary = "获取当前减脂目标")
@PostMapping("/active")
public Result<FatGoal> active(@RequestBody(required = false) Map<String, Object> params,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
if (memberId == null) return Result.error("memberId不能为空");
return Result.success(fatGoalService.getActive(memberId));
}
@Operation(summary = "创建减脂目标")
@PostMapping("/create")
public Result<FatGoal> create(@RequestBody FatGoal goal,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (goal.getMemberId() == null) goal.setMemberId(currentMemberId);
if (goal.getMemberId() == null) return Result.error("memberId不能为空");
if (goal.getTargetWeight() == null) return Result.error("目标体重不能为空");
if (goal.getStartWeight() == null) return Result.error("初始体重不能为空");
if (goal.getStartDate() == null) return Result.error("开始日期不能为空");
return Result.success(fatGoalService.create(goal));
}
@Operation(summary = "更新减脂目标")
@PostMapping("/update")
public Result<FatGoal> update(@RequestBody FatGoal goal) {
if (goal.getId() == null) return Result.error("id不能为空");
return Result.success(fatGoalService.update(goal));
}
@Operation(summary = "完成减脂目标")
@PostMapping("/complete")
public Result<FatGoal> complete(@RequestBody Map<String, Object> params) {
Long id = ParamUtils.getLong(params.get("id"));
if (id == null) return Result.error("id不能为空");
return Result.success(fatGoalService.complete(id));
}
@Operation(summary = "放弃减脂目标")
@PostMapping("/abandon")
public Result<FatGoal> abandon(@RequestBody Map<String, Object> params) {
Long id = ParamUtils.getLong(params.get("id"));
if (id == null) return Result.error("id不能为空");
return Result.success(fatGoalService.abandon(id));
}
}
[ ] 步骤 5:编译验证
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:grep -rn '@Mapping' cfc-backend/src/.../controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep fatloss
预期:输出 /api/fatloss/weight/*、/api/fatloss/diet/*、/api/fatloss/exercise/*、/api/fatloss/goal/*,无重复
[ ] 步骤 7:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/
git commit -m "feat(fatloss): add controllers for weight/diet/exercise/goal"
文件:
修改:确认 cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java 已拦截 /api/**
[x] 步骤 1:确认 JWT 拦截覆盖新接口**
运行:grep -n "includePathPatterns\|addPathPatterns" cfc-backend/src/main/java/com/etotem/cfc/config/*Interceptor*.java
预期:addPathPatterns("/api/**") 已存在,新接口自动受保护(/api/fatloss/* 需要登录)
如果拦截器配置已覆盖 /api/** 且 8 个公开路径不包含 /api/fatloss,则无需修改。
文件:
xiaofat-miniprogram/manifest.jsonxiaofat-miniprogram/pages.jsonxiaofat-miniprogram/App.vuexiaofat-miniprogram/main.js创建:xiaofat-miniprogram/config.js
[ ] 步骤 1:创建 manifest.json
{
"name": "减脂营",
"appid": "__UNI__FATLOSS",
"description": "科学减脂,从记录开始",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "待注册新AppID",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true,
"permission": {}
},
"vueVersion": "2"
}
[ ] 步骤 2:创建 pages.json(5 个 TabBar)
{
"pages": [
{
"path": "pages/index/index",
"style": { "navigationBarTitleText": "减脂营" }
},
{
"path": "pages/weight/index",
"style": { "navigationBarTitleText": "体重" }
},
{
"path": "pages/diet/index",
"style": { "navigationBarTitleText": "饮食" }
},
{
"path": "pages/exercise/index",
"style": { "navigationBarTitleText": "运动" }
},
{
"path": "pages/ai-coach/index",
"style": { "navigationBarTitleText": "AI教练" }
}
],
"subPackages": [
{
"root": "pages/weight",
"pages": [
{ "path": "create", "style": { "navigationBarTitleText": "记录体重" } },
{ "path": "chart", "style": { "navigationBarTitleText": "体重趋势" } }
]
},
{
"root": "pages/diet",
"pages": [
{ "path": "create", "style": { "navigationBarTitleText": "记录饮食" } },
{ "path": "food-query", "style": { "navigationBarTitleText": "食物热量查询" } }
]
},
{
"root": "pages/exercise",
"pages": [
{ "path": "create", "style": { "navigationBarTitleText": "记录运动" } }
]
},
{
"root": "pages/profile",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "我的" } }
]
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "减脂营",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#F97316",
"borderStyle": "black",
"backgroundColor": "#FFFFFF",
"list": [
{ "pagePath": "pages/index/index", "text": "首页" },
{ "pagePath": "pages/weight/index", "text": "体重" },
{ "pagePath": "pages/diet/index", "text": "饮食" },
{ "pagePath": "pages/exercise/index", "text": "运动" },
{ "pagePath": "pages/ai-coach/index", "text": "AI教练" }
]
},
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
}
}
[ ] 步骤 3:创建 config.js(环境自适应)
/**
* 减脂营 API 配置
* 环境自动检测(同主项目 config.js 逻辑)
*/
let API_BASE_URL = 'https://cfc.etotem.com.cn'
try {
const accountInfo = uni.getAccountInfoSync()
const env = accountInfo && accountInfo.miniProgram && accountInfo.miniProgram.envVersion
if (env) {
switch (env) {
case 'develop':
API_BASE_URL = 'https://cfc.etotem.com.cn'
break
case 'trial':
API_BASE_URL = 'https://cfc.etotem.com.cn'
break
case 'release':
API_BASE_URL = 'https://cfc.etotem.com.cn'
break
}
}
} catch (e) {
// 非小程序环境使用默认地址
}
export default {
API_BASE_URL,
api(path) {
return API_BASE_URL + path
}
}
[ ] 步骤 4:创建 App.vue
<script>
import { silentLogin } from './utils/api.js'
export default {
onLaunch: function() {
var cachedOpenid = uni.getStorageSync('openid')
if (!cachedOpenid) {
// 首次启动,静默登录获取 openid
uni.login({
provider: 'weixin',
success: function(loginRes) {
if (loginRes && loginRes.code) {
silentLogin(loginRes.code).then(function(res) {
if (res && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('openid', res.data.openid)
uni.setStorageSync('userId', res.data.userId)
uni.setStorageSync('userInfo', {
nickname: res.data.nickname,
userId: res.data.userId,
familyId: res.data.familyId
})
}
}).catch(function() {
console.warn('[减脂营] 静默登录失败,用户需手动登录')
})
}
}
})
}
}
}
</script>
<style>
/* 全局样式 */
page {
background-color: #F8F8F8;
font-size: 28rpx;
color: #333;
}
</style>
[ ] 步骤 5:创建 main.js
import Vue from 'vue'
import App from './App'
import store from './store'
Vue.config.productionTip = false
Vue.prototype.$store = store
App.mpType = 'app'
const app = new Vue({
...App,
store
})
app.$mount()
[ ] 步骤 6:语法校验
运行:node --check xiaofat-miniprogram/config.js && node --check xiaofat-miniprogram/main.js
预期:无输出,exit code 0
[ ] 步骤 7:Commit
git add xiaofat-miniprogram/
git commit -m "feat(fatloss-miniprogram): init project scaffold"
文件:
xiaofat-miniprogram/utils/api.jsxiaofat-miniprogram/utils/format.jsxiaofat-miniprogram/store/index.js创建:xiaofat-miniprogram/store/modules/user.js
[ ] 步骤 1:创建 utils/api.js(精简版请求封装)
import config from '../config.js'
const BASE_URL = config.API_BASE_URL
var _dedupMap = {}
var DEDUP_WINDOW_MS = 3000
function _makeDedupKey(method, url, body) {
var bodyStr = ''
try { bodyStr = JSON.stringify(body || {}) } catch (e) { bodyStr = '' }
return method + '|' + url + '|' + bodyStr
}
const request = (url, method = 'POST', data = {}, options = {}) => {
var fullUrl = BASE_URL + url
const token = uni.getStorageSync('token')
var key = _makeDedupKey(method, fullUrl, data)
if (!options._skipDedup) {
var cached = _dedupMap[key]
if (cached && Date.now() - cached.ts < DEDUP_WINDOW_MS) {
return cached.promise
}
}
var _promise = new Promise((resolve, reject) => {
uni.request({
url: fullUrl,
method: method,
data: data,
header: {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
},
success: function(res) {
if (res.statusCode === 401) {
// 登录过期静默续期
uni.login({
provider: 'weixin',
success: function(loginRes) {
if (loginRes && loginRes.code) {
silentLogin(loginRes.code).then(function(sr) {
if (sr && sr.data && sr.data.token) {
uni.setStorageSync('token', sr.data.token)
uni.setStorageSync('openid', sr.data.openid)
resolve(sr.data)
} else {
uni.showToast({ title: '登录已过期,请重新进入', icon: 'none' })
reject(res.data)
}
})
}
}
})
return
}
if (res.data && res.data.code === 200) {
resolve(res.data)
} else {
var msg = res.data && res.data.message ? res.data.message : '请求失败'
uni.showToast({ title: msg, icon: 'none' })
reject(res.data)
}
},
fail: function(err) {
console.error('[API] 网络错误 ' + method + ' ' + fullUrl, err)
uni.showToast({ title: '网络请求失败', icon: 'none' })
reject(err)
}
})
})
if (!options._skipDedup) {
_dedupMap[key] = { promise: _promise, ts: Date.now() }
_promise.then(function() {
setTimeout(function() { delete _dedupMap[key] }, DEDUP_WINDOW_MS)
}, function() {
setTimeout(function() { delete _dedupMap[key] }, DEDUP_WINDOW_MS)
})
}
return _promise
}
// ===== 认证 =====
export const silentLogin = (code, options) => {
return request('/api/auth/silent-login', 'POST', { code }, options || {})
}
export const wechatLogin = (code, userInfo) => {
return request('/api/auth/wechat-login', 'POST', { code, nickname: userInfo.nickname, avatar: userInfo.avatar })
}
export const getUserInfo = () => {
return request('/api/user/info', 'POST', {})
}
// ===== 减脂专题 API =====
// 体重
export const getWeightList = (memberId, startDate, endDate) => {
var data = {}
if (memberId) data.memberId = memberId
if (startDate) data.startDate = startDate
if (endDate) data.endDate = endDate
return request('/api/fatloss/weight/list', 'POST', data)
}
export const createWeightRecord = (data) => {
return request('/api/fatloss/weight/create', 'POST', data)
}
export const updateWeightRecord = (data) => {
return request('/api/fatloss/weight/update', 'POST', data)
}
export const deleteWeightRecord = (id) => {
return request('/api/fatloss/weight/delete', 'POST', { id: id })
}
// 饮食
export const getDietList = (memberId, startDate, endDate) => {
var data = {}
if (memberId) data.memberId = memberId
if (startDate) data.startDate = startDate
if (endDate) data.endDate = endDate
return request('/api/fatloss/diet/list', 'POST', data)
}
export const createDietRecord = (data) => {
return request('/api/fatloss/diet/create', 'POST', data)
}
export const deleteDietRecord = (id) => {
return request('/api/fatloss/diet/delete', 'POST', { id: id })
}
export const getCaloriesSummary = (date) => {
var data = {}
if (date) data.date = date
return request('/api/fatloss/diet/calories-summary', 'POST', data)
}
// 运动
export const getExerciseList = (memberId, startDate, endDate) => {
var data = {}
if (memberId) data.memberId = memberId
if (startDate) data.startDate = startDate
if (endDate) data.endDate = endDate
return request('/api/fatloss/exercise/list', 'POST', data)
}
export const createExerciseRecord = (data) => {
return request('/api/fatloss/exercise/create', 'POST', data)
}
export const deleteExerciseRecord = (id) => {
return request('/api/fatloss/exercise/delete', 'POST', { id: id })
}
// 目标
export const getActiveGoal = () => {
return request('/api/fatloss/goal/active', 'POST', {})
}
export const createGoal = (data) => {
return request('/api/fatloss/goal/create', 'POST', data)
}
export const updateGoal = (data) => {
return request('/api/fatloss/goal/update', 'POST', data)
}
export const completeGoal = (id) => {
return request('/api/fatloss/goal/complete', 'POST', { id: id })
}
export const abandonGoal = (id) => {
return request('/api/fatloss/goal/abandon', 'POST', { id: id })
}
// ===== AI 教练(走主系统 AiGateway) =====
export const aiCoachSend = (data) => {
return request('/api/ai/health-coach/send', 'POST', data)
}
[ ] 步骤 2:创建 utils/format.js
/**
* 时间格式化工具
* iOS 安全:统一用 parseDate 解析后手动拼格式
*/
export function parseDate(str) {
if (!str) return null
if (str instanceof Date) return str
var normalized = String(str)
.replace(' ', 'T')
.replace('CST', '')
.trim()
var d = new Date(normalized)
return isNaN(d.getTime()) ? null : d
}
export function formatDateTime(str) {
var d = parseDate(str)
if (!d) return ''
function pad(n) { return n < 10 ? '0' + n : '' + n }
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
}
export function formatDate(str) {
var d = parseDate(str)
if (!d) return ''
function pad(n) { return n < 10 ? '0' + n : '' + n }
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
}
export function today() {
var d = new Date()
function pad(n) { return n < 10 ? '0' + n : '' + n }
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
}
[ ] 步骤 3:创建 store/index.js 与 store/modules/user.js
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
user: user
}
})
// store/modules/user.js
export default {
state: {
token: '',
userId: null,
memberId: null,
nickname: '',
familyId: null,
openid: '',
isLoggedIn: false
},
mutations: {
setToken(state, token) {
state.token = token
uni.setStorageSync('token', token)
},
setUserInfo(state, info) {
state.userId = info.userId
state.memberId = info.memberId
state.nickname = info.nickname
state.familyId = info.familyId
state.openid = info.openid
state.isLoggedIn = !!info.userId
},
logout(state) {
state.token = ''
state.userId = null
state.memberId = null
state.nickname = ''
state.familyId = null
state.openid = ''
state.isLoggedIn = false
uni.removeStorageSync('token')
uni.removeStorageSync('openid')
uni.removeStorageSync('userId')
}
},
actions: {
initFromStorage({ commit }) {
var token = uni.getStorageSync('token')
var openid = uni.getStorageSync('openid')
var userId = uni.getStorageSync('userId')
if (token) {
commit('setToken', token)
}
if (userId) {
var userInfo = uni.getStorageSync('userInfo') || {}
commit('setUserInfo', {
userId: userId,
memberId: userInfo.memberId,
nickname: userInfo.nickname,
familyId: userInfo.familyId,
openid: openid
})
}
}
}
}
[ ] 步骤 4:语法校验
运行:node --check xiaofat-miniprogram/utils/api.js && node --check xiaofat-miniprogram/utils/format.js && node --check xiaofat-miniprogram/store/index.js && node --check xiaofat-miniprogram/store/modules/user.js
预期:无输出,exit code 0
[ ] 步骤 5:Commit
git add xiaofat-miniprogram/utils/ xiaofat-miniprogram/store/
git commit -m "feat(fatloss-miniprogram): add api wrappers and store"
文件:
创建:xiaofat-miniprogram/pages/index/index.vue
[ ] 步骤 1:创建 index.vue
<template>
<view class="page">
<!-- 未登录引导 -->
<view v-if="!isLoggedIn" class="login-guide">
<view class="logo">🏋️</view>
<text class="title">科学减脂,从记录开始</text>
<text class="subtitle">记录体重、饮食、运动,AI 为您定制专属方案</text>
<button class="btn-primary" @click="handleLogin">微信一键登录</button>
</view>
<!-- 已登录主界面 -->
<view v-else>
<!-- 今日概览卡片 -->
<view class="overview-card">
<view class="overview-header">
<text class="overview-title">今日概览</text>
<text class="overview-date">{{ todayStr }}</text>
</view>
<view class="overview-grid">
<view class="overview-item" @click="goPage('/pages/weight/index')">
<text class="ov-value">{{ todayCalories }}</text>
<text class="ov-label">摄入热量(kcal)</text>
</view>
<view class="overview-item" @click="goPage('/pages/exercise/index')">
<text class="ov-value">{{ todayBurned }}</text>
<text class="ov-label">消耗热量(kcal)</text>
</view>
<view class="overview-item">
<text class="ov-value">{{ todayWeight }}</text>
<text class="ov-label">最新体重(kg)</text>
</view>
</view>
<view class="net-balance">
热量缺口:{{ netCalories }} kcal
</view>
</view>
<!-- 目标进度 -->
<view class="goal-card" v-if="goal">
<text class="goal-title">当前目标</text>
<text class="goal-desc">从 {{ goal.startWeight }}kg → {{ goal.targetWeight }}kg</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: goalProgress + '%' }"></view>
</view>
<text class="goal-progress-text">{{ goalProgress }}%</text>
</view>
<view class="goal-card" v-else @click="goPage('/pages/profile/index')">
<text class="goal-title">还没有减脂目标?</text>
<text class="goal-desc">点击设置目标,开启减脂之旅 →</text>
</view>
<!-- 快捷操作 -->
<view class="quick-actions">
<view class="action-btn" @click="goPage('/pages/weight/create')">记录体重</view>
<view class="action-btn" @click="goPage('/pages/diet/create')">记录饮食</view>
<view class="action-btn" @click="goPage('/pages/exercise/create')">记录运动</view>
</view>
<!-- AI 教练入口 -->
<view class="ai-entry" @click="goPage('/pages/ai-coach/index')">
<text class="ai-icon">🤖</text>
<view class="ai-info">
<text class="ai-title">AI 减脂教练</text>
<text class="ai-subtitle">随时提问,获得个性化建议</text>
</view>
<text class="ai-arrow">→</text>
</view>
</view>
</view>
</template>
<script>
import { getCaloriesSummary, getWeightList, getExerciseList, getActiveGoal } from '../../utils/api.js'
import { formatDate, today } from '../../utils/format.js'
export default {
data() {
return {
isLoggedIn: false,
todayStr: formatDate(new Date()),
todayCalories: 0,
todayBurned: 0,
todayWeight: '--',
netCalories: 0,
goal: null,
goalProgress: 0
}
},
onShow() {
this.isLoggedIn = !!uni.getStorageSync('token')
if (this.isLoggedIn) {
this.loadOverview()
}
},
methods: {
loadOverview() {
var self = this
var todayStr = today()
// 当日热量摄入
getCaloriesSummary(todayStr).then(function(res) {
self.todayCalories = res.data || 0
}).catch(function() {})
// 当日运动消耗
getExerciseList(null, todayStr, todayStr).then(function(res) {
var records = res.data || []
var burned = 0
records.forEach(function(r) {
burned += (r.caloriesBurned || 0)
})
self.todayBurned = burned
}).catch(function() {})
// 最新体重
getWeightList(null, null, null).then(function(res) {
var records = res.data || []
if (records.length > 0) {
self.todayWeight = records[0].weight
}
}).catch(function() {})
// 当前目标
getActiveGoal().then(function(res) {
var goal = res.data
if (goal) {
self.goal = goal
var start = parseFloat(goal.startWeight)
var target = parseFloat(goal.targetWeight)
var diff = start - target
if (diff > 0) {
// 需要计算当前体重
getWeightList(null, null, null).then(function(wres) {
var records = wres.data || []
if (records.length > 0) {
var current = parseFloat(records[0].weight)
var progress = Math.min(100, Math.max(0, (start - current) / diff * 100))
self.goalProgress = Math.round(progress)
}
})
} else {
self.goalProgress = 100
}
}
}).catch(function() {})
},
handleLogin() {
var self = this
uni.login({
provider: 'weixin',
success: function(loginRes) {
if (loginRes && loginRes.code) {
// 调用主系统静默登录(首次会创建账号)
uni.request({
url: 'https://cfc.etotem.com.cn/api/auth/silent-login',
method: 'POST',
data: { code: loginRes.code },
header: { 'Content-Type': 'application/json' },
success: function(res) {
if (res.data && res.data.code === 200 && res.data.data) {
var d = res.data.data
uni.setStorageSync('token', d.token)
uni.setStorageSync('openid', d.openid)
uni.setStorageSync('userId', d.userId)
uni.setStorageSync('userInfo', {
nickname: d.nickname,
userId: d.userId,
familyId: d.familyId
})
self.$store.commit('setToken', d.token)
self.$store.commit('setUserInfo', {
userId: d.userId,
nickname: d.nickname,
familyId: d.familyId,
openid: d.openid
})
self.isLoggedIn = true
self.loadOverview()
} else {
uni.showToast({ title: '登录失败', icon: 'none' })
}
},
fail: function() {
uni.showToast({ title: '网络请求失败', icon: 'none' })
}
})
}
}
})
},
goPage(url) {
uni.navigateTo({ url: url })
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
}
.login-guide {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 200rpx;
}
.logo {
font-size: 120rpx;
margin-bottom: 30rpx;
}
.title {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.subtitle {
font-size: 26rpx;
color: #999;
text-align: center;
margin-bottom: 60rpx;
padding: 0 60rpx;
}
.btn-primary {
background: #F97316;
color: #fff;
border-radius: 44rpx;
padding: 20rpx 80rpx;
font-size: 32rpx;
}
.overview-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.overview-title {
font-size: 32rpx;
font-weight: bold;
}
.overview-date {
font-size: 24rpx;
color: #999;
}
.overview-grid {
display: flex;
justify-content: space-between;
}
.overview-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.ov-value {
font-size: 40rpx;
font-weight: bold;
color: #F97316;
}
.ov-label {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.net-balance {
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
font-size: 26rpx;
color: #666;
text-align: center;
}
.goal-card {
background: linear-gradient(135deg, #FF8C42, #F97316);
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
color: #fff;
}
.goal-title {
font-size: 32rpx;
font-weight: bold;
display: block;
margin-bottom: 12rpx;
}
.goal-desc {
font-size: 26rpx;
display: block;
margin-bottom: 20rpx;
}
.progress-bar {
height: 16rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 8rpx;
overflow: hidden;
margin-bottom: 12rpx;
}
.progress-fill {
height: 100%;
background: #fff;
border-radius: 8rpx;
}
.goal-progress-text {
font-size: 24rpx;
opacity: 0.9;
}
.quick-actions {
display: flex;
justify-content: space-between;
margin-bottom: 30rpx;
}
.action-btn {
flex: 1;
margin: 0 10rpx;
background: #fff;
border-radius: 12rpx;
padding: 24rpx 0;
text-align: center;
font-size: 26rpx;
color: #333;
border: 1rpx solid #eee;
}
.action-btn:first-child {
margin-left: 0;
}
.action-btn:last-child {
margin-right: 0;
}
.ai-entry {
display: flex;
align-items: center;
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.ai-icon {
font-size: 60rpx;
margin-right: 20rpx;
}
.ai-info {
flex: 1;
display: flex;
flex-direction: column;
}
.ai-title {
font-size: 30rpx;
font-weight: bold;
}
.ai-subtitle {
font-size: 24rpx;
color: #999;
margin-top: 6rpx;
}
.ai-arrow {
font-size: 32rpx;
color: #ccc;
}
</style>
[ ] 步骤 2:校验 Vue 文件脚本块语法
运行:node -e "const fs=require('fs');const s=fs.readFileSync('xiaofat-miniprogram/pages/index/index.vue','utf8');const m=s.match(/<script>([\s\S]*?)<\/script>/);if(m){new Function(m[1].replace(/export default/g,'return'))}console.log('OK')"
预期:输出 OK
[ ] 步骤 3:Commit
git add xiaofat-miniprogram/pages/index/index.vue
git commit -m "feat(fatloss-miniprogram): add home page"
文件:
xiaofat-miniprogram/pages/weight/index.vuexiaofat-miniprogram/pages/weight/create.vue创建:xiaofat-miniprogram/pages/weight/chart.vue
[ ] 步骤 1:创建 weight/index.vue(列表页)
<template>
<view class="page">
<view class="summary-header">
<view class="summary-item">
<text class="s-value">{{ latestWeight }}</text>
<text class="s-label">最新体重(kg)</text>
</view>
<view class="summary-item">
<text class="s-value">{{ latestBodyFat }}</text>
<text class="s-label">体脂率(%)</text>
</view>
<view class="summary-item">
<text class="s-value">{{ totalRecords }}</text>
<text class="s-label">记录次数</text>
</view>
</view>
<view class="list-header">
<text class="list-title">历史记录</text>
<text class="chart-link" @click="goChart">查看趋势</text>
</view>
<view v-if="records.length === 0" class="empty">
<text>还没有体重记录,点击下方按钮开始记录</text>
</view>
<view v-else class="record-list">
<view class="record-item" v-for="(item, index) in records" :key="'w' + index">
<view class="record-info">
<text class="record-date">{{ item.measureDate }}</text>
<text class="record-remark">{{ item.remark || '' }}</text>
</view>
<view class="record-values">
<text class="record-weight">{{ item.weight }} kg</text>
<text v-if="item.bodyFatPercent" class="record-fat">{{ item.bodyFatPercent }}%</text>
</view>
<text class="record-delete" @click="deleteRecord(item.id)">删除</text>
</view>
</view>
<button class="btn-primary fixed-bottom" @click="goCreate">+ 记录体重</button>
</view>
</template>
<script>
import { getWeightList, deleteWeightRecord } from '../../utils/api.js'
export default {
data() {
return {
records: [],
latestWeight: '--',
latestBodyFat: '--',
totalRecords: 0
}
},
onShow() {
this.loadData()
},
methods: {
loadData() {
var self = this
getWeightList(null, null, null).then(function(res) {
self.records = res.data || []
if (self.records.length > 0) {
self.latestWeight = self.records[0].weight
self.latestBodyFat = self.records[0].bodyFatPercent || '--'
}
self.totalRecords = self.records.length
}).catch(function() {})
},
deleteRecord(id) {
var self = this
uni.showModal({
title: '确认删除',
content: '确定删除这条体重记录吗?',
success: function(res) {
if (res.confirm) {
deleteWeightRecord(id).then(function() {
uni.showToast({ title: '已删除', icon: 'success' })
self.loadData()
}).catch(function() {})
}
}
})
},
goCreate() {
uni.navigateTo({ url: '/pages/weight/create' })
},
goChart() {
uni.navigateTo({ url: '/pages/weight/chart' })
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
padding-bottom: 140rpx;
box-sizing: border-box;
}
.summary-header {
display: flex;
justify-content: space-around;
background: #fff;
border-radius: 16rpx;
padding: 30rpx 0;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
}
.s-value {
font-size: 40rpx;
font-weight: bold;
color: #F97316;
}
.s-label {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.list-title {
font-size: 30rpx;
font-weight: bold;
}
.chart-link {
font-size: 26rpx;
color: #F97316;
}
.empty {
text-align: center;
color: #999;
font-size: 26rpx;
padding: 80rpx 0;
}
.record-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.record-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.record-info {
flex: 1;
display: flex;
flex-direction: column;
}
.record-date {
font-size: 26rpx;
color: #333;
}
.record-remark {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.record-values {
display: flex;
align-items: baseline;
margin-right: 20rpx;
}
.record-weight {
font-size: 30rpx;
font-weight: bold;
}
.record-fat {
font-size: 24rpx;
color: #999;
margin-left: 12rpx;
}
.record-delete {
font-size: 24rpx;
color: #ff4d4f;
padding: 10rpx;
}
.fixed-bottom {
position: fixed;
bottom: 40rpx;
left: 30rpx;
right: 30rpx;
background: #F97316;
color: #fff;
border-radius: 44rpx;
}
</style>
[ ] 步骤 2:创建 weight/create.vue
<template>
<view class="page">
<view class="form-card">
<view class="form-item">
<text class="label">体重 (kg) *</text>
<input class="input" type="digit" v-model="form.weight" placeholder="请输入体重" />
</view>
<view class="form-item">
<text class="label">体脂率 (%)</text>
<input class="input" type="digit" v-model="form.bodyFatPercent" placeholder="可选" />
</view>
<view class="form-item">
<text class="label">腰围 (cm)</text>
<input class="input" type="digit" v-model="form.waistCircumference" placeholder="可选" />
</view>
<view class="form-item">
<text class="label">测量日期 *</text>
<picker mode="date" :value="form.measureDate" @change="onDateChange">
<view class="picker-value">{{ form.measureDate || '选择日期' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">备注</text>
<input class="input" v-model="form.remark" placeholder="可选" />
</view>
</view>
<button class="btn-primary" @click="submit">保存记录</button>
</view>
</template>
<script>
import { createWeightRecord } from '../../utils/api.js'
import { today } from '../../utils/format.js'
export default {
data() {
return {
form: {
weight: '',
bodyFatPercent: '',
waistCircumference: '',
measureDate: today(),
remark: ''
}
}
},
methods: {
onDateChange(e) {
this.form.measureDate = e.detail.value
},
submit() {
var self = this
if (!this.form.weight) {
uni.showToast({ title: '请输入体重', icon: 'none' })
return
}
var data = {
weight: parseFloat(this.form.weight),
bodyFatPercent: this.form.bodyFatPercent ? parseFloat(this.form.bodyFatPercent) : null,
waistCircumference: this.form.waistCircumference ? parseFloat(this.form.waistCircumference) : null,
measureDate: this.form.measureDate,
remark: this.form.remark
}
createWeightRecord(data).then(function() {
uni.showToast({ title: '记录成功', icon: 'success' })
setTimeout(function() {
uni.navigateBack()
}, 1000)
}).catch(function() {})
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
}
.form-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 40rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.input {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
}
.picker-value {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
color: #333;
}
.btn-primary {
background: #F97316;
color: #fff;
border-radius: 44rpx;
}
</style>
[ ] 步骤 3:创建 weight/chart.vue(趋势图 - 使用 Canvas)
<template>
<view class="page">
<view class="chart-container">
<canvas canvas-id="weightChart" id="weightChart" class="chart-canvas"></canvas>
</view>
<view v-if="records.length === 0" class="empty">
<text>暂无数据,请先记录体重</text>
</view>
</view>
</template>
<script>
import { getWeightList } from '../../utils/api.js'
export default {
data() {
return {
records: []
}
},
onReady() {
this.loadData()
},
methods: {
loadData() {
var self = this
getWeightList(null, null, null).then(function(res) {
self.records = res.data || []
// 按日期升序
self.records.reverse()
if (self.records.length > 1) {
self.drawChart()
}
}).catch(function() {})
},
drawChart() {
var ctx = uni.createCanvasContext('weightChart', this)
var data = this.records
var width = 340
var height = 300
var padding = 30
// 计算数据范围
var weights = data.map(function(r) { return parseFloat(r.weight) })
var min = Math.min.apply(null, weights)
var max = Math.max.apply(null, weights)
var range = (max - min) || 1
// 上下留10%空间
min = min - range * 0.1
max = max + range * 0.1
range = (max - min) || 1
var chartW = width - padding * 2
var chartH = height - padding * 2
// 绘制网格和折线
ctx.setStrokeStyle('#eee')
ctx.setLineWidth(1)
for (var i = 0; i <= 4; i++) {
var y = padding + chartH * i / 4
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(width - padding, y)
ctx.stroke()
}
// 绘制折线
ctx.setStrokeStyle('#F97316')
ctx.setLineWidth(2)
ctx.beginPath()
var stepX = data.length > 1 ? chartW / (data.length - 1) : 0
for (var j = 0; j < data.length; j++) {
var x = padding + j * stepX
var value = parseFloat(data[j].weight)
var y2 = padding + chartH * (1 - (value - min) / range)
if (j === 0) {
ctx.moveTo(x, y2)
} else {
ctx.lineTo(x, y2)
}
}
ctx.stroke()
// 绘制数据点
ctx.setFillStyle('#F97316')
for (var k = 0; k < data.length; k++) {
var x2 = padding + k * stepX
var y3 = padding + chartH * (1 - (parseFloat(data[k].weight) - min) / range)
ctx.beginPath()
ctx.arc(x2, y3, 3, 0, Math.PI * 2)
ctx.fill()
}
// 标签:首尾日期
if (data.length > 0) {
ctx.setFillStyle('#999')
ctx.setFontSize(10)
ctx.fillText(data[0].measureDate, padding, height - 5)
if (data.length > 1) {
ctx.fillText(data[data.length - 1].measureDate, width - padding - 50, height - 5)
}
// 最大最小值标注
ctx.fillText('max ' + Math.max.apply(null, weights), padding + 5, padding + 10)
ctx.fillText('min ' + Math.min.apply(null, weights), padding + 5, padding + chartH - 5)
}
ctx.draw()
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
}
.chart-container {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.chart-canvas {
width: 680rpx;
height: 600rpx;
}
.empty {
text-align: center;
color: #999;
font-size: 26rpx;
padding: 80rpx 0;
}
</style>
[ ] 步骤 4:校验 Vue 文件语法 + Commit
git add xiaofat-miniprogram/pages/weight/
git commit -m "feat(fatloss-miniprogram): add weight tracking pages"
文件:
xiaofat-miniprogram/pages/diet/index.vue创建:xiaofat-miniprogram/pages/diet/create.vue
[ ] 步骤 1:创建 diet/index.vue
<template>
<view class="page">
<!-- 今日热量概览 -->
<view class="calories-card">
<text class="calories-title">今日摄入</text>
<text class="calories-value">{{ todayCalories }} kcal</text>
<text class="calories-target">目标:{{ dailyTarget }} kcal</text>
<view class="calories-bar">
<view class="calories-fill" :style="{ width: caloriesPercent + '%' }"></view>
</view>
</view>
<view class="diet-list">
<view v-for="mealGroup in mealGroups" :key="mealGroup.type" class="meal-section">
<view class="meal-header">
<text class="meal-name">{{ mealGroup.label }}</text>
<text class="meal-calories">{{ mealGroup.total }} kcal</text>
</view>
<view v-for="(item, index) in mealGroup.items" :key="'m' + index" class="meal-item">
<view class="meal-info">
<text class="meal-food">{{ item.foodName }}</text>
<text class="meal-gram">{{ item.foodWeight ? item.foodWeight + 'g' : '' }}</text>
</view>
<text class="meal-item-calories">{{ item.calories }} kcal</text>
<text class="meal-delete" @click="deleteRecord(item.id)">删除</text>
</view>
<view v-if="mealGroup.items.length === 0" class="meal-empty">暂无记录</view>
</view>
</view>
<button class="btn-primary fixed-bottom" @click="goCreate">+ 记录饮食</button>
</view>
</template>
<script>
import { getDietList, deleteDietRecord, getCaloriesSummary } from '../../utils/api.js'
import { today } from '../../utils/format.js'
export default {
data() {
return {
records: [],
todayCalories: 0,
dailyTarget: 1500,
mealGroups: [
{ type: 'breakfast', label: '早餐', items: [], total: 0 },
{ type: 'lunch', label: '午餐', items: [], total: 0 },
{ type: 'dinner', label: '晚餐', items: [], total: 0 },
{ type: 'snack', label: '加餐', items: [], total: 0 }
]
}
},
onShow() {
this.loadData()
},
computed: {
caloriesPercent() {
var pct = this.todayCalories / this.dailyTarget * 100
return Math.min(100, pct)
}
},
methods: {
loadData() {
var self = this
var todayStr = today()
getDietList(null, todayStr, todayStr).then(function(res) {
self.records = res.data || []
// 按餐次分组
self.mealGroups.forEach(function(g) {
g.items = self.records.filter(function(r) { return r.mealType === g.type })
g.total = g.items.reduce(function(sum, r) { return sum + (r.calories || 0) }, 0)
})
}).catch(function() {})
getCaloriesSummary(todayStr).then(function(res) {
self.todayCalories = res.data || 0
}).catch(function() {})
},
deleteRecord(id) {
var self = this
uni.showModal({
title: '确认删除',
content: '确定删除这条饮食记录吗?',
success: function(res) {
if (res.confirm) {
deleteDietRecord(id).then(function() {
uni.showToast({ title: '已删除', icon: 'success' })
self.loadData()
}).catch(function() {})
}
}
})
},
goCreate() {
uni.navigateTo({ url: '/pages/diet/create' })
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
padding-bottom: 140rpx;
box-sizing: border-box;
}
.calories-card {
background: linear-gradient(135deg, #FF8C42, #F97316);
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
color: #fff;
}
.calories-title {
display: block;
font-size: 26rpx;
opacity: 0.9;
}
.calories-value {
display: block;
font-size: 56rpx;
font-weight: bold;
margin: 10rpx 0;
}
.calories-target {
display: block;
font-size: 24rpx;
opacity: 0.8;
margin-bottom: 16rpx;
}
.calories-bar {
height: 12rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 6rpx;
overflow: hidden;
}
.calories-fill {
height: 100%;
background: #fff;
border-radius: 6rpx;
}
.diet-list {
background: #fff;
border-radius: 16rpx;
padding: 20rpx 30rpx;
}
.meal-section {
margin-bottom: 30rpx;
}
.meal-header {
display: flex;
justify-content: space-between;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 12rpx;
}
.meal-name {
font-size: 30rpx;
font-weight: bold;
}
.meal-calories {
font-size: 26rpx;
color: #F97316;
}
.meal-item {
display: flex;
align-items: center;
padding: 16rpx 0;
}
.meal-info {
flex: 1;
display: flex;
flex-direction: column;
}
.meal-food {
font-size: 26rpx;
}
.meal-gram {
font-size: 22rpx;
color: #999;
}
.meal-item-calories {
font-size: 26rpx;
color: #666;
margin-right: 20rpx;
}
.meal-delete {
font-size: 24rpx;
color: #ff4d4f;
}
.meal-empty {
text-align: center;
color: #ccc;
font-size: 24rpx;
padding: 16rpx 0;
}
.fixed-bottom {
position: fixed;
bottom: 40rpx;
left: 30rpx;
right: 30rpx;
background: #F97316;
color: #fff;
border-radius: 44rpx;
}
</style>
[ ] 步骤 2:创建 diet/create.vue
<template>
<view class="page">
<view class="form-card">
<view class="form-item">
<text class="label">餐次 *</text>
<picker :range="mealTypeOptions" range-key="label" @change="onMealTypeChange">
<view class="picker-value">{{ mealTypeLabel }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">食物名称 *</text>
<input class="input" v-model="form.foodName" placeholder="如:鸡胸肉" />
</view>
<view class="form-item">
<text class="label">食物重量 (克)</text>
<input class="input" type="digit" v-model="form.foodWeight" placeholder="可选" />
</view>
<view class="form-item">
<text class="label">热量 (kcal) *</text>
<input class="input" type="number" v-model="form.calories" placeholder="输入热量" />
</view>
<view class="form-item">
<text class="label">日期 *</text>
<picker mode="date" :value="form.eatDate" @change="onDateChange">
<view class="picker-value">{{ form.eatDate || '选择日期' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">备注</text>
<input class="input" v-model="form.remark" placeholder="可选" />
</view>
</view>
<button class="btn-primary" @click="submit">保存记录</button>
</view>
</template>
<script>
import { createDietRecord } from '../../utils/api.js'
import { today } from '../../utils/format.js'
export default {
data() {
return {
mealTypeOptions: [
{ type: 'breakfast', label: '早餐' },
{ type: 'lunch', label: '午餐' },
{ type: 'dinner', label: '晚餐' },
{ type: 'snack', label: '加餐' }
],
mealTypeLabel: '早餐',
form: {
mealType: 'breakfast',
foodName: '',
foodWeight: '',
calories: '',
eatDate: today(),
remark: ''
}
}
},
methods: {
onMealTypeChange(e) {
var idx = e.detail.value
this.mealTypeLabel = this.mealTypeOptions[idx].label
this.form.mealType = this.mealTypeOptions[idx].type
},
onDateChange(e) {
this.form.eatDate = e.detail.value
},
submit() {
var self = this
if (!this.form.foodName) {
uni.showToast({ title: '请输入食物名称', icon: 'none' })
return
}
if (!this.form.calories) {
uni.showToast({ title: '请输入热量', icon: 'none' })
return
}
var data = {
mealType: this.form.mealType,
foodName: this.form.foodName,
foodWeight: this.form.foodWeight ? parseFloat(this.form.foodWeight) : null,
calories: parseInt(this.form.calories),
eatDate: this.form.eatDate,
remark: this.form.remark
}
createDietRecord(data).then(function() {
uni.showToast({ title: '记录成功', icon: 'success' })
setTimeout(function() {
uni.navigateBack()
}, 1000)
}).catch(function() {})
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
}
.form-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 40rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.input {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
}
.picker-value {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
color: #333;
}
.btn-primary {
background: #F97316;
color: #fff;
border-radius: 44rpx;
}
</style>
[ ] 步骤 3:校验 + Commit
git add xiaofat-miniprogram/pages/diet/
git commit -m "feat(fatloss-miniprogram): add diet tracking pages"
文件:
xiaofat-miniprogram/pages/exercise/index.vue创建:xiaofat-miniprogram/pages/exercise/create.vue
[ ] 步骤 1:创建 exercise/index.vue
<template>
<view class="page">
<view class="summary-card">
<view class="summary-item">
<text class="s-value">{{ totalSessions }}</text>
<text class="s-label">本周运动次数</text>
</view>
<view class="summary-item">
<text class="s-value">{{ totalMinutes }}</text>
<text class="s-label">本周运动时长(分)</text>
</view>
<view class="summary-item">
<text class="s-value">{{ totalBurned }}</text>
<text class="s-label">本周消耗(kcal)</text>
</view>
</view>
<view v-if="records.length === 0" class="empty">
<text>还没有运动记录,点击下方按钮开始打卡</text>
</view>
<view v-else class="record-list">
<view class="record-item" v-for="(item, index) in records" :key="'e' + index">
<view class="record-info">
<text class="record-date">{{ item.exerciseDate }}</text>
<text class="record-type">{{ item.exerciseType }}</text>
</view>
<view class="record-values">
<text class="record-duration">{{ item.durationMin }} 分</text>
<text v-if="item.distanceKm" class="record-distance">{{ item.distanceKm }} km</text>
<text class="record-burned">{{ item.caloriesBurned }} kcal</text>
</view>
<text class="record-delete" @click="deleteRecord(item.id)">删除</text>
</view>
</view>
<button class="btn-primary fixed-bottom" @click="goCreate">+ 运动打卡</button>
</view>
</template>
<script>
import { getExerciseList, deleteExerciseRecord } from '../../utils/api.js'
import { today } from '../../utils/format.js'
export default {
data() {
return {
records: [],
totalSessions: 0,
totalMinutes: 0,
totalBurned: 0
}
},
onShow() {
this.loadData()
},
methods: {
loadData() {
var self = this
getExerciseList(null, null, null).then(function(res) {
self.records = res.data || []
var burned = 0
var minutes = 0
self.records.forEach(function(r) {
burned += (r.caloriesBurned || 0)
minutes += (r.durationMin || 0)
})
self.totalSessions = self.records.length
self.totalMinutes = minutes
self.totalBurned = burned
}).catch(function() {})
},
deleteRecord(id) {
var self = this
uni.showModal({
title: '确认删除',
content: '确定删除这条运动记录吗?',
success: function(res) {
if (res.confirm) {
deleteExerciseRecord(id).then(function() {
uni.showToast({ title: '已删除', icon: 'success' })
self.loadData()
}).catch(function() {})
}
}
})
},
goCreate() {
uni.navigateTo({ url: '/pages/exercise/create' })
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
padding-bottom: 140rpx;
box-sizing: border-box;
}
.summary-card {
display: flex;
justify-content: space-around;
background: #fff;
border-radius: 16rpx;
padding: 30rpx 0;
margin-bottom: 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
}
.s-value {
font-size: 36rpx;
font-weight: bold;
color: #10B981;
}
.s-label {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.empty {
text-align: center;
color: #999;
font-size: 26rpx;
padding: 80rpx 0;
}
.record-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.record-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.record-info {
flex: 1;
display: flex;
flex-direction: column;
}
.record-date {
font-size: 26rpx;
color: #333;
}
.record-type {
font-size: 22rpx;
color: #10B981;
margin-top: 4rpx;
}
.record-values {
display: flex;
align-items: center;
margin-right: 20rpx;
}
.record-duration {
font-size: 26rpx;
font-weight: bold;
}
.record-distance {
font-size: 22rpx;
color: #999;
margin-left: 10rpx;
}
.record-burned {
font-size: 24rpx;
color: #F97316;
margin-left: 16rpx;
}
.record-delete {
font-size: 24rpx;
color: #ff4d4f;
padding: 10rpx;
}
.fixed-bottom {
position: fixed;
bottom: 40rpx;
left: 30rpx;
right: 30rpx;
background: #10B981;
color: #fff;
border-radius: 44rpx;
}
</style>
[ ] 步骤 2:创建 exercise/create.vue
<template>
<view class="page">
<view class="form-card">
<view class="form-item">
<text class="label">运动类型 *</text>
<picker :range="exerciseTypes" @change="onTypeChange">
<view class="picker-value">{{ form.exerciseType || '选择运动类型' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">时长 (分钟) *</text>
<input class="input" type="number" v-model="form.durationMin" placeholder="请输入时长" />
</view>
<view class="form-item">
<text class="label">距离 (km)</text>
<input class="input" type="digit" v-model="form.distanceKm" placeholder="可选" />
</view>
<view class="form-item">
<text class="label">消耗热量 (kcal)</text>
<input class="input" type="number" v-model="form.caloriesBurned" placeholder="可选" />
</view>
<view class="form-item">
<text class="label">日期 *</text>
<picker mode="date" :value="form.exerciseDate" @change="onDateChange">
<view class="picker-value">{{ form.exerciseDate || '选择日期' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">备注</text>
<input class="input" v-model="form.remark" placeholder="可选" />
</view>
</view>
<button class="btn-primary" @click="submit">保存记录</button>
</view>
</template>
<script>
import { createExerciseRecord } from '../../utils/api.js'
import { today } from '../../utils/format.js'
export default {
data() {
return {
exerciseTypes: ['跑步', '快走', '游泳', '骑行', '健身', '瑜伽', '跳绳', '其他'],
form: {
exerciseType: '',
durationMin: '',
distanceKm: '',
caloriesBurned: '',
exerciseDate: today(),
remark: ''
}
}
},
methods: {
onTypeChange(e) {
this.form.exerciseType = this.exerciseTypes[e.detail.value]
},
onDateChange(e) {
this.form.exerciseDate = e.detail.value
},
submit() {
var self = this
if (!this.form.exerciseType) {
uni.showToast({ title: '请选择运动类型', icon: 'none' })
return
}
if (!this.form.durationMin) {
uni.showToast({ title: '请输入运动时长', icon: 'none' })
return
}
var data = {
exerciseType: this.form.exerciseType,
durationMin: parseInt(this.form.durationMin),
distanceKm: this.form.distanceKm ? parseFloat(this.form.distanceKm) : null,
caloriesBurned: this.form.caloriesBurned ? parseInt(this.form.caloriesBurned) : 0,
exerciseDate: this.form.exerciseDate,
remark: this.form.remark
}
createExerciseRecord(data).then(function() {
uni.showToast({ title: '打卡成功', icon: 'success' })
setTimeout(function() {
uni.navigateBack()
}, 1000)
}).catch(function() {})
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
}
.form-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 40rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.input {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
}
.picker-value {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
color: #333;
}
.btn-primary {
background: #10B981;
color: #fff;
border-radius: 44rpx;
}
</style>
[ ] 步骤 3:校验 + Commit
git add xiaofat-miniprogram/pages/exercise/
git commit -m "feat(fatloss-miniprogram): add exercise tracking pages"
文件:
创建:xiaofat-miniprogram/pages/ai-coach/index.vue
[ ] 步骤 1:创建 ai-coach/index.vue
<template>
<view class="chat-page">
<!-- 聊天消息列表 -->
<scroll-view scroll-y class="chat-list" :scroll-into-view="scrollIntoView">
<view v-for="(msg, index) in messages" :key="'m' + index" class="msg-row" :class="msg.role === 'user' ? 'row-user' : 'row-ai'">
<view class="msg-bubble" :class="msg.role === 'user' ? 'bubble-user' : 'bubble-ai'">
<text class="msg-text">{{ msg.content }}</text>
</view>
</view>
<view v-if="thinking" class="msg-row row-ai">
<view class="msg-bubble bubble-ai thinking">
<text>正在思考...</text>
</view>
</view>
<view id="chat-bottom" style="height: 10px;"></view>
</scroll-view>
<!-- 快捷提问 -->
<scroll-view scroll-x class="quick-questions">
<view class="quick-item" v-for="(q, index) in quickQuestions" :key="'q' + index" @click="askQuick(q)">
<text>{{ q }}</text>
</view>
</scroll-view>
<!-- 输入栏 -->
<view class="input-bar">
<input class="chat-input" v-model="inputText" placeholder="问AI教练任何减脂问题" confirm-type="send" @confirm="sendMessage" />
<button class="send-btn" @click="sendMessage" :disabled="!inputText">发送</button>
</view>
</view>
</template>
<script>
import { aiCoachSend } from '../../utils/api.js'
export default {
data() {
return {
messages: [
{ role: 'ai', content: '你好!我是你的 AI 减脂教练。我可以帮你分析体重数据、制定饮食计划、提供运动建议。有什么想问的吗?' }
],
inputText: '',
thinking: false,
scrollIntoView: 'chat-bottom',
quickQuestions: ['今天吃多少卡路里合适?', '我的体重为什么不降?', '推荐减脂早餐', '怎么安排运动?']
}
},
onShow() {
this.scrollToBottom()
},
methods: {
askQuick(q) {
this.inputText = q
this.sendMessage()
},
sendMessage() {
var self = this
var text = this.inputText.trim()
if (!text || this.thinking) return
this.messages.push({ role: 'user', content: text })
this.inputText = ''
this.thinking = true
this.scrollToBottom()
aiCoachSend({ message: text }).then(function(res) {
self.thinking = false
var answer = res.data && res.data.reply
? res.data.reply
: (res.data && res.data.content
? res.data.content
: '抱歉,我暂时无法回答这个问题。')
self.messages.push({ role: 'ai', content: answer })
self.scrollToBottom()
}).catch(function() {
self.thinking = false
self.messages.push({ role: 'ai', content: '网络异常,请稍后再试。' })
self.scrollToBottom()
})
},
scrollToBottom() {
var self = this
setTimeout(function() {
self.scrollIntoView = 'chat-bottom'
}, 100)
}
}
}
</script>
<style scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
}
.chat-list {
flex: 1;
padding: 20rpx;
box-sizing: border-box;
}
.msg-row {
display: flex;
margin-bottom: 20rpx;
}
.row-user {
justify-content: flex-end;
}
.row-ai {
justify-content: flex-start;
}
.msg-bubble {
max-width: 70%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
font-size: 28rpx;
line-height: 1.5;
}
.bubble-user {
background: #F97316;
color: #fff;
border-top-right-radius: 4rpx;
}
.bubble-ai {
background: #fff;
color: #333;
border-top-left-radius: 4rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.thinking {
color: #999;
}
.quick-questions {
white-space: nowrap;
padding: 16rpx 20rpx;
background: #fff;
}
.quick-item {
display: inline-block;
background: #FFF7ED;
color: #F97316;
border: 1rpx solid #FFE4CC;
border-radius: 24rpx;
padding: 10rpx 24rpx;
font-size: 24rpx;
margin-right: 16rpx;
}
.input-bar {
display: flex;
align-items: center;
padding: 16rpx 20rpx;
background: #fff;
border-top: 1rpx solid #eee;
}
.chat-input {
flex: 1;
border: 1rpx solid #e5e5e5;
border-radius: 36rpx;
padding: 14rpx 24rpx;
font-size: 28rpx;
margin-right: 16rpx;
}
.send-btn {
background: #F97316;
color: #fff;
border-radius: 36rpx;
font-size: 26rpx;
padding: 0 30rpx;
line-height: 64rpx;
height: 64rpx;
}
.send-btn[disabled] {
background: #ccc;
}
</style>
[ ] 步骤 2:校验 + Commit
git add xiaofat-miniprogram/pages/ai-coach/
git commit -m "feat(fatloss-miniprogram): add AI coach chat page"
文件:
创建:xiaofat-miniprogram/pages/profile/index.vue
[ ] 步骤 1:创建 profile/index.vue
<template>
<view class="page">
<!-- 用户信息 -->
<view class="user-card">
<view class="avatar">{{ nickname ? nickname.slice(0, 1) : '减' }}</view>
<view class="user-info">
<text class="user-name">{{ nickname || '减脂用户' }}</text>
<text class="user-days">累计打卡 {{ totalDays }} 天</text>
</view>
</view>
<!-- 目标设置 -->
<view class="section">
<text class="section-title">减脂目标</text>
<view v-if="goal" class="goal-detail">
<view class="goal-row">
<text class="goal-label">初始体重</text>
<text class="goal-value">{{ goal.startWeight }} kg</text>
</view>
<view class="goal-row">
<text class="goal-label">目标体重</text>
<text class="goal-value">{{ goal.targetWeight }} kg</text>
</view>
<view class="goal-row" v-if="goal.targetBodyFat">
<text class="goal-label">目标体脂率</text>
<text class="goal-value">{{ goal.targetBodyFat }}%</text>
</view>
<view class="goal-row" v-if="goal.targetDate">
<text class="goal-label">目标日期</text>
<text class="goal-value">{{ goal.targetDate }}</text>
</view>
<view class="goal-actions">
<button class="btn-sm btn-complete" @click="completeGoal">达成目标</button>
<button class="btn-sm btn-abandon" @click="abandonGoal">放弃目标</button>
</view>
</view>
<view v-else class="goal-empty">
<text>还没有设定减脂目标</text>
<button class="btn-primary btn-small" @click="showGoalForm = true">立即设定</button>
</view>
</view>
<!-- 设定目标表单(弹层) -->
<view v-if="showGoalForm" class="modal-mask" @click="showGoalForm = false">
<view class="modal-content" @click.stop>
<text class="modal-title">设定减脂目标</text>
<view class="form-item">
<text class="label">初始体重 (kg) *</text>
<input class="input" type="digit" v-model="goalForm.startWeight" placeholder="当前体重" />
</view>
<view class="form-item">
<text class="label">目标体重 (kg) *</text>
<input class="input" type="digit" v-model="goalForm.targetWeight" placeholder="目标体重" />
</view>
<view class="form-item">
<text class="label">目标体脂率 (%)</text>
<input class="input" type="digit" v-model="goalForm.targetBodyFat" placeholder="可选" />
</view>
<view class="form-item">
<text class="label">目标日期</text>
<picker mode="date" :value="goalForm.targetDate" @change="onTargetDateChange">
<view class="picker-value">{{ goalForm.targetDate || '选择日期' }}</view>
</picker>
</view>
<view class="modal-actions">
<button class="btn-sm btn-cancel" @click="showGoalForm = false">取消</button>
<button class="btn-sm btn-save" @click="submitGoal">保存目标</button>
</view>
</view>
</view>
<!-- 其他设置 -->
<view class="section">
<text class="section-title">其他</text>
<view class="setting-item" @click="logout">
<text class="setting-text">退出登录</text>
</view>
</view>
</view>
</template>
<script>
import { getActiveGoal, createGoal, completeGoal, abandonGoal } from '../../utils/api.js'
import { today } from '../../utils/format.js'
export default {
data() {
return {
nickname: uni.getStorageSync('userInfo') ? uni.getStorageSync('userInfo').nickname : '',
totalDays: 0,
goal: null,
showGoalForm: false,
goalForm: {
startWeight: '',
targetWeight: '',
targetBodyFat: '',
targetDate: ''
}
}
},
onShow() {
this.loadData()
},
methods: {
loadData() {
var self = this
getActiveGoal().then(function(res) {
self.goal = res.data || null
if (self.goal) {
self.goalForm.startWeight = self.goal.startWeight
self.goalForm.targetWeight = self.goal.targetWeight
self.goalForm.targetBodyFat = self.goal.targetBodyFat || ''
self.goalForm.targetDate = self.goal.targetDate || ''
}
}).catch(function() {})
},
onTargetDateChange(e) {
this.goalForm.targetDate = e.detail.value
},
submitGoal() {
var self = this
if (!this.goalForm.startWeight || !this.goalForm.targetWeight) {
uni.showToast({ title: '请填写体重', icon: 'none' })
return
}
var data = {
startWeight: parseFloat(this.goalForm.startWeight),
targetWeight: parseFloat(this.goalForm.targetWeight),
targetBodyFat: this.goalForm.targetBodyFat ? parseFloat(this.goalForm.targetBodyFat) : null,
targetDate: this.goalForm.targetDate || null,
startDate: today()
}
var promise = this.goal
? createGoal(data) // 简化:实现上应改为 update
: createGoal(data)
promise.then(function() {
uni.showToast({ title: '目标已保存', icon: 'success' })
self.showGoalForm = false
self.loadData()
}).catch(function() {})
},
completeGoal() {
var self = this
if (this.goal) {
completeGoal(this.goal.id).then(function() {
uni.showToast({ title: '恭喜达成目标!', icon: 'success' })
self.loadData()
}).catch(function() {})
}
},
abandonGoal() {
var self = this
if (this.goal) {
uni.showModal({
title: '放弃目标',
content: '确定要放弃当前的减脂目标吗?',
success: function(res) {
if (res.confirm) {
abandonGoal(self.goal.id).then(function() {
uni.showToast({ title: '已放弃', icon: 'none' })
self.loadData()
}).catch(function() {})
}
}
})
}
},
logout() {
var self = this
uni.showModal({
title: '退出登录',
content: '确定退出当前账号吗?',
success: function(res) {
if (res.confirm) {
uni.removeStorageSync('token')
uni.removeStorageSync('openid')
uni.removeStorageSync('userId')
uni.removeStorageSync('userInfo')
self.$store.commit('logout')
uni.reLaunch({ url: '/pages/index/index' })
}
}
})
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
}
.user-card {
display: flex;
align-items: center;
background: linear-gradient(135deg, #FF8C42, #F97316);
border-radius: 16rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
color: #fff;
}
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 44rpx;
margin-right: 24rpx;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-size: 36rpx;
font-weight: bold;
}
.user-days {
font-size: 24rpx;
opacity: 0.8;
margin-top: 8rpx;
}
.section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
}
.section-title {
display: block;
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.goal-row {
display: flex;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.goal-label {
font-size: 26rpx;
color: #666;
}
.goal-value {
font-size: 26rpx;
font-weight: bold;
}
.goal-actions {
display: flex;
margin-top: 20rpx;
}
.btn-sm {
font-size: 24rpx;
border-radius: 32rpx;
padding: 0 30rpx;
height: 56rpx;
line-height: 56rpx;
margin-right: 20rpx;
}
.btn-complete {
background: #10B981;
color: #fff;
}
.btn-abandon {
background: #fff;
color: #666;
border: 1rpx solid #ddd;
}
.goal-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 30rpx 0;
color: #999;
font-size: 26rpx;
}
.btn-small {
margin-top: 20rpx;
background: #F97316;
color: #fff;
border-radius: 32rpx;
font-size: 26rpx;
padding: 0 40rpx;
height: 60rpx;
line-height: 60rpx;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
width: 80%;
background: #fff;
border-radius: 16rpx;
padding: 40rpx 30rpx;
}
.modal-title {
display: block;
font-size: 32rpx;
font-weight: bold;
text-align: center;
margin-bottom: 30rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.label {
display: block;
font-size: 26rpx;
color: #333;
margin-bottom: 10rpx;
}
.input {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 14rpx 18rpx;
font-size: 26rpx;
}
.picker-value {
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 14rpx 18rpx;
font-size: 26rpx;
color: #333;
}
.modal-actions {
display: flex;
justify-content: flex-end;
margin-top: 30rpx;
}
.btn-cancel {
background: #fff;
color: #666;
border: 1rpx solid #ddd;
}
.btn-save {
background: #F97316;
color: #fff;
}
.setting-item {
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.setting-text {
font-size: 28rpx;
color: #ff4d4f;
}
</style>
[ ] 步骤 2:校验 + Commit
git add xiaofat-miniprogram/pages/profile/
git commit -m "feat(fatloss-miniprogram): add profile and goal page"
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:find xiaofat-miniprogram -name "*.js" | xargs -I{} node --check {}
预期:所有文件无语法错误
运行: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 个路由,每个唯一
运行:grep -c "fatloss" docs/superpowers/api/API_REFERENCE.md
预期:0(需要追加新接口文档)
在 docs/superpowers/api/API_REFERENCE.md 末尾追加:
## 减脂专题(/api/fatloss/*)
| 模块 | 端点 | 说明 |
|------|------|------|
| 体重 | `POST /api/fatloss/weight/list` | 体重记录列表(memberId/startDate/endDate) |
| 体重 | `POST /api/fatloss/weight/create` | 创建体重记录 |
| 体重 | `POST /api/fatloss/weight/update` | 更新体重记录 |
| 体重 | `POST /api/fatloss/weight/delete` | 删除体重记录 |
| 饮食 | `POST /api/fatloss/diet/list` | 饮食记录列表 |
| 饮食 | `POST /api/fatloss/diet/create` | 创建饮食记录 |
| 饮食 | `POST /api/fatloss/diet/delete` | 删除饮食记录 |
| 饮食 | `POST /api/fatloss/diet/calories-summary` | 当日总热量 |
| 运动 | `POST /api/fatloss/exercise/list` | 运动记录列表 |
| 运动 | `POST /api/fatloss/exercise/create` | 创建运动记录 |
| 运动 | `POST /api/fatloss/exercise/delete` | 删除运动记录 |
| 目标 | `POST /api/fatloss/goal/active` | 获取当前目标 |
| 目标 | `POST /api/fatloss/goal/create` | 创建目标 |
| 目标 | `POST /api/fatloss/goal/update` | 更新目标 |
| 目标 | `POST /api/fatloss/goal/complete` | 完成目标 |
| 目标 | `POST /api/fatloss/goal/abandon` | 放弃目标 |
[x] 步骤 6:最终 Commit**
git add docs/superpowers/api/API_REFERENCE.md
git commit -m "docs(api): add fatloss API reference"
以下专题在本计划(减脂 MVP)完成后进入规划,复用减脂专题已验证的基础设施 (登录流程、请求封装、AI 接入、数据表模式)。
HealthSleepRecord 表(睡眠打卡)