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

Merge branch 'refs/heads/cfclub-jiapu' into cfclub

jiapu 2 месяцев назад
Родитель
Сommit
9487c78bcd
40 измененных файлов с 1679 добавлено и 670 удалено
  1. 1 1
      cfc-backend/config/application-prod.yml
  2. 363 356
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  4. 27 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  5. 33 44
      cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java
  6. 27 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  7. 34 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java
  8. 68 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java
  9. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  10. 9 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java
  11. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java
  12. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  13. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  14. 11 4
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  15. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java
  16. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java
  17. 209 172
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  18. 31 8
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskStatsService.java
  19. 8 5
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  20. 203 10
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  21. 1 1
      cfc-backend/src/main/resources/application.yml
  22. 19 1
      cfc-backend/src/main/resources/schema.sql
  23. 2 1
      cfc-backend/startup.sh
  24. 2 1
      cfc-frontend/config.js
  25. 17 0
      cfc-frontend/pages.json
  26. 32 0
      cfc-frontend/pages/health-main/index.vue
  27. 287 1
      cfc-frontend/pages/health/health-plan-summary.vue
  28. 18 14
      cfc-frontend/pages/health/report-confirm.vue
  29. 63 6
      cfc-frontend/pages/health/report-detail.vue
  30. 3 3
      cfc-frontend/pages/login/login.vue
  31. 9 9
      cfc-frontend/pages/profile-main/profile.vue
  32. 49 2
      cfc-frontend/pages/stats/completion-rate.vue
  33. 21 3
      cfc-frontend/pages/tasks/tasks.vue
  34. 9 4
      cfc-frontend/utils/api.js
  35. 16 0
      cfc-web/src/api/admin.js
  36. 7 1
      cfc-web/src/api/assessment.js
  37. 5 4
      cfc-web/src/views/GuideFamilyTask.vue
  38. 55 2
      cfc-web/src/views/Tasks.vue
  39. 2 2
      cfc-web/src/views/admin/ReportCollectorTrainer.vue
  40. 2 2
      cfc-web/src/views/admin/ReportTemplateManage.vue

+ 1 - 1
cfc-backend/config/application-prod.yml

@@ -29,5 +29,5 @@ admin:
   skip-captcha: false
 
 wechat:
-  test-mode: false
+  test-mode: true
   appkey: ${WECHAT_APPKEY:}

Разница между файлами не показана из-за своего большого размера
+ 363 - 356
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java


+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.service.HealthPlanService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -122,9 +123,25 @@ public class HealthPlanController {
         if (planId == null) return Result.error("planId不能为空");
         HealthPlan plan = healthPlanService.getPlanDetail(planId);
         if (plan == null) return Result.error("方案不存在");
+        plan.setTasks(healthPlanService.getPlanTasks(planId));
         return Result.success(plan);
     }
 
+    @Operation(summary = "查询方案分解任务列表")
+    @PostMapping("/tasks")
+    public Result<List<Task>> planTasks(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        HealthPlan plan = healthPlanService.getPlanDetail(planId);
+        if (plan == null) return Result.error("方案不存在");
+        if (familyId != null && plan.getFamilyId() != null && !familyId.equals(plan.getFamilyId())) {
+            return Result.error("无权查看该方案任务");
+        }
+        List<Task> tasks = healthPlanService.getPlanTasks(planId);
+        return Result.success(tasks);
+    }
+
     @Operation(summary = "获取可用规划师列表")
     @PostMapping("/available-teachers")
     public Result<List<Map<String, Object>>> availableTeachers(

+ 27 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -159,6 +159,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.PurchaseService purchaseService;
 
+    @Resource
+    private com.etotem.cfc.service.HealthPlanService healthPlanService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -876,7 +879,7 @@ public class HealthReportController {
             return Result.success(result);
         }
 
-        if (file.isEmpty()) {
+        if (file == null || file.isEmpty()) {
             return Result.error("文件不能为空");
         }
 
@@ -1150,6 +1153,9 @@ public class HealthReportController {
             if (subjectId != null) {
                 detail.put("subjectId", subjectId);
             }
+            detail.put("reportId", created.getId());
+            detail.put("reportType", created.getReportType());
+            triggerAutoPlanAfterConfirm(detail, draft.getFamilyId(), subjectId, created.getReportType());
             return Result.success(detail);
 
         } catch (Exception e) {
@@ -1158,6 +1164,26 @@ public class HealthReportController {
         }
     }
 
+    /** 报告确认入库后自动生成并发布健康方案(舌诊等不适用类型跳过) */
+    private void triggerAutoPlanAfterConfirm(Map<String, Object> detail, Long familyId,
+                                             Long subjectId, String reportType) {
+        if (familyId == null || subjectId == null || !shouldAutoGeneratePlan(reportType)) {
+            return;
+        }
+        try {
+            Long planId = healthPlanService.autoGenerateAndPublish(familyId, subjectId, reportType);
+            if (planId != null) {
+                detail.put("planId", planId);
+            }
+        } catch (Exception e) {
+            log.warn("确认入库后自动生成方案失败 reportType={}: {}", reportType, e.getMessage());
+        }
+    }
+
+    private boolean shouldAutoGeneratePlan(String reportType) {
+        return reportType != null && !"tongue".equals(reportType);
+    }
+
     /**
      * 放弃草稿
      */

+ 33 - 44
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -207,21 +207,25 @@ public class UserController {
         Long familyId = user.getFamilyId();
         Map<String, Object> stats = new HashMap<>();
 
-        // 1. 总积分
-        Integer totalPoints = user.getTotalPoints() != null ? user.getTotalPoints() : 0;
+        List<FamilyMember> members = familyId != null
+                ? familyMemberMapper.selectList(new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getFamilyId, familyId))
+                : java.util.Collections.emptyList();
+
+        // 1. 总积分(任务积分记在 family_members,非 users.total_points)
+        int totalPoints = 0;
+        for (FamilyMember m : members) {
+            if (m.getTotalPoints() != null) {
+                totalPoints += m.getTotalPoints();
+            }
+        }
         stats.put("totalPoints", totalPoints);
 
-        // 2. 连续打卡天数(取所有孩子中最高值)
+        // 2. 连续打卡天数(取家庭成员中最高值)
         Integer maxStreakDays = 0;
-        if (familyId != null) {
-            List<FamilyMember> members = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
-            );
-            for (FamilyMember m : members) {
-                if (m.getStreakDays() != null && m.getStreakDays() > maxStreakDays) {
-                    maxStreakDays = m.getStreakDays();
-                }
+        for (FamilyMember m : members) {
+            if (m.getStreakDays() != null && m.getStreakDays() > maxStreakDays) {
+                maxStreakDays = m.getStreakDays();
             }
         }
         stats.put("streakDays", maxStreakDays);
@@ -240,11 +244,7 @@ public class UserController {
         // 4. 心愿兑换次数(按家庭成员统计)
         Long rewardsEarned = 0L;
         if (familyId != null) {
-            List<FamilyMember> exchangeMembers = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
-            );
-            for (FamilyMember m : exchangeMembers) {
+            for (FamilyMember m : members) {
                 Long cnt = pointsExchangeRecordMapper.selectCount(
                     new LambdaQueryWrapper<com.etotem.cfc.entity.PointsExchangeRecord>()
                         .eq(com.etotem.cfc.entity.PointsExchangeRecord::getFamilyMemberId, m.getId())
@@ -256,21 +256,15 @@ public class UserController {
 
         // 5. 收集勋章数(所有家庭成员)
         Integer badgesCollected = 0;
-        if (familyId != null) {
-            List<FamilyMember> members = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
-            );
-            for (FamilyMember m : members) {
-                try {
-                    Long cnt = childBadgeMapper.selectCount(
-                        new LambdaQueryWrapper<com.etotem.cfc.entity.ChildBadge>()
-                            .eq(com.etotem.cfc.entity.ChildBadge::getChildId, m.getId())
-                    );
-                    badgesCollected += cnt != null ? cnt.intValue() : 0;
-                } catch (Exception e) {
-                    log.warn("获取勋章数失败: memberId={}", m.getId(), e);
-                }
+        for (FamilyMember m : members) {
+            try {
+                Long cnt = childBadgeMapper.selectCount(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.ChildBadge>()
+                        .eq(com.etotem.cfc.entity.ChildBadge::getChildId, m.getId())
+                );
+                badgesCollected += cnt != null ? cnt.intValue() : 0;
+            } catch (Exception e) {
+                log.warn("获取勋章数失败: memberId={}", m.getId(), e);
             }
         }
         stats.put("badgesCollected", badgesCollected);
@@ -284,27 +278,22 @@ public class UserController {
 
         // 7. 参加挑战数(按家庭成员childId统计)
         Integer challengesJoined = 0;
-        if (familyId != null) {
-            List<FamilyMember> challengeMembers = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
+        for (FamilyMember m : members) {
+            Long cnt = familyChallengeProgressMapper.selectCount(
+                new LambdaQueryWrapper<com.etotem.cfc.entity.FamilyChallengeProgress>()
+                    .eq(com.etotem.cfc.entity.FamilyChallengeProgress::getChildId, m.getId())
             );
-            for (FamilyMember m : challengeMembers) {
-                Long cnt = familyChallengeProgressMapper.selectCount(
-                    new LambdaQueryWrapper<com.etotem.cfc.entity.FamilyChallengeProgress>()
-                        .eq(com.etotem.cfc.entity.FamilyChallengeProgress::getChildId, m.getId())
-                );
-                challengesJoined += cnt != null ? cnt.intValue() : 0;
-            }
+            challengesJoined += cnt != null ? cnt.intValue() : 0;
         }
         stats.put("challengesJoined", challengesJoined);
 
-        // 8. 上传健康报告数
+        // 8. 上传健康报告数(与 /api/health/report/list 一致,仅统计 active)
         Long healthReports = 0L;
         if (familyId != null) {
             healthReports = healthReportMapper.selectCount(
                 new LambdaQueryWrapper<com.etotem.cfc.entity.HealthReport>()
                     .eq(com.etotem.cfc.entity.HealthReport::getFamilyId, familyId)
+                    .eq(com.etotem.cfc.entity.HealthReport::getStatus, "active")
             );
         }
         stats.put("healthReports", healthReports);

+ 27 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -6,7 +6,11 @@ import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.dto.TaskReviewDTO;
+import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.UserService;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
 import javax.annotation.Resource;
 
 import org.springframework.util.DigestUtils;
@@ -58,6 +62,9 @@ public class AdminController {
     @Resource
     private UserService userService;
 
+    @Resource
+    private TaskService taskService;
+
     @Resource
     private GuidePackageMapper guidePackageMapper;
 
@@ -285,8 +292,8 @@ public class AdminController {
         // 为每条任务填充执行者名称
         java.util.List<Map<String, Object>> records = new java.util.ArrayList<>();
         for (Task t : result.getRecords()) {
-            Map<String, Object> item = new java.util.HashMap<>();
-            org.springframework.beans.BeanUtils.copyProperties(t, item);
+            Map<String, Object> item = JSON.parseObject(
+                    JSON.toJSONString(t), new TypeReference<Map<String, Object>>() {});
             // 根据 executorType 查昵称
             String executorType = t.getExecutorType();
             Long executorId = t.getExecutorId();
@@ -324,6 +331,24 @@ public class AdminController {
         return Result.success(stats);
     }
 
+    @Operation(summary = "获取待审核打卡任务")
+    @PostMapping("/tasks/pending-review")
+    public Result<List<Task>> getPendingReviewTasks(@RequestBody(required = false) Map<String, Object> params) {
+        Long familyId = params != null && params.get("familyId") != null
+                ? ParamUtils.getLong(params.get("familyId")) : null;
+        return Result.success(taskService.getAllPendingReviewTasks(familyId));
+    }
+
+    @Operation(summary = "审核打卡任务")
+    @PostMapping("/tasks/{id}/review")
+    public Result<Boolean> reviewTask(@PathVariable Long id, @RequestBody TaskReviewDTO dto) {
+        boolean success = taskService.reviewTask(id, dto);
+        if (!success) {
+            return Result.error("审核失败,任务可能不在待审核状态");
+        }
+        return Result.success(true);
+    }
+
     // 奖励管理
     @PostMapping("/rewards")
     public Result<Page<Reward>> getRewards(@RequestBody Map<String, Object> params) {

+ 34 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java

@@ -11,6 +11,8 @@ import com.etotem.cfc.service.FileStorageService;
 import com.etotem.cfc.service.GrowthGuidanceService;
 import com.etotem.cfc.service.ReportParseService;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.service.TaskPlanInstanceService;
+import com.etotem.cfc.entity.TaskPlanInstance;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
@@ -52,6 +54,32 @@ public class AssessmentReportController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private TaskPlanInstanceService taskPlanInstanceService;
+
+    @Operation(summary = "获取用户已生成的测评方案列表")
+    @PostMapping("/plans/list")
+    public Result<List<TaskPlanInstance>> getMyPlans(
+            @RequestParam("familyId") Long familyId,
+            @RequestParam(value = "childId", required = false) Long childId) {
+        try {
+            // 如果传了 childId,只查该孩子的方案;否则查整个家庭的方案
+            List<TaskPlanInstance> plans;
+            if (childId != null) {
+                plans = taskPlanInstanceService.getListByChildAndFamily(childId, familyId);
+            } else {
+                plans = taskPlanInstanceService.getListByFamily(familyId);
+            }
+            
+            // 按创建时间倒序排列
+            plans.sort((a, b) -> b.getCreatedAt().after(a.getCreatedAt()) ? 1 : -1);
+            return Result.success(plans);
+        } catch (Exception e) {
+            log.error("获取测评方案失败: familyId={}, childId={}", familyId, childId, e);
+            return Result.error("获取方案失败:" + e.getMessage());
+        }
+    }
+
     @Operation(summary = "家长上传测评报告")
     @PostMapping("/upload")
     public Result<DanAssessmentResult> uploadReport(
@@ -121,8 +149,8 @@ public class AssessmentReportController {
         return Result.success(list);
     }
 
-    @Operation(summary = "规划师确认家长上传报告")
-    @PostMapping("/{resultId}/confirm-upload")
+    @Operation(summary = "用户确认报告入库并自动生成方案")
+    @PostMapping("/{resultId}/confirm-inbox")
     public Result<DanAssessmentResult> confirmUpload(
             @PathVariable Long resultId,
             @RequestBody Map<String, Object> body) {
@@ -140,16 +168,16 @@ public class AssessmentReportController {
         }
         resultMapper.updateById(result);
 
-        // 自动生成测评方案
+        // 用户确认后立即自动生成测评方案(无需规划师审核)
         try {
             User child = userMapper.selectById(result.getChildId());
             Long familyId = child != null ? child.getFamilyId() : null;
             if (familyId != null) {
-                planGenerator.generate(result, familyId, result.getChildId());
-                log.info("已为测评结果{}自动生成方案", resultId);
+                TaskPlanInstance plan = planGenerator.generate(result, familyId, result.getChildId());
+                log.info("已为测评结果{}自动生成方案 planId={}", resultId, plan.getId());
             }
         } catch (Exception e) {
-            log.error("自动生成方案失败: resultId={}", resultId, e);
+            log.error("自动生成方案失败:resultId={}", resultId, e);
         }
 
         return Result.success(result);

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java

@@ -11,6 +11,10 @@ import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.GrowthPlanMapper;
 
 @Tag(name = "成长方案", description = "成长方案创建、更新、复盘等接口")
 @RestController
@@ -20,6 +24,12 @@ public class GrowthPlanController {
     @Resource
     private GrowthPlanService growthPlanService;
 
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private GrowthPlanMapper growthPlanMapper;
+
     @Operation(summary = "创建成长方案")
     @PostMapping("/create")
     public Result<GrowthPlan> createPlan(@RequestBody GrowthPlan plan,
@@ -92,4 +102,62 @@ public class GrowthPlanController {
         }
         return Result.success(true);
     }
+
+    @Operation(summary = "分页查询所有成长计划(管理端)")
+    @PostMapping("/list")
+    public Result<IPage<Map<String, Object>>> listPlans(@RequestBody Map<String, Object> params,
+                                                          @RequestAttribute("userId") Long userId,
+                                                          @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 10;
+        String keyword = params.get("keyword") != null ? (String) params.get("keyword") : null;
+
+        IPage<GrowthPlan> pageParam = new Page<>(page, size);
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<GrowthPlan> wrapper = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.orderByDesc(GrowthPlan::getCreatedAt);
+
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.and(w -> w.like(GrowthPlan::getPlanTitle, keyword)
+                    .or()
+                    .like(GrowthPlan::getPlanContent, keyword));
+        }
+
+        IPage<GrowthPlan> result = growthPlanMapper.selectPage(pageParam, wrapper);
+
+        // 关联 childName 字段(假名,不在数据库)
+        List<Map<String, Object>> withChildName = result.getRecords().stream().map(plan -> {
+            Map<String, Object> map = new java.util.HashMap<>();
+            map.put("id", plan.getId());
+            map.put("childId", plan.getChildId());
+            map.put("planTitle", plan.getPlanTitle());
+            map.put("planContent", plan.getPlanContent());
+            map.put("durationMonths", plan.getDurationMonths());
+            map.put("status", plan.getStatus());
+            map.put("startDate", plan.getStartDate());
+            map.put("endDate", plan.getEndDate());
+            map.put("teacherId", plan.getTeacherId());
+            map.put("createdAt", plan.getCreatedAt());
+            map.put("updatedAt", plan.getUpdatedAt());
+
+            // 查询孩子昵称
+            List<com.etotem.cfc.entity.User> users = userMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.User>()
+                    .eq(com.etotem.cfc.entity.User::getId, plan.getChildId()));
+            String childName = users.stream()
+                .map(u -> u.getNickname())
+                .findFirst()
+                .orElse("未知孩子");
+            map.put("childName", childName);
+
+            return map;
+        }).collect(java.util.stream.Collectors.toList());
+
+        IPage<Map<String, Object>> resultMap = new Page<>(page, size, result.getTotal());
+        resultMap.setRecords(withChildName);
+
+        return Result.success(resultMap);
+    }
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -454,7 +454,7 @@ public class GuideFamilyTaskController {
                         new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Task>()
                             .eq(Task::getFamilyMemberId, child.getId())
                             .eq(Task::getNeedReview, 1)
-                            .eq(Task::getStatus, "completed"));
+                            .eq(Task::getStatus, "pending_review"));
                     pendingReview += pending;
                 }
             }

+ 9 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java

@@ -41,12 +41,19 @@ public class PlanReviewController {
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
                                                            @RequestAttribute("userId") Long userId,
                                                            @RequestAttribute("role") String role,
-                                                           @RequestAttribute(value = "familyId", required = false) Long familyId) {
+                                                           @RequestAttribute(value = "familyId", required = false) Long jwtFamilyId) {
         if (!"teacher".equals(role) && !"admin".equals(role)) {
             return Result.error("无权限");
         }
         String status = (String) body.get("status");
-        if (familyId == null) return Result.noFamily("请先创建或加入家庭");
+        // 优先用 body 中的 familyId,JWT 作为默认值
+        Long familyId = ParamUtils.getLong(body.get("familyId"));
+        if (familyId == null) {
+            familyId = jwtFamilyId;
+        }
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         LambdaQueryWrapper<TaskPlanInstance> w = new LambdaQueryWrapper<TaskPlanInstance>()
                 .eq(TaskPlanInstance::getFamilyId, familyId)
                 .orderByDesc(TaskPlanInstance::getCreatedAt);

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import java.io.Serializable;
 import java.util.Date;
+import java.util.List;
 
 @Data
 @TableName("health_plans")
@@ -27,4 +28,8 @@ public class HealthPlan implements Serializable {
     @TableField("teacher_id") private Long teacherId;
     @TableField("created_at") private Date createdAt;
     @TableField("updated_at") private Date updatedAt;
+
+    /** 方案分解任务(非数据库字段,detail 接口填充) */
+    @TableField(exist = false)
+    private List<Task> tasks;
 }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import java.util.List;
 import java.util.Map;
 
@@ -21,4 +22,13 @@ public interface HealthPlanService {
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     List<Map<String, Object>> getAvailableTeachers(Long familyId);
+
+    /**
+     * 报告确认入库后自动生成并发布健康方案(无需规划师审核)
+     * @return 已发布方案 ID,生成失败时返回 null
+     */
+    Long autoGenerateAndPublish(Long familyId, Long memberId, String reportType);
+
+    /** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
+    List<Task> getPlanTasks(Long planId);
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -19,7 +19,7 @@ import java.util.Date;
 public class HealthReportDraftService {
 
     /** 草稿有效期(毫秒) */
-    public static final long DRAFT_TTL_MS = 24L * 60 * 60 * 1000;
+    public static final long DRAFT_TTL_MS = 72L * 60 * 60 * 1000;
 
     @Resource
     private HealthReportDraftMapper draftMapper;

+ 11 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -22,6 +22,7 @@ import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -48,6 +49,9 @@ public class HealthReportService {
 
     @Resource
     private HealthReportMapper healthReportMapper;
+    
+    @Resource
+    protected JdbcTemplate jdbcTemplate;
 
     @Resource
     private HealthIndicatorMapper healthIndicatorMapper;
@@ -1118,10 +1122,13 @@ public class HealthReportService {
     }
 
     public void bindReportToMember(Long reportId, Long subjectId) {
-        HealthReport report = healthReportMapper.selectById(reportId);
-        if (report != null) {
-            report.setSubjectId(subjectId);
-            healthReportMapper.updateById(report);
+        // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
+        String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
+        int updatedRows = jdbcTemplate.update(sql, subjectId, reportId);
+        if (updatedRows == 0) {
+            log.warn("reportId={}, subjectId={} 更新失败 - 报告不存在", reportId, subjectId);
+        } else {
+            log.info("已绑定报告 {} 到被检测人 {}", reportId, subjectId);
         }
     }
 

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java

@@ -53,6 +53,7 @@ public class PlanActivationService {
         Task task = new Task();
         task.setFamilyId(plan.getFamilyId());
         task.setChildId(plan.getChildId());
+        task.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
         task.setCreatorId(operatorId);
         task.setExecutorType("child");
         task.setExecutorId(plan.getChildId());

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java

@@ -39,6 +39,7 @@ public class PointsService implements PointsServiceInterface {
 
         Map<String, Object> result = new HashMap<>();
         result.put("balance", child.getTotalPoints());
+        result.put("totalPoints", child.getTotalPoints());
         // 下个月1日0点
         java.util.Calendar cal = java.util.Calendar.getInstance();
         cal.set(java.util.Calendar.DAY_OF_MONTH, 1);

+ 209 - 172
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -321,7 +321,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             .eq(Task::getFamilyMemberId, memberId)
             .ge(Task::getDeadline, startOfDay)
             .lt(Task::getDeadline, endOfDay)
-            .in(Task::getStatus, Arrays.asList("pending", "completed", "locked"))
+            .in(Task::getStatus, Arrays.asList("pending", "completed", "locked", "pending_review"))
             .orderByAsc(Task::getDeadline);
 
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
@@ -386,7 +386,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
                 .eq(Task::getCategory, category)
                 .ge(Task::getDeadline, startOfDay)
                 .lt(Task::getDeadline, endOfDay)
-                .in(Task::getStatus, Arrays.asList("pending", "completed"))
+                .in(Task::getStatus, Arrays.asList("pending", "completed", "pending_review"))
                 .orderByAsc(Task::getDeadline)));
     }
 
@@ -505,8 +505,8 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             return null;
         }
 
-        if ("completed".equals(task.getStatus())) {
-            return null; // 任务已完成,不能重复提交
+        if ("completed".equals(task.getStatus()) || "pending_review".equals(task.getStatus())) {
+            return null; // 任务已完成或已提交待审核,不能重复提交
         }
 
         // TASK-SYS-248: 前置任务校验——前置未完成则拒绝
@@ -554,200 +554,57 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         }
 
         Date now = new Date();
-        int pointsEarned;
 
         // 检查是否为小游戏任务
         if ("小游戏类".equals(task.getCategory()) && task.getMinigameCode() != null) {
-            // 小游戏任务不通过此接口完成,需要通过小游戏界面完成
-            // 这里只返回null,实际完成逻辑在completeMinigameTask方法中
             return null;
         }
 
-        pointsEarned = task.getPoints();
-boolean isEarly = now.before(task.getDeadline());
-boolean isLateGrace = false;
-
-// 检查是否在宽限期内
-if (!isEarly) {
-long diffMinutes = (now.getTime() - task.getDeadline().getTime()) / (1000 * 60);
-isLateGrace = diffMinutes <= LATE_GRACE_MINUTES;
-}
-
-FamilyMember child = familyMemberMapper.selectById(memberId);
-
-// POINT-002: 计算积分(支持自定义超额规则)
-String bonusRuleJson = task.getBonusRule();
-if (bonusRuleJson != null && !bonusRuleJson.isEmpty()) {
-// 解析自定义超额积分规则
-pointsEarned = calculatePointsWithBonusRule(task, now, isEarly, isLateGrace, child);
-} else {
-// 使用默认规则
-if (isEarly || isLateGrace) {
-// 提前完成或宽限期内完成,获得基础分 + 提前奖励
-pointsEarned += EARLY_BONUS;
-} else {
-            // 超时,检查是否开启扣分
-            pointsEarned = calculateLatePenalty(task, child, memberId);
-        }
-}
-
-        // 更新任务状态
-        task.setStatus("completed");
-        task.setCompletedAt(now);
-        task.setUpdatedAt(new Date());
-        taskMapper.updateById(task);
-
-        // 更新孩子积分
-        child.setTotalPoints(child.getTotalPoints() + pointsEarned);
-        
-        // 更新连续打卡
-        Date lastTaskDate = child.getLastTaskDate();
-        Date today = new Date();
-        Calendar cal = Calendar.getInstance();
-        cal.add(Calendar.DAY_OF_MONTH, -1);
-        Date yesterday = cal.getTime();
-        
-        if (lastTaskDate == null || lastTaskDate.before(yesterday)) {
-            child.setStreakDays(1); // 重新开始
-        } else if (lastTaskDate != null && isSameDay(lastTaskDate, today)) {
-            // 今天已经完成过任务,不增加天数
-        } else {
-            child.setStreakDays(child.getStreakDays() + 1);
-        }
-        child.setLastTaskDate(today);
-        child.setUpdatedAt(new Date());
-        familyMemberMapper.updateById(child);
-
-// 检查并发放打卡里程碑奖励
-int milestoneReward = streakService.checkAndAwardMilestone(memberId, child.getStreakDays());
-if (milestoneReward > 0) {
-pointsEarned += milestoneReward;
-log.info("孩子{}获得打卡里程碑奖励{}积分", memberId, milestoneReward);
-}
-
-// 记录积分流水
-PointsLog pointsLog = new PointsLog();
-pointsLog.setChildId(memberId);
-pointsLog.setTaskId(taskId);
-pointsLog.setAmount(pointsEarned);
-if (pointsEarned > 0) {
-pointsLog.setType(isEarly ? "bonus" : "earn");
-} else if (pointsEarned < 0) {
-pointsLog.setType("penalty");
-} else {
-pointsLog.setType("earn");
-}
-pointsLog.setDescription("完成任务: " + task.getTitle());
-pointsLog.setCreatedAt(new Date());
-
-// POINT-009: 设置积分过期时间
-if (pointsEarned > 0 && task.getPointsExpireDays() != null && task.getPointsExpireDays() > 0) {
-Calendar expireCal = Calendar.getInstance();
-expireCal.add(Calendar.DAY_OF_MONTH, task.getPointsExpireDays());
-pointsLog.setExpireAt(expireCal.getTime());
-}
-
-        pointsLogMapper.insert(pointsLog);
-
-        // 五维能量发放(与积分并行,独立系统,失败不影响积分)
-        try {
-            int energyAmount = Math.abs(pointsEarned);
-            if (energyAmount > 0) {
-                energyService.awardEnergy(memberId, "task", taskId, energyAmount,
-                        "完成任务: " + task.getTitle(), null);
-            }
-        } catch (Exception e) {
-log.warn("能量发放失败(不影响积分): memberId={}, taskId={}, error={}",
-            memberId, taskId, e.getMessage());
+        // 需审核的任务:提交后进入待审核,审核通过后再发放积分
+        if (task.getNeedReview() != null && task.getNeedReview() == 1) {
+            return submitTaskForReview(task, memberId, now, content, contentType);
         }
 
-        try { growthTaskService.updateProgress(child.getUserId(), "DAILY_TASK", 1); } catch (Exception e) { log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage()); }
-
-        // TASK-SYS-248: 更新任务执行进度(两阶段 execution 收尾 + 填写内容落库)
-        try {
-            TaskExecution activeExec = null;
-            if (task.getStartRequired() != null && task.getStartRequired() == 1) {
-                activeExec = taskExecutionMapper.selectOne(
-                        new LambdaQueryWrapper<TaskExecution>()
-                                .eq(TaskExecution::getTaskId, taskId)
-                                .eq(TaskExecution::getMemberId, memberId)
-                                .eq(TaskExecution::getStatus, "started")
-                                .last("LIMIT 1"));
-            }
-            if (activeExec != null) {
-                activeExec.setStatus("finished");
-                activeExec.setFinishedAt(now);
-                activeExec.setContent(content);
-                activeExec.setContentType(contentType);
-                taskExecutionMapper.updateById(activeExec);
-            } else if (content != null && !content.trim().isEmpty()) {
-                // 非两阶段任务的填写内容,落一条 finished 记录
-                TaskExecution exec = new TaskExecution();
-                exec.setTaskId(taskId);
-                exec.setMemberId(memberId);
-                exec.setStatus("finished");
-                exec.setStartedAt(now);
-                exec.setFinishedAt(now);
-                exec.setContent(content);
-                exec.setContentType(contentType);
-                exec.setCreatedAt(now);
-                taskExecutionMapper.insert(exec);
-            }
-        } catch (Exception e) {
-            log.warn("任务执行进度记录失败(不影响完成): taskId={}, error={}", taskId, e.getMessage());
-        }
-
-        // TASK-SYS-248: 前置链解锁——本任务完成后解锁依赖它的后置任务
-        try {
-            List<Task> nextTasks = taskMapper.selectList(
-                    new LambdaQueryWrapper<Task>()
-                            .eq(Task::getPrerequisiteTaskId, taskId));
-            for (Task next : nextTasks) {
-                if ("locked".equals(next.getStatus())) {
-                    next.setStatus("pending");
-                    next.setUpdatedAt(new Date());
-                    taskMapper.updateById(next);
-                    log.info("前置任务{}完成,解锁后置任务{}", taskId, next.getId());
-                }
-            }
-        } catch (Exception e) {
-            log.warn("后置任务解锁失败: taskId={}, error={}", taskId, e.getMessage());
-        }
+        recordTaskExecution(task, memberId, now, content, contentType);
+        int pointsEarned = awardTaskCompletionRewards(task, memberId, now);
+        FamilyMember child = familyMemberMapper.selectById(memberId);
 
         Map<String, Object> result = new HashMap<>();
         result.put("pointsEarned", pointsEarned);
-result.put("newBalance", child.getTotalPoints());
-result.put("needReview", task.getNeedReview() == 1);
-
-return result;
-}
+        result.put("newBalance", child != null ? child.getTotalPoints() : 0);
+        result.put("needReview", false);
+        return result;
+    }
 
+    @Transactional
     public boolean reviewTask(Long taskId, TaskReviewDTO dto) {
         Task task = taskMapper.selectById(taskId);
         if (task == null) {
             return false;
         }
 
-        // 兼容两种审核字段:approved 和 result
+        if (!"pending_review".equals(task.getStatus())) {
+            return "completed".equals(task.getStatus());
+        }
+
         boolean isApproved;
         if (dto.getApproved() != null) {
-            // 使用 approved 字段(推荐)
             isApproved = dto.getApproved();
         } else if ("pass".equals(dto.getResult())) {
-            // 兼容 result 字段
             isApproved = true;
         } else {
             isApproved = false;
         }
 
-        // 更新任务状态
         if (isApproved) {
-            task.setStatus("completed");
+            Date completionTime = task.getCompletedAt() != null ? task.getCompletedAt() : new Date();
+            awardTaskCompletionRewards(task, task.getFamilyMemberId(), completionTime);
         } else {
-            task.setStatus("pending"); // 审核不通过,任务重置
+            task.setStatus("pending");
+            task.setCompletedAt(null);
+            task.setUpdatedAt(new Date());
+            taskMapper.updateById(task);
         }
-        task.setUpdatedAt(new Date());
-        taskMapper.updateById(task);
 
         return true;
     }
@@ -793,7 +650,7 @@ return result;
     LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<>();
     wrapper.eq(Task::getFamilyId, user.getFamilyId())
         .eq(Task::getNeedReview, 1)
-        .eq(Task::getStatus, "completed")
+        .eq(Task::getStatus, "pending_review")
         .orderByDesc(Task::getCompletedAt);
 
     SortUtil.applySort(wrapper);
@@ -904,6 +761,186 @@ return result;
             .set(Task::getUpdatedAt, new Date()));
     }
 
+    private Map<String, Object> submitTaskForReview(Task task, Long memberId, Date now, String content, String contentType) {
+        task.setStatus("pending_review");
+        task.setCompletedAt(now);
+        task.setUpdatedAt(now);
+        taskMapper.updateById(task);
+
+        recordTaskExecution(task, memberId, now, content, contentType);
+
+        FamilyMember child = familyMemberMapper.selectById(memberId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("pointsEarned", 0);
+        result.put("newBalance", child != null ? child.getTotalPoints() : 0);
+        result.put("needReview", true);
+        result.put("message", "已提交,待审核");
+        return result;
+    }
+
+    private int awardTaskCompletionRewards(Task task, Long memberId, Date completionTime) {
+        Long taskId = task.getId();
+        int pointsEarned = task.getPoints() != null ? task.getPoints() : 0;
+        boolean isEarly = task.getDeadline() != null && completionTime.before(task.getDeadline());
+        boolean isLateGrace = false;
+
+        if (!isEarly && task.getDeadline() != null) {
+            long diffMinutes = (completionTime.getTime() - task.getDeadline().getTime()) / (1000 * 60);
+            isLateGrace = diffMinutes <= LATE_GRACE_MINUTES;
+        }
+
+        FamilyMember child = familyMemberMapper.selectById(memberId);
+        if (child == null) {
+            return 0;
+        }
+
+        String bonusRuleJson = task.getBonusRule();
+        if (bonusRuleJson != null && !bonusRuleJson.isEmpty()) {
+            pointsEarned = calculatePointsWithBonusRule(task, completionTime, isEarly, isLateGrace, child);
+        } else if (isEarly || isLateGrace) {
+            pointsEarned += EARLY_BONUS;
+        } else {
+            pointsEarned = calculateLatePenalty(task, child, memberId);
+        }
+
+        task.setStatus("completed");
+        task.setCompletedAt(completionTime);
+        task.setUpdatedAt(new Date());
+        taskMapper.updateById(task);
+
+        child.setTotalPoints(child.getTotalPoints() + pointsEarned);
+
+        Date lastTaskDate = child.getLastTaskDate();
+        Date today = new Date();
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -1);
+        Date yesterday = cal.getTime();
+
+        if (lastTaskDate == null || lastTaskDate.before(yesterday)) {
+            child.setStreakDays(1);
+        } else if (!isSameDay(lastTaskDate, today)) {
+            child.setStreakDays(child.getStreakDays() + 1);
+        }
+        child.setLastTaskDate(today);
+        child.setUpdatedAt(new Date());
+        familyMemberMapper.updateById(child);
+
+        int milestoneReward = streakService.checkAndAwardMilestone(memberId, child.getStreakDays());
+        if (milestoneReward > 0) {
+            pointsEarned += milestoneReward;
+            log.info("孩子{}获得打卡里程碑奖励{}积分", memberId, milestoneReward);
+        }
+
+        PointsLog pointsLog = new PointsLog();
+        pointsLog.setChildId(memberId);
+        pointsLog.setTaskId(taskId);
+        pointsLog.setAmount(pointsEarned);
+        if (pointsEarned > 0) {
+            pointsLog.setType(isEarly ? "bonus" : "earn");
+        } else if (pointsEarned < 0) {
+            pointsLog.setType("penalty");
+        } else {
+            pointsLog.setType("earn");
+        }
+        pointsLog.setDescription("完成任务: " + task.getTitle());
+        pointsLog.setCreatedAt(new Date());
+
+        if (pointsEarned > 0 && task.getPointsExpireDays() != null && task.getPointsExpireDays() > 0) {
+            Calendar expireCal = Calendar.getInstance();
+            expireCal.add(Calendar.DAY_OF_MONTH, task.getPointsExpireDays());
+            pointsLog.setExpireAt(expireCal.getTime());
+        }
+        pointsLogMapper.insert(pointsLog);
+
+        try {
+            int energyAmount = Math.abs(pointsEarned);
+            if (energyAmount > 0) {
+                energyService.awardEnergy(memberId, "task", taskId, energyAmount,
+                        "完成任务: " + task.getTitle(), null);
+            }
+        } catch (Exception e) {
+            log.warn("能量发放失败(不影响积分): memberId={}, taskId={}, error={}",
+                    memberId, taskId, e.getMessage());
+        }
+
+        try {
+            growthTaskService.updateProgress(child.getUserId(), "DAILY_TASK", 1);
+        } catch (Exception e) {
+            log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage());
+        }
+
+        unlockPrerequisiteTasks(taskId);
+        return pointsEarned;
+    }
+
+    private void recordTaskExecution(Task task, Long memberId, Date now, String content, String contentType) {
+        Long taskId = task.getId();
+        try {
+            TaskExecution activeExec = null;
+            if (task.getStartRequired() != null && task.getStartRequired() == 1) {
+                activeExec = taskExecutionMapper.selectOne(
+                        new LambdaQueryWrapper<TaskExecution>()
+                                .eq(TaskExecution::getTaskId, taskId)
+                                .eq(TaskExecution::getMemberId, memberId)
+                                .eq(TaskExecution::getStatus, "started")
+                                .last("LIMIT 1"));
+            }
+            if (activeExec != null) {
+                activeExec.setStatus("finished");
+                activeExec.setFinishedAt(now);
+                activeExec.setContent(content);
+                activeExec.setContentType(contentType);
+                taskExecutionMapper.updateById(activeExec);
+            } else if (content != null && !content.trim().isEmpty()) {
+                TaskExecution exec = new TaskExecution();
+                exec.setTaskId(taskId);
+                exec.setMemberId(memberId);
+                exec.setStatus("finished");
+                exec.setStartedAt(now);
+                exec.setFinishedAt(now);
+                exec.setContent(content);
+                exec.setContentType(contentType);
+                exec.setCreatedAt(now);
+                taskExecutionMapper.insert(exec);
+            }
+        } catch (Exception e) {
+            log.warn("任务执行进度记录失败(不影响完成): taskId={}, error={}", taskId, e.getMessage());
+        }
+    }
+
+    private void unlockPrerequisiteTasks(Long taskId) {
+        try {
+            List<Task> nextTasks = taskMapper.selectList(
+                    new LambdaQueryWrapper<Task>()
+                            .eq(Task::getPrerequisiteTaskId, taskId));
+            for (Task next : nextTasks) {
+                if ("locked".equals(next.getStatus())) {
+                    next.setStatus("pending");
+                    next.setUpdatedAt(new Date());
+                    taskMapper.updateById(next);
+                    log.info("前置任务{}完成,解锁后置任务{}", taskId, next.getId());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("后置任务解锁失败: taskId={}, error={}", taskId, e.getMessage());
+        }
+    }
+
+    /**
+     * 管理员/平台查询待审核任务
+     */
+    public List<Task> getAllPendingReviewTasks(Long familyId) {
+        LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
+                .eq(Task::getNeedReview, 1)
+                .eq(Task::getStatus, "pending_review")
+                .orderByDesc(Task::getCompletedAt);
+        if (familyId != null) {
+            wrapper.eq(Task::getFamilyId, familyId);
+        }
+        SortUtil.applySort(wrapper);
+        return taskMapper.selectList(wrapper);
+    }
+
     private boolean isSameDay(Date date1, Date date2) {
     Calendar cal1 = Calendar.getInstance();
     cal1.setTime(date1);
@@ -920,7 +957,7 @@ return result;
     return taskMapper.selectList(new LambdaQueryWrapper<Task>()
         .eq(Task::getFamilyMemberId, memberId)
         .eq(Task::getNeedReview, 1)
-        .eq(Task::getStatus, "completed")
+        .eq(Task::getStatus, "pending_review")
         .orderByDesc(Task::getCompletedAt));
   }
 
@@ -1151,8 +1188,8 @@ return 0; // 达到每日扣分上限
         return taskMapper.selectList(new LambdaQueryWrapper<Task>()
                 .eq(Task::getFamilyMemberId, familyMemberId)
                 .eq(Task::getNeedReview, 1)
-                .in(Task::getStatus, Arrays.asList("completed", "pending"))
-                .orderByDesc(Task::getCreatedAt));
+                .eq(Task::getStatus, "pending_review")
+                .orderByDesc(Task::getCompletedAt));
     }
 
     /**

+ 31 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/TaskStatsService.java

@@ -69,7 +69,7 @@ public class TaskStatsService {
         List<Map<String, Object>> byChild = new ArrayList<>();
         for (FamilyMember child : children) {
             List<Task> childTasks = tasks.stream()
-                .filter(t -> child.getId().equals(t.getChildId()))
+                .filter(t -> child.getId().equals(t.getChildId()) || child.getId().equals(t.getFamilyMemberId()))
                 .collect(Collectors.toList());
             if (!childTasks.isEmpty()) {
                 Map<String, Object> childStat = new HashMap<>();
@@ -98,13 +98,19 @@ public class TaskStatsService {
             wrapper.eq(Task::getFamilyId, familyId);
         }
         if (childId != null) {
-            wrapper.eq(Task::getChildId, childId);
+            wrapper.and(w -> w.eq(Task::getChildId, childId).or().eq(Task::getFamilyMemberId, childId));
         }
         if (startDate != null) {
             wrapper.ge(Task::getCreatedAt, startDate);
         }
         if (endDate != null) {
-            wrapper.le(Task::getCreatedAt, endDate);
+            Calendar endCal = Calendar.getInstance();
+            endCal.setTime(endDate);
+            endCal.set(Calendar.HOUR_OF_DAY, 23);
+            endCal.set(Calendar.MINUTE, 59);
+            endCal.set(Calendar.SECOND, 59);
+            endCal.set(Calendar.MILLISECOND, 999);
+            wrapper.le(Task::getCreatedAt, endCal.getTime());
         }
         wrapper.orderByDesc(Task::getCreatedAt);
 
@@ -119,23 +125,27 @@ public class TaskStatsService {
         Date now = new Date();
         int total = tasks.size();
         long completed = tasks.stream().filter(t -> "completed".equals(t.getStatus())).count();
-        long pending = tasks.stream().filter(t -> "pending".equals(t.getStatus())).count();
+        long pending = tasks.stream()
+            .filter(t -> "pending".equals(t.getStatus()) || "in_progress".equals(t.getStatus()))
+            .count();
+        long inReview = tasks.stream().filter(t -> "pending_review".equals(t.getStatus())).count();
         long overdue = tasks.stream()
-            .filter(t -> "pending".equals(t.getStatus()) && t.getDeadline() != null && t.getDeadline().before(now))
+            .filter(t -> ("pending".equals(t.getStatus()) || "in_progress".equals(t.getStatus()))
+                    && t.getDeadline() != null && t.getDeadline().before(now))
             .count();
 
-        long rate = total > 0 ? Math.round(completed * 1000.0 / total) * 10 : 0;
+        double rate = total > 0 ? Math.round(completed * 1000.0 / total) / 10.0 : 0;
 
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("totalTasks", total);
         result.put("completedTasks", completed);
         result.put("pendingTasks", pending);
+        result.put("inReviewTasks", inReview);
         result.put("overdueTasks", overdue);
         result.put("completionRate", rate);
 
-        // 待完成任务列表(含真实标题)
         List<Map<String, Object>> pendingTaskList = tasks.stream()
-            .filter(t -> "pending".equals(t.getStatus()))
+            .filter(t -> "pending".equals(t.getStatus()) || "in_progress".equals(t.getStatus()))
             .map(t -> {
                 Map<String, Object> item = new HashMap<>();
                 item.put("id", t.getId());
@@ -145,6 +155,19 @@ public class TaskStatsService {
             .collect(Collectors.toList());
         result.put("pendingTaskList", pendingTaskList);
 
+        List<Map<String, Object>> completedTaskList = tasks.stream()
+            .filter(t -> "completed".equals(t.getStatus()))
+            .map(t -> {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", t.getId());
+                item.put("title", t.getTitle() != null ? t.getTitle() : "");
+                item.put("completedAt", t.getCompletedAt());
+                item.put("points", t.getPoints());
+                return item;
+            })
+            .collect(Collectors.toList());
+        result.put("completedTaskList", completedTaskList);
+
         return result;
     }
 }

+ 8 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -141,13 +141,16 @@ private FamilyInvitationService familyInvitationService;
         }
 
         ensureUserHasFamily(user);
-
-        // 管理员不能登录小程序
+        
+        // 🔧 本地调试:允许 admin 登录小程序(仅用于测试!)
         if ("admin".equals(user.getRole())) {
-            throw new RuntimeException("该账号不允许登录小程序");
+            log.error("⚠️ 管理员账号 {} (userId={}, phone={}, openid={}) 尝试登录小程序,绕过限制", user.getNickname(), user.getId(), user.getPhone(), user.getOpenid());
+            // throw new RuntimeException("该账号不允许登录小程序");
+        } else {
+            log.info("✅ 普通用户 {} (userId={}, role={}) 登录成功", user.getNickname(), user.getId(), user.getRole());
         }
-
-        // 生成token
+        
+        // 生成 token
         String token = jwtConfig.generateToken(user.getId(), user.getRole());
 
         LoginResultDTO result = new LoginResultDTO();

+ 203 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -1,11 +1,13 @@
 package com.etotem.cfc.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -17,6 +19,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.HashMap;
@@ -44,7 +47,10 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     private UserMapper userMapper;
 
-    @Value("${cfc.langgraph.base-url:}")
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Value("${langgraph.base-url:}")
     private String langgraphBaseUrl;
 
     private final ObjectMapper objectMapper = new ObjectMapper();
@@ -125,6 +131,22 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return healthPlanMapper.selectById(planId);
     }
 
+    @Override
+    public List<Task> getPlanTasks(Long planId) {
+        if (planId == null) {
+            return java.util.Collections.emptyList();
+        }
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) {
+            return java.util.Collections.emptyList();
+        }
+        QueryWrapper<Task> wrapper = new QueryWrapper<>();
+        wrapper.eq("source_type", "plan")
+                .eq("source_id", planId)
+                .orderByAsc("id");
+        return taskMapper.selectList(wrapper);
+    }
+
     @Override
     public Long confirmPlan(Long planId) {
         HealthPlan plan = healthPlanMapper.selectById(planId);
@@ -249,15 +271,13 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         Long familyId = plan.getFamilyId();
         String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
             ? plan.getMemberIds().split(",") : new String[0];
-        Long childId = memberIdArray.length > 0 ? Long.valueOf(memberIdArray[0]) : null;
-        Long executorId = childId != null ? childId : null;
+        Long rawMemberId = memberIdArray.length > 0 ? Long.valueOf(memberIdArray[0].trim()) : null;
+        Long familyMemberId = resolveFamilyMemberId(rawMemberId, familyId);
+        Long childId = familyMemberId;
+        Long executorId = familyMemberId;
 
-        // 当天截止:23:59:59
-        Calendar cal = Calendar.getInstance();
-        cal.set(Calendar.HOUR_OF_DAY, 23);
-        cal.set(Calendar.MINUTE, 59);
-        cal.set(Calendar.SECOND, 59);
-        Date deadline = cal.getTime();
+        // 当天截止 23:59:59(严格落在今日,避免存成次日 00:00 导致今日任务查不到)
+        Date deadline = buildTodayDeadline();
 
         Date now = new Date();
 
@@ -303,6 +323,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
                 Task task = new Task();
                 task.setFamilyId(familyId);
+                task.setFamilyMemberId(familyMemberId);
                 task.setChildId(childId);
                 task.setExecutorType("child");
                 task.setExecutorId(executorId);
@@ -314,7 +335,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 task.setStatus("pending");
                 task.setSourceType("plan");
                 task.setSourceId(plan.getId());
-                task.setNeedReview(0);
+                task.setNeedReview(1);
                 task.setCreatedAt(now);
                 task.setUpdatedAt(now);
 
@@ -576,4 +597,176 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         }
         return result;
     }
+
+    @Override
+    public Long autoGenerateAndPublish(Long familyId, Long memberId, String reportType) {
+        if (familyId == null || memberId == null) {
+            return null;
+        }
+        String memberIds = String.valueOf(memberId);
+        String dimensions = "body";
+        String goal = buildGoalForReportType(reportType);
+
+        String memberName = null;
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null && member.getNickname() != null) {
+            memberName = member.getNickname();
+        }
+
+        JsonNode data = callLangGraphPlanData(familyId, memberIds, dimensions, goal);
+        String planJson;
+        String planContent;
+
+        if (data != null && data.has("overview")) {
+            planJson = buildPlanJsonFromData(data);
+            planContent = buildPlanContentFromData(data);
+        } else {
+            planContent = buildSamplePlan(memberIds, dimensions, goal);
+            planJson = buildFallbackPlanJson(planContent);
+        }
+
+        HealthPlan plan = new HealthPlan();
+        plan.setFamilyId(familyId);
+        plan.setMemberIds(memberIds);
+        plan.setDimensions(dimensions);
+        plan.setGoal(goal);
+        plan.setPlanContent(planContent);
+        plan.setPlanJson(planJson);
+        plan.setMemberName(memberName);
+        plan.setStatus("published");
+        Long planId = savePlan(plan);
+        log.info("报告确认后自动生成方案完成 planId={}, familyId={}, memberId={}, reportType={}",
+                planId, familyId, memberId, reportType);
+        return planId;
+    }
+
+    private String buildGoalForReportType(String reportType) {
+        if ("gut_flora".equals(reportType)) {
+            return "基于菌群检测报告的个性化健康管理方案";
+        }
+        return "基于健康检测报告的个性化健康管理方案";
+    }
+
+    private JsonNode callLangGraphPlanData(Long familyId, String memberIds, String dimensions, String goal) {
+        if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            RestTemplate restTemplate = new RestTemplate();
+            Map<String, Object> body = new HashMap<>();
+            body.put("familyId", familyId);
+            body.put("memberIds", memberIds);
+            body.put("dimensions", dimensions);
+            body.put("goal", goal);
+            String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
+            if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                if (root.has("success") && root.get("success").asBoolean() && root.has("data")) {
+                    return root.get("data");
+                }
+            }
+        } catch (Exception e) {
+            log.warn("报告入库后调用LangGraph生成方案失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    private String buildPlanJsonFromData(JsonNode data) {
+        try {
+            Map<String, Object> planJsonMap = new HashMap<>();
+            planJsonMap.put("overview", data.has("overview") ? data.get("overview").asText() : "");
+            List<Map<String, Object>> sections = new ArrayList<>();
+            if (data.has("sections") && data.get("sections").isArray()) {
+                for (JsonNode section : data.get("sections")) {
+                    Map<String, Object> sec = new HashMap<>();
+                    sec.put("key", section.has("key") ? section.get("key").asText() : "");
+                    sec.put("content", section.has("content") ? section.get("content").asText() : "");
+                    if (section.has("items") && section.get("items").isArray()) {
+                        sec.put("items", objectMapper.convertValue(section.get("items"), List.class));
+                    } else {
+                        sec.put("items", new ArrayList<>());
+                    }
+                    sections.add(sec);
+                }
+            }
+            planJsonMap.put("sections", sections);
+            return objectMapper.writeValueAsString(planJsonMap);
+        } catch (Exception e) {
+            log.warn("构建planJson失败: {}", e.getMessage());
+            return buildFallbackPlanJson(data.has("overview") ? data.get("overview").asText() : "");
+        }
+    }
+
+    private String buildPlanContentFromData(JsonNode data) {
+        StringBuilder sb = new StringBuilder();
+        if (data.has("overview")) {
+            sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
+        }
+        Map<String, String> titles = new HashMap<>();
+        titles.put("nutrition", "营养补充建议");
+        titles.put("diet", "饮食建议");
+        titles.put("exercise", "运动计划");
+        if (data.has("sections") && data.get("sections").isArray()) {
+            for (JsonNode section : data.get("sections")) {
+                String key = section.has("key") ? section.get("key").asText() : "";
+                String title = section.has("title") ? section.get("title").asText()
+                        : titles.getOrDefault(key, key);
+                if (section.has("content")) {
+                    sb.append("## ").append(title).append("\n\n")
+                            .append(section.get("content").asText()).append("\n\n");
+                }
+            }
+        }
+        return sb.toString();
+    }
+
+    private String buildFallbackPlanJson(String overview) {
+        try {
+            Map<String, Object> planJsonMap = new HashMap<>();
+            String overviewText = overview != null ? overview : "";
+            planJsonMap.put("overview", overviewText.length() > 200 ? overviewText.substring(0, 200) : overviewText);
+            List<Map<String, Object>> sections = new ArrayList<>();
+            for (String key : new String[]{"nutrition", "diet", "exercise"}) {
+                Map<String, Object> sec = new HashMap<>();
+                sec.put("key", key);
+                sec.put("content", buildSampleSection(key, null));
+                sec.put("items", new ArrayList<>());
+                sections.add(sec);
+            }
+            planJsonMap.put("sections", sections);
+            return objectMapper.writeValueAsString(planJsonMap);
+        } catch (Exception e) {
+            return "{\"overview\":\"\",\"sections\":[]}";
+        }
+    }
+
+    /** 归一 memberIds:支持 family_members.id 或 users.id */
+    private Long resolveFamilyMemberId(Long memberId, Long familyId) {
+        if (memberId == null) {
+            return null;
+        }
+        FamilyMember byId = familyMemberMapper.selectById(memberId);
+        if (byId != null) {
+            return byId.getId();
+        }
+        QueryWrapper<FamilyMember> wrapper = new QueryWrapper<>();
+        wrapper.eq("user_id", memberId);
+        if (familyId != null) {
+            wrapper.eq("family_id", familyId);
+        }
+        wrapper.last("LIMIT 1");
+        FamilyMember linked = familyMemberMapper.selectOne(wrapper);
+        return linked != null ? linked.getId() : memberId;
+    }
+
+    /** 今日 23:59:59,与 TaskService.getTodayTasks 的 [startOfDay, endOfDay) 区间对齐 */
+    private Date buildTodayDeadline() {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
 }

+ 1 - 1
cfc-backend/src/main/resources/application.yml

@@ -75,7 +75,7 @@ wechat:
   notify-url: http://cfc.bianwoyou.com.cn/api/payment/notify
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
-  test-mode: false  # 测试模式:跳过微信API调用,使用模拟数据
+  test-mode: true  # 测试模式:跳过微信 API 调用,使用模拟数据
   appkey: ${WECHAT_APPKEY:cXqYJseX2MUaRM5kVHLQRskw5l5ZrDNo}          # 虚拟支付 AppKey 现网(环境变量可覆盖)
   sandbox-appkey: ${WECHAT_SANDBOX_APPKEY:sKcsYR0e9T603wvD5yFcvISBcFLh3TXH}  # 虚拟支付 AppKey 沙箱(环境变量可覆盖)
   virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}   # 0=现网 1=沙箱

+ 19 - 1
cfc-backend/src/main/resources/schema.sql

@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     category VARCHAR(32),
     need_review TINYINT DEFAULT 0 COMMENT '是否需要审核',
     review_by_category TINYINT DEFAULT 0 COMMENT '是否按分类审核',
-    status ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', 'overdue', 'cancelled') DEFAULT 'pending',
+    status ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', 'overdue', 'cancelled', 'locked', 'pending_review') DEFAULT 'pending',
     completed_at DATETIME,
     bonus_rule JSON COMMENT '积分奖励规则',
     points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
@@ -1819,6 +1819,24 @@ CREATE TABLE IF NOT EXISTS health_report_edit_logs (
     INDEX idx_report_id (report_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕';
 
+-- 菌群检测专项数据表(针对 gut_flora 类型报告)
+CREATE TABLE IF NOT EXISTS health_gut_flora (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
+    report_id BIGINT NOT NULL COMMENT '关联报告 ID',
+    bacteria_name VARCHAR(100) NOT NULL COMMENT '菌种名称',
+    bacteria_value VARCHAR(50) COMMENT '菌种丰度值',
+    normal_range VARCHAR(100) COMMENT '正常范围',
+    status VARCHAR(50) COMMENT '状态:正常/偏低/偏高',
+    category VARCHAR(50) COMMENT '分类:core/probiotic/harmful/other/pathogen_genus/pathogen_detection/taxonomy',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    population_level VARCHAR(100) DEFAULT NULL COMMENT '处于人群%水平',
+    detection_rate VARCHAR(100) DEFAULT NULL COMMENT '%正常人有检出',
+    description TEXT COMMENT '菌种说明',
+    `level` VARCHAR(20) DEFAULT 'GENUS' COMMENT '级别:CLASS/ORDER/FAMILY/GENUS/SPECIES',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_report (report_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菌群检测专项数据(每种菌种的丰度值和评估状态)';
+
 -- 教育体系配置表
 CREATE TABLE IF NOT EXISTS education_systems (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',

+ 2 - 1
cfc-backend/startup.sh

@@ -13,7 +13,8 @@
 set -e
 
 # ===== 配置 =====
-PROFILE="${1:-prod}"
+# PROFILE="${1:-prod}"
+PROFILE="${1:-dev}"
 JAR_NAME="cfc-backend-1.0.0.jar"
 CONFIG_DIR="$(cd "$(dirname "$0")" && pwd)/config"
 

+ 2 - 1
cfc-frontend/config.js

@@ -19,7 +19,8 @@ try {
   if (env) {
     switch (env) {
       case 'develop':
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
+        //API_BASE_URL = 'http://cfc.iwintrue.com'
+        API_BASE_URL = 'http://192.168.1.57:9082'
         break
       case 'trial':
         API_BASE_URL = 'https://cfc.etotem.com.cn'

+ 17 - 0
cfc-frontend/pages.json

@@ -384,6 +384,23 @@
         }
       ]
     },
+    {
+      "root": "pages/assessment-plan",
+      "pages": [
+        {
+          "path": "list",
+          "style": {
+            "navigationBarTitleText": "我的测评方案"
+          }
+        },
+        {
+          "path": "detail",
+          "style": {
+            "navigationBarTitleText": "方案详情"
+          }
+        }
+      ]
+    },
     {
       "root": "pages/dan-assessment",
       "pages": [

+ 32 - 0
cfc-frontend/pages/health-main/index.vue

@@ -236,6 +236,13 @@
           <text class="empty-text">暂无报告和测评结果</text>
           <text class="empty-sub">上传报告或完成测评开启健康之旅</text>
         </view>
+        
+        <!-- 新增:查看测评方案入口 -->
+        <view class="plan-entry" @click="goToAssessmentPlans">
+          <text class="entry-icon">📋</text>
+          <text class="entry-text">查看我的测评方案</text>
+          <text class="entry-arrow">›</text>
+        </view>
       </view>
 
       <view class="section section-plan">
@@ -508,6 +515,15 @@ export default {
       }
       uni.navigateTo({ url: url })
     },
+    // 跳转到测评方案列表
+    goToAssessmentPlans: function() {
+      var familyId = this.familyId || uni.getStorageSync('familyId')
+      if (!familyId) {
+        uni.showToast({ title: '请先选择家庭', icon: 'none' })
+        return
+      }
+      uni.navigateTo({ url: '/pages/assessment-plan/list?familyId=' + familyId })
+    },
     _reportTypeLabel: function(type) {
       var map = { gut_flora: '肠道', physical_exam: '体检', tongue: '舌诊', blood: '血液' }
       return map[type] || '健康报告'
@@ -682,6 +698,22 @@ export default {
 .quiz-score { display: flex; align-items: baseline; margin-right: 16rpx; }
 .score-value { font-size: 36rpx; font-weight: bold; color: #F97316; }
 .score-unit { font-size: 22rpx; color: #999; margin-left: 4rpx; }
+
+/* ===== 测评方案入口 ===== */
+.plan-entry {
+  background: linear-gradient(135deg, #60A5FA 0%, #3B82F6 100%);
+  border-radius: 16rpx;
+  padding: 28rpx 30rpx;
+  margin-top: 24rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  box-shadow: 0 4rpx 16rpx rgba(59, 130, 246, 0.3);
+}
+.entry-icon { font-size: 40rpx; margin-right: 16rpx; }
+.entry-text { font-size: 28rpx; color: #fff; font-weight: bold; flex: 1; }
+.entry-arrow { font-size: 36rpx; color: #fff; }
 .quiz-btn { background: #F97316; color: #fff; font-size: 24rpx; border: none; border-radius: 20rpx; padding: 10rpx 30rpx; height: 56rpx; line-height: 36rpx; margin: 0; }
 .section-data-view { margin: 16rpx 24rpx; }
 .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }

+ 287 - 1
cfc-frontend/pages/health/health-plan-summary.vue

@@ -240,6 +240,39 @@
       <view v-if="planDetail.planContent" class="plan-preview">
         <text class="plan-preview-text">{{ planDetail.planContent }}</text>
       </view>
+
+      <!-- 方案分解任务 -->
+      <view class="plan-tasks-section">
+        <view class="plan-tasks-header">
+          <text class="plan-tasks-title">📋 分解任务</text>
+          <text class="plan-tasks-count" v-if="planTasks.length > 0">{{ planTaskCompletedCount }}/{{ planTasks.length }} 已完成</text>
+        </view>
+        <view class="plan-tasks-loading" v-if="planTasksLoading">
+          <text class="plan-tasks-loading-text">加载任务中...</text>
+        </view>
+        <view class="plan-task-list" v-else-if="planTasks.length > 0">
+          <view class="plan-task-item" v-for="item in planTasks" :key="item.id">
+            <view class="plan-task-main">
+              <text class="plan-task-title">{{ item.title }}</text>
+              <view class="plan-task-tags">
+                <text class="plan-task-tag" v-if="item.category">{{ item.category }}</text>
+                <text class="plan-task-tag plan-task-tag-freq">{{ getTaskFreqText(item) }}</text>
+              </view>
+            </view>
+            <view class="plan-task-right">
+              <text class="plan-task-status" :class="'plan-task-status-' + getTaskStatusClass(item.status)">{{ getTaskStatusText(item.status) }}</text>
+              <view class="plan-task-actions" v-if="item.status !== 'completed' && item.status !== 'pending_review'">
+                <text class="plan-task-btn plan-task-btn-complete" @click.stop="completePlanTask(item)">打卡完成</text>
+                <text class="plan-task-btn plan-task-btn-go" @click.stop="goToTaskCenter">任务中心</text>
+              </view>
+            </view>
+          </view>
+        </view>
+        <view class="plan-tasks-empty" v-else>
+          <text class="plan-tasks-empty-text">{{ getPlanTasksEmptyText() }}</text>
+        </view>
+      </view>
+
       <view class="plan-actions">
         <button class="btn-edit" @click="reCreate" v-if="planDetail.status !== 'confirmed' && planDetail.status !== 'published' && planDetail.status !== 'pending_review'">重新制定</button>
         <button class="btn-done plan-done-btn" @click="goBack">返回</button>
@@ -259,7 +292,7 @@
 </template>
 
 <script>
-import { getFamilyMemberList, generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, approvePlan } from '../../utils/api'
+import { getFamilyMemberList, generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, getHealthPlanTasks, approvePlan, completeTask } from '../../utils/api'
 import { getHealthStatus, saveHealthStatus } from '../../utils/api'
 
 // v2: 预置疾病清单(菌群报告病症风险 ★ + 互联网统计常见病 两源合并去重,8 组 54 项)
@@ -375,6 +408,8 @@ export default {
       viewMode: false,
       planDetail: null,
       planId: null,
+      planTasks: [],
+      planTasksLoading: false,
       // UI 状态
       loading: false,
       taskSubmitted: false
@@ -410,6 +445,13 @@ export default {
     },
     selectedNames: function() {
       return this.selectedDiseases.map(function(d) { return d.name })
+    },
+    planTaskCompletedCount: function() {
+      var count = 0
+      for (var i = 0; i < this.planTasks.length; i++) {
+        if (this.planTasks[i] && this.planTasks[i].status === 'completed') count++
+      }
+      return count
     }
   },
   onLoad: function(options) {
@@ -865,12 +907,123 @@ export default {
               self.planData.overview = res.data.planContent.substring(0, 200)
             }
           }
+          self.loadPlanTasksFromDetail(res.data)
         }
       }).catch(function() {
         uni.showToast({ title: '方案加载失败', icon: 'none' })
         setTimeout(function() { uni.navigateBack() }, 1500)
       })
     },
+    loadPlanTasksFromDetail: function(planData) {
+      if (planData && planData.tasks instanceof Array) {
+        this.planTasks = planData.tasks
+        this.planTasksLoading = false
+        return
+      }
+      this.loadPlanTasks()
+    },
+    loadPlanTasks: function() {
+      var self = this
+      if (!self.planId) {
+        self.planTasks = []
+        return
+      }
+      self.planTasksLoading = true
+      getHealthPlanTasks({ planId: self.planId }).then(function(res) {
+        if (res && res.code === 200 && res.data instanceof Array) {
+          self.planTasks = res.data
+        } else {
+          self.planTasks = []
+        }
+      }).catch(function() {
+        self.planTasks = []
+      }).finally(function() {
+        self.planTasksLoading = false
+      })
+    },
+    getTaskStatusText: function(status) {
+      var map = {
+        pending: '待完成',
+        in_progress: '进行中',
+        pending_review: '待审核',
+        completed: '已完成',
+        expired: '已过期',
+        rejected: '已驳回'
+      }
+      return map[status] || '待完成'
+    },
+    getTaskStatusClass: function(status) {
+      if (status === 'completed') return 'done'
+      if (status === 'pending_review') return 'review'
+      if (status === 'expired' || status === 'rejected') return 'warn'
+      return 'pending'
+    },
+    getTaskFreqText: function(task) {
+      if (!task) return '任务'
+      if (task.repeatType === 'none' || task.taskType === 'onetime') return '一次性'
+      if (task.frequency === 'weekly' || task.repeatType === 'weekly') return '每周'
+      if (task.frequency === 'monthly' || task.repeatType === 'monthly') return '每月'
+      return '每日'
+    },
+    getPlanTasksEmptyText: function() {
+      if (this.planDetail && (this.planDetail.status === 'published' || this.planDetail.status === 'confirmed')) {
+        return '方案已发布,暂未解析出可执行任务'
+      }
+      if (this.planDetail && this.planDetail.status === 'pending_review') {
+        return '方案待规划师审核,审核通过后将自动分解任务'
+      }
+      return '方案发布后,系统将自动分解为每日/每周任务'
+    },
+    getPlanMemberId: function(item) {
+      if (item && item.familyMemberId) {
+        return item.familyMemberId
+      }
+      if (this.planDetail && this.planDetail.memberIds) {
+        var parts = this.planDetail.memberIds.split(',')
+        if (parts.length > 0 && parts[0]) {
+          return parseInt(parts[0]) || parts[0]
+        }
+      }
+      var stored = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId')
+      return stored || null
+    },
+    completePlanTask: function(item) {
+      var self = this
+      if (!item || !item.id || item.status === 'completed' || item.status === 'pending_review') {
+        return
+      }
+      var memberId = self.getPlanMemberId(item)
+      if (!memberId) {
+        uni.showToast({ title: '未找到执行成员', icon: 'none' })
+        return
+      }
+      uni.showModal({
+        title: '完成任务',
+        content: '确定完成「' + item.title + '」吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          completeTask(item.id, memberId, '').then(function(r) {
+            if (r && r.code === 200) {
+              var needReview = r.data && r.data.needReview
+              if (needReview) {
+                uni.showToast({ title: '已提交,待审核', icon: 'none' })
+              } else {
+                var points = (r.data && r.data.pointsEarned) || item.points || 0
+                uni.showToast({ title: '获得 ' + points + ' 积分', icon: 'success' })
+              }
+              self.loadPlanTasks()
+            } else {
+              uni.showToast({ title: (r && r.message) || '完成失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '完成失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    goToTaskCenter: function() {
+      uni.navigateTo({ url: '/pages/tasks/tasks' })
+    },
     reCreate: function() {
       this.step = 1
       this.viewMode = false
@@ -1272,6 +1425,139 @@ export default {
   line-height: 1.8;
   white-space: pre-wrap;
 }
+.plan-tasks-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.plan-tasks-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.plan-tasks-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.plan-tasks-count {
+  font-size: 22rpx;
+  color: #64748B;
+}
+.plan-tasks-loading {
+  padding: 24rpx 0;
+  display: flex;
+  justify-content: center;
+}
+.plan-tasks-loading-text {
+  font-size: 24rpx;
+  color: #999;
+}
+.plan-task-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+.plan-task-item {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  justify-content: space-between;
+  padding: 20rpx;
+  background: #F8FAFC;
+  border-radius: 12rpx;
+  border: 1rpx solid #E2E8F0;
+}
+.plan-task-right {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 12rpx;
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+.plan-task-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8rpx;
+}
+.plan-task-btn {
+  font-size: 22rpx;
+  padding: 8rpx 18rpx;
+  border-radius: 20rpx;
+}
+.plan-task-btn-complete {
+  color: #fff;
+  background: #F97316;
+}
+.plan-task-btn-go {
+  color: #0369A1;
+  background: #E0F2FE;
+}
+.plan-task-main {
+  flex: 1;
+  min-width: 0;
+  padding-right: 16rpx;
+}
+.plan-task-title {
+  font-size: 26rpx;
+  color: #1E293B;
+  line-height: 1.5;
+}
+.plan-task-tags {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 8rpx;
+  margin-top: 8rpx;
+}
+.plan-task-tag {
+  font-size: 20rpx;
+  color: #0369A1;
+  background: #E0F2FE;
+  padding: 4rpx 12rpx;
+  border-radius: 16rpx;
+}
+.plan-task-tag-freq {
+  color: #92400E;
+  background: #FEF3C7;
+}
+.plan-task-status {
+  flex-shrink: 0;
+  font-size: 22rpx;
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+}
+.plan-task-status-pending {
+  color: #B45309;
+  background: #FEF3C7;
+}
+.plan-task-status-done {
+  color: #047857;
+  background: #D1FAE5;
+}
+.plan-task-status-review {
+  color: #92400E;
+  background: #FFEDD5;
+}
+.plan-task-status-warn {
+  color: #B91C1C;
+  background: #FEE2E2;
+}
+.plan-tasks-empty {
+  padding: 24rpx 0 8rpx;
+}
+.plan-tasks-empty-text {
+  font-size: 24rpx;
+  color: #94A3B8;
+  line-height: 1.6;
+  text-align: center;
+}
 .plan-actions { display: flex; gap: 16rpx; }
 .plan-actions .btn-edit { flex: 1; margin-bottom: 0; }
 .plan-done-btn {

+ 18 - 14
cfc-frontend/pages/health/report-confirm.vue

@@ -794,7 +794,7 @@ export default {
       if (!this.canConfirm) return
       var self = this
       self.confirming = true
-      uni.showLoading({ title: '确认入库...' })
+      uni.showLoading({ title: '确认入库并生成方案...' })
 
       var postData = {
         draftId: self.draftId,
@@ -815,19 +815,23 @@ export default {
       confirmFn(postData).then(function(res) {
         uni.hideLoading()
         if (res.code === 200) {
-          uni.showToast({ title: '入库成功', icon: 'success' })
-          var reportId = res.data && res.data.reportId
-          var reportType = res.data && res.data.reportType || ''
-          setTimeout(function() {
-            if (reportId) {
-              // 所有报告确认入库后统一进入方案制定流程(菌群报告自动出方案)
-              var subjectId = postData.subjectId || ''
-              var autoParam = reportType === 'gut_flora' ? '&autoExecute=1' : ''
-              uni.redirectTo({ url: '/pages/health/health-plan-summary?initialMemberIds=' + subjectId + '&subjectId=' + subjectId + autoParam })
-            } else {
-              uni.navigateBack()
-            }
-          }, 500)
+          var data = res.data || {}
+          var reportId = data.reportId || (data.report && data.report.id)
+          var planId = data.planId
+          if (planId && self.payload.type !== 'tongue') {
+            uni.showToast({ title: '方案已生成', icon: 'success' })
+            setTimeout(function() {
+              uni.redirectTo({ url: '/pages/health/health-plan-summary?planId=' + planId })
+            }, 500)
+          } else if (reportId) {
+            uni.showToast({ title: '入库成功', icon: 'success' })
+            setTimeout(function() {
+              var reportType = data.reportType || ''
+              uni.redirectTo({ url: '/pages/health/report-detail?reportType=' + reportType + '&reportId=' + reportId })
+            }, 500)
+          } else {
+            uni.navigateBack()
+          }
         } else {
           uni.showToast({ title: res.message || '确认失败', icon: 'none' })
         }

+ 63 - 6
cfc-frontend/pages/health/report-detail.vue

@@ -680,6 +680,12 @@ export default {
         self.parsingStep = (self.parsingStep + 1) % messages.length
         self.parsingText = messages[self.parsingStep]
       }, 2500)
+      // 调用 parse-report-preview,需要文件路径;从 upload 页传来的 draftId 需要重新获取文件路径
+      // 这里使用后端解析接口:先上传文件,然后解析
+      // 由于只传了 draftId,回调服务器已有的草稿进行解析
+      // 使用上传接口模式:需要文件路径
+      // 这里通过 draftId 请求后端确认,后端会使用已存储的文件进行解析
+      // 兼容模式:直接请求确认,由后端决定如何解析
       parseReportDraft(self.draftId).then(function(res) {
         self.parsing = false
         if (self.parsingTimer) { clearInterval(self.parsingTimer); self.parsingTimer = null }
@@ -695,6 +701,7 @@ export default {
       })
     },
     applyParseResult(detail) {
+      console.log('[报告入库] applyParseResult', detail)
       var self = this
       self.payload = detail.payload || self.defaultPayload()
       self.extractedName = detail.extractedName || ''
@@ -706,6 +713,13 @@ export default {
       self.needBind = !!detail.needBind
       self.candidates = detail.candidates || []
       self.loadFamilyMembers()
+      console.log('[报告入库] applyParseResult 后状态', {
+        needBind: self.needBind,
+        matchedMemberId: self.matchedMemberId,
+        candidates: self.candidates.length,
+        selectedMemberId: self.selectedMemberId,
+        canConfirm: self.canConfirm
+      })
       if (!self.needBind) {
         self.selectedMemberId = self.matchedMemberId
       }
@@ -714,6 +728,17 @@ export default {
         self.selectedMemberId = best.id || best.userId
         self.selectedMemberObj = best
       }
+      // 如果需要绑定且有候选成员,默认自动选中第一个
+      if (self.needBind && !self.selectedMemberId && self.candidates.length > 0) {
+        setTimeout(function() {
+          self.toggleMemberPanel()
+          uni.showToast({ title: '请确认被检测人', icon: 'none' })
+        }, 300)
+      }
+      console.log('[报告入库] applyParseResult 最终状态', {
+        selectedMemberId: self.selectedMemberId,
+        canConfirm: self.canConfirm
+      })
     },
     loadFamilyMembers() {
       var self = this
@@ -925,8 +950,27 @@ export default {
       else this.cancelEdit()
     },
     handleRightAction() {
-      if (this.pageMode === 'draft') this.doConfirm()
-      else this.saveEdit()
+      if (this.pageMode === 'draft') {
+        console.log('[报告入库] 点击确认按钮', {
+          pageMode: this.pageMode,
+          draftId: this.draftId,
+          confirming: this.confirming,
+          canConfirm: this.canConfirm,
+          selectedMemberId: this.selectedMemberId,
+          needBind: this.needBind,
+          matchedMemberId: this.matchedMemberId,
+          memberId: this.memberId,
+          payloadType: this.payload.type,
+          indicatorsCount: (this.payload.indicators || []).length
+        })
+        // 如果需要绑定但没有选择会员,先弹出会员选择面板
+        if (this.needBind && !this.selectedMemberId && !this.createdMemberId) {
+          uni.showToast({ title: '请先选择被检测人', icon: 'none' })
+          this.toggleMemberPanel()
+          return
+        }
+        this.doConfirm()
+      } else this.saveEdit()
     },
     doDiscard() {
       var self = this
@@ -948,22 +992,35 @@ export default {
       })
     },
     doConfirm() {
-      if (!this.canConfirm) return
+      console.log('[报告入库] doConfirm 开始', { canConfirm: this.canConfirm, draftId: this.draftId })
+      if (!this.canConfirm) {
+        console.warn('[报告入库] canConfirm 为 false,跳过执行')
+        return
+      }
       var self = this
       self.confirming = true
-      uni.showLoading({ title: '确认入库...' })
+      uni.showLoading({ title: '确认入库并生成方案...' })
       var postData = { draftId: self.draftId, payload: self.payload }
       if (self.selectedMemberId) postData.subjectId = self.selectedMemberId
       else if (self.createdMemberId) postData.subjectId = self.createdMemberId
       else if (self.matchedMemberId) postData.subjectId = self.matchedMemberId
       else if (self.memberId) postData.subjectId = self.memberId
+      console.log('[报告入库] 准备调用 API', postData)
       var confirmFn = self.payload.type === 'tongue' ? confirmTongue : confirmReportPreview
       confirmFn(postData).then(function(res) {
+        console.log('[报告入库] API 响应', res)
         uni.hideLoading()
         self.confirming = false
         if (res.code === 200) {
-          var reportId = res.data && res.data.reportId
-          if (reportId && self.payload.type !== 'tongue') {
+          var data = res.data || {}
+          var reportId = data.reportId || (data.report && data.report.id)
+          var planId = data.planId
+          if (planId && self.payload.type !== 'tongue') {
+            setTimeout(function() {
+              uni.redirectTo({ url: '/pages/health/health-plan-summary?planId=' + planId })
+              uni.showToast({ title: '方案已生成', icon: 'success' })
+            }, 600)
+          } else if (reportId && self.payload.type !== 'tongue') {
             setTimeout(function() {
               uni.redirectTo({ url: '/pages/health/report-detail?reportType=' + (self.payload.type || 'gut_flora') + '&reportId=' + reportId })
               uni.showToast({ title: '入库成功', icon: 'success' })

+ 3 - 3
cfc-frontend/pages/login/login.vue

@@ -34,7 +34,7 @@
             @getphonenumber="getPhoneNumber"
           >
             <text>手机号快捷登录</text>
-          </button>
+          </button>
       </view>
 
       <!-- 暂不登录 -->
@@ -70,7 +70,7 @@ export default {
       redirectUrl: ''
     }
   },
-  onLoad(options) {
+  onLoad(options) {
       // 处理邀请码
       if (options.inviteCode) {
         uni.setStorageSync('inviteCode', options.inviteCode)
@@ -83,7 +83,7 @@ export default {
       // 注意:不在 onLoad 预取 login code —— 微信 code 一次性有效(5分钟且用过即废),
       // 预取后如果用户隔几分钟再点击授权,code 已失效会报 invalid code (40029)。
       // 正确做法:getPhoneNumber 中用户点击授权时实时获取。
-    },
+    },
   methods: {
     handleImageError(e) {
       console.log('Logo 加载失败,使用默认显示')

+ 9 - 9
cfc-frontend/pages/profile-main/profile.vue

@@ -347,14 +347,7 @@ export default {
       }).catch(function() {})
     },
     loadPoints() {
-      var self = this
-      var memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('userId')
-      if (!memberId) return
-      getPointsBalance(memberId).then(function(res) {
-        if (res && res.data) {
-          self.totalPoints = res.data.totalPoints || res.data.points || 0
-        }
-      }).catch(function() {})
+      // 累计积分由 loadProfileStats 汇总家庭成员积分,此处不再单独覆盖
     },
     loadFamily() {
       var self = this
@@ -444,7 +437,14 @@ export default {
       uni.navigateTo({ url: '/pages/points/points' })
     },
     goToStreak: function() {
-      uni.navigateTo({ url: '/pages/stats/completion-rate' })
+      var memberId = uni.getStorageSync('currentMemberId') || uni.getStorageSync('currentChildId')
+      var url = '/pages/stats/completion-rate'
+      if (memberId) {
+        url += '?memberId=' + memberId
+      } else {
+        url += '?familyView=1'
+      }
+      uni.navigateTo({ url: url })
     },
     goToTasks: function() {
       uni.navigateTo({ url: '/pages/tasks/tasks' })

+ 49 - 2
cfc-frontend/pages/stats/completion-rate.vue

@@ -129,6 +129,19 @@
           <text class="empty-title">暂无任务数据</text>
           <text class="empty-hint">创建任务后将在这里展示完成率分析</text>
         </view>
+
+        <!-- 已完成任务 -->
+        <view class="section-block" v-if="stats.completedTaskList && stats.completedTaskList.length > 0">
+          <view class="section-header">
+            <text class="section-title">已完成打卡</text>
+          </view>
+          <view class="completed-list">
+            <view class="completed-item" v-for="item in stats.completedTaskList" :key="item.id">
+              <text class="completed-title">{{ item.title }}</text>
+              <text class="completed-meta">+{{ item.points || 0 }} 积分</text>
+            </view>
+          </view>
+        </view>
       </template>
     </view>
   </view>
@@ -153,10 +166,12 @@ export default {
         totalTasks: 0,
         completedTasks: 0,
         pendingTasks: 0,
+        inReviewTasks: 0,
         overdueTasks: 0,
         completionRate: 0,
         byChild: [],
-        categoryBreakdown: []
+        categoryBreakdown: [],
+        completedTaskList: []
       }
     }
   },
@@ -208,10 +223,12 @@ export default {
             totalTasks: data.totalTasks || 0,
             completedTasks: data.completedTasks || 0,
             pendingTasks: data.pendingTasks || 0,
+            inReviewTasks: data.inReviewTasks || 0,
             overdueTasks: data.overdueTasks || 0,
             completionRate: data.completionRate || 0,
             byChild: data.byChild || [],
-            categoryBreakdown: data.categoryBreakdown || []
+            categoryBreakdown: data.categoryBreakdown || [],
+            completedTaskList: data.completedTaskList || []
           }
         }
       } catch (e) {
@@ -589,6 +606,36 @@ export default {
   flex-shrink: 0;
 }
 
+/* 已完成任务列表 */
+.completed-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.completed-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
+}
+
+.completed-title {
+  font-size: 28rpx;
+  color: #2D3436;
+  flex: 1;
+  padding-right: 16rpx;
+}
+
+.completed-meta {
+  font-size: 24rpx;
+  color: #10B981;
+  flex-shrink: 0;
+}
+
 /* 空状态 */
 .empty-section {
   display: flex;

+ 21 - 3
cfc-frontend/pages/tasks/tasks.vue

@@ -63,6 +63,7 @@
           <button class="btn-complete" @click="handleTaskAction(task)">{{ getActionLabel(task) }}</button>
         </view>
         <view class="completed-badge" v-else-if="task.status === 'completed'">已完成</view>
+        <view class="review-badge" v-else-if="task.status === 'pending_review'">待审核</view>
         <view class="lock-badge" v-else>🔒 未解锁</view>
       </view>
       <view class="empty" v-if="!loading && todayTasks.length === 0">
@@ -220,7 +221,7 @@ export default {
     const role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     this.isParent = role === 'parent'
     this.currentUserId = uni.getStorageSync('userId')
-    this.currentMemberId = uni.getStorageSync('currentChildId') || this.currentUserId
+    this.currentMemberId = uni.getStorageSync('currentMemberId') || uni.getStorageSync('currentChildId')
     this.checkMembership()
     this.loadInitialData()
   },
@@ -242,6 +243,10 @@ export default {
       try {
         const membersRes = await getFamilyMemberList({ visibleOnly: true })
         this.familyMembers = membersRes.data || []
+        if (!this.currentMemberId && this.familyMembers.length > 0) {
+          var first = this.familyMembers[0]
+          this.currentMemberId = first && first.id ? first.id : null
+        }
         this.loadTodayTasks()
         this.loadPendingTasks()
         this.loadCreatedTasks()
@@ -291,7 +296,11 @@ export default {
       }
       try {
         const res = await completeTaskApi(task.id, this.currentMemberId, '')
-        uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+        if (res.data && res.data.needReview) {
+          uni.showToast({ title: '已提交,待审核', icon: 'none' })
+        } else {
+          uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+        }
         this.loadTodayTasks()
       } catch (e) {
         console.error('完成任务失败', e)
@@ -381,7 +390,11 @@ export default {
       if (!task || !this.inputContent) return
       try {
         const res = await completeTaskApi(task.id, this.currentMemberId, '', this.inputContent, 'text')
-        uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+        if (res.data && res.data.needReview) {
+          uni.showToast({ title: '已提交,待审核', icon: 'none' })
+        } else {
+          uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+        }
         this.closeInputModal()
         this.loadTodayTasks()
       } catch (e) {
@@ -679,6 +692,11 @@ export default {
   font-size: 26rpx;
 }
 
+.review-badge {
+  color: #D97706;
+  font-size: 26rpx;
+}
+
 .lock-badge {
   color: #B0B0B0;
   font-size: 26rpx;

+ 9 - 4
cfc-frontend/utils/api.js

@@ -2035,12 +2035,9 @@ export const uploadReportOnly = (filePath, type) => {
   })
 }
 
-// 阶段1:按 draftId 触发解析,返回完整 payload + 成员匹配
-export const parseReportDraft = (draftId) => request('/api/health/report/parse', 'POST', { draftId })
-
 export const confirmReportPreview = (data) => request('/api/health/report/confirm', 'POST', data)
 
-// 鏀惧純鑽夌
+// 放弃草稿
 export const discardReportDraft = (draftId) => request('/api/health/report/discard', 'POST', { draftId })
 
 // ===== 鎶ュ憡璋冪爺闂嵎 =====
@@ -2098,6 +2095,7 @@ export const approvePlan = (data) => request('/api/health/plan/pending-review/ap
 export const rejectPlan = (data) => request('/api/health/plan/pending-review/reject', 'POST', data)
 export const getHealthPlanList = (data) => request('/api/health/plan/list', 'POST', data)
 export const getHealthPlanDetail = (data) => request('/api/health/plan/detail', 'POST', data)
+export const getHealthPlanTasks = (data) => request('/api/health/plan/tasks', 'POST', data)
 
 // ===== 閭€璇烽噷绋嬬 =====
 export const getInviteMilestones = () => {
@@ -2251,6 +2249,13 @@ export const getSelfCheckHistory = () => {
 export const parseTonguePreview = (data) => request('/api/health/report/parse-preview?type=tongue', 'POST', data)
 export const confirmTongue = (data) => request('/api/health/report/confirm?type=tongue', 'POST', data)
 export const discardTongue = (data) => request('/api/health/report/discard?type=tongue', 'POST', data)
+
+// ===== DAN 测评报告相关 =====
+export const parseReportDraft = (draftId) => request('/api/health/report/parse', 'POST', { draftId })
+export const confirmAssessmentReportConfirmInbox = (resultId, overallScore) => 
+  request('/api/dan-assessment/result/' + resultId + '/confirm-inbox', 'POST', { overallScore })
+export const getMyAssessmentPlans = (familyId, childId) => 
+  request('/api/dan-assessment-result/plans/list', 'POST', { familyId, childId })
 // 鎸囨爣瀹氫箟
 export const getIndicatorDefinitions = (data) => request('/api/admin/indicator/list', 'POST', data)
 

+ 16 - 0
cfc-web/src/api/admin.js

@@ -208,6 +208,22 @@ export function getTaskStats() {
   })
 }
 
+export function getPendingReviewTasks(params) {
+  return request({
+    url: '/api/admin/tasks/pending-review',
+    method: 'post',
+    data: params || {}
+  })
+}
+
+export function reviewAdminTask(taskId, data) {
+  return request({
+    url: `/api/admin/tasks/${taskId}/review`,
+    method: 'post',
+    data
+  })
+}
+
 // 奖励管理
 export function getRewardsList(params) {
   return request({

+ 7 - 1
cfc-web/src/api/assessment.js

@@ -85,4 +85,10 @@ export function savePlanRule(data) {
 
 export function deletePlanRule(data) {
   return request({ url: '/api/admin/plan-rules/delete', method: 'post', data })
-}
+}
+
+// ===== 测评方案实例管理 =====
+export const listFamilyPlans = (data) => request({ url: '/api/guide/plans/family-list', method: 'post', data })
+export const reviewPlan = (data) => request({ url: '/api/guide/plans/review', method: 'post', data })
+export const activatePlan = (data) => request({ url: '/api/guide/plans/activate', method: 'post', data })
+export const getGuidePlanDetail = (data) => request({ url: '/api/guide/plans/detail', method: 'post', data })

+ 5 - 4
cfc-web/src/views/GuideFamilyTask.vue

@@ -23,6 +23,7 @@
         <el-form-item label="状态">
           <el-select v-model="searchForm.status" placeholder="全部" clearable @change="loadChildTasks">
             <el-option label="待完成" value="pending"></el-option>
+            <el-option label="待审核" value="pending_review"></el-option>
             <el-option label="已完成" value="completed"></el-option>
             <el-option label="已超时" value="overdue"></el-option>
           </el-select>
@@ -125,8 +126,8 @@
               </el-button>
               <el-dropdown-menu slot="dropdown">
                 <el-dropdown-item v-if="row.status !== 'cancelled'" command="delete" icon="el-icon-delete">删除</el-dropdown-item>
-                <el-dropdown-item v-if="row.status === 'completed' && row.needReview === 1" command="reviewPass" icon="el-icon-check">通过</el-dropdown-item>
-                <el-dropdown-item v-if="row.status === 'completed' && row.needReview === 1" command="reviewReject" icon="el-icon-close">拒绝</el-dropdown-item>
+                <el-dropdown-item v-if="row.status === 'pending_review' && row.needReview === 1" command="reviewPass" icon="el-icon-check">通过</el-dropdown-item>
+                <el-dropdown-item v-if="row.status === 'pending_review' && row.needReview === 1" command="reviewReject" icon="el-icon-close">拒绝</el-dropdown-item>
               </el-dropdown-menu>
             </el-dropdown>
           </template>
@@ -312,11 +313,11 @@ taskLoading: false,
       this.loadChildTasks()
     },
     getStatusType(status) {
-      const map = { pending: 'info', completed: 'success', overdue: 'danger', cancelled: 'info' }
+      const map = { pending: 'info', pending_review: 'warning', completed: 'success', overdue: 'danger', cancelled: 'info' }
       return map[status] || 'info'
     },
     getStatusText(status) {
-      const map = { pending: '待完成', completed: '已完成', overdue: '已超时', cancelled: '已取消' }
+      const map = { pending: '待完成', pending_review: '待审核', completed: '已完成', overdue: '已超时', cancelled: '已取消' }
       return map[status] || status
     },
     formatDate(date) {

+ 55 - 2
cfc-web/src/views/Tasks.vue

@@ -27,6 +27,7 @@
         <el-form-item label="状态">
           <el-select v-model="searchForm.status" placeholder="全部状态" clearable @change="handleSearch">
             <el-option label="进行中" value="pending"></el-option>
+            <el-option label="待审核" value="pending_review"></el-option>
             <el-option label="已完成" value="completed"></el-option>
             <el-option label="已逾期" value="overdue"></el-option>
             <el-option label="已取消" value="cancelled"></el-option>
@@ -128,6 +129,15 @@
             {{ formatDate(scope.row.createdAt) }}
           </template>
         </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template slot-scope="scope">
+            <template v-if="scope.row.status === 'pending_review' && scope.row.needReview">
+              <el-button size="mini" type="success" @click="handleReview(scope.row, true)">通过</el-button>
+              <el-button size="mini" type="danger" @click="handleReview(scope.row, false)">拒绝</el-button>
+            </template>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
       </el-table>
 
       <!-- 分页 -->
@@ -142,11 +152,26 @@
         layout="total, sizes, prev, pager, next, jumper"
       ></el-pagination>
     </el-card>
+
+    <el-dialog title="审核任务" :visible.sync="reviewDialogVisible" width="400px">
+      <el-form>
+        <el-form-item label="任务标题">{{ currentTask.title }}</el-form-item>
+        <el-form-item label="提交时间">{{ formatDate(currentTask.completedAt) }}</el-form-item>
+        <el-form-item label="审核意见">
+          <el-input type="textarea" v-model="reviewComment" rows="3" placeholder="请输入审核意见(选填)"></el-input>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="reviewDialogVisible = false">取消</el-button>
+        <el-button type="danger" @click="confirmReview(false)">拒绝</el-button>
+        <el-button type="primary" @click="confirmReview(true)">通过</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
-import { getTasksList, getTaskStats, getFamilyList } from '@/api/admin'
+import { getTasksList, getTaskStats, getFamilyList, reviewAdminTask } from '@/api/admin'
 import SortMixin from '@/mixins/SortMixin'
 
 export default {
@@ -186,7 +211,11 @@ export default {
         current: 1,
         size: 10,
         total: 0
-      }
+      },
+      reviewDialogVisible: false,
+      reviewAction: true,
+      reviewComment: '',
+      currentTask: {}
     }
   },
   mounted() {
@@ -282,6 +311,7 @@ export default {
     getStatusType(status) {
       const types = {
         pending: 'warning',
+        pending_review: 'warning',
         completed: 'success',
         overdue: 'danger',
         cancelled: 'info'
@@ -291,12 +321,35 @@ export default {
     getStatusText(status) {
       const texts = {
         pending: '进行中',
+        pending_review: '待审核',
         completed: '已完成',
         overdue: '已逾期',
         cancelled: '已取消'
       }
       return texts[status] || status
     },
+    handleReview(task, approved) {
+      this.currentTask = task || {}
+      this.reviewAction = approved
+      this.reviewComment = ''
+      this.reviewDialogVisible = true
+    },
+    async confirmReview(approved) {
+      if (!this.currentTask || !this.currentTask.id) {
+        return
+      }
+      try {
+        await reviewAdminTask(this.currentTask.id, {
+          approved: approved != null ? approved : this.reviewAction,
+          comment: this.reviewComment
+        })
+        this.$message.success(approved ? '已通过,积分已发放' : '已拒绝')
+        this.reviewDialogVisible = false
+        this.loadTasks()
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    },
     formatDate(dateStr) {
       if (!dateStr) return '-'
       const date = new Date(dateStr)

+ 2 - 2
cfc-web/src/views/admin/ReportCollectorTrainer.vue

@@ -117,7 +117,7 @@
 </template>
 
 <script>
-import { getUnknownClusters, listUploads, generateTypeFromCluster } from '@/api/reportParser'
+import { getUnknownClusters, getClusterUploads, generateTypeFromCluster } from '@/api/reportParser'
 import { getReportTemplates } from '@/api/reportTemplate'
 
 export default {
@@ -163,7 +163,7 @@ export default {
       this.uploads = []
       this.selectedUpload = null
       this.uploadLoading = true
-      listUploads({ clusterId: row.id }).then(res => {
+      getClusterUploads({ clusterId: row.id }).then(res => {
         this.uploads = res.data || []
       }).catch(() => {}).finally(() => { this.uploadLoading = false })
     },

+ 2 - 2
cfc-web/src/views/admin/ReportTemplateManage.vue

@@ -139,7 +139,7 @@
         <el-form-item>
           <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
           <el-button @click="handlePreview">预览效果</el-button>
-        </el-formItem>
+        </el-form-item>
       </el-form>
     </el-card>
 
@@ -398,7 +398,7 @@ export default {
 .block-item:last-child { border-bottom: none; }
 .block-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
 .drag-handle { cursor: move; color: #bbb; font-size: 16px; }
-.block-type-tag { background: #f0f9ff; color: #2c7be5; padding: 2rpx 8rpx; border-radius: 4px; font-size: 12px; }
+.block-type-tag { background: #f0f9ff; color: #2c7be5; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
 .block-title-display { color: #333; font-size: 14px; }
 .block-del { margin-left: auto; }
 .hint-text { margin-left: 12px; color: #999; font-size: 12px; }

Некоторые файлы не были показаны из-за большого количества измененных файлов