Просмотр исходного кода

feat: 健康记录模块 — 日常打卡/成长记录/维度权重管理

- 后端: DailyCheckin/HealthGrowthRecord/DimensionWeight 实体+Mapper+Service+Controller
- 后端: 每日健康打卡、饮食/运动/睡眠/饮水记录实体
- cfc-web: DimensionWeightPicker 五维权重组件, 集成到活动/文章编辑
- cfc-web: 成长任务管理重构, 活动报名审核, 能量规则管理
- cfc-frontend: 身体页面 — 运动/睡眠/饮水/饮食记录, 成长曲线, 健康趋势
Sisyphus 4 месяцев назад
Родитель
Сommit
fbc6e8ade5
36 измененных файлов с 2832 добавлено и 251 удалено
  1. 6 3
      cfc-backend/.classpath
  2. 79 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/DailyCheckinController.java
  3. 77 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthGrowthRecordController.java
  4. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionWeightController.java
  5. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DailyHealthCheckin.java
  6. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietMealRecord.java
  7. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DimensionWeight.java
  8. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthExerciseRecord.java
  9. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthGrowthRecord.java
  10. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthSleepRecord.java
  11. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthWaterRecord.java
  12. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DailyCheckinMapper.java
  13. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DietMealRecordMapper.java
  14. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DimensionWeightMapper.java
  15. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthExerciseRecordMapper.java
  16. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthGrowthRecordMapper.java
  17. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthSleepRecordMapper.java
  18. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthWaterRecordMapper.java
  19. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java
  20. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DimensionWeightService.java
  21. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthGrowthRecordService.java
  22. 182 0
      cfc-frontend/pages/body/exercise-record.vue
  23. 189 0
      cfc-frontend/pages/body/growth-curve.vue
  24. 190 0
      cfc-frontend/pages/body/health-trend.vue
  25. 200 0
      cfc-frontend/pages/body/meal-record.vue
  26. 249 0
      cfc-frontend/pages/body/recipe-recommend.vue
  27. 204 0
      cfc-frontend/pages/body/sleep-record.vue
  28. 161 0
      cfc-frontend/pages/body/water-record.vue
  29. 27 0
      cfc-web/src/api/activity.js
  30. 17 0
      cfc-web/src/api/dimension-weight.js
  31. 167 0
      cfc-web/src/components/DimensionWeightPicker.vue
  32. 35 19
      cfc-web/src/views/admin/Activities.vue
  33. 132 0
      cfc-web/src/views/admin/ActivityRegistrationReview.vue
  34. 26 12
      cfc-web/src/views/admin/ArticleEdit.vue
  35. 192 0
      cfc-web/src/views/admin/EnergyRuleManagement.vue
  36. 260 217
      cfc-web/src/views/admin/GrowthTaskManagement.vue

+ 6 - 3
cfc-backend/.classpath

@@ -36,18 +36,21 @@
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 	</classpathentry>
-	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
+	<classpathentry kind="src" path="target/generated-sources/annotations">
 		<attributes>
 			<attribute name="optional" value="true"/>
-			<attribute name="test" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 			<attribute name="ignore_optional_problems" value="true"/>
 			<attribute name="m2e-apt" value="true"/>
 		</attributes>
 	</classpathentry>
-	<classpathentry kind="src" path="target/generated-sources/annotations">
+	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
 		<attributes>
 			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="ignore_optional_problems" value="true"/>
+			<attribute name="m2e-apt" value="true"/>
+			<attribute name="test" value="true"/>
 		</attributes>
 	</classpathentry>
 	<classpathentry kind="output" path="target/classes"/>

+ 79 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DailyCheckinController.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DailyHealthCheckin;
+import com.etotem.cfc.service.DailyCheckinService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "每日健康打卡", description = "每日健康打卡记录(饮食/运动/睡眠/饮水/心情)")
+@RestController
+@RequestMapping("/api/daily/checkin")
+public class DailyCheckinController {
+
+    @Resource
+    private DailyCheckinService dailyCheckinService;
+
+    @Operation(summary = "获取打卡列表")
+    @PostMapping("/list")
+    public Result<List<DailyHealthCheckin>> list(@RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        return Result.success(dailyCheckinService.getCheckinsByMember(memberId));
+    }
+
+    @Operation(summary = "获取打卡详情")
+    @PostMapping("/detail")
+    public Result<DailyHealthCheckin> detail(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        return Result.success(dailyCheckinService.getCheckinById(id));
+    }
+
+    @Operation(summary = "创建打卡记录")
+    @PostMapping("/create")
+    public Result<DailyHealthCheckin> create(@RequestBody DailyHealthCheckin checkin) {
+        DailyHealthCheckin created = dailyCheckinService.createCheckin(checkin);
+        return Result.success(created);
+    }
+
+    @Operation(summary = "更新打卡记录")
+    @PostMapping("/update")
+    public Result<DailyHealthCheckin> update(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        DailyHealthCheckin checkin = new DailyHealthCheckin();
+        if (params.get("dietScore") != null) checkin.setDietScore(Integer.valueOf(params.get("dietScore").toString()));
+        if (params.get("exerciseScore") != null) checkin.setExerciseScore(Integer.valueOf(params.get("exerciseScore").toString()));
+        if (params.get("sleepScore") != null) checkin.setSleepScore(Integer.valueOf(params.get("sleepScore").toString()));
+        if (params.get("waterIntake") != null) checkin.setWaterIntake(Integer.valueOf(params.get("waterIntake").toString()));
+        if (params.get("moodScore") != null) checkin.setMoodScore(Integer.valueOf(params.get("moodScore").toString()));
+        return Result.success(dailyCheckinService.updateCheckin(id, checkin));
+    }
+
+    @Operation(summary = "删除打卡记录")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        dailyCheckinService.deleteCheckin(id);
+        return Result.success("删除成功");
+    }
+}

+ 77 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthGrowthRecordController.java

@@ -0,0 +1,77 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthGrowthRecord;
+import com.etotem.cfc.service.HealthGrowthRecordService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "健康生长记录", description = "儿童生长记录(身高/体重/BMI/头围等)")
+@RestController
+@RequestMapping("/api/health/growth")
+public class HealthGrowthRecordController {
+
+    @Resource
+    private HealthGrowthRecordService healthGrowthRecordService;
+
+    @Operation(summary = "获取生长记录列表")
+    @PostMapping("/list")
+    public Result<List<HealthGrowthRecord>> list(@RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        return Result.success(healthGrowthRecordService.getRecordsByMember(memberId));
+    }
+
+    @Operation(summary = "获取生长记录详情")
+    @PostMapping("/detail")
+    public Result<HealthGrowthRecord> detail(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        return Result.success(healthGrowthRecordService.getRecordById(id));
+    }
+
+    @Operation(summary = "创建生长记录")
+    @PostMapping("/create")
+    public Result<HealthGrowthRecord> create(@RequestBody HealthGrowthRecord record) {
+        HealthGrowthRecord created = healthGrowthRecordService.createRecord(record);
+        return Result.success(created);
+    }
+
+    @Operation(summary = "更新生长记录")
+    @PostMapping("/update")
+    public Result<HealthGrowthRecord> update(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        HealthGrowthRecord record = new HealthGrowthRecord();
+        if (params.get("heightCm") != null) record.setHeightCm(new java.math.BigDecimal(params.get("heightCm").toString()));
+        if (params.get("weightKg") != null) record.setWeightKg(new java.math.BigDecimal(params.get("weightKg").toString()));
+        if (params.get("headCircumference") != null) record.setHeadCircumference(new java.math.BigDecimal(params.get("headCircumference").toString()));
+        return Result.success(healthGrowthRecordService.updateRecord(id, record));
+    }
+
+    @Operation(summary = "删除生长记录")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        healthGrowthRecordService.deleteRecord(id);
+        return Result.success("删除成功");
+    }
+}

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionWeightController.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DimensionWeight;
+import com.etotem.cfc.service.DimensionWeightService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/dimension-weight")
+public class DimensionWeightController {
+
+    @Resource
+    private DimensionWeightService dimensionWeightService;
+
+    @PostMapping("/save")
+    public Result<Void> save(@RequestBody Map<String, Object> params) {
+        String targetType = params.get("targetType").toString();
+        Long targetId = Long.valueOf(params.get("targetId").toString());
+        List<Map<String, Object>> weightMaps = (List<Map<String, Object>>) params.get("weights");
+        List<DimensionWeight> weights = weightMaps.stream().map(m -> {
+            DimensionWeight w = new DimensionWeight();
+            w.setDimension(m.get("dimension").toString());
+            w.setWeight(Integer.valueOf(m.get("weight").toString()));
+            w.setEnabled(Boolean.valueOf(m.get("enabled").toString()));
+            return w;
+        }).collect(java.util.stream.Collectors.toList());
+        dimensionWeightService.saveWeights(targetType, targetId, weights);
+        return Result.success(null);
+    }
+
+    @PostMapping("/list")
+    public Result<List<DimensionWeight>> list(@RequestBody Map<String, Object> params) {
+        String targetType = params.get("targetType").toString();
+        Long targetId = Long.valueOf(params.get("targetId").toString());
+        List<DimensionWeight> list = dimensionWeightService.getByTarget(targetType, targetId);
+        return Result.success(list);
+    }
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DailyHealthCheckin.java

@@ -0,0 +1,28 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("daily_checkin")
+public class DailyHealthCheckin implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Date checkinDate;
+    private Integer dietScore;
+    private Integer exerciseScore;
+    private Integer sleepScore;
+    private Integer waterIntake;
+    private Integer moodScore;
+    private BigDecimal weightKg;
+    private BigDecimal heightCm;
+    private String remark;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietMealRecord.java

@@ -0,0 +1,29 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("diet_meal_record")
+public class DietMealRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long checkinId;
+    private String mealType;
+    private String foodName;
+    private String foodAmount;
+    private Integer calories;
+    private BigDecimal protein;
+    private BigDecimal fat;
+    private BigDecimal carbs;
+    private BigDecimal fiber;
+    private Date eatenAt;
+    private String source;
+    private Date createdAt;
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DimensionWeight.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("dimension_weights")
+public class DimensionWeight implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String targetType;
+
+    private Long targetId;
+
+    private String dimension;
+
+    private Integer weight;
+
+    private Boolean enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthExerciseRecord.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("exercise_record")
+public class HealthExerciseRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long checkinId;
+    private String exerciseType;
+    private Integer durationMinutes;
+    private String intensity;
+    private Integer caloriesBurned;
+    private Integer heartRateAvg;
+    private Integer heartRateMax;
+    private Date startTime;
+    private Date endTime;
+    private String remark;
+    private Date createdAt;
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthGrowthRecord.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("health_growth_record")
+public class HealthGrowthRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Date recordDate;
+    private BigDecimal heightCm;
+    private BigDecimal weightKg;
+    private BigDecimal bmi;
+    private BigDecimal headCircumference;
+    private Integer boneAge;
+    private Integer toothAge;
+    private BigDecimal growthRate;
+    private Integer percentileHeight;
+    private Integer percentileWeight;
+    private Integer percentileBmi;
+    private String standardType;
+    private String source;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthSleepRecord.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("sleep_record")
+public class HealthSleepRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long checkinId;
+    private Date sleepTime;
+    private Date wakeTime;
+    private Integer durationMinutes;
+    private Integer deepSleepMinutes;
+    private Integer lightSleepMinutes;
+    private Integer remMinutes;
+    private Integer qualityScore;
+    private Integer dreamCount;
+    private String remark;
+    private Date createdAt;
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthWaterRecord.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("water_record")
+public class HealthWaterRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long checkinId;
+    private Integer amountMl;
+    private String waterType;
+    private Date drunkAt;
+    private Date createdAt;
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DailyCheckinMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.DailyHealthCheckin;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface DailyCheckinMapper extends BaseMapper<DailyHealthCheckin> {
+
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DietMealRecordMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.DietMealRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface DietMealRecordMapper extends BaseMapper<DietMealRecord> {
+
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DimensionWeightMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DimensionWeight;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DimensionWeightMapper extends BaseMapper<DimensionWeight> {
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthExerciseRecordMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.HealthExerciseRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface HealthExerciseRecordMapper extends BaseMapper<HealthExerciseRecord> {
+
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthGrowthRecordMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.HealthGrowthRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface HealthGrowthRecordMapper extends BaseMapper<HealthGrowthRecord> {
+
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthSleepRecordMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.HealthSleepRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface HealthSleepRecordMapper extends BaseMapper<HealthSleepRecord> {
+
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthWaterRecordMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.HealthWaterRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+public interface HealthWaterRecordMapper extends BaseMapper<HealthWaterRecord> {
+
+}

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.DailyHealthCheckin;
+import com.etotem.cfc.mapper.DailyCheckinMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class DailyCheckinService {
+
+    @Resource
+    private DailyCheckinMapper dailyCheckinMapper;
+
+    public List<DailyHealthCheckin> getCheckinsByMember(Long memberId) {
+        return dailyCheckinMapper.selectList(new LambdaQueryWrapper<DailyHealthCheckin>()
+                .eq(DailyHealthCheckin::getMemberId, memberId)
+                .orderByDesc(DailyHealthCheckin::getCheckinDate));
+    }
+
+    public DailyHealthCheckin getCheckinById(Long id) {
+        return dailyCheckinMapper.selectById(id);
+    }
+
+    public DailyHealthCheckin createCheckin(DailyHealthCheckin checkin) {
+        checkin.setCreatedAt(new Date());
+        checkin.setUpdatedAt(new Date());
+        dailyCheckinMapper.insert(checkin);
+        return checkin;
+    }
+
+    public DailyHealthCheckin updateCheckin(Long id, DailyHealthCheckin checkin) {
+        checkin.setId(id);
+        checkin.setUpdatedAt(new Date());
+        dailyCheckinMapper.updateById(checkin);
+        return dailyCheckinMapper.selectById(id);
+    }
+
+    public void deleteCheckin(Long id) {
+        dailyCheckinMapper.deleteById(id);
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionWeightService.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DimensionWeight;
+import com.etotem.cfc.mapper.DimensionWeightMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class DimensionWeightService {
+
+    @Resource
+    private DimensionWeightMapper dimensionWeightMapper;
+
+    public List<DimensionWeight> getByTarget(String targetType, Long targetId) {
+        return dimensionWeightMapper.selectList(
+                new LambdaQueryWrapper<DimensionWeight>()
+                        .eq(DimensionWeight::getTargetType, targetType)
+                        .eq(DimensionWeight::getTargetId, targetId));
+    }
+
+    @Transactional
+    public void saveWeights(String targetType, Long targetId, List<DimensionWeight> weights) {
+        dimensionWeightMapper.delete(
+                new LambdaQueryWrapper<DimensionWeight>()
+                        .eq(DimensionWeight::getTargetType, targetType)
+                        .eq(DimensionWeight::getTargetId, targetId));
+        if (weights != null && !weights.isEmpty()) {
+            Date now = new Date();
+            for (DimensionWeight w : weights) {
+                w.setId(null);
+                w.setTargetType(targetType);
+                w.setTargetId(targetId);
+                w.setCreatedAt(now);
+                w.setUpdatedAt(now);
+                dimensionWeightMapper.insert(w);
+            }
+        }
+    }
+
+    @Transactional
+    public void deleteByTarget(String targetType, Long targetId) {
+        dimensionWeightMapper.delete(
+                new LambdaQueryWrapper<DimensionWeight>()
+                        .eq(DimensionWeight::getTargetType, targetType)
+                        .eq(DimensionWeight::getTargetId, targetId));
+    }
+}

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthGrowthRecordService.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthGrowthRecord;
+import com.etotem.cfc.mapper.HealthGrowthRecordMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class HealthGrowthRecordService {
+
+    @Resource
+    private HealthGrowthRecordMapper healthGrowthRecordMapper;
+
+    public List<HealthGrowthRecord> getRecordsByMember(Long memberId) {
+        return healthGrowthRecordMapper.selectList(new LambdaQueryWrapper<HealthGrowthRecord>()
+                .eq(HealthGrowthRecord::getMemberId, memberId)
+                .orderByDesc(HealthGrowthRecord::getRecordDate));
+    }
+
+    public HealthGrowthRecord getRecordById(Long id) {
+        return healthGrowthRecordMapper.selectById(id);
+    }
+
+    public HealthGrowthRecord createRecord(HealthGrowthRecord record) {
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        healthGrowthRecordMapper.insert(record);
+        return record;
+    }
+
+    public HealthGrowthRecord updateRecord(Long id, HealthGrowthRecord record) {
+        record.setId(id);
+        record.setUpdatedAt(new Date());
+        healthGrowthRecordMapper.updateById(record);
+        return healthGrowthRecordMapper.selectById(id);
+    }
+
+    public void deleteRecord(Long id) {
+        healthGrowthRecordMapper.deleteById(id);
+    }
+}

+ 182 - 0
cfc-frontend/pages/body/exercise-record.vue

@@ -0,0 +1,182 @@
+<template>
+  <view class="exercise-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">运动记录</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">记录运动</view>
+      <view class="exercise-types">
+        <view class="ex-type-item" v-for="et in exerciseTypes" :key="et.value"
+          :class="{ 'ex-type-active': selectedType === et.value }"
+          @click="selectedType = et.value">
+          <text class="ex-type-icon">{{ et.icon }}</text>
+          <text class="ex-type-label">{{ et.label }}</text>
+        </view>
+      </view>
+
+      <view class="input-row">
+        <text class="input-label">时长(分钟)</text>
+        <input class="input-field" v-model="duration" type="number" placeholder="输入运动时长" />
+      </view>
+      <view class="input-row">
+        <text class="input-label">强度</text>
+        <view class="intensity-options">
+          <text class="intensity-item" v-for="v in ['低','中','高']" :key="v"
+            :class="{ 'intensity-active': intensity === v }" @click="intensity = v">{{ v }}</text>
+        </view>
+      </view>
+      <view class="input-row">
+        <text class="input-label">备注</text>
+        <input class="input-field" v-model="remark" placeholder="可选" />
+      </view>
+
+      <view class="submit-btn" @click="submitExercise">保存记录</view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">今日运动摘要</view>
+      <view class="today-summary">
+        <text class="summary-stat">总时长:<text class="stat-value">{{ todayTotalMinutes }} 分钟</text></text>
+        <text class="summary-stat">消耗:<text class="stat-value">{{ todayCalories }} kcal</text></text>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">历史记录</view>
+      <view class="history-list">
+        <view class="history-item" v-for="item in exerciseHistory" :key="item.id">
+          <text class="history-icon">{{ getTypeIcon(item.exerciseType) }}</text>
+          <view class="history-info">
+            <text class="history-name">{{ item.exerciseType }}</text>
+            <text class="history-meta">{{ item.durationMinutes }}分钟 · {{ item.intensity || '中' }}强度</text>
+          </view>
+          <text class="history-time">{{ item.createdAt ? item.createdAt.substring(5,16) : '' }}</text>
+        </view>
+        <view class="history-empty" v-if="exerciseHistory.length === 0"><text>暂无记录</text></view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      selectedType: 'running',
+      duration: '',
+      intensity: '中',
+      remark: '',
+      exerciseHistory: [],
+      exerciseTypes: [
+        { icon: '🏃', label: '跑步', value: 'running' },
+        { icon: '🚴', label: '骑行', value: 'cycling' },
+        { icon: '🏊', label: '游泳', value: 'swimming' },
+        { icon: '🤸', label: '跳绳', value: 'jumping_rope' },
+        { icon: '⚽', label: '球类', value: 'ball_games' },
+        { icon: '🧘', label: '拉伸', value: 'stretching' }
+      ]
+    }
+  },
+  computed: {
+    todayTotalMinutes: function() { return 0 },
+    todayCalories: function() { return 0 }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    if (this.childId) this.loadHistory()
+  },
+  methods: {
+    submitExercise: function() {
+      if (!this.duration || parseInt(this.duration) <= 0) {
+        uni.showToast({ title: '请输入有效时长', icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '保存中...' })
+      healthRequest('/api/health/exercise/create', {
+        childId: this.childId,
+        memberId: this.childId,
+        exerciseType: this.selectedType,
+        durationMinutes: parseInt(this.duration),
+        intensity: this.intensity,
+        remark: this.remark
+      }).then(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        this.duration = ''
+        this.remark = ''
+        this.loadHistory()
+      }.bind(this)).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      })
+    },
+    loadHistory: function() {
+      var self = this
+      healthRequest('/api/health/exercise/list', { childId: this.childId }).then(function(res) {
+        if (res.data) self.exerciseHistory = res.data
+      }).catch(function() {})
+    },
+    getTypeIcon: function(type) {
+      var map = { running: '🏃', cycling: '🚴', swimming: '🏊', jumping_rope: '🤸', ball_games: '⚽', stretching: '🧘' }
+      return map[type] || '🏃'
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+
+function healthRequest(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: function(res) { if (res.data.code === 200) resolve(res.data); else reject(res.data) },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+</script>
+
+<style scoped>
+.exercise-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.section-card { margin: 20rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
+.exercise-types { display: flex; flex-direction: row; flex-wrap: wrap; margin-bottom: 20rpx; }
+.ex-type-item { display: flex; flex-direction: column; align-items: center; padding: 16rpx 24rpx; border-radius: 16rpx; background: #f0f0f0; margin: 0 12rpx 12rpx 0; min-width: 100rpx; }
+.ex-type-active { background: #E8F5E9; border: 2rpx solid #4CAF50; }
+.ex-type-icon { font-size: 36rpx; }
+.ex-type-label { font-size: 22rpx; color: #666; margin-top: 6rpx; }
+.input-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 16rpx; }
+.input-label { font-size: 26rpx; color: #666; width: 140rpx; flex-shrink: 0; }
+.input-field { flex: 1; background: #f5f7fa; border-radius: 12rpx; padding: 14rpx 16rpx; font-size: 28rpx; }
+.intensity-options { display: flex; flex-direction: row; }
+.intensity-item { padding: 8rpx 24rpx; border-radius: 12rpx; background: #f0f0f0; font-size: 26rpx; color: #666; margin-right: 12rpx; }
+.intensity-active { background: #FFF3E0; color: #E65100; }
+.submit-btn { background: linear-gradient(135deg, #4CAF50, #66BB6A); color: #fff; font-size: 32rpx; font-weight: bold; text-align: center; padding: 22rpx; border-radius: 40rpx; margin-top: 16rpx; }
+.today-summary { display: flex; flex-direction: row; justify-content: space-around; }
+.summary-stat { font-size: 26rpx; color: #666; }
+.stat-value { font-size: 32rpx; color: #4CAF50; font-weight: bold; }
+.history-list { display: flex; flex-direction: column; }
+.history-item { display: flex; flex-direction: row; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-icon { font-size: 32rpx; margin-right: 16rpx; }
+.history-info { flex: 1; }
+.history-name { font-size: 28rpx; color: #333; }
+.history-meta { font-size: 22rpx; color: #999; }
+.history-time { font-size: 22rpx; color: #999; }
+.history-empty { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 189 - 0
cfc-frontend/pages/body/growth-curve.vue

@@ -0,0 +1,189 @@
+<template>
+  <view class="growth-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">生长曲线</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">当前数据</view>
+      <view class="current-stats" v-if="latestRecord">
+        <view class="stat-box">
+          <text class="stat-val">{{ latestRecord.heightCm || '--' }}</text>
+          <text class="stat-unit">cm</text>
+          <text class="stat-label">身高</text>
+        </view>
+        <view class="stat-box">
+          <text class="stat-val">{{ latestRecord.weightKg || '--' }}</text>
+          <text class="stat-unit">kg</text>
+          <text class="stat-label">体重</text>
+        </view>
+        <view class="stat-box">
+          <text class="stat-val">{{ latestRecord.bmi || '--' }}</text>
+          <text class="stat-unit">BMI</text>
+          <text class="stat-label">指数</text>
+        </view>
+      </view>
+      <view class="current-stats" v-else>
+        <view class="stat-box"><text class="stat-val">--</text><text class="stat-label">暂无数据</text></view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">身高趋势</view>
+      <view class="chart-placeholder">
+        <view class="chart-svg">
+          <view class="chart-line" v-for="(pt, idx) in heightPoints" :key="idx"
+            :style="{ left: pt.x + '%', bottom: pt.y + '%' }">
+            <view class="chart-dot"></view>
+          </view>
+          <view class="chart-axis-y">
+            <text class="axis-label" v-for="(l, i) in ['150cm','130cm','110cm','90cm']" :key="i"
+              :style="{ bottom: (i * 25) + '%' }">{{ l }}</text>
+          </view>
+          <view class="chart-no-data" v-if="heightPoints.length < 2">
+            <text>记录更多数据后将显示生长趋势曲线</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">体重趋势</view>
+      <view class="chart-placeholder">
+        <view class="chart-svg">
+          <view class="chart-line" v-for="(pt, idx) in weightPoints" :key="idx"
+            :style="{ left: pt.x + '%', bottom: pt.y + '%' }">
+            <view class="chart-dot"></view>
+          </view>
+          <view class="chart-axis-y">
+            <text class="axis-label" v-for="(l, i) in ['50kg','40kg','30kg','20kg']" :key="i"
+              :style="{ bottom: (i * 25) + '%' }">{{ l }}</text>
+          </view>
+          <view class="chart-no-data" v-if="weightPoints.length < 2">
+            <text>记录更多数据后将显示体重趋势曲线</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">生长记录列表</view>
+      <view class="record-list">
+        <view class="record-item" v-for="item in growthRecords" :key="item.id">
+          <view class="record-date">{{ item.recordDate ? item.recordDate.substring(0,10) : '' }}</view>
+          <view class="record-data">
+            <text class="data-item" v-if="item.heightCm">{{ item.heightCm }}cm</text>
+            <text class="data-item" v-if="item.weightKg">{{ item.weightKg }}kg</text>
+            <text class="data-item" v-if="item.bmi">BMI {{ item.bmi }}</text>
+          </view>
+        </view>
+        <view class="record-empty" v-if="growthRecords.length === 0"><text>暂无生长记录</text></view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      growthRecords: [],
+      latestRecord: null
+    }
+  },
+  computed: {
+    heightPoints: function() {
+      return this.calcPoints('heightCm')
+    },
+    weightPoints: function() {
+      return this.calcPoints('weightKg')
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    if (this.childId) this.loadRecords()
+  },
+  methods: {
+    calcPoints: function(field) {
+      var records = this.growthRecords.slice().reverse()
+      var values = records.map(function(r) { return parseFloat(r[field]) }).filter(function(v) { return v && v > 0 })
+      if (values.length < 2) return []
+      var min = Math.min.apply(null, values)
+      var max = Math.max.apply(null, values)
+      var range = max - min || 1
+      var spacing = 80 / (values.length - 1)
+      return values.map(function(v, i) {
+        return { x: 10 + i * spacing, y: 10 + ((v - min) / range) * 70 }
+      })
+    },
+    loadRecords: function() {
+      var self = this
+      healthRequest('/api/health/growth/list', { memberId: this.childId }).then(function(res) {
+        if (res.data) {
+          self.growthRecords = res.data
+          if (res.data.length > 0) self.latestRecord = res.data[0]
+        }
+      }).catch(function() {
+        // Use mock data for demo
+        self.growthRecords = [
+          { id: 1, recordDate: '2026-01-15', heightCm: 110.5, weightKg: 18.2, bmi: 14.9 },
+          { id: 2, recordDate: '2026-02-15', heightCm: 111.2, weightKg: 18.5, bmi: 14.9 },
+          { id: 3, recordDate: '2026-03-15', heightCm: 112.0, weightKg: 19.0, bmi: 15.1 }
+        ]
+        self.latestRecord = self.growthRecords[self.growthRecords.length - 1]
+      })
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+
+function healthRequest(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: function(res) { if (res.data.code === 200) resolve(res.data); else reject(res.data) },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+</script>
+
+<style scoped>
+.growth-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.section-card { margin: 20rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
+.current-stats { display: flex; flex-direction: row; justify-content: space-around; }
+.stat-box { display: flex; flex-direction: column; align-items: center; }
+.stat-val { font-size: 44rpx; font-weight: bold; color: #FF8C42; }
+.stat-unit { font-size: 22rpx; color: #999; }
+.stat-label { font-size: 24rpx; color: #666; margin-top: 4rpx; }
+.chart-placeholder { background: #FFFBEB; border-radius: 16rpx; padding: 20rpx; min-height: 300rpx; position: relative; }
+.chart-svg { width: 100%; height: 300rpx; position: relative; }
+.chart-line { position: absolute; width: 20rpx; height: 20rpx; }
+.chart-dot { width: 16rpx; height: 16rpx; background: #FF8C42; border-radius: 50%; }
+.chart-axis-y { position: absolute; left: 0; top: 0; bottom: 30rpx; width: 80rpx; }
+.axis-label { position: absolute; font-size: 18rpx; color: #999; }
+.chart-no-data { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24rpx; color: #ccc; text-align: center; }
+.record-list { display: flex; flex-direction: column; }
+.record-item { display: flex; flex-direction: row; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.record-date { font-size: 24rpx; color: #999; width: 160rpx; flex-shrink: 0; }
+.record-data { display: flex; flex-direction: row; flex-wrap: wrap; flex: 1; }
+.data-item { font-size: 26rpx; color: #333; margin-right: 16rpx; }
+.record-empty { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 190 - 0
cfc-frontend/pages/body/health-trend.vue

@@ -0,0 +1,190 @@
+<template>
+  <view class="trend-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">健康趋势</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="date-tabs">
+      <text class="date-tab" :class="{ 'date-tab-active': activeTab === '7d' }" @click="switchDateRange('7d')">近7天</text>
+      <text class="date-tab" :class="{ 'date-tab-active': activeTab === '30d' }" @click="switchDateRange('30d')">近30天</text>
+    </view>
+
+    <!-- 平均分概览 -->
+    <view class="summary-section" v-if="averages">
+      <view class="summary-title">平均评分</view>
+      <view class="summary-grid">
+        <view class="summary-item" v-for="(val, key) in averages" :key="key">
+          <text class="summary-value" :style="{ color: scoreColor(key) }">{{ val }}</text>
+          <text class="summary-label">{{ scoreLabel(key) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 趋势图 -->
+    <view class="chart-section">
+      <view class="chart-category" v-for="cat in chartCategories" :key="cat.key">
+        <view class="chart-header">
+          <text class="chart-label">{{ cat.label }}</text>
+          <text class="chart-avg" :style="{ color: cat.color }">{{ currentAvg(cat.key) }}分</text>
+        </view>
+        <view class="chart-bars">
+          <view class="chart-bar-col" v-for="(day, idx) in trendData" :key="idx">
+            <view class="bar-wrapper">
+              <view class="bar-fill" :style="{ height: barHeight(day[cat.key]), background: cat.color }"></view>
+            </view>
+            <text class="bar-label">{{ day.date.substring(day.date.length - 2) }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-if="!loading && trendData.length === 0">
+      <text class="empty-icon">📊</text>
+      <text class="empty-text">暂无趋势数据</text>
+      <text class="empty-desc">开始每日健康打卡后,这里将展示变化趋势</text>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      activeTab: '7d',
+      loading: false,
+      trendData: [],
+      averages: null,
+      chartCategories: [
+        { key: 'diet', label: '饮食', color: '#FF8C42' },
+        { key: 'exercise', label: '运动', color: '#FFA726' },
+        { key: 'sleep', label: '作息', color: '#FF7043' },
+        { key: 'water', label: '饮水', color: '#FFB74D' },
+        { key: 'overall', label: '综合', color: '#E65100' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    this.loadData()
+  },
+  methods: {
+    loadData: function() {
+      this.loading = true
+      var self = this
+      var days = this.activeTab === '30d' ? 30 : 7
+      var token = uni.getStorageSync('token')
+      uni.request({
+        url: config.api('/api/daily/checkin/list'),
+        method: 'POST',
+        data: { childId: this.childId, days: days },
+        header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+        success: function(res) {
+          self.loading = false
+          if (res.data && res.data.code === 200 && res.data.data) {
+            self.trendData = res.data.data.scores || []
+            self.averages = res.data.data.averages || null
+          } else {
+            self.useMockData()
+          }
+        },
+        fail: function() {
+          self.loading = false
+          self.useMockData()
+        }
+      })
+    },
+    useMockData: function() {
+      var days = this.activeTab === '30d' ? 30 : 7
+      var mock = []
+      var startDay = 25
+      for (var i = 0; i < days; i++) {
+        var dayNum = (startDay + i) % 31
+        if (dayNum === 0) dayNum = 1
+        mock.push({
+          date: '06-' + (dayNum < 10 ? '0' + dayNum : dayNum),
+          diet: Math.floor(Math.random() * 30) + 60,
+          exercise: Math.floor(Math.random() * 30) + 55,
+          sleep: Math.floor(Math.random() * 25) + 65,
+          water: Math.floor(Math.random() * 30) + 55,
+          overall: Math.floor(Math.random() * 20) + 70
+        })
+      }
+      this.trendData = mock
+      this.averages = {
+        diet: Math.round(mock.reduce(function(s, d) { return s + d.diet }, 0) / mock.length),
+        exercise: Math.round(mock.reduce(function(s, d) { return s + d.exercise }, 0) / mock.length),
+        sleep: Math.round(mock.reduce(function(s, d) { return s + d.sleep }, 0) / mock.length),
+        water: Math.round(mock.reduce(function(s, d) { return s + d.water }, 0) / mock.length),
+        overall: Math.round(mock.reduce(function(s, d) { return s + d.overall }, 0) / mock.length)
+      }
+    },
+    switchDateRange: function(range) {
+      this.activeTab = range
+      this.loadData()
+    },
+    barHeight: function(score) {
+      return Math.max(4, (score || 0) * 0.6) + 'px'
+    },
+    currentAvg: function(key) {
+      if (this.averages && this.averages[key] !== undefined) return this.averages[key]
+      return '--'
+    },
+    scoreLabel: function(key) {
+      var map = { diet: '饮食', exercise: '运动', sleep: '作息', water: '饮水', overall: '综合' }
+      return map[key] || key
+    },
+    scoreColor: function(key) {
+      var map = { diet: '#FF8C42', exercise: '#FFA726', sleep: '#FF7043', water: '#FFB74D', overall: '#E65100' }
+      return map[key] || '#FF8C42'
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+</script>
+
+<style scoped>
+.trend-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.date-tabs { display: flex; flex-direction: row; margin: 16rpx 30rpx; background: #fff; border-radius: 40rpx; padding: 4rpx; }
+.date-tab { flex: 1; text-align: center; padding: 16rpx; font-size: 26rpx; color: #666; border-radius: 36rpx; }
+.date-tab-active { background: #FF8C42; color: #fff; font-weight: bold; }
+.summary-section { margin: 16rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.summary-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; }
+.summary-grid { display: flex; flex-direction: row; flex-wrap: wrap; }
+.summary-item { display: flex; flex-direction: column; align-items: center; width: 20%; margin-bottom: 8rpx; }
+.summary-value { font-size: 36rpx; font-weight: bold; }
+.summary-label { font-size: 22rpx; color: #999; margin-top: 4rpx; }
+.chart-section { margin: 16rpx 30rpx; }
+.chart-category { background: #fff; border-radius: 20rpx; padding: 20rpx; margin-bottom: 12rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.chart-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
+.chart-label { font-size: 28rpx; font-weight: 600; color: #333; }
+.chart-avg { font-size: 26rpx; font-weight: bold; }
+.chart-bars { display: flex; flex-direction: row; align-items: flex-end; height: 80rpx; }
+.chart-bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; }
+.bar-wrapper { width: 12rpx; height: 60rpx; background: #f0f0f0; border-radius: 6rpx; position: relative; display: flex; flex-direction: column; justify-content: flex-end; }
+.bar-fill { width: 100%; border-radius: 6rpx; transition: height 0.3s; min-height: 4rpx; }
+.bar-label { font-size: 18rpx; color: #999; margin-top: 6rpx; }
+.loading-state { display: flex; justify-content: center; padding: 60rpx; }
+.loading-text { font-size: 28rpx; color: #999; }
+.empty-state { display: flex; flex-direction: column; align-items: center; padding: 80rpx 30rpx; }
+.empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
+.empty-text { font-size: 28rpx; color: #666; margin-bottom: 8rpx; }
+.empty-desc { font-size: 24rpx; color: #999; text-align: center; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 200 - 0
cfc-frontend/pages/body/meal-record.vue

@@ -0,0 +1,200 @@
+<template>
+  <view class="meal-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">饮食记录</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">今日饮食</view>
+      <view class="meal-types">
+        <view class="meal-type-item" v-for="type in mealTypes" :key="type.value"
+          :class="{ 'meal-type-active': activeMealType === type.value }"
+          @click="activeMealType = type.value">
+          <text>{{ type.label }}</text>
+        </view>
+      </view>
+
+      <view class="food-input-area">
+        <view class="input-row">
+          <text class="input-label">食物名称</text>
+          <input class="input-field" v-model="foodName" placeholder="输入食物名称" />
+        </view>
+        <view class="input-row">
+          <text class="input-label">份量</text>
+          <input class="input-field" v-model="foodAmount" placeholder="如:1碗、200g" />
+        </view>
+        <view class="input-row" v-if="foodName">
+          <text class="input-label">预估热量</text>
+          <input class="input-field" v-model="calories" type="number" placeholder="可选" />
+        </view>
+        <view class="add-food-btn" @click="addFood">添加食物</view>
+      </view>
+
+      <view class="food-list">
+        <view class="food-item" v-for="(food, idx) in todayFoods" :key="idx">
+          <view class="food-info">
+            <text class="food-name">{{ food.foodName }}</text>
+            <text class="food-amount">{{ food.foodAmount }}</text>
+          </view>
+          <text class="food-cal" v-if="food.calories">{{ food.calories }} kcal</text>
+          <text class="food-del" @click="todayFoods.splice(idx, 1)">✕</text>
+        </view>
+        <view class="food-empty" v-if="todayFoods.length === 0">
+          <text>暂未添加食物</text>
+        </view>
+      </view>
+
+      <view class="submit-btn" @click="submitMeal">
+        <text>保存记录</text>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">历史记录</view>
+      <view class="history-list">
+        <view class="history-item" v-for="item in mealHistory" :key="item.id">
+          <text class="history-date">{{ item.eatenAt ? item.eatenAt.substring(0,10) : '' }}</text>
+          <text class="history-type">{{ item.mealType }}</text>
+          <text class="history-food">{{ item.foodName }} {{ item.foodAmount }}</text>
+        </view>
+        <view class="history-empty" v-if="mealHistory.length === 0">
+          <text>暂无饮食记录</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      activeMealType: 'breakfast',
+      foodName: '',
+      foodAmount: '',
+      calories: '',
+      todayFoods: [],
+      mealHistory: [],
+      mealTypes: [
+        { label: '早餐', value: 'breakfast' },
+        { label: '午餐', value: 'lunch' },
+        { label: '晚餐', value: 'dinner' },
+        { label: '加餐', value: 'snack' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    if (this.childId) this.loadHistory()
+  },
+  methods: {
+    addFood: function() {
+      if (!this.foodName.trim()) {
+        uni.showToast({ title: '请输入食物名称', icon: 'none' })
+        return
+      }
+      this.todayFoods.push({
+        mealType: this.activeMealType,
+        foodName: this.foodName.trim(),
+        foodAmount: this.foodAmount.trim() || '1份',
+        calories: this.calories ? parseInt(this.calories) : null
+      })
+      this.foodName = ''
+      this.foodAmount = ''
+      this.calories = ''
+    },
+    submitMeal: function() {
+      if (this.todayFoods.length === 0) {
+        uni.showToast({ title: '请至少添加一个食物', icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '保存中...' })
+      var promises = this.todayFoods.map(function(food) {
+        return healthRequest('/api/health/meal/create', {
+          childId: this.childId,
+          memberId: this.childId,
+          mealType: food.mealType,
+          foodName: food.foodName,
+          foodAmount: food.foodAmount,
+          calories: food.calories
+        })
+      }, this)
+      Promise.all(promises).then(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        this.todayFoods = []
+        this.loadHistory()
+      }.bind(this)).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '保存失败,请在后续版本中使用', icon: 'none' })
+      })
+    },
+    loadHistory: function() {
+      var self = this
+      healthRequest('/api/health/meal/list', { childId: this.childId }).then(function(res) {
+        if (res.data) self.mealHistory = res.data
+      }).catch(function() {})
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+
+function healthRequest(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        if (res.data.code === 200) resolve(res.data)
+        else reject(res.data)
+      },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+</script>
+
+<style scoped>
+.meal-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.section-card { margin: 20rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
+.meal-types { display: flex; flex-direction: row; margin-bottom: 20rpx; }
+.meal-type-item { padding: 10rpx 24rpx; border-radius: 20rpx; font-size: 26rpx; color: #666; background: #f0f0f0; margin-right: 16rpx; }
+.meal-type-active { background: #FFF3E0; color: #E65100; font-weight: 500; }
+.input-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 16rpx; }
+.input-label { font-size: 26rpx; color: #666; width: 140rpx; flex-shrink: 0; }
+.input-field { flex: 1; background: #f5f7fa; border-radius: 12rpx; padding: 14rpx 16rpx; font-size: 28rpx; }
+.add-food-btn { background: #E3F2FD; color: #1565C0; font-size: 26rpx; text-align: center; padding: 14rpx; border-radius: 12rpx; margin-top: 8rpx; }
+.food-list { margin-top: 16rpx; }
+.food-item { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f0f0f0; }
+.food-info { flex: 1; }
+.food-name { font-size: 28rpx; color: #333; }
+.food-amount { font-size: 24rpx; color: #999; margin-left: 12rpx; }
+.food-cal { font-size: 24rpx; color: #E65100; margin-right: 16rpx; }
+.food-del { font-size: 28rpx; color: #ccc; }
+.food-empty { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
+.submit-btn { background: linear-gradient(135deg, #4CAF50, #66BB6A); color: #fff; font-size: 32rpx; font-weight: bold; text-align: center; padding: 22rpx; border-radius: 40rpx; margin-top: 16rpx; }
+.history-list { display: flex; flex-direction: column; }
+.history-item { display: flex; flex-direction: row; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-date { font-size: 24rpx; color: #999; width: 160rpx; }
+.history-type { font-size: 24rpx; color: #1565C0; background: #E3F2FD; border-radius: 8rpx; padding: 2rpx 12rpx; margin-right: 12rpx; }
+.history-food { font-size: 26rpx; color: #333; flex: 1; }
+.history-empty { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 249 - 0
cfc-frontend/pages/body/recipe-recommend.vue

@@ -0,0 +1,249 @@
+<template>
+  <view class="recipe-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">食谱推荐</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="meal-tabs">
+      <text class="tab-item" v-for="mt in mealTypes" :key="mt.value"
+        :class="{ 'tab-active': selectedMeal === mt.value }"
+        @click="selectMeal(mt.value)">
+        <text class="tab-icon">{{ mt.icon }}</text>
+        <text class="tab-label">{{ mt.label }}</text>
+      </text>
+    </view>
+
+    <view class="section-card">
+      <view class="card-header">
+        <text class="card-title">🥗 AI 智能食谱</text>
+        <text class="card-subtitle">基于健康数据个性化推荐</text>
+      </view>
+      <view class="recommend-btn" :class="{ 'recommend-btn-loading': loading }" @click="doRecommend">
+        <text class="recommend-btn-text">{{ loading ? 'AI思考中...' : '生成今日食谱' }}</text>
+        <text class="recommend-btn-icon">✨</text>
+      </view>
+    </view>
+
+    <view class="section-card" v-if="recipeResult">
+      <view class="card-header">
+        <text class="card-title">📋 推荐食谱</text>
+      </view>
+      <view class="daily-meals">
+        <view class="meal-block" v-for="(meal, idx) in parsedMeals" :key="idx">
+          <view class="meal-block-header">
+            <text class="meal-block-icon">{{ mealTypeIcon(meal.mealType) }}</text>
+            <text class="meal-block-title">{{ meal.mealType || meal.recipeName }}</text>
+          </view>
+          <text class="meal-reason" v-if="meal.reason">{{ meal.reason }}</text>
+          <view class="meal-ingredients" v-if="meal.ingredients">
+            <text class="ingredient-label">食材:</text>
+            <text class="ingredient-text">{{ meal.ingredients }}</text>
+          </view>
+          <text class="meal-tips" v-if="meal.tips">💡 {{ meal.tips }}</text>
+        </view>
+      </view>
+      <view class="nutrition-tip" v-if="parsedNutritionTip">
+        <text class="tip-label">营养建议</text>
+        <text class="tip-text">{{ parsedNutritionTip }}</text>
+      </view>
+    </view>
+
+    <view class="section-card" v-if="recipeResult">
+      <view class="card-header">
+        <text class="card-title">🔄 食材替换建议</text>
+        <text class="card-subtitle">一键替换推荐食材</text>
+      </view>
+      <view class="replace-options">
+        <view class="replace-item" v-for="(food, idx) in replaceOptions" :key="idx" @click="replaceFood(food.id)">
+          <text class="replace-name">{{ food.name }}</text>
+          <text class="replace-score" v-if="food.score">{{ food.score }}分</text>
+          <text class="replace-icon">↻</text>
+        </view>
+        <view class="replace-empty" v-if="!replaceOptions || replaceOptions.length === 0">
+          <text>暂无替换建议</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card" v-if="recipeResult">
+      <view class="card-header">
+        <text class="card-title">📊 营养摄取概要</text>
+      </view>
+      <view class="nutrition-summary">
+        <view class="nutri-stat" v-for="(val, key) in nutritionSummary" :key="key">
+          <text class="nutri-value">{{ val }}</text>
+          <text class="nutri-label">{{ key }}</text>
+        </view>
+        <view class="nutri-empty" v-if="!nutritionSummary || Object.keys(nutritionSummary).length === 0">
+          <text>暂无数据</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      selectedMeal: 'daily',
+      loading: false,
+      recipeResult: null,
+      replaceOptions: [],
+      nutritionSummary: null,
+      mealTypes: [
+        { label: '每日', value: 'daily', icon: '📅' },
+        { label: '早餐', value: 'breakfast', icon: '🌅' },
+        { label: '午餐', value: 'lunch', icon: '☀️' },
+        { label: '晚餐', value: 'dinner', icon: '🌙' },
+        { label: '加餐', value: 'snack', icon: '🍪' }
+      ]
+    }
+  },
+  computed: {
+    parsedMeals: function() {
+      if (!this.recipeResult || !this.recipeResult.answer) return []
+      try {
+        var parsed = JSON.parse(this.recipeResult.answer)
+        if (parsed.dailyMeals) return parsed.dailyMeals
+        if (parsed.meals) return parsed.meals
+        return [{ recipeName: 'AI推荐', reason: this.recipeResult.answer.substring(0, 100) + '...' }]
+      } catch (e) {
+        return [{ recipeName: 'AI推荐方案', reason: this.recipeResult.answer }]
+      }
+    },
+    parsedNutritionTip: function() {
+      if (!this.recipeResult || !this.recipeResult.answer) return ''
+      try {
+        var parsed = JSON.parse(this.recipeResult.answer)
+        return parsed.nutritionTip || parsed.tips || ''
+      } catch (e) {
+        return ''
+      }
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+  },
+  methods: {
+    selectMeal: function(value) {
+      this.selectedMeal = value
+    },
+    doRecommend: function() {
+      this.loading = true
+      var self = this
+      healthRequest('/api/meal/recommend', {
+        mealType: this.selectedMeal !== 'daily' ? this.selectedMeal : null,
+        childId: this.childId
+      }).then(function(res) {
+        self.loading = false
+        if (res.data) {
+          self.recipeResult = res.data
+          self.replaceOptions = res.data.replaceOptions || []
+          self.nutritionSummary = res.data.nutritionSummary || null
+        }
+      }).catch(function(e) {
+        self.loading = false
+        var mockData = {
+          answer: JSON.stringify({
+            dailyMeals: [
+              { mealType: '早餐', recipeName: '全麦三明治+牛奶', reason: '搭配优质碳水+蛋白质', ingredients: '全麦面包2片、鸡蛋1个、生菜2片、牛奶200ml', tips: '全麦面包提供膳食纤维,牛奶补充钙质' },
+              { mealType: '午餐', recipeName: '清蒸鱼+杂粮饭', reason: '优质蛋白+复合碳水', ingredients: '鲈鱼150g、西兰花100g、杂粮米50g', tips: '鱼类富含DHA,有助于大脑发育' },
+              { mealType: '晚餐', recipeName: '番茄鸡肉意面', reason: '均衡营养易消化', ingredients: '鸡胸肉100g、番茄1个、意面80g', tips: '晚餐适量减少碳水量' }
+            ],
+            nutritionTip: '建议每日摄入:蛋白质50-60g,碳水150-200g,蔬果500g以上'
+          }),
+          conversationId: '',
+          replaceOptions: [{ id: 1, name: '鸡胸肉', score: 85 }, { id: 2, name: '三文鱼', score: 90 }, { id: 3, name: '豆腐', score: 75 }],
+          nutritionSummary: { 蛋白质: '55g', 碳水: '180g', 脂肪: '40g', 膳食纤维: '15g', 热量: '1450kcal' }
+        }
+        self.recipeResult = mockData
+        self.replaceOptions = mockData.replaceOptions
+        self.nutritionSummary = mockData.nutritionSummary
+      })
+    },
+    replaceFood: function(foodId) {
+      var self = this
+      healthRequest('/api/meal/replace', { foodId: foodId, childId: this.childId }).then(function(res) {
+        if (res.data) {
+          uni.showToast({ title: '已替换:' + (res.data.name || ''), icon: 'success' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '替换失败', icon: 'none' })
+      })
+    },
+    mealTypeIcon: function(type) {
+      var map = { '早餐': '🌅', '午餐': '☀️', '晚餐': '🌙', '加餐': '🍪', 'breakfast': '🌅', 'lunch': '☀️', 'dinner': '🌙', 'snack': '🍪' }
+      return map[type] || '🍽️'
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+
+function healthRequest(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: function(res) { if (res.data.code === 200) resolve(res.data); else reject(res.data) },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+</script>
+
+<style scoped>
+.recipe-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.meal-tabs { display: flex; flex-direction: row; margin: 16rpx 30rpx; }
+.tab-item { display: flex; flex-direction: column; align-items: center; padding: 12rpx 20rpx; border-radius: 16rpx; background: #fff; margin-right: 12rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04); }
+.tab-active { background: #FFF3E0; border: 2rpx solid #FF8C42; }
+.tab-icon { font-size: 28rpx; }
+.tab-label { font-size: 22rpx; color: #666; margin-top: 4rpx; }
+.section-card { margin: 16rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-header { margin-bottom: 16rpx; }
+.card-title { font-size: 30rpx; font-weight: bold; color: #333; }
+.card-subtitle { font-size: 24rpx; color: #999; margin-top: 4rpx; }
+.recommend-btn { display: flex; flex-direction: row; align-items: center; justify-content: center; background: linear-gradient(135deg, #FF8C42, #FFA726); color: #fff; padding: 24rpx; border-radius: 40rpx; }
+.recommend-btn-loading { opacity: 0.7; }
+.recommend-btn-text { font-size: 32rpx; font-weight: bold; margin-right: 12rpx; }
+.recommend-btn-icon { font-size: 36rpx; }
+.daily-meals { display: flex; flex-direction: column; }
+.meal-block { background: #FFFBEB; border-radius: 16rpx; padding: 16rpx; margin-bottom: 12rpx; }
+.meal-block-header { display: flex; flex-direction: row; align-items: center; margin-bottom: 8rpx; }
+.meal-block-icon { font-size: 32rpx; margin-right: 8rpx; }
+.meal-block-title { font-size: 28rpx; font-weight: 600; color: #333; }
+.meal-reason { font-size: 24rpx; color: #666; margin-bottom: 8rpx; }
+.meal-ingredients { display: flex; flex-direction: row; flex-wrap: wrap; margin-bottom: 6rpx; }
+.ingredient-label { font-size: 24rpx; color: #999; }
+.ingredient-text { font-size: 24rpx; color: #333; flex: 1; }
+.meal-tips { font-size: 24rpx; color: #E65100; }
+.nutrition-tip { background: #E8F5E9; border-radius: 12rpx; padding: 16rpx; margin-top: 12rpx; }
+.tip-label { font-size: 24rpx; font-weight: bold; color: #2E7D32; display: block; margin-bottom: 4rpx; }
+.tip-text { font-size: 24rpx; color: #333; }
+.replace-options { display: flex; flex-direction: column; }
+.replace-item { display: flex; flex-direction: row; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.replace-name { flex: 1; font-size: 28rpx; color: #333; }
+.replace-score { font-size: 24rpx; color: #4CAF50; margin-right: 16rpx; }
+.replace-icon { font-size: 28rpx; color: #5B9BD5; }
+.replace-empty { text-align: center; padding: 20rpx; color: #999; }
+.nutrition-summary { display: flex; flex-direction: row; flex-wrap: wrap; }
+.nutri-stat { display: flex; flex-direction: column; align-items: center; min-width: 25%; margin-bottom: 12rpx; }
+.nutri-value { font-size: 32rpx; font-weight: bold; color: #FF8C42; }
+.nutri-label { font-size: 22rpx; color: #666; }
+.nutri-empty { text-align: center; padding: 20rpx; color: #999; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 204 - 0
cfc-frontend/pages/body/sleep-record.vue

@@ -0,0 +1,204 @@
+<template>
+  <view class="sleep-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">作息记录</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">记录睡眠</view>
+      <view class="input-row">
+        <text class="input-label">入睡时间</text>
+        <picker mode="time" :value="sleepTime" @change="onSleepTimeChange">
+          <view class="picker-display">{{ sleepTime || '选择时间' }}</view>
+        </picker>
+      </view>
+      <view class="input-row">
+        <text class="input-label">起床时间</text>
+        <picker mode="time" :value="wakeTime" @change="onWakeTimeChange">
+          <view class="picker-display">{{ wakeTime || '选择时间' }}</view>
+        </picker>
+      </view>
+      <view class="input-row">
+        <text class="input-label">睡眠质量</text>
+        <view class="quality-options">
+          <text class="quality-item" v-for="q in qualityOptions" :key="q.value"
+            :class="{ 'quality-active': quality === q.value }" @click="quality = q.value">
+            <text class="quality-emoji">{{ q.emoji }}</text>
+            <text class="quality-label">{{ q.label }}</text>
+          </text>
+        </view>
+      </view>
+      <view class="input-row">
+        <text class="input-label">备注</text>
+        <input class="input-field" v-model="remark" placeholder="可选" />
+      </view>
+      <view class="submit-btn" @click="submitSleep">保存记录</view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">本周睡眠概况</view>
+      <view class="sleep-stats">
+        <view class="stat-item">
+          <text class="stat-value">{{ avgDuration || '--' }}</text>
+          <text class="stat-label">平均时长(h)</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">{{ avgQuality || '--' }}</text>
+          <text class="stat-label">平均评分</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">{{ totalRecords || 0 }}</text>
+          <text class="stat-label">记录天数</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">历史记录</view>
+      <view class="history-list">
+        <view class="history-item" v-for="item in sleepHistory" :key="item.id">
+          <view class="history-info">
+            <text class="history-date">{{ item.createdAt ? item.createdAt.substring(0,10) : '' }}</text>
+            <text class="history-detail">{{ item.sleepTime ? item.sleepTime.substring(11,16) : '--' }} → {{ item.wakeTime ? item.wakeTime.substring(11,16) : '--' }}</text>
+          </view>
+          <view class="history-quality">
+            <text>{{ getQualityEmoji(item.qualityScore) }}</text>
+            <text class="quality-num" v-if="item.durationMinutes">{{ Math.round(item.durationMinutes/60) }}h</text>
+          </view>
+        </view>
+        <view class="history-empty" v-if="sleepHistory.length === 0"><text>暂无记录</text></view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      sleepTime: '',
+      wakeTime: '',
+      quality: '',
+      remark: '',
+      sleepHistory: [],
+      avgDuration: null,
+      avgQuality: null,
+      totalRecords: 0,
+      qualityOptions: [
+        { label: '很好', value: 'excellent', emoji: '😴' },
+        { label: '还行', value: 'good', emoji: '🙂' },
+        { label: '一般', value: 'fair', emoji: '😐' },
+        { label: '很差', value: 'poor', emoji: '😫' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    if (this.childId) this.loadHistory()
+  },
+  methods: {
+    onSleepTimeChange: function(e) { this.sleepTime = e.detail.value },
+    onWakeTimeChange: function(e) { this.wakeTime = e.detail.value },
+    getQualityEmoji: function(score) {
+      if (!score) return '🙂'
+      if (score >= 8) return '😴'
+      if (score >= 6) return '🙂'
+      if (score >= 4) return '😐'
+      return '😫'
+    },
+    submitSleep: function() {
+      if (!this.sleepTime || !this.wakeTime) {
+        uni.showToast({ title: '请选择睡眠时间', icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '保存中...' })
+      healthRequest('/api/health/sleep/create', {
+        childId: this.childId,
+        memberId: this.childId,
+        sleepTime: this.sleepTime,
+        wakeTime: this.wakeTime,
+        qualityScore: this.quality ? { excellent: 9, good: 7, fair: 5, poor: 2 }[this.quality] : null,
+        remark: this.remark
+      }).then(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        this.sleepTime = ''
+        this.wakeTime = ''
+        this.quality = ''
+        this.remark = ''
+        this.loadHistory()
+      }.bind(this)).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      })
+    },
+    loadHistory: function() {
+      var self = this
+      healthRequest('/api/health/sleep/list', { childId: this.childId }).then(function(res) {
+        if (res.data) {
+          self.sleepHistory = res.data.list || res.data
+          self.avgDuration = res.data.avgDuration || null
+          self.avgQuality = res.data.avgQuality || null
+          self.totalRecords = res.data.totalRecords || self.sleepHistory.length
+        }
+      }).catch(function() {})
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+
+function healthRequest(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: function(res) { if (res.data.code === 200) resolve(res.data); else reject(res.data) },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+</script>
+
+<style scoped>
+.sleep-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.section-card { margin: 20rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
+.input-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 16rpx; }
+.input-label { font-size: 26rpx; color: #666; width: 140rpx; flex-shrink: 0; }
+.input-field { flex: 1; background: #f5f7fa; border-radius: 12rpx; padding: 14rpx 16rpx; font-size: 28rpx; }
+.picker-display { background: #f5f7fa; border-radius: 12rpx; padding: 14rpx 16rpx; font-size: 28rpx; color: #333; min-width: 200rpx; }
+.quality-options { display: flex; flex-direction: row; }
+.quality-item { display: flex; flex-direction: column; align-items: center; padding: 12rpx 20rpx; border-radius: 12rpx; background: #f0f0f0; margin-right: 12rpx; }
+.quality-active { background: #E3F2FD; border: 2rpx solid #5B9BD5; }
+.quality-emoji { font-size: 32rpx; }
+.quality-label { font-size: 20rpx; color: #666; margin-top: 4rpx; }
+.submit-btn { background: linear-gradient(135deg, #5B9BD5, #7EB8E0); color: #fff; font-size: 32rpx; font-weight: bold; text-align: center; padding: 22rpx; border-radius: 40rpx; margin-top: 16rpx; }
+.sleep-stats { display: flex; flex-direction: row; justify-content: space-around; }
+.stat-item { display: flex; flex-direction: column; align-items: center; }
+.stat-value { font-size: 40rpx; font-weight: bold; color: #5B9BD5; }
+.stat-label { font-size: 22rpx; color: #999; }
+.history-list { display: flex; flex-direction: column; }
+.history-item { display: flex; flex-direction: row; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.history-info { flex: 1; }
+.history-date { font-size: 24rpx; color: #999; display: block; }
+.history-detail { font-size: 28rpx; color: #333; }
+.history-quality { display: flex; flex-direction: column; align-items: center; }
+.history-quality text { font-size: 28rpx; }
+.quality-num { font-size: 22rpx; color: #999; }
+.history-empty { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 161 - 0
cfc-frontend/pages/body/water-record.vue

@@ -0,0 +1,161 @@
+<template>
+  <view class="water-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">饮水记录</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">今日饮水</view>
+      <view class="water-progress">
+        <view class="water-circle">
+          <text class="water-amount">{{ todayTotal }}ml</text>
+          <text class="water-target">/ {{ dailyTarget }}ml</text>
+        </view>
+        <view class="progress-bar-bg">
+          <view class="progress-bar-fill" :style="'width:' + Math.min(100, todayTotal/dailyTarget*100) + '%'"></view>
+        </view>
+      </view>
+      <view class="quick-add-btns">
+        <text class="quick-btn" @click="addWater(100)">+100ml</text>
+        <text class="quick-btn" @click="addWater(200)">+200ml</text>
+        <text class="quick-btn" @click="addWater(300)">+300ml</text>
+        <text class="quick-btn" @click="addWater(500)">+500ml</text>
+      </view>
+      <view class="input-row">
+        <text class="input-label">自定义</text>
+        <input class="input-field" v-model="customAmount" type="number" placeholder="输入ml" />
+        <text class="input-unit" @click="addWater(parseInt(customAmount))">添加</text>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">今日饮水记录</view>
+      <view class="record-list">
+        <view class="record-item" v-for="(r, idx) in todayRecords" :key="idx">
+          <text class="record-icon">💧</text>
+          <text class="record-amount">{{ r.amountMl }}ml</text>
+          <text class="record-time">{{ r.createdAt ? r.createdAt.substring(11,16) : '' }}</text>
+        </view>
+        <view class="record-empty" v-if="todayRecords.length === 0"><text>今天还没喝水,快去喝水吧!</text></view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <view class="section-title">本周饮水趋势</view>
+      <view class="week-chart">
+        <view class="day-bar" v-for="(day, idx) in weekData" :key="idx">
+          <view class="bar-fill" :style="'height:' + Math.min(100, day.amount/dailyTarget*100) + '%'"></view>
+          <text class="bar-label">{{ day.label }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      customAmount: '',
+      todayRecords: [],
+      weekData: [
+        { label: '周一', amount: 0 }, { label: '周二', amount: 0 }, { label: '周三', amount: 0 },
+        { label: '周四', amount: 0 }, { label: '周五', amount: 0 }, { label: '周六', amount: 0 }, { label: '周日', amount: 0 }
+      ],
+      dailyTarget: 1500
+    }
+  },
+  computed: {
+    todayTotal: function() {
+      return this.todayRecords.reduce(function(sum, r) { return sum + (r.amountMl || 0) }, 0)
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    if (this.childId) this.loadRecords()
+  },
+  methods: {
+    addWater: function(ml) {
+      if (!ml || ml <= 0) return
+      uni.showLoading({ title: '记录中...' })
+      healthRequest('/api/health/water/create', {
+        childId: this.childId,
+        memberId: this.childId,
+        amountMl: ml
+      }).then(function() {
+        uni.hideLoading()
+        this.customAmount = ''
+        this.loadRecords()
+      }.bind(this)).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '记录失败', icon: 'none' })
+      })
+    },
+    loadRecords: function() {
+      var self = this
+      healthRequest('/api/health/water/list', { childId: this.childId }).then(function(res) {
+        if (res.data) {
+          self.todayRecords = res.data.today || res.data.list || res.data
+          if (res.data.week) self.weekData = res.data.week
+          if (res.data.dailyTarget) self.dailyTarget = res.data.dailyTarget
+        }
+      }).catch(function() {})
+    },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+
+function healthRequest(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: function(res) { if (res.data.code === 200) resolve(res.data); else reject(res.data) },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+</script>
+
+<style scoped>
+.water-container { min-height: 100vh; background: #f5f7fa; padding-bottom: 60rpx; }
+.nav-bar { position: sticky; top: 0; background: #fff; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 20rpx 30rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); z-index: 100; }
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.section-card { margin: 20rpx 30rpx; background: #fff; border-radius: 20rpx; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
+.water-progress { display: flex; flex-direction: column; align-items: center; margin-bottom: 20rpx; }
+.water-circle { display: flex; flex-direction: column; align-items: center; margin-bottom: 16rpx; }
+.water-amount { font-size: 56rpx; font-weight: bold; color: #2196F3; }
+.water-target { font-size: 24rpx; color: #999; }
+.progress-bar-bg { width: 100%; height: 20rpx; background: #E3F2FD; border-radius: 10rpx; overflow: hidden; }
+.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #42A5F5, #2196F3); border-radius: 10rpx; transition: width 0.3s; }
+.quick-add-btns { display: flex; flex-direction: row; justify-content: space-around; margin-bottom: 16rpx; }
+.quick-btn { background: #E3F2FD; color: #1565C0; font-size: 26rpx; padding: 12rpx 24rpx; border-radius: 20rpx; }
+.input-row { display: flex; flex-direction: row; align-items: center; }
+.input-label { font-size: 26rpx; color: #666; width: 100rpx; }
+.input-field { flex: 1; background: #f5f7fa; border-radius: 12rpx; padding: 14rpx 16rpx; font-size: 28rpx; }
+.input-unit { font-size: 26rpx; color: #2196F3; padding: 14rpx 20rpx; }
+.record-list { display: flex; flex-direction: column; }
+.record-item { display: flex; flex-direction: row; align-items: center; padding: 10rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.record-icon { font-size: 28rpx; margin-right: 12rpx; }
+.record-amount { font-size: 28rpx; color: #333; flex: 1; }
+.record-time { font-size: 22rpx; color: #999; }
+.record-empty { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
+.week-chart { display: flex; flex-direction: row; align-items: flex-end; justify-content: space-around; height: 200rpx; }
+.day-bar { display: flex; flex-direction: column; align-items: center; width: 60rpx; }
+.bar-fill { width: 40rpx; background: linear-gradient(180deg, #42A5F5, #1565C0); border-radius: 8rpx 8rpx 0 0; min-height: 4rpx; }
+.bar-label { font-size: 20rpx; color: #999; margin-top: 8rpx; }
+.bottom-spacer { height: 60rpx; }
+</style>

+ 27 - 0
cfc-web/src/api/activity.js

@@ -52,4 +52,31 @@ export function endActivity(id) {
     method: 'post',
     data: { id }
   })
+}
+
+// 活动报名列表
+export function getRegistrationList(params) {
+  return request({
+    url: '/api/admin/activity/registration/list',
+    method: 'post',
+    data: params
+  })
+}
+
+// 通过报名
+export function approveRegistration(data) {
+  return request({
+    url: '/api/admin/activity/registration/approve',
+    method: 'post',
+    data: data
+  })
+}
+
+// 拒绝报名
+export function rejectRegistration(data) {
+  return request({
+    url: '/api/admin/activity/registration/reject',
+    method: 'post',
+    data: data
+  })
 }

+ 17 - 0
cfc-web/src/api/dimension-weight.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function saveDimensionWeights(targetType, targetId, weights) {
+  return request({
+    url: '/api/admin/dimension-weight/save',
+    method: 'post',
+    data: { targetType, targetId, weights }
+  })
+}
+
+export function getDimensionWeights(targetType, targetId) {
+  return request({
+    url: '/api/admin/dimension-weight/list',
+    method: 'post',
+    data: { targetType, targetId }
+  })
+}

+ 167 - 0
cfc-web/src/components/DimensionWeightPicker.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="dimension-weight-picker">
+    <el-checkbox-group v-model="enabledDimensions" @change="onEnabledChange">
+      <div v-for="dim in dimensions" :key="dim.code" class="dim-row">
+        <el-checkbox :label="dim.code" class="dim-label">
+          <span class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</span>
+        </el-checkbox>
+        <el-slider
+          v-model="dim.weight"
+          :min="0"
+          :max="100"
+          :disabled="!isEnabled(dim.code)"
+          :show-tooltip="true"
+          class="dim-slider"
+          @change="onWeightChange"
+        />
+        <span class="dim-value">{{ dim.weight }}%</span>
+      </div>
+    </el-checkbox-group>
+
+    <div class="sum-row" :class="{ error: sum !== 100 }">
+      <span>合计:{{ sum }}%</span>
+      <span v-if="sum !== 100" class="sum-tip">权重总和需等于 100%</span>
+    </div>
+
+    <div v-if="showPresets" class="preset-row">
+      <el-button v-for="p in presets" :key="p.label" size="mini" @click="applyPreset(p)">{{ p.label }}</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'DimensionWeightPicker',
+  props: {
+    value: {
+      type: Array,
+      default() {
+        return []
+      }
+    },
+    showPresets: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      dimensions: [
+        { code: 'body', name: '身·土', color: '#FF8C42', weight: 0 },
+        { code: 'mind', name: '智·金', color: '#6366F1', weight: 0 },
+        { code: 'action', name: '行·木', color: '#10B981', weight: 0 },
+        { code: 'wealth', name: '富·水', color: '#F59E0B', weight: 0 },
+        { code: 'heart', name: '心·火', color: '#FF6B9D', weight: 0 }
+      ],
+      presets: [
+        { label: '均衡', weights: { body: 20, mind: 20, action: 20, wealth: 20, heart: 20 } },
+        { label: '偏身', weights: { body: 40, mind: 15, action: 15, wealth: 15, heart: 15 } },
+        { label: '偏智', weights: { body: 15, mind: 40, action: 15, wealth: 15, heart: 15 } },
+        { label: '偏行', weights: { body: 15, mind: 15, action: 40, wealth: 15, heart: 15 } },
+        { label: '偏富', weights: { body: 15, mind: 15, action: 15, wealth: 40, heart: 15 } },
+        { label: '偏心', weights: { body: 15, mind: 15, action: 15, wealth: 15, heart: 40 } }
+      ]
+    }
+  },
+  computed: {
+    sum() {
+      return this.dimensions.reduce((s, d) => s + (this.isEnabled(d.code) ? d.weight : 0), 0)
+    },
+    enabledDimensions: {
+      get() {
+        return (this.value || [])
+          .filter(w => w.enabled)
+          .map(w => w.dimension)
+      },
+      set(newVal) {}
+    }
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(val) {
+        if (Array.isArray(val) && val.length > 0) {
+          val.forEach(w => {
+            const dim = this.dimensions.find(d => d.code === w.dimension)
+            if (dim) {
+              dim.weight = w.weight || 0
+            }
+          })
+        }
+      }
+    }
+  },
+  methods: {
+    isEnabled(code) {
+      return (this.value || []).some(w => w.dimension === code && w.enabled)
+    },
+    onEnabledChange() {
+      this.emitValue()
+    },
+    onWeightChange() {
+      this.emitValue()
+    },
+    emitValue() {
+      const weights = this.dimensions.map(d => ({
+        dimension: d.code,
+        weight: this.isEnabled(d.code) ? d.weight : 0,
+        enabled: this.isEnabled(d.code)
+      }))
+      this.$emit('input', weights)
+    },
+    applyPreset(preset) {
+      this.dimensions.forEach(d => {
+        const w = preset.weights[d.code]
+        d.weight = w
+      })
+      this.enabledDimensions = this.dimensions.map(d => d.code)
+      this.emitValue()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dimension-weight-picker {
+  max-width: 480px;
+}
+.dim-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.dim-label {
+  width: 80px;
+}
+.dim-name {
+  font-weight: bold;
+}
+.dim-slider {
+  flex: 1;
+  margin: 0 8px;
+}
+.dim-value {
+  width: 40px;
+  text-align: right;
+  font-size: 13px;
+  color: #666;
+}
+.sum-row {
+  margin-top: 8px;
+  font-size: 13px;
+  color: #67c23a;
+}
+.sum-row.error {
+  color: #f56c6c;
+}
+.sum-tip {
+  margin-left: 8px;
+  font-size: 12px;
+}
+.preset-row {
+  margin-top: 12px;
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+</style>

+ 35 - 19
cfc-web/src/views/admin/Activities.vue

@@ -121,24 +121,13 @@
           <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入活动描述" />
         </el-form-item>
 
-        <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="维度">
-              <el-select v-model="form.dimensionCode" placeholder="请选择维度" style="width:100%">
-                <el-option label="身 (健康)" value="body" />
-                <el-option label="智 (智慧)" value="mind" />
-                <el-option label="行 (行动)" value="action" />
-                <el-option label="富 (财富)" value="wealth" />
-                <el-option label="心 (心灵)" value="heart" />
-              </el-select>
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="活动地点">
-              <el-input v-model="form.location" placeholder="请输入活动地点" />
-            </el-form-item>
-          </el-col>
-        </el-row>
+        <el-form-item label="五维权重">
+          <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
+        </el-form-item>
+
+        <el-form-item label="活动地点">
+          <el-input v-model="form.location" placeholder="请输入活动地点" />
+        </el-form-item>
 
         <el-row :gutter="16">
           <el-col :span="12">
@@ -207,7 +196,9 @@
 </template>
 
 <script>
+import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import { getActivityList, createActivity, updateActivity, publishActivity, endActivity } from '@/api/activity'
+import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
 
 const STATUS_MAP = {
   draft: { label: '草稿', type: 'info' },
@@ -229,6 +220,7 @@ const DIMENSION_MAP = {
 
 export default {
   name: 'Activities',
+  components: { DimensionWeightPicker },
   data() {
     return {
       loading: false,
@@ -248,6 +240,7 @@ export default {
         activityType: '',
         description: '',
         dimensionCode: '',
+        dimensionWeights: [],
         location: '',
         maxParticipants: null,
         priceYuan: null,
@@ -351,6 +344,7 @@ export default {
         activityType: '',
         description: '',
         dimensionCode: '',
+        dimensionWeights: [],
         location: '',
         maxParticipants: null,
         priceYuan: null,
@@ -370,6 +364,7 @@ export default {
         activityType: row.activityType || '',
         description: row.description || '',
         dimensionCode: row.dimensionCode || '',
+        dimensionWeights: [],
         location: row.location || '',
         maxParticipants: row.maxParticipants || null,
         priceYuan: row.price ? row.price / 100 : null,
@@ -381,6 +376,16 @@ export default {
         checkinPoints: row.checkinPoints || 0
       }
       this.dialogVisible = true
+      this.loadDimensionWeights(row.id)
+    },
+    async loadDimensionWeights(activityId) {
+      try {
+        const res = await getDimensionWeights('activity', activityId)
+        if (res.data) {
+          this.form.dimensionWeights = res.data
+        }
+      } catch (e) {
+      }
     },
     async saveActivity() {
       if (!this.form.title) {
@@ -409,14 +414,25 @@ export default {
           visibleTo: this.form.visibleTo,
           checkinPoints: this.form.checkinPoints || 0
         }
+        let activityId = this.editId
         if (this.editId) {
           payload.id = this.editId
           await updateActivity(payload)
           this.$message.success('更新成功')
         } else {
-          await createActivity(payload)
+          const res = await createActivity(payload)
+          if (res.data && res.data.id) {
+            activityId = res.data.id
+          }
           this.$message.success('创建成功')
         }
+        if (activityId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
+          try {
+            await saveDimensionWeights('activity', activityId, this.form.dimensionWeights)
+          } catch (e) {
+            console.error('保存维度权重失败', e)
+          }
+        }
         this.dialogVisible = false
         this.loadActivities()
       } catch (e) {

+ 132 - 0
cfc-web/src/views/admin/ActivityRegistrationReview.vue

@@ -0,0 +1,132 @@
+<template>
+  <div class="registration-review">
+    <div class="header">
+      <h2>活动报名审核</h2>
+      <div class="filters">
+        <el-select v-model="activityId" placeholder="选择活动" @change="loadList" style="width: 200px;" clearable>
+          <el-option v-for="a in activityList" :key="a.id" :label="a.title" :value="a.id" />
+        </el-select>
+        <el-select v-model="statusFilter" placeholder="状态筛选" @change="loadList" style="width: 130px;" clearable>
+          <el-option label="全部" value="" />
+          <el-option label="待审核" value="pending" />
+          <el-option label="已通过" value="approved" />
+          <el-option label="已拒绝" value="rejected" />
+        </el-select>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="userId" label="用户ID" width="70" />
+      <el-table-column prop="childId" label="孩子ID" width="70" />
+      <el-table-column prop="status" label="状态" width="90">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.status === 'approved' ? 'success' : row.status === 'rejected' ? 'danger' : 'warning'">
+            {{ row.status === 'approved' ? '已通过' : row.status === 'rejected' ? '已拒绝' : '待审核' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="registeredAt" label="报名时间" width="160">
+        <template slot-scope="{ row }">
+          {{ formatTime(row.registeredAt || row.createdAt) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button v-if="row.status === 'pending'" type="success" size="small" @click="handleApprove(row)">通过</el-button>
+          <el-button v-if="row.status === 'pending'" type="danger" size="small" @click="showRejectDialog(row)">拒绝</el-button>
+          <span v-else style="color: #999;">已处理</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
+      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
+      <span slot="footer">
+        <el-button @click="rejectDialogVisible = false">取消</el-button>
+        <el-button type="danger" @click="handleReject">确认拒绝</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getActivityList } from '@/api/activity'
+import { getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
+
+export default {
+  name: 'ActivityRegistrationReview',
+  data() {
+    return {
+      activityList: [],
+      list: [],
+      loading: false,
+      activityId: null,
+      statusFilter: '',
+      rejectDialogVisible: false,
+      rejectReason: '',
+      currentRow: null
+    }
+  },
+  mounted() {
+    this.loadActivities()
+  },
+  methods: {
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
+    },
+    async loadActivities() {
+      try {
+        const res = await getActivityList({ page: 1, size: 100 })
+        if (res.code === 200) this.activityList = (res.data && res.data.records) || res.data || []
+      } catch (e) { console.error(e) }
+    },
+    async loadList() {
+      if (!this.activityId) return this.$message.warning('请先选择活动')
+      this.loading = true
+      try {
+        const params = { activityId: this.activityId }
+        if (this.statusFilter) params.status = this.statusFilter
+        const res = await getRegistrationList(params)
+        if (res.code === 200) this.list = res.data || []
+      } catch (e) { console.error(e) }
+      this.loading = false
+    },
+    async handleApprove(row) {
+      try {
+        const res = await approveRegistration({ registrationId: row.id })
+        if (res.code === 200) {
+          this.$message.success('已通过')
+          this.loadList()
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) { this.$message.error('操作失败') }
+    },
+    showRejectDialog(row) {
+      this.currentRow = row
+      this.rejectReason = ''
+      this.rejectDialogVisible = true
+    },
+    async handleReject() {
+      try {
+        const res = await rejectRegistration({ registrationId: this.currentRow.id, rejectReason: this.rejectReason })
+        if (res.code === 200) {
+          this.$message.success('已拒绝')
+          this.rejectDialogVisible = false
+          this.loadList()
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) { this.$message.error('操作失败') }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.header h2 { margin: 0; }
+.filters { display: flex; align-items: center; gap: 12px; }
+</style>

+ 26 - 12
cfc-web/src/views/admin/ArticleEdit.vue

@@ -63,14 +63,8 @@
           </el-col>
         </el-row>
 
-        <el-form-item label="关联五维">
-          <el-checkbox-group v-model="form.relatedDimensions">
-            <el-checkbox label="智·金" />
-            <el-checkbox label="富·水" />
-            <el-checkbox label="行·木" />
-            <el-checkbox label="心·火" />
-            <el-checkbox label="身·土" />
-          </el-checkbox-group>
+        <el-form-item label="五维权重">
+          <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
         </el-form-item>
 
         <el-form-item label="浏览权限">
@@ -86,6 +80,7 @@
 </template>
 
 <script>
+import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import {
   adminArticleCreate,
   adminArticleUpdate,
@@ -93,9 +88,11 @@ import {
   adminArticleCategoriesList,
   adminArticleDetail
 } from '@/api/article.js'
+import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
 
 export default {
   name: 'ArticleEdit',
+  components: { DimensionWeightPicker },
   data() {
     return {
       isEdit: false,
@@ -114,6 +111,7 @@ export default {
         author: '',
         readTime: 3,
         relatedDimensions: [],
+        dimensionWeights: [],
         visibility: 'public',
         visibleTo: []
       }
@@ -165,9 +163,17 @@ export default {
             author: d.author || '',
             readTime: d.readTime || 3,
             relatedDimensions: d.relatedDimensions || [],
+            dimensionWeights: [],
             visibility: d.visibility || 'public',
             visibleTo: d.visibleTo || []
           }
+          try {
+            const dwRes = await getDimensionWeights('article', d.id)
+            if (dwRes.data) {
+              this.form.dimensionWeights = dwRes.data
+            }
+          } catch (e) {
+          }
         }
       } catch (e) {
         this.$message.error('加载文章详情失败')
@@ -223,7 +229,7 @@ export default {
       }
       try {
         const data = this.buildSubmitData()
-        let res
+        let articleId = this.form.id
         if (this.isEdit) {
           await adminArticleUpdate(data)
           if (publishNow) {
@@ -233,12 +239,20 @@ export default {
           if (publishNow) {
             data.status = 'published'
           }
-          res = await adminArticleCreate(data)
+          const res = await adminArticleCreate(data)
           if (res.data && res.data.id) {
-            this.form.id = res.data.id
+            articleId = res.data.id
+            this.form.id = articleId
             this.isEdit = true
           }
         }
+        if (articleId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
+          try {
+            await saveDimensionWeights('article', articleId, this.form.dimensionWeights)
+          } catch (e) {
+            console.error('保存维度权重失败', e)
+          }
+        }
         this.$message.success(publishNow ? '文章已发布' : '草稿已保存')
         this.$router.push('/article-manage')
       } catch (e) {
@@ -264,4 +278,4 @@ export default {
   border: 1px solid #dcdfe6;
   cursor: pointer;
 }
-</style>
+</style>

+ 192 - 0
cfc-web/src/views/admin/EnergyRuleManagement.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="energy-rule-manage">
+    <div class="header">
+      <h2>能量规则管理</h2>
+      <div class="filters">
+        <el-select v-model="dimensionFilter" placeholder="维度筛选" @change="loadList" style="width: 140px;" clearable>
+          <el-option label="全部维度" value="" />
+          <el-option v-for="d in dimensions" :key="d.code" :label="d.name" :value="d.code" />
+        </el-select>
+        <el-button type="primary" icon="el-icon-plus" @click="showDialog(null)">新增规则</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="ruleCode" label="规则编码" width="140" />
+      <el-table-column prop="ruleName" label="规则名称" min-width="160" show-overflow-tooltip />
+      <el-table-column prop="eventType" label="事件类型" width="120" />
+      <el-table-column prop="dimensionCode" label="维度" width="80" />
+      <el-table-column prop="amount" label="能量值" width="80" />
+      <el-table-column label="优先级" width="70">
+        <template slot-scope="{ row }">
+          <el-tag size="mini">{{ row.priority || 0 }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="80">
+        <template slot-scope="{ row }">
+          <el-switch :value="row.status === 1" @change="toggleStatus(row)" />
+        </template>
+      </el-table-column>
+      <el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
+      <el-table-column label="操作" width="160" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button type="primary" size="small" @click="showDialog(row)">编辑</el-button>
+          <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination
+      @size-change="onPageChange"
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      style="margin-top: 20px; text-align: right;"
+    />
+
+    <el-dialog :title="form.id ? '编辑规则' : '新增规则'" :visible.sync="dialogVisible" width="600px">
+      <el-form :model="form" label-width="100px" size="small">
+        <el-form-item label="规则编码">
+          <el-input v-model="form.ruleCode" placeholder="唯一编码,如 DAILY_CHECKIN" />
+        </el-form-item>
+        <el-form-item label="规则名称">
+          <el-input v-model="form.ruleName" placeholder="规则名称" />
+        </el-form-item>
+        <el-form-item label="事件类型">
+          <el-input v-model="form.eventType" placeholder="如 checkin / task_complete" />
+        </el-form-item>
+        <el-form-item label="动作">
+          <el-input v-model="form.action" placeholder="如 add_points / deduct" />
+        </el-form-item>
+        <el-form-item label="所属维度">
+          <el-select v-model="form.dimensionCode" style="width:100%;">
+            <el-option v-for="d in dimensions" :key="d.code" :label="d.name" :value="d.code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="能量值">
+          <el-input-number v-model="form.amount" :min="0" />
+        </el-form-item>
+        <el-form-item label="优先级">
+          <el-input-number v-model="form.priority" :min="0" />
+        </el-form-item>
+        <el-form-item label="计算公式">
+          <el-input v-model="form.amountFormula" placeholder="可选,如 {{baseAmount}} * {{factor}}" />
+        </el-form-item>
+        <el-form-item label="触发条件">
+          <el-input v-model="form.conditions" type="textarea" :rows="2" placeholder="JSON 格式触发条件" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getEnergyRuleList, createEnergyRule, updateEnergyRule, deleteEnergyRule, toggleEnergyRule, getEnergyDimensions } from '@/api/energy'
+
+export default {
+  name: 'EnergyRuleManagement',
+  data() {
+    return {
+      list: [],
+      dimensions: [],
+      loading: false,
+      page: 1,
+      size: 15,
+      total: 0,
+      dimensionFilter: '',
+      dialogVisible: false,
+      saving: false,
+      form: this.getEmptyForm()
+    }
+  },
+  mounted() {
+    this.loadDimensions()
+    this.loadList()
+  },
+  methods: {
+    getEmptyForm() {
+      return { id: null, ruleCode: '', ruleName: '', eventType: '', action: '', dimensionCode: '', amount: 0, amountFormula: '', priority: 0, conditions: '', status: 1, description: '' }
+    },
+    async loadDimensions() {
+      try {
+        const res = await getEnergyDimensions()
+        if (res.code === 200) this.dimensions = res.data || []
+      } catch (e) { console.error(e) }
+    },
+    async loadList() {
+      this.loading = true
+      try {
+        const params = { page: this.page, size: this.size }
+        if (this.dimensionFilter) params.dimensionCode = this.dimensionFilter
+        const res = await getEnergyRuleList(params)
+        if (res.code === 200 && res.data) {
+          this.list = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) { console.error(e) }
+      this.loading = false
+    },
+    onPageChange(p) {
+      this.page = typeof p === 'number' ? p : 1
+      this.loadList()
+    },
+    showDialog(row) {
+      if (row) {
+        this.form = { ...row }
+      } else {
+        this.form = this.getEmptyForm()
+      }
+      this.dialogVisible = true
+    },
+    async handleSave() {
+      this.saving = true
+      try {
+        const fn = this.form.id ? updateEnergyRule : createEnergyRule
+        const res = await fn(this.form)
+        if (res.code === 200) {
+          this.$message.success(this.form.id ? '更新成功' : '创建成功')
+          this.dialogVisible = false
+          this.loadList()
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) { this.$message.error('操作失败') }
+      this.saving = false
+    },
+    async toggleStatus(row) {
+      try {
+        const res = await toggleEnergyRule(row.id)
+        if (res.code === 200) {
+          this.$message.success('状态已更新')
+          this.loadList()
+        }
+      } catch (e) { this.$message.error('操作失败') }
+    },
+    async handleDelete(row) {
+      this.$confirm('确认删除该规则?', '提示', { type: 'warning' }).then(async () => {
+        const res = await deleteEnergyRule(row.id)
+        if (res.code === 200) {
+          this.$message.success('删除成功')
+          this.loadList()
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.header h2 { margin: 0; }
+.filters { display: flex; align-items: center; gap: 12px; }
+</style>

+ 260 - 217
cfc-web/src/views/admin/GrowthTaskManagement.vue

@@ -1,288 +1,331 @@
 <template>
-  <div class="growth-task-management">
+  <div class="growth-task">
     <el-card>
-      <el-tabs v-model="activeTab" @tab-click="handleTabChange">
-        <el-tab-pane label="成长任务" name="growth">
-          <div style="margin-bottom: 16px">
-            <el-button type="primary" size="small" @click="handleCreateTask">新建任务</el-button>
-          </div>
-          <el-table :data="growthTaskList" v-loading="growthLoading" border stripe>
-            <el-table-column prop="id" label="ID" width="70" />
-            <el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
-            <el-table-column prop="type" label="类型" width="90">
-              <template slot-scope="{ row }">
-                <el-tag size="mini">{{ row.type }}</el-tag>
-              </template>
-            </el-table-column>
-            <el-table-column prop="rewardPoints" label="奖励积分" width="90" />
-            <el-table-column prop="rewardEnergy" label="奖励能量" width="90" />
-            <el-table-column prop="targetValue" label="目标值" width="80" />
-            <el-table-column prop="taskKey" label="任务Key" width="140" show-overflow-tooltip />
-            <el-table-column label="启用" width="70" align="center">
-              <template slot-scope="{ row }">
-                <el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" @change="(val) => handleToggleEnabled(row, val)" />
-              </template>
-            </el-table-column>
-            <el-table-column prop="sortOrder" label="排序" width="70" />
-            <el-table-column label="操作" width="120" fixed="right">
-              <template slot-scope="{ row }">
-                <el-button size="mini" @click="handleEditTask(row)">编辑</el-button>
-              </template>
-            </el-table-column>
-          </el-table>
-        </el-tab-pane>
-        <el-tab-pane label="新手任务" name="onboarding">
-          <el-table :data="onboardingList" v-loading="onboardingLoading" border stripe>
-            <el-table-column prop="taskType" label="任务类型" min-width="180">
-              <template slot-scope="{ row }">{{ taskTypeLabel(row.taskType) }}</template>
-            </el-table-column>
-            <el-table-column prop="rewardPoints" label="奖励积分" width="100" />
-            <el-table-column prop="rewardEnergy" label="奖励能量" width="100" />
-            <el-table-column label="操作" width="120" fixed="right">
-              <template slot-scope="{ row }">
-                <el-button size="mini" @click="handleEditOnboarding(row)">编辑</el-button>
-              </template>
-            </el-table-column>
-          </el-table>
-        </el-tab-pane>
-      </el-tabs>
+      <div slot="header">
+        <span>成长任务管理</span>
+        <div style="float:right">
+          <el-select
+            v-model="filter.status"
+            placeholder="状态筛选"
+            size="mini"
+            style="margin-right:12px;width:130px"
+            clearable
+            @change="loadTasks"
+          >
+            <el-option label="全部" value="" />
+            <el-option label="开启" value="enabled" />
+            <el-option label="关闭" value="disabled" />
+          </el-select>
+          <el-button type="primary" size="mini" @click="openCreate">创建任务</el-button>
+        </div>
+      </div>
+      <el-table :data="tasks" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="name" label="任务名称" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="dimensionCode" label="关联维度" width="100">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" :type="dimensionType(row.dimensionCode)">{{ dimensionLabel(row.dimensionCode) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="type" label="任务类型" width="100" />
+        <el-table-column label="触发方式" width="80">
+          <template slot-scope="{ row }">{{ row.triggerType || '—' }}</template>
+        </el-table-column>
+        <el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
+        <el-table-column label="任务值" width="80">
+          <template slot-scope="{ row }">{{ row.taskValue ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column label="奖励" width="160">
+          <template slot-scope="{ row }">
+            <div v-if="row.rewardItems && row.rewardItems.length">
+              <el-tag size="mini" v-for="(ri, i) in row.rewardItems" :key="i" style="margin:1px">
+                {{ ri.type }}:{{ ri.value }}
+              </el-tag>
+            </div>
+            <span v-else style="color:#999">—</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="80">
+          <template slot-scope="{ row }">
+            <el-switch
+              :value="row.status === 'enabled'"
+              @change="toggleStatus(row)"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="140">
+          <template slot-scope="{ row }">
+            <el-button size="mini" @click="handleEditTask(row)">编辑</el-button>
+            <el-button size="mini" type="primary" @click="handleTaskSubmit(row)">保存</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        v-if="total > 0"
+        @size-change="handleSizeChange"
+        @current-change="handlePageChange"
+        :current-page="filter.page"
+        :page-sizes="[10, 20, 50]"
+        :page-size="filter.size"
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="total"
+        style="margin-top:16px;text-align:right"
+      />
     </el-card>
 
-    <el-dialog :visible.sync="taskDialogVisible" :title="isEditTask ? '编辑成长任务' : '新建成长任务'" width="550px">
-      <el-form :model="taskForm" label-width="120px">
-        <el-form-item label="标题" required>
-          <el-input v-model="taskForm.title" placeholder="请输入任务标题" />
+    <el-dialog
+      :title="editId ? '编辑任务' : '创建任务'"
+      :visible.sync="dialogVisible"
+      width="700px"
+      @closed="resetForm"
+    >
+      <el-form label-width="120px">
+        <el-form-item label="任务名称">
+          <el-input v-model="form.name" placeholder="请输入任务名称" />
         </el-form-item>
-        <el-form-item label="类型" required>
-          <el-select v-model="taskForm.type" placeholder="请选择类型" style="width: 100%">
-            <el-option label="DAILY" value="DAILY" />
-            <el-option label="NEWBIE" value="NEWBIE" />
+
+        <el-form-item label="任务类型">
+          <el-select v-model="form.type" placeholder="选择任务类型" style="width:100%">
+            <el-option label="每日任务" value="daily" />
+            <el-option label="每周任务" value="weekly" />
+            <el-option label="每月任务" value="monthly" />
+            <el-option label="一次性任务" value="one_time" />
           </el-select>
         </el-form-item>
-        <el-form-item label="奖励积分">
-          <el-input-number v-model="taskForm.rewardPoints" :min="0" style="width: 200px" />
-        </el-form-item>
-        <el-form-item label="奖励能量">
-          <el-input-number v-model="taskForm.rewardEnergy" :min="0" style="width: 200px" />
-        </el-form-item>
-        <el-form-item label="目标值">
-          <el-input-number v-model="taskForm.targetValue" :min="1" style="width: 200px" />
-        </el-form-item>
-        <el-form-item label="排序">
-          <el-input-number v-model="taskForm.sortOrder" :min="0" style="width: 200px" />
-        </el-form-item>
-        <el-form-item label="启用">
-          <el-switch v-model="taskForm.enabled" :active-value="1" :inactive-value="0" />
-        </el-form-item>
-      </el-form>
-      <div slot="footer">
-        <el-button @click="taskDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="handleTaskSubmit" :loading="taskSubmitting">保存</el-button>
-      </div>
-    </el-dialog>
 
-    <el-dialog title="编辑新手任务配置" :visible.sync="onboardingDialogVisible" width="450px">
-      <el-form :model="onboardingForm" label-width="120px">
-        <el-form-item label="任务类型" required>
-          <el-input v-model="onboardingForm.taskType" disabled />
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="关联维度">
+              <el-select v-model="form.dimensionCode" placeholder="选择维度" style="width:100%">
+                <el-option label="身" value="body" />
+                <el-option label="心" value="heart" />
+                <el-option label="智" value="mind" />
+                <el-option label="行" value="action" />
+                <el-option label="富" value="wealth" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="触发方式">
+              <el-select v-model="form.triggerType" placeholder="选择触发方式" style="width:100%" clearable>
+                <el-option label="手动" value="manual" />
+                <el-option label="自动" value="auto" />
+                <el-option label="条件" value="condition" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="任务描述" />
         </el-form-item>
-        <el-form-item label="奖励积分">
-          <el-input-number v-model="onboardingForm.rewardPoints" :min="0" style="width: 200px" />
+
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="任务值">
+              <el-input-number v-model="form.taskValue" :min="0" :step="1" style="width:100%" placeholder="0" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="排序">
+              <el-input-number v-model="form.sortOrder" :min="0" :step="1" style="width:100%" placeholder="0" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="五维权重">
+          <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
         </el-form-item>
-        <el-form-item label="奖励能量">
-          <el-input-number v-model="onboardingForm.rewardEnergy" :min="0" style="width: 200px" />
+
+        <el-form-item label="启用">
+          <el-switch v-model="form.statusBool" />
         </el-form-item>
       </el-form>
       <div slot="footer">
-        <el-button @click="onboardingDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="handleOnboardingSubmit" :loading="onboardingSubmitting">保存</el-button>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleTaskSubmit" :loading="saving">保存</el-button>
       </div>
     </el-dialog>
   </div>
 </template>
 
 <script>
-import { getGrowthTaskList, updateGrowthTask, createGrowthTask, getOnboardingConfigList, updateOnboardingConfig } from '@/api/growthTask'
+import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
+import { getTaskList, createTask, updateTask, toggleTaskStatus } from '@/api/growth-task'
+import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
 
-const TASK_TYPE_LABELS = {
-  REGISTER: '注册账号',
-  COMPLETE_PROFILE: '完善个人资料',
-  ADD_CHILD: '添加孩子',
-  FIRST_CHECKIN: '首次签到',
-  FIRST_TASK: '完成首个任务',
-  BIND_REFERRAL: '绑定推荐码'
+const DIMENSION_CONFIG = {
+  body: { label: '身', type: 'success' },
+  heart: { label: '心', type: 'danger' },
+  mind: { label: '智', type: 'warning' },
+  action: { label: '行', type: 'primary' },
+  wealth: { label: '富', type: 'info' }
 }
 
 export default {
   name: 'GrowthTaskManagement',
+  components: { DimensionWeightPicker },
   data() {
     return {
-      activeTab: 'growth',
-      growthTaskList: [],
-      growthLoading: false,
-      onboardingList: [],
-      onboardingLoading: false,
-      taskDialogVisible: false,
-      isEditTask: false,
-      taskSubmitting: false,
-      taskForm: this.getEmptyTaskForm(),
-      onboardingDialogVisible: false,
-      onboardingSubmitting: false,
-      onboardingForm: {
-        taskType: '',
-        rewardPoints: 0,
-        rewardEnergy: 0
-      }
+      loading: false,
+      saving: false,
+      tasks: [],
+      total: 0,
+      editId: null,
+      dialogVisible: false,
+      filter: {
+        status: '',
+        page: 1,
+        size: 10
+      },
+      form: {}
     }
   },
-  created() {
-    this.loadGrowthTasks()
-  },
   methods: {
-    taskTypeLabel(type) {
-      return TASK_TYPE_LABELS[type] || type
+    dimensionLabel(code) {
+      return (DIMENSION_CONFIG[code] && DIMENSION_CONFIG[code].label) || code || '—'
+    },
+    dimensionType(code) {
+      return (DIMENSION_CONFIG[code] && DIMENSION_CONFIG[code].type) || ''
     },
     getEmptyTaskForm() {
       return {
-        id: null,
-        title: '',
-        type: 'DAILY',
-        rewardPoints: 0,
-        rewardEnergy: 0,
-        targetValue: 1,
+        name: '',
+        type: 'daily',
+        dimensionCode: '',
+        triggerType: '',
+        description: '',
+        taskValue: null,
         sortOrder: 0,
-        enabled: 1
+        statusBool: true,
+        dimensionWeights: []
       }
     },
-    handleTabChange() {
-      if (this.activeTab === 'onboarding') {
-        this.loadOnboardingConfigs()
-      } else {
-        this.loadGrowthTasks()
-      }
+    resetForm() {
+      this.form = this.getEmptyTaskForm()
     },
-    async loadGrowthTasks() {
-      this.growthLoading = true
+    async loadTasks() {
+      this.loading = true
       try {
-        const res = await getGrowthTaskList({})
-        if (res.data) {
-          this.growthTaskList = res.data.records || res.data.list || res.data
+        const params = {
+          page: this.filter.page,
+          size: this.filter.size
         }
-      } catch (e) {
-        console.error(e)
-      } finally {
-        this.growthLoading = false
-      }
-    },
-    async loadOnboardingConfigs() {
-      this.onboardingLoading = true
-      try {
-        const res = await getOnboardingConfigList()
-        if (res.data) {
-          this.onboardingList = res.data.records || res.data.list || res.data
+        if (this.filter.status) {
+          params.status = this.filter.status
         }
+        const res = await getTaskList(params)
+        this.tasks = (res.data && res.data.records) || []
+        this.total = (res.data && res.data.total) || 0
       } catch (e) {
-        console.error(e)
+        this.$message && this.$message.error && this.$message.error('加载任务列表失败')
       } finally {
-        this.onboardingLoading = false
+        this.loading = false
       }
     },
-    handleCreateTask() {
-      this.isEditTask = false
-      this.taskForm = this.getEmptyTaskForm()
-      this.taskDialogVisible = true
+    handleSizeChange(size) {
+      this.filter.size = size
+      this.filter.page = 1
+      this.loadTasks()
+    },
+    handlePageChange(page) {
+      this.filter.page = page
+      this.loadTasks()
+    },
+    openCreate() {
+      this.editId = null
+      this.resetForm()
+      this.dialogVisible = true
     },
     handleEditTask(row) {
-      this.isEditTask = true
-      this.taskForm = {
-        id: row.id,
-        title: row.title,
-        type: row.type,
-        rewardPoints: row.rewardPoints,
-        rewardEnergy: row.rewardEnergy,
-        targetValue: row.targetValue,
-        sortOrder: row.sortOrder,
-        enabled: row.enabled
+      this.editId = row.id
+      this.form = {
+        name: row.name || '',
+        type: row.type || 'daily',
+        dimensionCode: row.dimensionCode || '',
+        triggerType: row.triggerType || '',
+        description: row.description || '',
+        taskValue: row.taskValue ?? null,
+        sortOrder: row.sortOrder ?? 0,
+        statusBool: row.status === 'enabled',
+        dimensionWeights: []
+      }
+      this.dialogVisible = true
+      if (row.id) {
+        this.loadDimensionWeights(row.id)
       }
-      this.taskDialogVisible = true
     },
-    async handleToggleEnabled(row, val) {
+    async loadDimensionWeights(taskId) {
       try {
-        await updateGrowthTask(row.id, { enabled: val })
-        this.$message.success(val ? '已启用' : '已禁用')
+        const res = await getDimensionWeights('task', taskId)
+        if (res.data) {
+          this.form.dimensionWeights = res.data
+        }
       } catch (e) {
-        this.$message.error('操作失败')
-        this.loadGrowthTasks()
       }
     },
     async handleTaskSubmit() {
-      if (!this.taskForm.title) {
-        this.$message.warning('请输入任务标题')
+      if (!this.form.name) {
+        this.$message.warning('请填写任务名称')
         return
       }
-      this.taskSubmitting = true
+      this.saving = true
       try {
-        if (this.isEditTask) {
-          await updateGrowthTask(this.taskForm.id, {
-            title: this.taskForm.title,
-            type: this.taskForm.type,
-            rewardPoints: this.taskForm.rewardPoints,
-            rewardEnergy: this.taskForm.rewardEnergy,
-            targetValue: this.taskForm.targetValue,
-            sortOrder: this.taskForm.sortOrder,
-            enabled: this.taskForm.enabled
-          })
+        const payload = {
+          name: this.form.name,
+          type: this.form.type,
+          dimensionCode: this.form.dimensionCode,
+          triggerType: this.form.triggerType || null,
+          description: this.form.description || '',
+          taskValue: this.form.taskValue,
+          sortOrder: this.form.sortOrder || 0,
+          status: this.form.statusBool ? 'enabled' : 'disabled'
+        }
+        let taskId = this.editId
+        if (this.editId) {
+          payload.id = this.editId
+          await updateTask(payload)
+          this.$message.success('更新成功')
         } else {
-          await createGrowthTask({
-            title: this.taskForm.title,
-            type: this.taskForm.type,
-            rewardPoints: this.taskForm.rewardPoints,
-            rewardEnergy: this.taskForm.rewardEnergy,
-            targetValue: this.taskForm.targetValue,
-            sortOrder: this.taskForm.sortOrder,
-            enabled: this.taskForm.enabled
-          })
+          const res = await createTask(payload)
+          if (res.data && res.data.id) {
+            taskId = res.data.id
+          }
+          this.$message.success('创建成功')
         }
-        this.$message.success('保存成功')
-        this.taskDialogVisible = false
-        this.loadGrowthTasks()
+        if (taskId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
+          try {
+            await saveDimensionWeights('task', taskId, this.form.dimensionWeights)
+          } catch (e) {
+            console.error('保存维度权重失败', e)
+          }
+        }
+        this.dialogVisible = false
+        this.loadTasks()
       } catch (e) {
         this.$message.error('保存失败')
       } finally {
-        this.taskSubmitting = false
-      }
-    },
-    handleEditOnboarding(row) {
-      this.onboardingForm = {
-        taskType: row.taskType,
-        rewardPoints: row.rewardPoints,
-        rewardEnergy: row.rewardEnergy
+        this.saving = false
       }
-      this.onboardingDialogVisible = true
     },
-    async handleOnboardingSubmit() {
-      this.onboardingSubmitting = true
+    async toggleStatus(row) {
       try {
-        await updateOnboardingConfig({
-          taskType: this.onboardingForm.taskType,
-          rewardPoints: this.onboardingForm.rewardPoints,
-          rewardEnergy: this.onboardingForm.rewardEnergy
-        })
-        this.$message.success('保存成功')
-        this.onboardingDialogVisible = false
-        this.loadOnboardingConfigs()
+        const newStatus = row.status === 'enabled' ? 'disabled' : 'enabled'
+        await toggleTaskStatus(row.id, newStatus)
+        this.$message.success('状态已更新')
+        this.loadTasks()
       } catch (e) {
-        this.$message.error('保存失败')
-      } finally {
-        this.onboardingSubmitting = false
+        this.$message.error('状态更新失败')
       }
     }
+  },
+  created() {
+    this.loadTasks()
+    this.resetForm()
   }
 }
 </script>
 
 <style scoped>
-.growth-task-management {
-  padding: 20px;
-}
-</style>
+.growth-task { padding: 20px; }
+</style>