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

feat(cognitive): 认知评估增强 — 行动清单/常模参考线/历史趋势/训练方案系统

Phase A — 家庭行动清单
- 新增 cognitive-recommendations.yml(3-tier × 6维离线任务/游戏/文章推荐)
- 新增 CognitiveTrainingConfigService + CognitiveRecommendationDTO
- POST /api/cognitive/recommendations — 获取行动清单
- POST /api/cognitive/recommendations/create-task — 一键添加任务
- cognitive-report.vue 新增「家庭行动建议」区块

Phase B1 — 常模参考线
- 新增 CognitiveNorm 实体 + cognitive_norms 表 + 种子数据(6维×4年龄带)
- 新增 CognitiveNormMapper / CognitiveNormService
- POST /api/cognitive/norms — 返回百分位等级(较弱/偏弱/中等/良好/优秀)
- 条形图显示同龄中位参考线 + 等级标签

Phase B2 — 历史趋势
- 新增 CognitiveTrendItemDTO
- POST /api/cognitive/trend — 历史六维分数序列
- cognitive-report.vue 新增「历史趋势」区块(前后对比表)

Phase C — 训练方案系统(规划师端)
- 新增 TrainingPlan / TrainingPlanItem / TrainingPlanTask 实体 + schema
- 新增 TrainingPlanMapper / TrainingPlanService
  — CRUD / generateFromRecommendations / deployPlan / getExecutionBoard
- 新增 TrainingPlanController(guide端9个端点)
- 前端: guide/training-plans/index.vue(列表/生成/下发/删除)
- 前端: guide/training-plans/detail.vue(编辑/看板模式)
- api.js 新增训练方案相关API(10个方法)
Xiaogang Liao 4 месяцев назад
Родитель
Сommit
0af81d6888
21 измененных файлов с 3566 добавлено и 28 удалено
  1. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 108 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/CognitiveController.java
  3. 179 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/TrainingPlanController.java
  4. 57 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CognitiveRecommendationDTO.java
  5. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CognitiveTrendItemDTO.java
  6. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateCognitiveTaskDTO.java
  7. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CognitiveNorm.java
  8. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TrainingPlan.java
  9. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TrainingPlanItem.java
  10. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TrainingPlanTask.java
  11. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CognitiveNormMapper.java
  12. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TrainingPlanMapper.java
  13. 97 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveNormService.java
  14. 317 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveTrainingConfigService.java
  15. 262 0
      cfc-backend/src/main/resources/cognitive-recommendations.yml
  16. 97 1
      cfc-backend/src/main/resources/schema_assessment.sql
  17. 8 0
      cfc-frontend/pages.json
  18. 968 0
      cfc-frontend/pages/guide/training-plans/detail.vue
  19. 599 0
      cfc-frontend/pages/guide/training-plans/index.vue
  20. 625 26
      cfc-frontend/pages/wisdom-detail/cognitive-report.vue
  21. 57 0
      cfc-frontend/utils/api.js

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -43,7 +43,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             initializeDefaultData();
             initFoodTables();
             runIndexMigrations();
-            recordSchemaVersion(2);
+            recordSchemaVersion(3);
             log.info("数据库初始化完成!");
         } else {
             log.info("数据库初始化已执行,跳过(schema_version >= 1)");

+ 108 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/CognitiveController.java

@@ -1,8 +1,14 @@
 package com.etotem.cfc.controller.assessment;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CognitiveRecommendationDTO;
+import com.etotem.cfc.dto.CognitiveTrendItemDTO;
+import com.etotem.cfc.dto.CreateCognitiveTaskDTO;
 import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.service.CognitiveNormService;
 import com.etotem.cfc.service.CognitiveService;
+import com.etotem.cfc.service.CognitiveTrainingConfigService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -18,6 +24,15 @@ public class CognitiveController {
     @Resource
     private CognitiveService cognitiveService;
 
+    @Resource
+    private CognitiveTrainingConfigService cognitiveTrainingConfigService;
+
+    @Resource
+    private CognitiveNormService cognitiveNormService;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
     @Operation(summary = "获取孩子最新六维认知得分")
     @PostMapping("/child")
     public Result<Map<String, Object>> getChildCognitiveScores(
@@ -107,4 +122,97 @@ public class CognitiveController {
         }
         return Result.success("更新成功");
     }
+
+    @Operation(summary = "获取认知训练建议(根据最新DAN评估生成家庭行动清单)")
+    @PostMapping("/recommendations")
+    public Result<CognitiveRecommendationDTO> getRecommendations(
+            @RequestBody Map<String, Object> body) {
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString())
+                : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+
+        CognitiveRecommendationDTO rec = cognitiveTrainingConfigService.generateRecommendations(childId);
+        if (rec == null) {
+            return Result.error("暂无评估数据,请先完成认知评估");
+        }
+        return Result.success(rec);
+    }
+
+    @Operation(summary = "从认知训练建议创建任务(一键添加到今日任务)")
+    @PostMapping("/recommendations/create-task")
+    public Result<Long> createTaskFromRecommendation(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody CreateCognitiveTaskDTO dto) {
+        if (dto.getChildId() == null) {
+            return Result.error("childId不能为空");
+        }
+        if (dto.getDimensionKey() == null) {
+            return Result.error("dimensionKey不能为空");
+        }
+
+        Long taskId = cognitiveTrainingConfigService.createTaskFromRecommendation(
+                userId, dto.getChildId(), dto.getDimensionKey(), dto.getTaskIndex());
+        if (taskId == null) {
+            return Result.error("创建任务失败,请检查评估数据");
+        }
+        return Result.success(taskId);
+    }
+
+    @Operation(summary = "获取认知维度百分位常模(与同龄人对比)")
+    @PostMapping("/norms")
+    public Result<Map<String, Object>> getNorms(@RequestBody Map<String, Object> body) {
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString())
+                : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+
+        Map<String, Object> norms = cognitiveNormService.getNorms(childId);
+        if (norms.isEmpty()) {
+            return Result.error("暂无常模数据");
+        }
+        return Result.success(norms);
+    }
+
+    @Operation(summary = "获取认知维度历史趋势(六维分数+综合分按时序排列)")
+    @PostMapping("/trend")
+    public Result<List<CognitiveTrendItemDTO>> getTrend(@RequestBody Map<String, Object> body) {
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString())
+                : null;
+        int limit = body.get("limit") != null
+                ? Integer.parseInt(body.get("limit").toString())
+                : 5;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+
+        List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanAssessmentResult>()
+                        .eq(DanAssessmentResult::getChildId, childId)
+                        .eq(DanAssessmentResult::getStatus, "completed")
+                        .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                        .last("LIMIT " + limit)
+        );
+
+        List<CognitiveTrendItemDTO> trend = new ArrayList<>();
+        for (DanAssessmentResult r : results) {
+            CognitiveTrendItemDTO item = new CognitiveTrendItemDTO();
+            item.setAssessmentDate(r.getAssessmentDate());
+            item.setOverallScore(r.getOverallScore());
+            item.setPerceptionScore(r.getPerceptionScore());
+            item.setFocusScore(r.getFocusScore());
+            item.setMemoryScore(r.getMemoryScore());
+            item.setLogicScore(r.getLogicScore());
+            item.setSpatialScore(r.getSpatialScore());
+            item.setProcessingSpeedScore(r.getProcessingSpeedScore());
+            trend.add(item);
+        }
+
+        return Result.success(trend);
+    }
 }

+ 179 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/TrainingPlanController.java

@@ -0,0 +1,179 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.TrainingPlan;
+import com.etotem.cfc.entity.TrainingPlanItem;
+import com.etotem.cfc.service.TrainingPlanService;
+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 = "规划师端训练方案CRUD/生成/下发/看板")
+@RestController
+@RequestMapping("/api/guide/training-plans")
+public class TrainingPlanController {
+
+    @Resource
+    private TrainingPlanService trainingPlanService;
+
+    @Operation(summary = "获取规划师的所有训练方案列表")
+    @PostMapping("/list")
+    public Result<List<TrainingPlan>> getPlanList(@RequestAttribute("userId") Long guideId,
+            @RequestBody Map<String, Object> body) {
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString())
+                : null;
+        List<TrainingPlan> plans;
+        if (childId != null) {
+            plans = trainingPlanService.getPlansByGuideAndChild(guideId, childId);
+        } else {
+            plans = trainingPlanService.getPlansByGuide(guideId);
+        }
+        return Result.success(plans);
+    }
+
+    @Operation(summary = "获取方案详情(含项目列表)")
+    @PostMapping("/detail")
+    public Result<Map<String, Object>> getPlanDetail(@RequestBody Map<String, Object> body) {
+        Long planId = body.get("planId") != null
+                ? Long.valueOf(body.get("planId").toString())
+                : null;
+        if (planId == null) {
+            return Result.error("planId不能为空");
+        }
+        TrainingPlan plan = trainingPlanService.getPlanById(planId);
+        if (plan == null) {
+            return Result.error("方案不存在");
+        }
+        List<TrainingPlanItem> items = trainingPlanService.getPlanItems(planId);
+        Map<String, Object> result = new java.util.LinkedHashMap<>();
+        result.put("plan", plan);
+        result.put("items", items);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "创建空白训练方案")
+    @PostMapping("/create")
+    public Result<Long> createPlan(@RequestAttribute("userId") Long guideId,
+            @RequestBody Map<String, Object> body) {
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString())
+                : null;
+        String title = body.get("title") != null ? body.get("title").toString() : null;
+        String description = body.get("description") != null ? body.get("description").toString() : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Long planId = trainingPlanService.createPlan(guideId, childId, title, description, "manual");
+        return Result.success(planId);
+    }
+
+    @Operation(summary = "更新方案基本信息")
+    @PostMapping("/update")
+    public Result<Boolean> updatePlan(@RequestBody Map<String, Object> body) {
+        Long planId = body.get("planId") != null
+                ? Long.valueOf(body.get("planId").toString())
+                : null;
+        String title = body.get("title") != null ? body.get("title").toString() : null;
+        String description = body.get("description") != null ? body.get("description").toString() : null;
+        if (planId == null) {
+            return Result.error("planId不能为空");
+        }
+        trainingPlanService.updatePlan(planId, title, description);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "添加方案项目")
+    @PostMapping("/items/add")
+    public Result<Long> addItem(@RequestBody TrainingPlanItem item) {
+        if (item.getPlanId() == null) {
+            return Result.error("planId不能为空");
+        }
+        Long itemId = trainingPlanService.addItem(item.getPlanId(), item);
+        return Result.success(itemId);
+    }
+
+    @Operation(summary = "更新方案项目")
+    @PostMapping("/items/update")
+    public Result<Boolean> updateItem(@RequestBody TrainingPlanItem item) {
+        if (item.getId() == null) {
+            return Result.error("itemId不能为空");
+        }
+        trainingPlanService.updateItem(item);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "删除方案项目")
+    @PostMapping("/items/delete")
+    public Result<Boolean> deleteItem(@RequestBody Map<String, Object> body) {
+        Long itemId = body.get("itemId") != null
+                ? Long.valueOf(body.get("itemId").toString())
+                : null;
+        if (itemId == null) {
+            return Result.error("itemId不能为空");
+        }
+        trainingPlanService.deleteItem(itemId);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "从推荐结果生成训练方案")
+    @PostMapping("/generate-from-recommendations")
+    public Result<Long> generateFromRecommendations(@RequestAttribute("userId") Long guideId,
+            @RequestBody Map<String, Object> body) {
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString())
+                : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Long planId = trainingPlanService.generateFromRecommendations(guideId, childId);
+        if (planId == null) {
+            return Result.error("生成失败,请确认该孩子已完成认知评估");
+        }
+        return Result.success(planId);
+    }
+
+    @Operation(summary = "下发训练方案(批量创建任务)")
+    @PostMapping("/deploy")
+    public Result<List<Long>> deployPlan(@RequestAttribute("userId") Long operatorId,
+            @RequestBody Map<String, Object> body) {
+        Long planId = body.get("planId") != null
+                ? Long.valueOf(body.get("planId").toString())
+                : null;
+        if (planId == null) {
+            return Result.error("planId不能为空");
+        }
+        List<Long> taskIds = trainingPlanService.deployPlan(planId, operatorId);
+        return Result.success(taskIds);
+    }
+
+    @Operation(summary = "训练方案执行看板")
+    @PostMapping("/execution-board")
+    public Result<Map<String, Object>> getExecutionBoard(@RequestBody Map<String, Object> body) {
+        Long planId = body.get("planId") != null
+                ? Long.valueOf(body.get("planId").toString())
+                : null;
+        if (planId == null) {
+            return Result.error("planId不能为空");
+        }
+        Map<String, Object> board = trainingPlanService.getExecutionBoard(planId);
+        return Result.success(board);
+    }
+
+    @Operation(summary = "删除训练方案")
+    @PostMapping("/delete")
+    public Result<Boolean> deletePlan(@RequestBody Map<String, Object> body) {
+        Long planId = body.get("planId") != null
+                ? Long.valueOf(body.get("planId").toString())
+                : null;
+        if (planId == null) {
+            return Result.error("planId不能为空");
+        }
+        trainingPlanService.deletePlan(planId);
+        return Result.success(true);
+    }
+}

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CognitiveRecommendationDTO.java

@@ -0,0 +1,57 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 认知推荐结果 — 包含各维度建议 + 综合建议
+ */
+@Data
+public class CognitiveRecommendationDTO {
+    private List<DimensionRecommendation> recommendations;
+    private OverallRecommendation overall;
+    private Long childId;
+    private String childName;
+
+    @Data
+    public static class DimensionRecommendation {
+        private String dimensionKey;
+        private String label;
+        private String icon;
+        private Integer score;
+        private String level;
+        private String priority;
+        private String suggestion;
+        private List<TaskSuggestion> tasks;
+        private List<GameSuggestion> games;
+        private List<ArticleSuggestion> articles;
+    }
+
+    @Data
+    public static class TaskSuggestion {
+        private String title;
+        private String description;
+        private String category;
+        private Integer points;
+    }
+
+    @Data
+    public static class GameSuggestion {
+        private String code;
+        private String name;
+    }
+
+    @Data
+    public static class ArticleSuggestion {
+        private Long id;
+        private String title;
+    }
+
+    @Data
+    public static class OverallRecommendation {
+        private Integer totalScore;
+        private String suggestion;
+        private Integer weakCount;
+    }
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CognitiveTrendItemDTO.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class CognitiveTrendItemDTO {
+    private Date assessmentDate;
+    private Integer overallScore;
+    private Integer perceptionScore;
+    private Integer focusScore;
+    private Integer memoryScore;
+    private Integer logicScore;
+    private Integer spatialScore;
+    private Integer processingSpeedScore;
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateCognitiveTaskDTO.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 从认知推荐创建任务的请求
+ */
+@Data
+public class CreateCognitiveTaskDTO {
+    private Long childId;
+    private String dimensionKey;
+    private Integer taskIndex;       // 推荐列表中选第几个任务
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CognitiveNorm.java

@@ -0,0 +1,26 @@
+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("cognitive_norms")
+public class CognitiveNorm implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String dimensionKey;  // perception / focus / memory / logic / spatial / processingSpeed
+    private String gender;        // male / female / all
+    private Integer ageMin;       // 月
+    private Integer ageMax;       // 月
+    private Integer percentile5;
+    private Integer percentile25;
+    private Integer percentile50;
+    private Integer percentile75;
+    private Integer percentile95;
+    private String source;
+    private Date createdAt;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TrainingPlan.java

@@ -0,0 +1,24 @@
+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("training_plans")
+public class TrainingPlan implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long guideId;       // 规划师ID
+    private Long childId;       // 目标孩子ID
+    private String title;       // 方案标题
+    private String description; // 方案描述
+    private String status;      // draft / published / completed
+    private String source;      // manual / recommendations
+    private Date deployedAt;    // 下发时间
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TrainingPlanItem.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.util.Date;
+
+@Data
+@TableName("training_plan_items")
+public class TrainingPlanItem implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long planId;            // 所属方案ID
+    private Integer sequence;       // 排序号
+    private String dimensionKey;    // 认知维度 key
+    private String suggestion;      // 建议文案
+    private String taskTitle;       // 任务标题
+    private String taskDesc;        // 任务描述
+    private String minigameCode;    // 关联小游戏 code
+    private Long articleId;         // 关联文章ID
+    private Integer taskPoints;     // 任务积分
+    private Integer deadlineDays;   // 截止天数(从部署日起算)
+    private String status;          // pending / completed / skipped
+    private Date completedAt;
+    private Date createdAt;
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TrainingPlanTask.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("training_plan_tasks")
+public class TrainingPlanTask implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long planId;        // 方案ID
+    private Long planItemId;    // 方案项目ID
+    private Long taskId;        // 实际Task ID(关联tasks表)
+    private Long childId;       // 执行孩子
+    private String status;      // pending / completed
+    private Date createdAt;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CognitiveNormMapper.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CognitiveNorm;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.util.List;
+
+@Mapper
+public interface CognitiveNormMapper extends BaseMapper<CognitiveNorm> {
+
+    @Select("SELECT * FROM cognitive_norms WHERE dimension_key = #{dimensionKey} " +
+        "AND (gender = #{gender} OR gender = 'all') " +
+        "AND age_min <= #{ageMonths} AND age_max > #{ageMonths} " +
+        "ORDER BY gender DESC LIMIT 1")
+    CognitiveNorm selectNormByDimension(@Param("dimensionKey") String dimensionKey,
+        @Param("gender") String gender, @Param("ageMonths") Integer ageMonths);
+
+    @Select("SELECT * FROM cognitive_norms WHERE " +
+        "gender = 'all' AND age_min <= #{ageMonths} AND age_max > #{ageMonths}")
+    List<CognitiveNorm> selectAllNormsByAge(@Param("ageMonths") Integer ageMonths);
+}

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TrainingPlanMapper.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TrainingPlan;
+import com.etotem.cfc.entity.TrainingPlanItem;
+import com.etotem.cfc.entity.TrainingPlanTask;
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
+
+@Mapper
+public interface TrainingPlanMapper extends BaseMapper<TrainingPlan> {
+
+    @Select("SELECT * FROM training_plans WHERE guide_id = #{guideId} ORDER BY created_at DESC")
+    List<TrainingPlan> selectByGuideId(@Param("guideId") Long guideId);
+
+    @Select("SELECT * FROM training_plans WHERE guide_id = #{guideId} AND child_id = #{childId} ORDER BY created_at DESC")
+    List<TrainingPlan> selectByGuideIdAndChildId(@Param("guideId") Long guideId, @Param("childId") Long childId);
+
+    @Select("SELECT * FROM training_plan_items WHERE plan_id = #{planId} ORDER BY sequence ASC")
+    List<TrainingPlanItem> selectItemsByPlanId(@Param("planId") Long planId);
+
+    @Insert("INSERT INTO training_plan_items (plan_id, sequence, dimension_key, suggestion, task_title, task_desc, minigame_code, article_id, task_points, deadline_days, status) " +
+            "VALUES (#{planId}, #{sequence}, #{dimensionKey}, #{suggestion}, #{taskTitle}, #{taskDesc}, #{minigameCode}, #{articleId}, #{taskPoints}, #{deadlineDays}, 'pending')")
+    @Options(useGeneratedKeys = true, keyProperty = "id")
+    Long insertItem(TrainingPlanItem item);
+
+    @Update("UPDATE training_plan_items SET task_title=#{taskTitle}, task_desc=#{taskDesc}, minigame_code=#{minigameCode}, article_id=#{articleId}, task_points=#{taskPoints}, deadline_days=#{deadlineDays} WHERE id=#{id}")
+    void updateItem(TrainingPlanItem item);
+
+    @Delete("DELETE FROM training_plan_items WHERE id = #{itemId}")
+    void deleteItem(@Param("itemId") Long itemId);
+
+    @Select("SELECT tpt.* FROM training_plan_tasks tpt WHERE tpt.plan_id = #{planId}")
+    List<TrainingPlanTask> selectTasksByPlanId(@Param("planId") Long planId);
+
+    @Insert("INSERT INTO training_plan_tasks (plan_id, plan_item_id, task_id, child_id, status) VALUES (#{planId}, #{planItemId}, #{taskId}, #{childId}, 'pending')")
+    @Options(useGeneratedKeys = true, keyProperty = "id")
+    void insertPlanTask(TrainingPlanTask planTask);
+}

+ 97 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveNormService.java

@@ -0,0 +1,97 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.CognitiveNorm;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.CognitiveNormMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class CognitiveNormService {
+
+    @Resource
+    private CognitiveNormMapper cognitiveNormMapper;
+
+    @Resource
+    private CognitiveService cognitiveService;
+
+    /**
+     * 获取孩子各维度的百分位常模信息
+     */
+    public Map<String, Object> getNorms(Long childId) {
+        DanAssessmentResult result = cognitiveService.getChildLatestResult(childId);
+        if (result == null || result.getChildAge() == null) {
+            return Collections.emptyMap();
+        }
+
+        int ageMonths = result.getChildAge() * 12;
+        String gender = result.getChildGender() != null ? result.getChildGender() : "all";
+
+        List<CognitiveNorm> norms = cognitiveNormMapper.selectAllNormsByAge(ageMonths);
+        if (norms.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, CognitiveNorm> normMap = new HashMap<>();
+        for (CognitiveNorm n : norms) {
+            normMap.put(n.getDimensionKey(), n);
+        }
+
+        Map<String, Object> resultMap = new LinkedHashMap<>();
+        resultMap.put("childId", childId);
+        resultMap.put("childAge", result.getChildAge());
+
+        addNormInfo(resultMap, normMap, "perception", result.getPerceptionScore());
+        addNormInfo(resultMap, normMap, "focus", result.getFocusScore());
+        addNormInfo(resultMap, normMap, "memory", result.getMemoryScore());
+        addNormInfo(resultMap, normMap, "logic", result.getLogicScore());
+        addNormInfo(resultMap, normMap, "spatial", result.getSpatialScore());
+        addNormInfo(resultMap, normMap, "processingSpeed", result.getProcessingSpeedScore());
+
+        return resultMap;
+    }
+
+    private void addNormInfo(Map<String, Object> map, Map<String, CognitiveNorm> normMap,
+                              String dimensionKey, Integer score) {
+        CognitiveNorm norm = normMap.get(dimensionKey);
+        if (norm == null) return;
+
+        Map<String, Object> info = new LinkedHashMap<>();
+        info.put("score", score != null ? score : 0);
+        info.put("p5", norm.getPercentile5());
+        info.put("p25", norm.getPercentile25());
+        info.put("p50", norm.getPercentile50());
+        info.put("p75", norm.getPercentile75());
+        info.put("p95", norm.getPercentile95());
+
+        String level = "未知";
+        int percentile = estimatePercentile(score, norm);
+        if (percentile >= 0) {
+            if (percentile <= 5) level = "较弱";
+            else if (percentile <= 25) level = "偏弱";
+            else if (percentile <= 75) level = "中等";
+            else if (percentile <= 95) level = "良好";
+            else level = "优秀";
+        }
+        info.put("percentile", percentile);
+        info.put("level", level);
+
+        map.put(dimensionKey + "Norm", info);
+    }
+
+    private int estimatePercentile(Integer score, CognitiveNorm norm) {
+        if (score == null) return -1;
+        Integer[] thresholds = {norm.getPercentile5(), norm.getPercentile25(),
+            norm.getPercentile50(), norm.getPercentile75(), norm.getPercentile95()};
+        int[] pcts = {5, 25, 50, 75, 95};
+
+        for (int i = thresholds.length - 1; i >= 0; i--) {
+            if (thresholds[i] != null && score >= thresholds[i]) {
+                return pcts[i];
+            }
+        }
+        return 0;
+    }
+}

+ 317 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveTrainingConfigService.java

@@ -0,0 +1,317 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.CognitiveRecommendationDTO;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Service;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.Constructor;
+
+import javax.annotation.Resource;
+import java.io.InputStream;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 认知训练推荐配置服务
+ * 加载 cognitive-recommendations.yml,根据 DAN 评估结果生成家庭行动建议
+ */
+@Slf4j
+@Service
+public class CognitiveTrainingConfigService implements InitializingBean {
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private TaskService taskService;
+
+    private CognitiveRecommendationsConfig config;
+
+    @Override
+    public void afterPropertiesSet() {
+        try {
+            Yaml yaml = new Yaml(new Constructor(CognitiveRecommendationsConfig.class));
+            ClassPathResource resource = new ClassPathResource("cognitive-recommendations.yml");
+            try (InputStream is = resource.getInputStream()) {
+                config = yaml.load(is);
+            }
+            log.info("已加载 cognitive-recommendations.yml,共 {} 个维度配置",
+                    config.getDimensions() != null ? config.getDimensions().size() : 0);
+        } catch (Exception e) {
+            log.error("加载 cognitive-recommendations.yml 失败", e);
+            config = new CognitiveRecommendationsConfig();
+        }
+    }
+
+    /**
+     * 根据孩子最新 DAN 评估结果,生成训练建议
+     */
+    public CognitiveRecommendationDTO generateRecommendations(Long childId) {
+        DanAssessmentResult result = findLatestResult(childId);
+        if (result == null) {
+            return null;
+        }
+        return buildRecommendations(childId, result);
+    }
+
+    /**
+     * 根据指定的评估结果生成建议
+     */
+    public CognitiveRecommendationDTO generateRecommendations(Long childId, DanAssessmentResult result) {
+        if (result == null) {
+            return null;
+        }
+        return buildRecommendations(childId, result);
+    }
+
+    /**
+     * 从推荐中创建任务
+     */
+    public Long createTaskFromRecommendation(Long userId, Long childId, String dimensionKey, Integer taskIndex) {
+        DanAssessmentResult result = findLatestResult(childId);
+        if (result == null) {
+            return null;
+        }
+
+        CognitiveRecommendationDTO dto = buildRecommendations(childId, result);
+        if (dto == null || dto.getRecommendations() == null) {
+            return null;
+        }
+
+        // 找到对应维度的推荐
+        for (CognitiveRecommendationDTO.DimensionRecommendation rec : dto.getRecommendations()) {
+            if (rec.getDimensionKey().equals(dimensionKey)
+                    && rec.getTasks() != null
+                    && taskIndex != null && taskIndex < rec.getTasks().size()) {
+
+                CognitiveRecommendationDTO.TaskSuggestion taskSugg = rec.getTasks().get(taskIndex);
+                CreateTaskDTO createDTO = new CreateTaskDTO();
+                createDTO.setTitle(taskSugg.getTitle());
+                createDTO.setDescription(taskSugg.getDescription());
+                createDTO.setCategory(taskSugg.getCategory());
+                createDTO.setPoints(taskSugg.getPoints());
+                createDTO.setChildId(childId);
+                createDTO.setChildIds(Collections.singletonList(childId));
+                createDTO.setExecutorType("child");
+                createDTO.setExecutorId(childId);
+                createDTO.setTaskType("recurring");
+                createDTO.setFrequency("daily");
+                createDTO.setNeedReview(0);
+                createDTO.setDuration(15);
+
+                // 使用当天日期格式化任务标题
+                String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("M月d日"));
+                String title = taskSugg.getTitle().replace("{date}", dateStr);
+                createDTO.setTitle(title);
+
+                return taskService.createTask(userId, createDTO);
+            }
+        }
+        return null;
+    }
+
+    // ==================== 内部方法 ====================
+
+    private DanAssessmentResult findLatestResult(Long childId) {
+        return danAssessmentResultMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DanAssessmentResult>()
+                        .eq(DanAssessmentResult::getChildId, childId)
+                        .eq(DanAssessmentResult::getStatus, "completed")
+                        .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                        .last("LIMIT 1"));
+    }
+
+    private CognitiveRecommendationDTO buildRecommendations(Long childId, DanAssessmentResult result) {
+        if (config == null || config.getDimensions() == null) {
+            return null;
+        }
+
+        CognitiveRecommendationDTO dto = new CognitiveRecommendationDTO();
+        dto.setChildId(childId);
+
+        Child child = childMapper.selectById(childId);
+        dto.setChildName(child != null ? child.getNickname() : "");
+
+        List<CognitiveRecommendationDTO.DimensionRecommendation> recs = new ArrayList<>();
+        int weakCount = 0;
+
+        for (DimensionConfig dimCfg : config.getDimensions()) {
+            Integer score = getDimensionScore(result, dimCfg.getKey());
+            if (score == null) continue;
+
+            RangeConfig matched = findMatchingRange(dimCfg, score);
+            if (matched == null) continue;
+
+            CognitiveRecommendationDTO.DimensionRecommendation rec = new CognitiveRecommendationDTO.DimensionRecommendation();
+            rec.setDimensionKey(dimCfg.getKey());
+            rec.setLabel(dimCfg.getLabel());
+            rec.setIcon(dimCfg.getIcon());
+            rec.setScore(score);
+            rec.setLevel(matched.getLevel());
+            rec.setPriority(matched.getPriority());
+            rec.setSuggestion(matched.getSuggestion());
+
+            // 任务建议
+            if (matched.getTaskTemplate() != null) {
+                String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("M月d日"));
+                CognitiveRecommendationDTO.TaskSuggestion task = new CognitiveRecommendationDTO.TaskSuggestion();
+                task.setTitle(matched.getTaskTemplate().getTitle().replace("{date}", dateStr));
+                task.setDescription(matched.getTaskTemplate().getDescription());
+                task.setCategory(matched.getTaskTemplate().getCategory());
+                task.setPoints(matched.getTaskTemplate().getPoints());
+                rec.setTasks(Collections.singletonList(task));
+            }
+
+            // 游戏建议
+            if (matched.getRelatedGames() != null && !matched.getRelatedGames().isEmpty()) {
+                rec.setGames(matched.getRelatedGames().stream().map(code -> {
+                    CognitiveRecommendationDTO.GameSuggestion g = new CognitiveRecommendationDTO.GameSuggestion();
+                    g.setCode(code);
+                    g.setName(getGameDisplayName(code));
+                    return g;
+                }).collect(Collectors.toList()));
+            }
+
+            // 文章建议
+            if (matched.getRelatedArticleTags() != null && !matched.getRelatedArticleTags().isEmpty()) {
+                List<Article> articles = articleMapper.selectList(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Article>()
+                                .eq(Article::getStatus, "published")
+                                .and(w -> {
+                                    for (String tag : matched.getRelatedArticleTags()) {
+                                        w.like(Article::getTags, tag).or();
+                                    }
+                                })
+                                .orderByDesc(Article::getViewCount)
+                                .last("LIMIT 2"));
+                if (articles != null && !articles.isEmpty()) {
+                    rec.setArticles(articles.stream().map(a -> {
+                        CognitiveRecommendationDTO.ArticleSuggestion as = new CognitiveRecommendationDTO.ArticleSuggestion();
+                        as.setId(a.getId());
+                        as.setTitle(a.getTitle());
+                        return as;
+                    }).collect(Collectors.toList()));
+                }
+            }
+
+            recs.add(rec);
+
+            if ("偏弱".equals(matched.getLevel()) || "high".equals(matched.getPriority())) {
+                weakCount++;
+            }
+        }
+
+        dto.setRecommendations(recs);
+
+        // 综合建议
+        if (result.getOverallScore() != null && config.getOverall() != null) {
+            CognitiveRecommendationDTO.OverallRecommendation overall = new CognitiveRecommendationDTO.OverallRecommendation();
+            overall.setTotalScore(result.getOverallScore());
+            overall.setWeakCount(weakCount);
+            for (OverallConfig oc : config.getOverall()) {
+                if (result.getOverallScore() >= oc.getMin() && result.getOverallScore() < oc.getMax()) {
+                    overall.setSuggestion(oc.getSuggestion());
+                    break;
+                }
+            }
+            if (overall.getSuggestion() == null && !config.getOverall().isEmpty()) {
+                overall.setSuggestion(config.getOverall().get(config.getOverall().size() - 1).getSuggestion());
+            }
+            dto.setOverall(overall);
+        }
+
+        return dto;
+    }
+
+    private Integer getDimensionScore(DanAssessmentResult result, String key) {
+        switch (key) {
+            case "perceptionScore": return result.getPerceptionScore();
+            case "focusScore": return result.getFocusScore();
+            case "memoryScore": return result.getMemoryScore();
+            case "logicScore": return result.getLogicScore();
+            case "spatialScore": return result.getSpatialScore();
+            case "processingSpeedScore": return result.getProcessingSpeedScore();
+            default: return null;
+        }
+    }
+
+    private RangeConfig findMatchingRange(DimensionConfig dim, int score) {
+        if (dim.getRanges() == null) return null;
+        for (RangeConfig range : dim.getRanges()) {
+            if (score >= range.getMin() && score <= range.getMax()) {
+                return range;
+            }
+        }
+        return null;
+    }
+
+    private String getGameDisplayName(String code) {
+        switch (code) {
+            case "schulte": return "舒尔特方格";
+            case "1a2b": return "猜数字";
+            case "sudoku": return "数独";
+            default: return code;
+        }
+    }
+
+    // ==================== YAML 映射 POJO ====================
+
+    @Data
+    public static class CognitiveRecommendationsConfig {
+        private List<DimensionConfig> dimensions;
+        private List<OverallConfig> overall;
+    }
+
+    @Data
+    public static class DimensionConfig {
+        private String key;
+        private String label;
+        private String icon;
+        private List<RangeConfig> ranges;
+    }
+
+    @Data
+    public static class RangeConfig {
+        private int min;
+        private int max;
+        private String level;
+        private String priority;
+        private String suggestion;
+        private TaskTemplateConfig taskTemplate;
+        private List<String> relatedGames;
+        private List<String> relatedArticleTags;
+    }
+
+    @Data
+    public static class TaskTemplateConfig {
+        private String title;
+        private String description;
+        private String category;
+        private int points;
+    }
+
+    @Data
+    public static class OverallConfig {
+        private int min;
+        private int max;
+        private String suggestion;
+    }
+}

+ 262 - 0
cfc-backend/src/main/resources/cognitive-recommendations.yml

@@ -0,0 +1,262 @@
+# 认知维度→行动推荐映射配置
+# 系统根据 DAN 评估各维度分数,匹配对应配置生成家庭行动建议
+# 规划师团队可自行修改此文件,无需改代码
+
+dimensions:
+  - key: perceptionScore
+    label: 感知能力
+    icon: 👁
+    ranges:
+      - min: 0
+        max: 65
+        level: 偏弱
+        priority: high
+        suggestion: "感知能力偏弱,建议多进行视觉辨别和感官观察类活动,每周 3-4 次"
+        task_template:
+          title: "感知训练 - {date}"
+          description: "完成 15 分钟感知能力练习(如找不同、视觉追踪、拼图)"
+          category: "wisdom"
+          points: 10
+        related_games: []
+        related_article_tags: ["perception", "感知"]
+      - min: 65
+        max: 80
+        level: 正常
+        priority: normal
+        suggestion: "感知能力处于正常范围,建议每周 1-2 次练习保持"
+        task_template:
+          title: "感知练习 - {date}"
+          description: "完成 10 分钟感知趣味练习"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["perception", "感知"]
+      - min: 80
+        max: 100
+        level: 优秀
+        priority: low
+        suggestion: "感知能力优秀!鼓励挑战更高难度的观察力任务"
+        task_template:
+          title: "感知挑战 - {date}"
+          description: "完成一个进阶感知任务"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["perception", "感知"]
+
+  - key: focusScore
+    label: 专注力
+    icon: 🎯
+    ranges:
+      - min: 0
+        max: 65
+        level: 偏弱
+        priority: high
+        suggestion: "专注力偏弱,建议每天做一次舒尔特方格训练,每次 5 轮"
+        task_template:
+          title: "专注力训练 - {date}"
+          description: "完成 5 轮舒尔特方格训练,记录每次完成时间"
+          category: "wisdom"
+          points: 10
+        related_games: ["schulte"]
+        related_article_tags: ["focus", "专注"]
+      - min: 65
+        max: 80
+        level: 正常
+        priority: normal
+        suggestion: "专注力正常,建议每周做 3 次舒尔特方格保持"
+        task_template:
+          title: "专注力练习 - {date}"
+          description: "完成 3 轮舒尔特方格训练"
+          category: "wisdom"
+          points: 5
+        related_games: ["schulte"]
+        related_article_tags: ["focus", "专注"]
+      - min: 80
+        max: 100
+        level: 优秀
+        priority: low
+        suggestion: "专注力优秀!可挑战更高难度的专注任务"
+        task_template:
+          title: "专注力挑战 - {date}"
+          description: "完成 5 轮舒尔特方格,挑战个人最佳记录"
+          category: "wisdom"
+          points: 5
+        related_games: ["schulte"]
+        related_article_tags: ["focus", "专注"]
+
+  - key: memoryScore
+    label: 记忆力
+    icon: 🧠
+    ranges:
+      - min: 0
+        max: 65
+        level: 偏弱
+        priority: high
+        suggestion: "记忆力偏弱,建议每天进行 10 分钟记忆训练(如联想记忆、词语复述)"
+        task_template:
+          title: "记忆力训练 - {date}"
+          description: "完成 10 分钟记忆练习(家长准备一组词语让孩子复述,逐步增加数量)"
+          category: "wisdom"
+          points: 10
+        related_games: []
+        related_article_tags: ["memory", "记忆"]
+      - min: 65
+        max: 80
+        level: 正常
+        priority: normal
+        suggestion: "记忆力正常,建议每周做 2-3 次记忆练习保持"
+        task_template:
+          title: "记忆力练习 - {date}"
+          description: "完成一组记忆训练,记录正确回忆数量"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["memory", "记忆"]
+      - min: 80
+        max: 100
+        level: 优秀
+        priority: low
+        suggestion: "记忆力优秀!可尝试记忆更长序列或学习记忆宫殿法"
+        task_template:
+          title: "记忆力挑战 - {date}"
+          description: "完成进阶记忆训练"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["memory", "记忆"]
+
+  - key: logicScore
+    label: 逻辑思维
+    icon: 🧩
+    ranges:
+      - min: 0
+        max: 65
+        level: 偏弱
+        priority: high
+        suggestion: "逻辑思维偏弱,建议每周 3 次逻辑训练(数独、猜数字、推理问答)"
+        task_template:
+          title: "逻辑训练 - {date}"
+          description: "完成 15 分钟逻辑思维练习(数独或推理题)"
+          category: "wisdom"
+          points: 10
+        related_games: ["sudoku", "1a2b"]
+        related_article_tags: ["logic", "逻辑"]
+      - min: 65
+        max: 80
+        level: 正常
+        priority: normal
+        suggestion: "逻辑思维正常,建议每周 1-2 次数独或猜数字游戏保持"
+        task_template:
+          title: "逻辑练习 - {date}"
+          description: "完成一局数独或猜数字游戏"
+          category: "wisdom"
+          points: 5
+        related_games: ["sudoku", "1a2b"]
+        related_article_tags: ["logic", "逻辑"]
+      - min: 80
+        max: 100
+        level: 优秀
+        priority: low
+        suggestion: "逻辑思维优秀!可挑战更高难度推理题"
+        task_template:
+          title: "逻辑挑战 - {date}"
+          description: "完成一局高难度数独或推理题"
+          category: "wisdom"
+          points: 5
+        related_games: ["sudoku", "1a2b"]
+        related_article_tags: ["logic", "逻辑"]
+
+  - key: spatialScore
+    label: 空间思维
+    icon: 🧊
+    ranges:
+      - min: 0
+        max: 65
+        level: 偏弱
+        priority: high
+        suggestion: "空间思维偏弱,建议多进行拼图、积木、折纸等空间类活动"
+        task_template:
+          title: "空间训练 - {date}"
+          description: "完成 15 分钟空间思维练习(拼图/七巧板/折纸)"
+          category: "wisdom"
+          points: 10
+        related_games: []
+        related_article_tags: ["spatial", "空间"]
+      - min: 65
+        max: 80
+        level: 正常
+        priority: normal
+        suggestion: "空间思维正常,建议每周 1-2 次空间类活动保持"
+        task_template:
+          title: "空间练习 - {date}"
+          description: "完成一组空间思维趣味练习"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["spatial", "空间"]
+      - min: 80
+        max: 100
+        level: 优秀
+        priority: low
+        suggestion: "空间思维优秀!可挑战立体拼图或更复杂的空间任务"
+        task_template:
+          title: "空间挑战 - {date}"
+          description: "完成一个进阶空间思维任务"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["spatial", "空间"]
+
+  - key: processingSpeedScore
+    label: 加工速度
+    icon: ⚡
+    ranges:
+      - min: 0
+        max: 65
+        level: 偏弱
+        priority: high
+        suggestion: "加工速度偏弱,建议每天进行限时反应训练(如限时配对、快速找不同)"
+        task_template:
+          title: "加工速度训练 - {date}"
+          description: "完成 10 分钟限时反应速度练习"
+          category: "wisdom"
+          points: 10
+        related_games: []
+        related_article_tags: ["speed", "加工速度"]
+      - min: 65
+        max: 80
+        level: 正常
+        priority: normal
+        suggestion: "加工速度正常,建议每周做 2 次限时训练保持"
+        task_template:
+          title: "加工速度练习 - {date}"
+          description: "完成一组限时反应练习"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["speed", "加工速度"]
+      - min: 80
+        max: 100
+        level: 优秀
+        priority: low
+        suggestion: "加工速度优秀!可挑战更短时限的高强度任务"
+        task_template:
+          title: "加工速度挑战 - {date}"
+          description: "完成一个限时挑战任务"
+          category: "wisdom"
+          points: 5
+        related_games: []
+        related_article_tags: ["speed", "加工速度"]
+
+# 综合评分建议(无对应具体维度,单独配置)
+overall:
+  - min: 0
+    max: 60
+    suggestion: "综合认知评分偏低,建议重点关注偏弱维度,制定系统性训练计划"
+  - min: 60
+    max: 80
+    suggestion: "综合认知评分正常,坚持日常训练可稳步提升"
+  - min: 80
+    max: 100
+    suggestion: "综合认知评分优秀!继续保持良好训练习惯"

+ 97 - 1
cfc-backend/src/main/resources/schema_assessment.sql

@@ -66,4 +66,100 @@ ADD COLUMN remind_months INT COMMENT '提醒周期(月)';
 
 -- 创建索引
 CREATE INDEX idx_task_level ON admin_task_templates(task_level);
-CREATE INDEX idx_is_system_task ON admin_task_templates(is_system_task);
+CREATE INDEX idx_is_system_task ON admin_task_templates(is_system_task);
+
+-- ============================================
+-- 认知常模表(百分位参考)
+-- ============================================
+CREATE TABLE IF NOT EXISTS cognitive_norms (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    dimension_key VARCHAR(50) NOT NULL COMMENT '维度键:perception/focus/memory/logic/spatial/processingSpeed',
+    gender VARCHAR(10) DEFAULT 'all' COMMENT '性别:male/female/all',
+    age_min INT NOT NULL COMMENT '最小月龄(含)',
+    age_max INT NOT NULL COMMENT '最大月龄(不含)',
+    percentile5 INT COMMENT '第5百分位分数',
+    percentile25 INT COMMENT '第25百分位分数',
+    percentile50 INT COMMENT '第50百分位分数(中位数)',
+    percentile75 INT COMMENT '第75百分位分数',
+    percentile95 INT COMMENT '第95百分位分数',
+    source VARCHAR(100) DEFAULT 'DAN常模' COMMENT '数据来源',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_dimension_age (dimension_key, gender, age_min, age_max)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认知维度百分位常模表';
+
+-- 种子数据: 6-12岁 各维度常模(年龄月: 72-144月, 性别: all)
+-- 感知能力
+INSERT INTO cognitive_norms (dimension_key, gender, age_min, age_max, percentile5, percentile25, percentile50, percentile75, percentile95, source) VALUES
+('perception', 'all', 0, 84, 20, 35, 50, 65, 80, 'DAN常模'),
+('perception', 'all', 84, 108, 25, 40, 55, 70, 85, 'DAN常模'),
+('perception', 'all', 108, 144, 30, 45, 60, 75, 88, 'DAN常模'),
+('perception', 'all', 144, 216, 35, 50, 65, 78, 90, 'DAN常模'),
+('focus', 'all', 0, 84, 15, 28, 42, 55, 70, 'DAN常模'),
+('focus', 'all', 84, 108, 20, 33, 48, 62, 78, 'DAN常模'),
+('focus', 'all', 108, 144, 25, 38, 53, 68, 82, 'DAN常模'),
+('focus', 'all', 144, 216, 30, 45, 58, 72, 85, 'DAN常模'),
+('memory', 'all', 0, 84, 18, 32, 46, 60, 75, 'DAN常模'),
+('memory', 'all', 84, 108, 22, 37, 52, 65, 80, 'DAN常模'),
+('memory', 'all', 108, 144, 28, 42, 56, 70, 84, 'DAN常模'),
+('memory', 'all', 144, 216, 32, 47, 60, 74, 86, 'DAN常模'),
+('logic', 'all', 0, 84, 12, 25, 38, 52, 65, 'DAN常模'),
+('logic', 'all', 84, 108, 18, 30, 45, 58, 72, 'DAN常模'),
+('logic', 'all', 108, 144, 25, 38, 52, 65, 78, 'DAN常模'),
+('logic', 'all', 144, 216, 30, 45, 58, 70, 82, 'DAN常模'),
+('spatial', 'all', 0, 84, 15, 28, 40, 55, 70, 'DAN常模'),
+('spatial', 'all', 84, 108, 20, 35, 48, 62, 76, 'DAN常模'),
+('spatial', 'all', 108, 144, 28, 40, 55, 68, 80, 'DAN常模'),
+('spatial', 'all', 144, 216, 32, 45, 58, 72, 84, 'DAN常模'),
+('processingSpeed', 'all', 0, 84, 10, 22, 35, 48, 62, 'DAN常模'),
+('processingSpeed', 'all', 84, 108, 15, 28, 42, 55, 70, 'DAN常模'),
+('processingSpeed', 'all', 108, 144, 22, 35, 48, 62, 75, 'DAN常模'),
+('processingSpeed', 'all', 144, 216, 28, 42, 55, 68, 80, 'DAN常模');
+
+-- ============================================
+-- 训练方案系统(guide端训练计划管理)
+-- ============================================
+CREATE TABLE IF NOT EXISTS training_plans (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    guide_id BIGINT NOT NULL COMMENT '规划师ID',
+    child_id BIGINT NOT NULL COMMENT '孩子ID',
+    title VARCHAR(200) NOT NULL COMMENT '方案标题',
+    description TEXT COMMENT '方案描述',
+    status VARCHAR(20) DEFAULT 'draft' COMMENT '状态:draft草稿/published已下发/completed已完成',
+    source VARCHAR(20) DEFAULT 'manual' COMMENT '来源:manual手动/recommendations推荐生成',
+    deployed_at DATETIME COMMENT '下发时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_guide (guide_id),
+    INDEX idx_child (child_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='训练方案表';
+
+CREATE TABLE IF NOT EXISTS training_plan_items (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    plan_id BIGINT NOT NULL COMMENT '所属方案ID',
+    sequence INT DEFAULT 0 COMMENT '排序号',
+    dimension_key VARCHAR(50) COMMENT '认知维度:perception/focus/memory/logic/spatial/processingSpeed',
+    suggestion TEXT COMMENT '建议文案',
+    task_title VARCHAR(200) COMMENT '任务标题',
+    task_desc TEXT COMMENT '任务描述',
+    minigame_code VARCHAR(50) COMMENT '关联小游戏code',
+    article_id BIGINT COMMENT '关联文章ID',
+    task_points INT DEFAULT 2 COMMENT '任务积分',
+    deadline_days INT DEFAULT 7 COMMENT '截止天数(从部署日起)',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT '状态:pending/completed/skipped',
+    completed_at DATETIME COMMENT '完成时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_plan (plan_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='训练方案项目表';
+
+CREATE TABLE IF NOT EXISTS training_plan_tasks (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    plan_id BIGINT NOT NULL COMMENT '方案ID',
+    plan_item_id BIGINT NOT NULL COMMENT '方案项目ID',
+    task_id BIGINT NOT NULL COMMENT '实际Task ID',
+    child_id BIGINT NOT NULL COMMENT '执行孩子ID',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/completed',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_plan (plan_id),
+    INDEX idx_task (task_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='训练方案任务关联表';

+ 8 - 0
cfc-frontend/pages.json

@@ -204,6 +204,14 @@
         {
           "path": "hierarchy/index",
           "style": { "navigationBarTitleText": "下级成长规划师" }
+        },
+        {
+          "path": "training-plans/index",
+          "style": { "navigationBarTitleText": "训练方案" }
+        },
+        {
+          "path": "training-plans/detail",
+          "style": { "navigationBarTitleText": "方案详情" }
         }
       ]
     },

+ 968 - 0
cfc-frontend/pages/guide/training-plans/detail.vue

@@ -0,0 +1,968 @@
+<template>
+  <view class="container">
+    <!-- ===== 看板模式 ===== -->
+    <view class="board-section" v-if="mode === 'board' && boardData">
+      <view class="board-header">
+        <text class="board-title">{{ boardData.title }}</text>
+        <text class="board-progress">{{ boardData.progress }}%</text>
+      </view>
+      <view class="progress-bar-wrap">
+        <view class="progress-bar-fill" :style="{ width: boardData.progress + '%' }"></view>
+      </view>
+      <view class="board-summary">
+        <view class="summary-item">
+          <text class="summary-value">{{ boardData.completedItems }}</text>
+          <text class="summary-label">已完成</text>
+        </view>
+        <view class="summary-item">
+          <text class="summary-value">{{ boardData.totalItems }}</text>
+          <text class="summary-label">总任务</text>
+        </view>
+        <view class="summary-item">
+          <text class="summary-value">{{ boardData.totalItems - boardData.completedItems }}</text>
+          <text class="summary-label">进行中</text>
+        </view>
+      </view>
+
+      <view class="board-items" v-if="boardData.itemStatus">
+        <view class="board-item" v-for="item in boardData.itemStatus" :key="item.itemId">
+          <view class="bi-left">
+            <view class="bi-check" :class="item.status === 'completed' ? 'done' : ''">
+              <text v-if="item.status === 'completed'">&#x2714;</text>
+              <text v-else>&#x25CB;</text>
+            </view>
+            <view class="bi-info">
+              <text class="bi-dim">{{ dimensionLabel(item.dimensionKey) }}</text>
+              <text class="bi-title">{{ item.taskTitle }}</text>
+            </view>
+          </view>
+          <view class="bi-right">
+            <text class="bi-status" :class="'bi-' + item.status">{{ itemStatusText(item.status) }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 编辑模式 ===== -->
+    <view class="edit-section" v-else>
+      <!-- 基本信息 -->
+      <view class="section-card">
+        <view class="section-title">方案信息</view>
+        <view class="form-item">
+          <text class="form-label">方案标题</text>
+          <input class="form-input" v-model="planForm.title" placeholder="请输入方案标题" :disabled="!editable" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">方案描述</text>
+          <textarea class="form-textarea" v-model="planForm.description" placeholder="请输入方案描述" :disabled="!editable" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">状态</text>
+          <text class="status-badge" :class="'status-' + planForm.status">{{ statusText(planForm.status) }}</text>
+        </view>
+        <view class="form-actions" v-if="editable && !isNew">
+          <view class="btn-save" @click="savePlan">
+            <text>保存</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 训练项目 -->
+      <view class="section-card">
+        <view class="section-header">
+          <text class="section-title">训练项目</text>
+          <view class="btn-add-item" v-if="editable" @click="showAddItem">
+            <text>+ 添加项目</text>
+          </view>
+        </view>
+
+        <view class="item-list" v-if="items.length > 0">
+          <view class="plan-item" v-for="(item, idx) in items" :key="item.id || idx">
+            <view class="item-header">
+              <view class="item-seq">{{ idx + 1 }}</view>
+              <view class="item-dim-tag">{{ dimensionLabel(item.dimensionKey) }}</view>
+              <view class="item-status-badge" :class="'item-' + item.status">{{ itemStatusText(item.status) }}</view>
+            </view>
+            <view class="item-body">
+              <view class="item-field">
+                <text class="item-field-label">任务标题</text>
+                <input v-if="editable" class="item-input" v-model="item.taskTitle" @blur="updateItemField(item)" />
+                <text v-else class="item-field-value">{{ item.taskTitle }}</text>
+              </view>
+              <view class="item-field">
+                <text class="item-field-label">任务描述</text>
+                <input v-if="editable" class="item-input" v-model="item.taskDesc" @blur="updateItemField(item)" />
+                <text v-else class="item-field-value">{{ item.taskDesc }}</text>
+              </view>
+              <view class="item-field-row">
+                <view class="item-field half">
+                  <text class="item-field-label">积分</text>
+                  <input v-if="editable" class="item-input" type="number" v-model="item.taskPoints" @blur="updateItemField(item)" />
+                  <text v-else class="item-field-value">{{ item.taskPoints || 2 }}</text>
+                </view>
+                <view class="item-field half">
+                  <text class="item-field-label">截止天数</text>
+                  <input v-if="editable" class="item-input" type="number" v-model="item.deadlineDays" @blur="updateItemField(item)" />
+                  <text v-else class="item-field-value">{{ item.deadlineDays || 7 }}天</text>
+                </view>
+              </view>
+              <view class="item-field" v-if="item.minigameCode">
+                <text class="item-field-label">小游戏</text>
+                <text class="item-field-value">{{ item.minigameCode }}</text>
+              </view>
+            </view>
+            <view class="item-actions" v-if="editable">
+              <view class="item-action-btn delete" @click="deleteItem(item.id, idx)">
+                <text>删除</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <view class="empty-items" v-else>
+          <text class="empty-items-text">暂无训练项目,点击上方"添加项目"开始配置</text>
+        </view>
+      </view>
+
+      <!-- 操作栏 -->
+      <view class="action-bar" v-if="editable && !isNew">
+        <view class="btn-deploy" @click="handleDeploy" v-if="planForm.status === 'draft'">
+          <text>下发训练方案</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 添加项目弹窗 -->
+    <view class="modal-mask" v-if="showItemModal" @click="showItemModal = false">
+      <view class="modal-card" @click.stop>
+        <text class="modal-title">添加训练项目</text>
+        <view class="modal-form">
+          <view class="form-item">
+            <text class="form-label">认知维度</text>
+            <picker class="dim-picker" :value="dimPickerIdx" :range="dimensionOptions" range-key="label" @change="onDimChange">
+              <text class="picker-text">{{ newItem.dimensionKey ? dimensionLabel(newItem.dimensionKey) : '请选择维度' }}</text>
+            </picker>
+          </view>
+          <view class="form-item">
+            <text class="form-label">任务标题</text>
+            <input class="form-input" v-model="newItem.taskTitle" placeholder="请输入任务标题" />
+          </view>
+          <view class="form-item">
+            <text class="form-label">任务描述</text>
+            <textarea class="form-textarea" v-model="newItem.taskDesc" placeholder="请输入任务描述" />
+          </view>
+          <view class="form-item">
+            <text class="form-label">建议文案</text>
+            <textarea class="form-textarea" v-model="newItem.suggestion" placeholder="给家长的建议" />
+          </view>
+          <view class="form-item-row">
+            <view class="form-item half">
+              <text class="form-label">积分</text>
+              <input class="form-input" type="number" v-model="newItem.taskPoints" />
+            </view>
+            <view class="form-item half">
+              <text class="form-label">截止天数</text>
+              <input class="form-input" type="number" v-model="newItem.deadlineDays" />
+            </view>
+          </view>
+        </view>
+        <view class="modal-actions">
+          <view class="btn-cancel" @click="showItemModal = false"><text>取消</text></view>
+          <view class="btn-confirm" @click="addItemConfirm"><text>确认添加</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 新建模式: 选择孩子 -->
+    <view class="modal-mask" v-if="showChildModal" @click="showChildModal = false">
+      <view class="modal-card" @click.stop>
+        <text class="modal-title">选择孩子</text>
+        <view class="modal-child-list">
+          <view class="modal-child-item" v-for="child in bindedChildren" :key="child.id"
+            @click="selectCreateChild(child.id)">
+            <text>{{ child.name || '孩子' + child.id }}</text>
+          </view>
+          <view class="empty-small-text" v-if="bindedChildren.length === 0">暂无可用孩子</view>
+        </view>
+        <view class="modal-footer">
+          <view class="btn-cancel" @click="showChildModal = false"><text>取消</text></view>
+        </view>
+      </view>
+    </view>
+
+    <view class="loading" v-if="loading"><text class="loading-text">加载中...</text></view>
+  </view>
+</template>
+
+<script>
+import { getTrainingPlanDetail, createTrainingPlan, updateTrainingPlan, addTrainingPlanItem, updateTrainingPlanItem, deleteTrainingPlanItem, deployTrainingPlan, getExecutionBoard, getGuideBindedChildren } from '../../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      planId: null,
+      isNew: false,
+      mode: 'edit',
+      planForm: { title: '', description: '', status: 'draft' },
+      items: [],
+      boardData: null,
+      loading: false,
+      editable: true,
+      showItemModal: false,
+      showChildModal: false,
+      bindedChildren: [],
+      newItem: { dimensionKey: '', taskTitle: '', taskDesc: '', suggestion: '', taskPoints: 2, deadlineDays: 7 },
+      dimPickerIdx: 0,
+      dimensionOptions: [
+        { key: 'perception', label: '感知能力' },
+        { key: 'focus', label: '专注力' },
+        { key: 'memory', label: '记忆力' },
+        { key: 'logic', label: '逻辑思维' },
+        { key: 'spatial', label: '空间思维' },
+        { key: 'processingSpeed', label: '加工速度' }
+      ]
+    }
+  },
+  onLoad(options) {
+    this.planId = options.planId ? parseInt(options.planId) : null
+    this.mode = options.mode || 'edit'
+
+    if (!this.planId || this.planId === 0) {
+      this.isNew = true
+      this.planForm = { title: '认知训练方案', description: '', status: 'draft' }
+      this.showChildModalForNew()
+    } else {
+      this.loadDetail()
+    }
+  },
+  methods: {
+    loadDetail() {
+      var self = this
+      self.loading = true
+      if (self.mode === 'board') {
+        getExecutionBoard(self.planId).then(function(res) {
+          self.loading = false
+          if (res.code === 200) {
+            self.boardData = res.data
+          }
+        }).catch(function() { self.loading = false })
+      } else {
+        getTrainingPlanDetail(self.planId).then(function(res) {
+          self.loading = false
+          if (res.code === 200 && res.data) {
+            self.planForm = res.data.plan || {}
+            self.items = res.data.items || []
+            self.editable = self.planForm.status === 'draft'
+          }
+        }).catch(function() { self.loading = false })
+      }
+    },
+    showChildModalForNew() {
+      getGuideBindedChildren().then(function(res) {
+        if (res.code === 200 && res.data) {
+          var children = []
+          var data = res.data
+          for (var i = 0; i < data.length; i++) {
+            var family = data[i]
+            if (family.children) {
+              for (var j = 0; j < family.children.length; j++) {
+                children.push(family.children[j])
+              }
+            }
+            if (family.members) {
+              for (var k = 0; k < family.members.length; k++) {
+                var m = family.members[k]
+                if (m.childId) children.push({ id: m.childId, name: m.name || m.childName })
+              }
+            }
+          }
+          self.bindedChildren = children
+        }
+      }).catch(function() {})
+      this.showChildModal = true
+    },
+    selectCreateChild(childId) {
+      var self = this
+      self.showChildModal = false
+      createTrainingPlan(childId, self.planForm.title, self.planForm.description).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.planId = res.data
+          self.isNew = false
+          uni.showToast({ title: '方案已创建', icon: 'success' })
+          setTimeout(function() {
+            uni.redirectTo({ url: '/pages/guide/training-plans/detail?planId=' + self.planId })
+          }, 1000)
+        } else {
+          uni.showToast({ title: res.message || '创建失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    },
+    savePlan() {
+      var self = this
+      updateTrainingPlan(self.planId, self.planForm.title, self.planForm.description).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    },
+    showAddItem() {
+      this.newItem = { dimensionKey: '', taskTitle: '', taskDesc: '', suggestion: '', taskPoints: 2, deadlineDays: 7 }
+      this.dimPickerIdx = 0
+      this.showItemModal = true
+    },
+    onDimChange(e) {
+      this.dimPickerIdx = e.detail.value
+      this.newItem.dimensionKey = this.dimensionOptions[this.dimPickerIdx].key
+    },
+    addItemConfirm() {
+      var self = this
+      if (!this.newItem.dimensionKey) {
+        uni.showToast({ title: '请选择维度', icon: 'none' })
+        return
+      }
+      if (!this.newItem.taskTitle) {
+        uni.showToast({ title: '请输入任务标题', icon: 'none' })
+        return
+      }
+      var payload = {
+        planId: this.planId,
+        dimensionKey: this.newItem.dimensionKey,
+        taskTitle: this.newItem.taskTitle,
+        taskDesc: this.newItem.taskDesc,
+        suggestion: this.newItem.suggestion,
+        taskPoints: parseInt(this.newItem.taskPoints) || 2,
+        deadlineDays: parseInt(this.newItem.deadlineDays) || 7,
+        sequence: this.items.length + 1
+      }
+      addTrainingPlanItem(payload).then(function(res) {
+        if (res.code === 200) {
+          self.showItemModal = false
+          uni.showToast({ title: '已添加', icon: 'success' })
+          self.loadDetail()
+        } else {
+          uni.showToast({ title: res.message || '添加失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    },
+    updateItemField(item) {
+      updateTrainingPlanItem(item).catch(function() {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      })
+    },
+    deleteItem(itemId, idx) {
+      var self = this
+      if (!itemId) {
+        self.items.splice(idx, 1)
+        return
+      }
+      uni.showModal({
+        title: '确认删除',
+        content: '确定删除此项目?',
+        success: function(res) {
+          if (res.confirm) {
+            deleteTrainingPlanItem(itemId).then(function(r) {
+              if (r.code === 200) {
+                self.items.splice(idx, 1)
+                uni.showToast({ title: '已删除', icon: 'success' })
+              }
+            }).catch(function() {})
+          }
+        }
+      })
+    },
+    handleDeploy() {
+      var self = this
+      uni.showModal({
+        title: '确认下发',
+        content: '将一次性创建所有训练任务,是否继续?',
+        success: function(res) {
+          if (res.confirm) {
+            uni.showLoading({ title: '下发中...' })
+            deployTrainingPlan(self.planId).then(function(r) {
+              uni.hideLoading()
+              if (r.code === 200) {
+                uni.showToast({ title: '下发成功', icon: 'success' })
+                self.loadDetail()
+              } else {
+                uni.showToast({ title: r.message || '下发失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.hideLoading()
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    dimensionLabel(key) {
+      var map = { perception: '感知', focus: '专注', memory: '记忆', logic: '逻辑', spatial: '空间', processingSpeed: '加工速度' }
+      return map[key] || key || ''
+    },
+    statusText(status) {
+      var map = { draft: '草稿', published: '已下发', completed: '已完成' }
+      return map[status] || status || '草稿'
+    },
+    itemStatusText(status) {
+      var map = { pending: '待完成', completed: '已完成', skipped: '已跳过' }
+      return map[status] || status || '待完成'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 60rpx;
+}
+
+/* ===== 看板 ===== */
+.board-section {
+  padding: 30rpx;
+}
+
+.board-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.board-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.board-progress {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #667eea;
+}
+
+.progress-bar-wrap {
+  height: 12rpx;
+  background: #e5e7eb;
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 24rpx;
+}
+
+.progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #667eea, #764ba2);
+  border-radius: 6rpx;
+  transition: width 0.3s;
+}
+
+.board-summary {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+  margin-bottom: 24rpx;
+}
+
+.summary-item {
+  flex: 1;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  text-align: center;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
+}
+
+.summary-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #667eea;
+  display: block;
+}
+
+.summary-label {
+  font-size: 24rpx;
+  color: #888;
+  display: block;
+  margin-top: 4rpx;
+}
+
+.board-items {
+  background: #fff;
+  border-radius: 20rpx;
+  overflow: hidden;
+}
+
+.board-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 24rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.board-item:last-child { border-bottom: none; }
+
+.bi-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex: 1;
+  gap: 16rpx;
+}
+
+.bi-check {
+  width: 44rpx;
+  height: 44rpx;
+  border-radius: 50%;
+  border: 3rpx solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22rpx;
+  color: #d1d5db;
+  flex-shrink: 0;
+}
+
+.bi-check.done {
+  background: #22c55e;
+  border-color: #22c55e;
+  color: #fff;
+}
+
+.bi-info {
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+
+.bi-dim {
+  font-size: 22rpx;
+  color: #888;
+}
+
+.bi-title {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+
+.bi-right {}
+
+.bi-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 16rpx;
+}
+
+.bi-pending { background: #f3f4f6; color: #666; }
+.bi-completed { background: #d1fae5; color: #059669; }
+.bi-skipped { background: #fee2e2; color: #dc2626; }
+
+/* ===== 编辑模式 ===== */
+.edit-section {
+  padding: 20rpx 30rpx;
+}
+
+.section-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+}
+
+.section-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 16rpx;
+}
+
+.section-header .section-title {
+  margin-bottom: 0;
+}
+
+.form-item {
+  margin-bottom: 20rpx;
+}
+
+.form-item:last-child { margin-bottom: 0; }
+
+.form-label {
+  font-size: 26rpx;
+  color: #888;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.form-input {
+  width: 100%;
+  height: 72rpx;
+  background: #f9fafb;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  box-sizing: border-box;
+  border: 1rpx solid #e5e7eb;
+}
+
+.form-textarea {
+  width: 100%;
+  background: #f9fafb;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  box-sizing: border-box;
+  border: 1rpx solid #e5e7eb;
+  min-height: 120rpx;
+  display: block;
+}
+
+.form-actions {
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+  margin-top: 20rpx;
+}
+
+.btn-save {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  padding: 16rpx 48rpx;
+  border-radius: 32rpx;
+}
+
+.btn-save:active { opacity: 0.8; }
+
+.btn-save text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+
+.status-badge {
+  display: inline-block;
+  font-size: 24rpx;
+  padding: 6rpx 20rpx;
+  border-radius: 16rpx;
+}
+
+.status-draft { background: #f3f4f6; color: #666; }
+.status-published { background: #dbeafe; color: #2563eb; }
+.status-completed { background: #d1fae5; color: #059669; }
+
+/* 添加项目按钮 */
+.btn-add-item {
+  background: #eef2ff;
+  padding: 8rpx 24rpx;
+  border-radius: 20rpx;
+}
+
+.btn-add-item:active { opacity: 0.7; }
+
+.btn-add-item text {
+  font-size: 26rpx;
+  color: #667eea;
+  font-weight: 500;
+}
+
+/* 项目列表 */
+.item-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.plan-item {
+  background: #f9fafb;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  border: 1rpx solid #e5e7eb;
+}
+
+.item-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 12rpx;
+}
+
+.item-seq {
+  width: 36rpx;
+  height: 36rpx;
+  background: #667eea;
+  border-radius: 50%;
+  color: #fff;
+  font-size: 22rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: bold;
+}
+
+.item-dim-tag {
+  background: #eef2ff;
+  color: #667eea;
+  font-size: 22rpx;
+  padding: 4rpx 14rpx;
+  border-radius: 12rpx;
+  font-weight: 500;
+}
+
+.item-status-badge {
+  font-size: 22rpx;
+  padding: 4rpx 14rpx;
+  border-radius: 12rpx;
+}
+
+.item-pending { background: #f3f4f6; color: #666; }
+.item-completed { background: #d1fae5; color: #059669; }
+
+.item-body {
+  display: flex;
+  flex-direction: column;
+  gap: 10rpx;
+}
+
+.item-field {
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+
+.item-field-row {
+  display: flex;
+  flex-direction: row;
+  gap: 12rpx;
+}
+
+.item-field.half {
+  flex: 1;
+}
+
+.item-field-label {
+  font-size: 22rpx;
+  color: #999;
+}
+
+.item-field-value {
+  font-size: 26rpx;
+  color: #333;
+}
+
+.item-input {
+  background: #fff;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 8rpx;
+  padding: 8rpx 12rpx;
+  font-size: 26rpx;
+  color: #333;
+}
+
+.item-actions {
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+  margin-top: 10rpx;
+  border-top: 1rpx solid #e5e7eb;
+  padding-top: 10rpx;
+}
+
+.item-action-btn {
+  padding: 8rpx 20rpx;
+  border-radius: 16rpx;
+  font-size: 24rpx;
+}
+
+.item-action-btn.delete {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.item-action-btn.delete:active { opacity: 0.7; }
+
+.empty-items {
+  padding: 40rpx 0;
+  text-align: center;
+}
+
+.empty-items-text {
+  font-size: 26rpx;
+  color: #999;
+}
+
+/* 底部操作栏 */
+.action-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #fff;
+  padding: 20rpx 30rpx;
+  box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.06);
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+}
+
+.btn-deploy {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  padding: 20rpx 80rpx;
+  border-radius: 40rpx;
+  box-shadow: 0 4rpx 16rpx rgba(102,126,234,0.3);
+}
+
+.btn-deploy:active { opacity: 0.85; }
+
+.btn-deploy text {
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: bold;
+}
+
+/* 弹窗 */
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 100;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.modal-card {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 40rpx;
+  width: 640rpx;
+  max-height: 75vh;
+  overflow-y: auto;
+}
+
+.modal-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 24rpx;
+}
+
+.modal-form {
+  margin-bottom: 24rpx;
+}
+
+.form-item-row {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+
+.form-item.half {
+  flex: 1;
+}
+
+.dim-picker {
+  background: #f9fafb;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+}
+
+.picker-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.modal-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  justify-content: center;
+}
+
+.btn-cancel, .btn-confirm {
+  padding: 16rpx 48rpx;
+  border-radius: 32rpx;
+  font-size: 28rpx;
+}
+
+.btn-cancel {
+  background: #f3f4f6;
+  color: #666;
+}
+
+.btn-confirm {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  color: #fff;
+}
+
+.btn-cancel:active, .btn-confirm:active { opacity: 0.7; }
+
+.modal-child-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  margin-bottom: 20rpx;
+}
+
+.modal-child-item {
+  padding: 20rpx;
+  background: #f9fafb;
+  border-radius: 12rpx;
+}
+
+.modal-child-item:active { background: #eef2ff; }
+
+.modal-child-item text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.modal-footer {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+}
+
+.empty-small-text {
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  padding: 20rpx 0;
+}
+
+.loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 80rpx;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+</style>

+ 599 - 0
cfc-frontend/pages/guide/training-plans/index.vue

@@ -0,0 +1,599 @@
+<template>
+  <view class="container">
+    <view class="header">
+      <text class="title">训练方案</text>
+      <view class="header-actions">
+        <view class="btn-secondary" @click="showChildPicker">
+          <text class="btn-secondary-text">筛选孩子</text>
+        </view>
+        <view class="btn-primary" @click="goToCreate">
+          <text class="btn-primary-text">+ 新建方案</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="child-filter-bar" v-if="selectedChildId">
+      <text class="filter-label">当前: {{ selectedChildName || '孩子' }}</text>
+      <view class="filter-clear" @click="clearChildFilter">
+        <text class="filter-clear-text">清除</text>
+      </view>
+    </view>
+
+    <view class="plan-list" v-if="plans.length > 0">
+      <view class="plan-card" v-for="plan in plans" :key="plan.id" @click="goToDetail(plan.id)">
+        <view class="plan-header">
+          <text class="plan-title">{{ plan.title || '未命名方案' }}</text>
+          <text class="plan-status" :class="'status-' + plan.status">{{ statusText(plan.status) }}</text>
+        </view>
+        <view class="plan-meta" v-if="plan.childId">
+          <text class="meta-text">孩子ID: {{ plan.childId }}</text>
+          <text class="meta-date">{{ formatDate(plan.createdAt) }}</text>
+        </view>
+        <view class="plan-desc" v-if="plan.description">{{ plan.description }}</view>
+        <view class="plan-actions" @click.stop>
+          <view class="action-btn outline" v-if="plan.status === 'draft'" @click="goToDetail(plan.id)">
+            <text>编辑</text>
+          </view>
+          <view class="action-btn outline" v-if="plan.status === 'published'" @click="goToBoard(plan.id)">
+            <text>执行看板</text>
+          </view>
+          <view class="action-btn danger" v-if="plan.status === 'draft'" @click="confirmDelete(plan.id)">
+            <text>删除</text>
+          </view>
+          <view class="action-btn primary" v-if="plan.status === 'draft'" @click="handleDeploy(plan.id)">
+            <text>下发</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="empty" v-else>
+      <text class="empty-icon">&#x1F3EB;</text>
+      <text class="empty-title">暂无训练方案</text>
+      <text class="empty-desc">为绑定家庭创建认知训练方案,或从推荐结果自动生成</text>
+      <view class="empty-btns">
+        <view class="btn-secondary" @click="showGenerateDialog">
+          <text class="btn-secondary-text">从推荐生成</text>
+        </view>
+        <view class="btn-primary" @click="goToCreate">
+          <text class="btn-primary-text">新建方案</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 生成方案弹窗 -->
+    <view class="modal-mask" v-if="showGenerate" @click="showGenerate = false">
+      <view class="modal-card" @click.stop>
+        <text class="modal-title">从推荐结果生成方案</text>
+        <text class="modal-desc">将根据该孩子最新的认知评估结果,自动生成针对性训练方案</text>
+        <view class="modal-child-list">
+          <view class="modal-child-item" v-for="child in bindedChildren" :key="child.id"
+            @click="generateFromRec(child.id)">
+            <text class="modal-child-name">{{ child.name || '孩子' + child.id }}</text>
+            <text class="modal-child-id">ID: {{ child.id }}</text>
+          </view>
+          <view class="empty-small" v-if="bindedChildren.length === 0">
+            <text class="empty-small-text">暂无可用孩子,请先绑定家庭</text>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <view class="btn-cancel" @click="showGenerate = false">
+            <text>取消</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 孩子选择器弹窗 -->
+    <view class="modal-mask" v-if="showPicker" @click="showPicker = false">
+      <view class="modal-card" @click.stop>
+        <text class="modal-title">选择孩子</text>
+        <view class="modal-child-list">
+          <view class="modal-child-item" v-for="child in bindedChildren" :key="child.id"
+            @click="selectChild(child.id, child.name)">
+            <text class="modal-child-name">{{ child.name || '孩子' + child.id }}</text>
+            <text class="modal-child-id">ID: {{ child.id }}</text>
+          </view>
+          <view class="modal-child-item" @click="selectChild(null, '全部')">
+            <text class="modal-child-name">全部</text>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <view class="btn-cancel" @click="showPicker = false">
+            <text>取消</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="loading" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getTrainingPlanList, generatePlanFromRecommendations, deployTrainingPlan, deleteTrainingPlan } from '../../../utils/api.js'
+import { getGuideBindedChildren } from '../../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      plans: [],
+      bindedChildren: [],
+      selectedChildId: null,
+      selectedChildName: null,
+      showGenerate: false,
+      showPicker: false,
+      loading: false
+    }
+  },
+  onShow() {
+    this.loadPlans()
+    this.loadBindedChildren()
+  },
+  methods: {
+    loadPlans() {
+      var self = this
+      self.loading = true
+      getTrainingPlanList(self.selectedChildId).then(function(res) {
+        self.loading = false
+        if (res.code === 200) {
+          self.plans = res.data || []
+        }
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    loadBindedChildren() {
+      var self = this
+      getGuideBindedChildren().then(function(res) {
+        if (res.code === 200) {
+          self.bindedChildren = res.data || []
+        }
+      }).catch(function() {})
+    },
+    goToDetail(planId) {
+      uni.navigateTo({ url: '/pages/guide/training-plans/detail?planId=' + planId })
+    },
+    goToBoard(planId) {
+      uni.navigateTo({ url: '/pages/guide/training-plans/detail?planId=' + planId + '&mode=board' })
+    },
+    goToCreate() {
+      uni.navigateTo({ url: '/pages/guide/training-plans/detail?planId=0' })
+    },
+    showChildPicker() {
+      this.showPicker = true
+    },
+    clearChildFilter() {
+      this.selectedChildId = null
+      this.selectedChildName = null
+      this.loadPlans()
+    },
+    selectChild(childId, childName) {
+      this.selectedChildId = childId
+      this.selectedChildName = childName
+      this.showPicker = false
+      this.loadPlans()
+    },
+    showGenerateDialog() {
+      this.showGenerate = true
+    },
+    generateFromRec(childId) {
+      var self = this
+      uni.showModal({
+        title: '确认生成',
+        content: '将根据该孩子的评估结果自动生成训练方案,是否继续?',
+        success: function(res) {
+          if (res.confirm) {
+            self.showGenerate = false
+            uni.showLoading({ title: '生成中...' })
+            generatePlanFromRecommendations(childId).then(function(res) {
+              uni.hideLoading()
+              if (res.code === 200 && res.data) {
+                uni.showToast({ title: '生成成功', icon: 'success' })
+                self.loadPlans()
+                setTimeout(function() {
+                  self.goToDetail(res.data)
+                }, 1000)
+              } else {
+                uni.showToast({ title: res.message || '生成失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.hideLoading()
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    handleDeploy(planId) {
+      var self = this
+      uni.showModal({
+        title: '确认下发',
+        content: '确定下发此方案?将一次性创建所有训练任务。',
+        success: function(res) {
+          if (res.confirm) {
+            uni.showLoading({ title: '下发中...' })
+            deployTrainingPlan(planId).then(function(res) {
+              uni.hideLoading()
+              if (res.code === 200) {
+                uni.showToast({ title: '下发成功', icon: 'success' })
+                self.loadPlans()
+              } else {
+                uni.showToast({ title: res.message || '下发失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.hideLoading()
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    confirmDelete(planId) {
+      var self = this
+      uni.showModal({
+        title: '确认删除',
+        content: '删除后无法恢复,是否确定删除?',
+        success: function(res) {
+          if (res.confirm) {
+            deleteTrainingPlan(planId).then(function(res) {
+              if (res.code === 200) {
+                uni.showToast({ title: '删除成功', icon: 'success' })
+                self.loadPlans()
+              } else {
+                uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    statusText(status) {
+      var map = { draft: '草稿', published: '已下发', completed: '已完成' }
+      return map[status] || status || '草稿'
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      var y = d.getFullYear()
+      var m = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      return y + '-' + m + '-' + day
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 40rpx;
+}
+
+.header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx;
+  background: #fff;
+}
+
+.title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.header-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  padding: 12rpx 28rpx;
+  border-radius: 32rpx;
+}
+
+.btn-primary:active { opacity: 0.8; }
+
+.btn-primary-text {
+  color: #fff;
+  font-size: 26rpx;
+  font-weight: 500;
+}
+
+.btn-secondary {
+  background: #f0f0f0;
+  padding: 12rpx 28rpx;
+  border-radius: 32rpx;
+}
+
+.btn-secondary:active { opacity: 0.8; }
+
+.btn-secondary-text {
+  color: #666;
+  font-size: 26rpx;
+}
+
+.child-filter-bar {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 30rpx;
+  background: #eef2ff;
+}
+
+.filter-label {
+  font-size: 26rpx;
+  color: #667eea;
+}
+
+.filter-clear {
+  padding: 4rpx 16rpx;
+  background: #fff;
+  border-radius: 16rpx;
+}
+
+.filter-clear-text {
+  font-size: 24rpx;
+  color: #999;
+}
+
+.plan-list {
+  padding: 20rpx 30rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.plan-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05);
+}
+
+.plan-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 12rpx;
+}
+
+.plan-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+}
+
+.plan-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 16rpx;
+  margin-left: 16rpx;
+}
+
+.status-draft {
+  background: #f3f4f6;
+  color: #666;
+}
+
+.status-published {
+  background: #dbeafe;
+  color: #2563eb;
+}
+
+.status-completed {
+  background: #d1fae5;
+  color: #059669;
+}
+
+.plan-meta {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+
+.meta-text, .meta-date {
+  font-size: 24rpx;
+  color: #999;
+}
+
+.plan-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.5;
+  margin-bottom: 16rpx;
+}
+
+.plan-actions {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 12rpx;
+  margin-top: 16rpx;
+  border-top: 1rpx solid #f0f0f0;
+  padding-top: 16rpx;
+}
+
+.action-btn {
+  padding: 10rpx 24rpx;
+  border-radius: 24rpx;
+  font-size: 24rpx;
+}
+
+.action-btn:active { opacity: 0.7; }
+
+.action-btn.outline {
+  background: #f9fafb;
+  color: #374151;
+  border: 1rpx solid #e5e7eb;
+}
+
+.action-btn.primary {
+  background: #667eea;
+  color: #fff;
+}
+
+.action-btn.danger {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 40rpx 80rpx;
+}
+
+.empty-icon {
+  font-size: 100rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: bold;
+  margin-bottom: 12rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  line-height: 1.6;
+  margin-bottom: 40rpx;
+}
+
+.empty-btns {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+
+.loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 80rpx;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+
+/* 弹窗 */
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 100;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.modal-card {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 40rpx;
+  width: 600rpx;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+.modal-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 12rpx;
+}
+
+.modal-desc {
+  font-size: 26rpx;
+  color: #888;
+  display: block;
+  margin-bottom: 24rpx;
+  line-height: 1.5;
+}
+
+.modal-child-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  margin-bottom: 24rpx;
+}
+
+.modal-child-item {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx;
+  background: #f9fafb;
+  border-radius: 12rpx;
+}
+
+.modal-child-item:active {
+  background: #eef2ff;
+}
+
+.modal-child-name {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+
+.modal-child-id {
+  font-size: 24rpx;
+  color: #999;
+}
+
+.empty-small {
+  padding: 24rpx 0;
+  text-align: center;
+}
+
+.empty-small-text {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.modal-footer {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+}
+
+.btn-cancel {
+  padding: 16rpx 48rpx;
+  background: #f3f4f6;
+  border-radius: 32rpx;
+}
+
+.btn-cancel:active { opacity: 0.7; }
+
+.btn-cancel text {
+  font-size: 28rpx;
+  color: #666;
+}
+</style>

+ 625 - 26
cfc-frontend/pages/wisdom-detail/cognitive-report.vue

@@ -4,6 +4,7 @@
       tagline="认知能力 · 全面评估"
       quote="知人者智,自知者明" />
 
+    <!-- ===== 报告卡片 ===== -->
     <view class="report-card" v-if="report">
       <view class="report-header">
         <text class="report-title">认知测评报告</text>
@@ -15,10 +16,15 @@
         <text class="overall-label">综合认知评分</text>
         <view class="overall-row">
           <text class="overall-value">{{ report.overallScore || 0 }}</text>
-          
         </view>
       </view>
 
+      <!-- 常模参考线说明 -->
+      <view class="norm-legend" v-if="norms">
+        <view class="norm-legend-item"><view class="norm-dot norm-dot-p50"></view>同龄中位</view>
+        <view class="norm-legend-item"><view class="norm-dot norm-dot-child"></view>当前得分</view>
+      </view>
+
       <!-- 六维条形图 -->
       <view class="dimension-list">
         <view class="dimension-item" v-for="dim in dimensions" :key="dim.key">
@@ -27,10 +33,16 @@
               <text class="dimension-icon">{{ dim.icon }}</text>
               <text class="dimension-name">{{ dim.label }}</text>
             </view>
-            <text class="dimension-score">{{ report[dim.key] || 0 }}</text>
+            <view class="dimension-score-wrap">
+              <text class="dimension-score">{{ report[dim.key] || 0 }}</text>
+              <text class="norm-level-tag" v-if="normLevel(dim.key)" :class="'nl-' + normLevel(dim.key)">{{ normLevel(dim.key) }}</text>
+            </view>
           </view>
           <view class="dimension-bar">
             <view class="dimension-bar-fill" :style="{ width: (report[dim.key] || 0) + '%' }"></view>
+            <view class="norm-marker" :style="{ left: normP50(dim.key) + '%' }">
+              <view class="norm-marker-line"></view>
+            </view>
           </view>
           <text class="dimension-desc">{{ dim.desc }}</text>
         </view>
@@ -65,6 +77,87 @@
       </view>
     </view>
 
+    <!-- ===== 家庭行动建议 ===== -->
+    <view class="action-plan-section" v-if="recommendations && recommendations.length > 0">
+      <view class="action-plan-header">
+        <text class="action-plan-icon">&#x1F4CB;</text>
+        <text class="action-plan-title">家庭行动建议</text>
+      </view>
+      <text class="action-plan-desc">基于本次测评结果,为你推荐以下训练方向</text>
+
+      <!-- 综合评分 -->
+      <view class="overall-suggestion" v-if="overall">
+        <text class="overall-suggestion-text">{{ overall.suggestion }}</text>
+      </view>
+
+      <!-- 各维度推荐 -->
+      <view class="rec-item" v-for="rec in recommendations" :key="rec.dimensionKey"
+        :class="'rec-level-' + (rec.priority || 'normal')">
+        <view class="rec-header">
+          <view class="rec-dim-row">
+            <text class="rec-icon">{{ rec.icon }}</text>
+            <text class="rec-label">{{ rec.label }}</text>
+            <text class="rec-score">{{ rec.score }}分</text>
+          </view>
+          <text class="rec-level-tag" :class="'tag-' + (rec.priority || 'normal')">{{ rec.level }}</text>
+        </view>
+        <text class="rec-suggestion">{{ rec.suggestion }}</text>
+        <view class="rec-actions" v-if="rec.tasks && rec.tasks.length > 0">
+          <view class="rec-tag-btn add-task-btn" @click="addTask(rec)">
+            <text class="rec-tag-btn-icon">&#x2795;</text>
+            <text class="rec-tag-btn-text">添加到今日任务</text>
+          </view>
+          <view class="rec-tag-btn game-btn" v-if="rec.games && rec.games.length > 0"
+            @click="goGame(rec.games[0].code)">
+            <text class="rec-tag-btn-text">去训练</text>
+          </view>
+          <view class="rec-tag-btn article-btn" v-if="rec.articles && rec.articles.length > 0"
+            @click="goArticle(rec.articles[0].id)">
+            <text class="rec-tag-btn-text">读文章</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 历史趋势 ===== -->
+    <view class="trend-section" v-if="trendData && trendData.length > 1">
+      <view class="trend-header">
+        <text class="trend-icon">&#x1F4C8;</text>
+        <text class="trend-title">历史趋势</text>
+      </view>
+
+      <view class="trend-overview-row">
+        <view class="trend-stat" v-for="stat in trendStats" :key="stat.key">
+          <text class="trend-stat-label">{{ stat.label }}</text>
+          <text class="trend-stat-value" :class="'ts-' + stat.direction">{{ stat.display }}</text>
+          <text class="trend-stat-sub" v-if="stat.pct != 0">{{ stat.pct > 0 ? '↑' : '↓' }}{{ Math.abs(stat.pct) }}%</text>
+        </view>
+      </view>
+
+      <!-- 最近两次对比 -->
+      <view class="trend-compare" v-if="trendData.length >= 2">
+        <view class="tc-header">
+          <text class="tc-title">前后两次对比</text>
+        </view>
+        <view class="tc-dim-row tc-header-row">
+          <text class="tc-dim-name">维度</text>
+          <text class="tc-old">上次</text>
+          <text class="tc-new">本次</text>
+          <text class="tc-change">变化</text>
+        </view>
+        <view class="tc-dim-row" v-for="dim in dimensions" :key="dim.key + 'trend'">
+          <text class="tc-dim-name">{{ dim.label }}</text>
+          <text class="tc-old">{{ prevScore(dim.key) }}</text>
+          <text class="tc-new" :class="scoreUp(dim.key) ? 'sc-up' : (scoreDown(dim.key) ? 'sc-down' : '')">{{ latestScore(dim.key) }}</text>
+          <text class="tc-change">
+            <text v-if="scoreUp(dim.key)" class="change-up">&#x25B2; +{{ scoreDiff(dim.key) }}</text>
+            <text v-else-if="scoreDown(dim.key)" class="change-down">&#x25BC; {{ scoreDiff(dim.key) }}</text>
+            <text v-else class="change-flat">—</text>
+          </text>
+        </view>
+      </view>
+    </view>
+
     <!-- 加载中 -->
     <view class="loading-state" v-if="loading">
       <text class="loading-text">加载中...</text>
@@ -74,7 +167,7 @@
     <view class="empty-state" v-if="!report && !loading">
       <text class="empty-icon">&#x1F9E0;</text>
       <text class="empty-title">暂无认知测评数据</text>
-      <text class="empty-desc">完成 DAN 认知评估后,可在此查看详细的六维认知分析报告</text>
+      <text class="empty-desc">完成 DAN 认知评估后,可在此查看详细的六维认知分析报告和家庭训练建议</text>
       <view class="empty-btn" @click="goAssessment">预约测评</view>
     </view>
   </view>
@@ -82,7 +175,7 @@
 
 <script>
 import PageBanner from '../../components/PageBanner.vue'
-import { getAssessmentLatestResult } from '../../utils/api.js'
+import { getAssessmentLatestResult, getCognitiveRecommendations, createTaskFromRecommendation, getCognitiveNorms, getCognitiveTrend } from '../../utils/api.js'
 
 export default {
   components: { PageBanner },
@@ -90,7 +183,12 @@ export default {
     return {
       childId: null,
       report: null,
+      recommendations: null,
+      overall: null,
+      norms: null,
+      trendData: null,
       loading: true,
+      addingTask: false,
       dimensions: [
         { key: 'perceptionScore', label: '感知能力', icon: '\u{1F441}', desc: '感官信息处理能力' },
         { key: 'focusScore', label: '专注力', icon: '\u{1F3AF}', desc: '持续集中和抗干扰能力' },
@@ -101,16 +199,30 @@ export default {
       ]
     }
   },
+  computed: {
+    trendStats: function() {
+      if (!this.trendData || this.trendData.length < 2) return []
+      var latest = this.trendData[0]
+      var prev = this.trendData[this.trendData.length - 1]
+      var diff = (latest.overallScore || 0) - (prev.overallScore || 0)
+      var pct = prev.overallScore ? Math.round(diff / prev.overallScore * 100) : 0
+      return [
+        { key: 'count', label: '测评次数', display: this.trendData.length + '次', direction: 'neutral', pct: 0 },
+        { key: 'trend', label: '综合趋势', display: diff > 0 ? '上升' : (diff < 0 ? '下降' : '持平'), direction: diff > 0 ? 'up' : (diff < 0 ? 'down' : 'flat'), pct: pct },
+        { key: 'latest', label: '最新综合', display: latest.overallScore || 0 + '分', direction: 'neutral', pct: 0 }
+      ]
+    }
+  },
   onLoad: function(options) {
     if (options && options.childId) {
       this.childId = options.childId
     } else {
       this.childId = uni.getStorageSync('currentChildId') || null
     }
-    this.loadReport()
+    this.loadData()
   },
   methods: {
-    loadReport: function() {
+    loadData: function() {
       var self = this
       var childId = self.childId
       if (!childId) {
@@ -118,20 +230,102 @@ export default {
         return
       }
       self.loading = true
+
+      // 并行加载报告和建议
       getAssessmentLatestResult(childId).then(function(res) {
         if (res.code === 200 && res.data) {
           self.report = res.data
         }
+      }).catch(function() {})
+
+      getCognitiveRecommendations(childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.recommendations = res.data.recommendations || []
+          self.overall = res.data.overall || null
+        }
+      }).catch(function() {})
+
+      getCognitiveNorms(childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.norms = res.data
+        }
+      }).catch(function() {})
+
+      getCognitiveTrend(childId, 5).then(function(res) {
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          self.trendData = res.data
+        }
+      }).catch(function() {})
+
+      // 都完成后关闭加载
+      setTimeout(function() {
         self.loading = false
+      }, 800)
+    },
+    addTask: function(rec) {
+      if (this.addingTask) return
+      this.addingTask = true
+      var self = this
+      createTaskFromRecommendation(self.childId, rec.dimensionKey, 0).then(function(res) {
+        self.addingTask = false
+        if (res.code === 200 && res.data) {
+          uni.showToast({ title: '已添加到今日任务', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '添加失败', icon: 'none' })
+        }
       }).catch(function() {
-        self.loading = false
+        self.addingTask = false
+        uni.showToast({ title: '网络异常,请重试', icon: 'none' })
       })
     },
+    goGame: function(code) {
+      var url = ''
+      if (code === 'schulte') {
+        url = '/pages/games/schulte'
+      } else if (code === '1a2b') {
+        url = '/pages/games/1a2b'
+      } else if (code === 'sudoku') {
+        url = '/pages/games/sudoku'
+      }
+      if (url) {
+        uni.navigateTo({ url: url })
+      }
+    },
+    goArticle: function(articleId) {
+      uni.navigateTo({ url: '/pages/mind/article-detail?id=' + articleId })
+    },
     goAssessment: function() {
       uni.navigateTo({ url: '/pages/assessment/apply' })
     },
     goTraining: function() {
-      uni.navigateTo({ url: '/pages/mind/training?childId=' + (this.childId || '') })
+      uni.navigateTo({ url: '/pages/wisdom-detail/training-hub?childId=' + (this.childId || '') })
+    },
+    normLevel: function(dimKey) {
+      if (!this.norms) return ''
+      var normKey = dimKey.replace('Score', '') + 'Norm'
+      var info = this.norms[normKey]
+      return info && info.level ? info.level : ''
+    },
+    normP50: function(dimKey) {
+      if (!this.norms) return 50
+      var normKey = dimKey.replace('Score', '') + 'Norm'
+      var info = this.norms[normKey]
+      return info && info.p50 ? info.p50 : 50
+    },
+    prevScore: function(dimKey) {
+      return this.trendData && this.trendData.length > 1 ? this.trendData[1][dimKey] || 0 : 0
+    },
+    latestScore: function(dimKey) {
+      return this.trendData && this.trendData.length > 0 ? this.trendData[0][dimKey] || 0 : 0
+    },
+    scoreDiff: function(dimKey) {
+      return this.latestScore(dimKey) - this.prevScore(dimKey)
+    },
+    scoreUp: function(dimKey) {
+      return this.scoreDiff(dimKey) > 0
+    },
+    scoreDown: function(dimKey) {
+      return this.scoreDiff(dimKey) < 0
     }
   }
 }
@@ -202,19 +396,7 @@ export default {
   margin-right: 20rpx;
 }
 
-.dan-badge {
-  background: rgba(255,255,255,0.25);
-  border-radius: 12rpx;
-  padding: 6rpx 20rpx;
-}
-
-.dan-level {
-  font-size: 28rpx;
-  font-weight: bold;
-  color: #FFD700;
-}
-
-/* ===== 七维条形图 ===== */
+/* ===== 六维条形图 ===== */
 .dimension-list {
   margin-bottom: 30rpx;
 }
@@ -254,12 +436,82 @@ export default {
   color: #667eea;
 }
 
+.dimension-desc {
+  font-size: 22rpx;
+  color: #999;
+  line-height: 1.4;
+}
+
+/* ===== 常模参考线 ===== */
+.norm-legend {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 24rpx;
+  margin-bottom: 20rpx;
+  padding: 12rpx 16rpx;
+  background: #f8f9ff;
+  border-radius: 12rpx;
+}
+
+.norm-legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  font-size: 22rpx;
+  color: #888;
+}
+
+.norm-dot {
+  width: 16rpx;
+  height: 16rpx;
+  border-radius: 50%;
+  margin-right: 8rpx;
+}
+
+.norm-dot-p50 {
+  background: #FF9500;
+}
+
+.norm-dot-child {
+  background: #667eea;
+}
+
+.dimension-score-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.norm-level-tag {
+  font-size: 20rpx;
+  padding: 2rpx 12rpx;
+  border-radius: 16rpx;
+  font-weight: 500;
+}
+
+.nl-较弱, .nl-偏弱 {
+  background: #FEF2F2;
+  color: #EF4444;
+}
+
+.nl-中等 {
+  background: #FFFBEB;
+  color: #F59E0B;
+}
+
+.nl-良好, .nl-优秀 {
+  background: #F0FDF4;
+  color: #22C55E;
+}
+
 .dimension-bar {
   height: 16rpx;
   background: #f0f0f0;
   border-radius: 8rpx;
-  overflow: hidden;
   margin-bottom: 8rpx;
+  position: relative;
 }
 
 .dimension-bar-fill {
@@ -269,10 +521,20 @@ export default {
   transition: width 0.5s;
 }
 
-.dimension-desc {
-  font-size: 22rpx;
-  color: #999;
-  line-height: 1.4;
+.norm-marker {
+  position: absolute;
+  top: -4rpx;
+  width: 2rpx;
+  height: 24rpx;
+  z-index: 2;
+  transform: translateX(-50%);
+}
+
+.norm-marker-line {
+  width: 4rpx;
+  height: 24rpx;
+  background: #FF9500;
+  border-radius: 2rpx;
 }
 
 /* ===== 分析/建议 ===== */
@@ -354,6 +616,178 @@ export default {
   color: #666;
 }
 
+/* ===== 家庭行动建议 ===== */
+.action-plan-section {
+  margin: 30rpx 30rpx 40rpx;
+}
+
+.action-plan-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.action-plan-icon {
+  font-size: 36rpx;
+  margin-right: 10rpx;
+}
+
+.action-plan-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.action-plan-desc {
+  font-size: 26rpx;
+  color: #888;
+  margin-bottom: 20rpx;
+  display: block;
+}
+
+.overall-suggestion {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 16rpx;
+  padding: 24rpx 28rpx;
+  margin-bottom: 24rpx;
+}
+
+.overall-suggestion-text {
+  font-size: 26rpx;
+  color: #fff;
+  line-height: 1.6;
+  display: block;
+}
+
+/* 各维度推荐卡片 */
+.rec-item {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05);
+  border-left: 8rpx solid #667eea;
+}
+
+.rec-item.rec-level-high {
+  border-left-color: #EF4444;
+}
+
+.rec-item.rec-level-normal {
+  border-left-color: #F59E0B;
+}
+
+.rec-item.rec-level-low {
+  border-left-color: #22C55E;
+}
+
+.rec-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+
+.rec-dim-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.rec-icon {
+  font-size: 28rpx;
+  margin-right: 8rpx;
+}
+
+.rec-label {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-right: 12rpx;
+}
+
+.rec-score {
+  font-size: 24rpx;
+  color: #888;
+}
+
+.rec-level-tag {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  font-weight: 500;
+}
+
+.tag-high {
+  background: #FEF2F2;
+  color: #EF4444;
+}
+
+.tag-normal {
+  background: #FFFBEB;
+  color: #F59E0B;
+}
+
+.tag-low {
+  background: #F0FDF4;
+  color: #22C55E;
+}
+
+.rec-suggestion {
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+  display: block;
+  margin-bottom: 16rpx;
+}
+
+.rec-actions {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+
+.rec-tag-btn {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 12rpx 24rpx;
+  border-radius: 32rpx;
+  font-size: 24rpx;
+}
+
+.rec-tag-btn:active {
+  opacity: 0.7;
+}
+
+.add-task-btn {
+  background: #EEF2FF;
+  color: #667eea;
+}
+
+.game-btn {
+  background: #FEF3C7;
+  color: #D97706;
+}
+
+.article-btn {
+  background: #F0FDF4;
+  color: #16A34A;
+}
+
+.rec-tag-btn-icon {
+  font-size: 22rpx;
+  margin-right: 6rpx;
+}
+
+.rec-tag-btn-text {
+  font-size: 24rpx;
+  font-weight: 500;
+}
+
 /* ===== 加载中 ===== */
 .loading-state {
   display: flex;
@@ -404,4 +838,169 @@ export default {
   border-radius: 40rpx;
   box-shadow: 0 4rpx 16rpx rgba(102,126,234,0.3);
 }
+
+/* ===== 历史趋势 ===== */
+.trend-section {
+  margin: 30rpx 30rpx 40rpx;
+}
+
+.trend-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.trend-icon {
+  font-size: 36rpx;
+  margin-right: 10rpx;
+}
+
+.trend-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.trend-overview-row {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  margin-bottom: 20rpx;
+}
+
+.trend-stat {
+  flex: 1;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  text-align: center;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
+}
+
+.trend-stat-label {
+  font-size: 24rpx;
+  color: #888;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.trend-stat-value {
+  font-size: 32rpx;
+  font-weight: bold;
+  display: block;
+}
+
+.ts-up {
+  color: #22C55E;
+}
+
+.ts-down {
+  color: #EF4444;
+}
+
+.ts-flat {
+  color: #F59E0B;
+}
+
+.ts-neutral {
+  color: #333;
+}
+
+.trend-stat-sub {
+  font-size: 22rpx;
+  margin-top: 4rpx;
+  display: block;
+}
+
+.ts-up + .trend-stat-sub {
+  color: #22C55E;
+}
+
+.ts-down + .trend-stat-sub {
+  color: #EF4444;
+}
+
+.trend-compare {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
+}
+
+.tc-header {
+  margin-bottom: 16rpx;
+  padding-bottom: 12rpx;
+  border-bottom: 2rpx solid #f0f0f0;
+}
+
+.tc-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.tc-dim-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+
+.tc-header-row {
+  padding-bottom: 10rpx;
+  border-bottom: 2rpx solid #e8e8e8;
+}
+
+.tc-dim-row:last-child {
+  border-bottom: none;
+}
+
+.tc-dim-name {
+  flex: 2;
+  font-size: 26rpx;
+  color: #333;
+}
+
+.tc-old {
+  flex: 1;
+  font-size: 24rpx;
+  color: #999;
+  text-align: center;
+}
+
+.tc-new {
+  flex: 1;
+  font-size: 26rpx;
+  font-weight: bold;
+  text-align: center;
+  color: #333;
+}
+
+.sc-up {
+  color: #22C55E;
+}
+
+.sc-down {
+  color: #EF4444;
+}
+
+.tc-change {
+  flex: 1.5;
+  text-align: right;
+  font-size: 24rpx;
+}
+
+.change-up {
+  color: #22C55E;
+}
+
+.change-down {
+  color: #EF4444;
+}
+
+.change-flat {
+  color: #999;
+}
 </style>

+ 57 - 0
cfc-frontend/utils/api.js

@@ -442,6 +442,10 @@ export const getBoundFamilies = () => {
   return request('/api/guide/families', 'POST')
 }
 
+export const getGuideBindedChildren = () => {
+  return request('/api/guide/families', 'POST')
+}
+
 // 确认绑定家庭
 export const confirmBind = (data) => {
   return request('/api/guide/bind/confirm', 'POST', data)
@@ -589,6 +593,52 @@ export const getParentGuide = () => {
   return request('/api/guide/parent-guide', 'GET')
 }
 
+// ===== 训练方案管理 =====
+export const getTrainingPlanList = (childId) => {
+  return request('/api/guide/training-plans/list', 'POST',
+    childId ? { childId } : {})
+}
+
+export const getTrainingPlanDetail = (planId) => {
+  return request('/api/guide/training-plans/detail', 'POST', { planId })
+}
+
+export const createTrainingPlan = (childId, title, description) => {
+  return request('/api/guide/training-plans/create', 'POST', { childId, title, description })
+}
+
+export const updateTrainingPlan = (planId, title, description) => {
+  return request('/api/guide/training-plans/update', 'POST', { planId, title, description })
+}
+
+export const addTrainingPlanItem = (item) => {
+  return request('/api/guide/training-plans/items/add', 'POST', item)
+}
+
+export const updateTrainingPlanItem = (item) => {
+  return request('/api/guide/training-plans/items/update', 'POST', item)
+}
+
+export const deleteTrainingPlanItem = (itemId) => {
+  return request('/api/guide/training-plans/items/delete', 'POST', { itemId })
+}
+
+export const generatePlanFromRecommendations = (childId) => {
+  return request('/api/guide/training-plans/generate-from-recommendations', 'POST', { childId })
+}
+
+export const deployTrainingPlan = (planId) => {
+  return request('/api/guide/training-plans/deploy', 'POST', { planId })
+}
+
+export const getExecutionBoard = (planId) => {
+  return request('/api/guide/training-plans/execution-board', 'POST', { planId })
+}
+
+export const deleteTrainingPlan = (planId) => {
+  return request('/api/guide/training-plans/delete', 'POST', { planId })
+}
+
 // 添加成长规划师套餐
 export const addGuidePackage = (guideId, packageId, price, commissionRate) => {
   return request('/api/guide/package/add', 'POST', { guideId, packageId, price, commissionRate })
@@ -1174,6 +1224,13 @@ export const getEmiReport = (childId) => request('/api/emireport/latest', 'POST'
 export const getAssessmentLatestResult = (childId) => request('/api/assessment/latest-result', 'POST', { childId })
 export const getAssessmentHistory = (childId, size) => request('/api/assessment/history', 'POST', { childId, size: size || 5 })
 
+// ===== 认知训练建议(家庭行动清单) =====
+export const getCognitiveRecommendations = (childId) => request('/api/cognitive/recommendations', 'POST', { childId })
+export const createTaskFromRecommendation = (childId, dimensionKey, taskIndex) =>
+  request('/api/cognitive/recommendations/create-task', 'POST', { childId, dimensionKey, taskIndex })
+export const getCognitiveNorms = (childId) => request('/api/cognitive/norms', 'POST', { childId })
+export const getCognitiveTrend = (childId, limit) => request('/api/cognitive/trend', 'POST', { childId, limit: limit || 5 })
+
 // ===== 五维能量系统(账本模式) =====
 export const getEnergyOverview = (childId) => {
   return request('/api/energy/overview', 'POST', { childId })