Browse Source

merge origin/cfclub: 合并主链路远程实现,修复本地修复冲突(保留双方语义、去重复方法、lombok兼容)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 3 weeks ago
parent
commit
95335b064e
73 changed files with 4355 additions and 1129 deletions
  1. 3 0
      .gitignore
  2. 1 1
      cfc-backend/config/application-prod.yml
  3. 363 356
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  4. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java
  5. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  6. 27 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  7. 33 44
      cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java
  8. 27 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  9. 36 5
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  10. 34 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java
  11. 67 11
      cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java
  12. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  13. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideManagementController.java
  14. 9 5
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java
  15. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
  16. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java
  17. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
  18. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  19. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  20. 11 4
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  21. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java
  22. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java
  23. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java
  24. 209 172
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  25. 31 8
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskStatsService.java
  26. 8 5
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  27. 203 10
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  28. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java
  29. 1 1
      cfc-backend/src/main/resources/application.yml
  30. 52 1
      cfc-backend/src/main/resources/schema.sql
  31. 0 137
      cfc-backend/src/test/java/com/etotem/cfc/service/DifySyncServiceTest.java
  32. 2 1
      cfc-backend/startup.sh
  33. 13 2
      cfc-frontend/components/wuxing-sandbox.vue
  34. 3 2
      cfc-frontend/config.js
  35. 35 0
      cfc-frontend/pages.json
  36. 11 11
      cfc-frontend/pages/body-detail/index.vue
  37. 32 0
      cfc-frontend/pages/health-main/index.vue
  38. 353 66
      cfc-frontend/pages/health/health-plan-summary.vue
  39. 18 14
      cfc-frontend/pages/health/report-confirm.vue
  40. 63 6
      cfc-frontend/pages/health/report-detail.vue
  41. 23 5
      cfc-frontend/pages/index-home/index.vue
  42. 3 3
      cfc-frontend/pages/login/login.vue
  43. 1 1
      cfc-frontend/pages/mind-detail/index.vue
  44. 9 9
      cfc-frontend/pages/profile-main/profile.vue
  45. 127 0
      cfc-frontend/pages/profile/portrait-edit.vue
  46. 49 2
      cfc-frontend/pages/stats/completion-rate.vue
  47. 21 3
      cfc-frontend/pages/tasks/tasks.vue
  48. 257 0
      cfc-frontend/pages/wealth-detail/member-wealth-detail.vue
  49. 43 1
      cfc-frontend/pages/wealth/index.vue
  50. 1 1
      cfc-frontend/pages/wisdom-detail/index.vue
  51. 19 6
      cfc-frontend/utils/api.js
  52. 54 0
      cfc-langgraph/app/api/adapter.py
  53. 9 0
      cfc-langgraph/app/graphs/chat_graph.py
  54. 7 0
      cfc-langgraph/app/graphs/health_butler_graph.py
  55. 9 0
      cfc-langgraph/app/graphs/health_coach_graph.py
  56. 159 0
      cfc-langgraph/app/graphs/nutrition_graph.py
  57. 110 0
      cfc-langgraph/app/portrait_service.py
  58. 1 1
      cfc-web/.last_build_commit
  59. 2 2
      cfc-web/package-lock.json
  60. 1 1
      cfc-web/package.json
  61. 106 0
      cfc-web/public/CHANGELOG-v1.0.md
  62. 107 1
      cfc-web/public/CHANGELOG.md
  63. 16 0
      cfc-web/src/api/admin.js
  64. 7 1
      cfc-web/src/api/assessment.js
  65. 5 4
      cfc-web/src/views/GuideFamilyTask.vue
  66. 55 2
      cfc-web/src/views/Tasks.vue
  67. 2 2
      cfc-web/src/views/admin/ReportCollectorTrainer.vue
  68. 2 2
      cfc-web/src/views/admin/ReportTemplateManage.vue
  69. 1 0
      cfc.pid
  70. 1 1
      docs/superpowers/PROJECT-OVERVIEW.md
  71. 801 0
      docs/superpowers/plans/2026-08-24-user-portrait-prompt.md
  72. 171 208
      docs/superpowers/specs/2026-08-08-tabbar-redesign.md
  73. 305 0
      docs/superpowers/specs/2026-08-24-user-portrait-prompt-design.md

+ 3 - 0
.gitignore

@@ -37,6 +37,9 @@ coverage/
 *~
 *~
 ~$*
 ~$*
 
 
+# 健康报告解析临时结果(含用户健康数据,不入库)
+**/uploads/json-results/
+
 # Sisyphus work directory
 # Sisyphus work directory
 .sisyphus/
 .sisyphus/
 
 

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

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

File diff suppressed because it is too large
+ 363 - 356
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java


+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java

@@ -1,15 +1,18 @@
 package com.etotem.cfc.config;
 package com.etotem.cfc.config;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import java.io.IOException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
 import org.springframework.web.multipart.MultipartException;
 import org.springframework.web.multipart.MultipartException;
 
 
 @RestControllerAdvice
 @RestControllerAdvice
@@ -63,6 +66,27 @@ public class GlobalExceptionHandler {
         return Result.error(400, "请求格式错误,请使用文件表单上传");
         return Result.error(400, "请求格式错误,请使用文件表单上传");
     }
     }
 
 
+    // 旧客户端用 GET 等废弃方法调用已统一为 POST 的接口(如 /api/notices/list),返回干净的 405,不打错误栈
+    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+    public Result<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
+        log.warn("请求方法不支持: uri={}, method={}", request.getRequestURI(), e.getMethod());
+        return Result.error(405, "请求方法不支持,本平台接口统一使用POST");
+    }
+
+    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+    public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
+        log.warn("路径参数类型错误: uri={}, error={}", request.getRequestURI(), e.getMessage());
+        return Result.error(400, "请求参数格式错误");
+    }
+
+    // 客户端中断连接(Broken pipe / ClientAbortException):连接已断开,无法写响应,
+    // 只记录日志,避免在 @ExceptionHandler 中二次抛异常导致 "Failure in @ExceptionHandler"
+    @ExceptionHandler(IOException.class)
+    public Result<Void> handleClientAbort(IOException e, HttpServletRequest request) {
+        log.warn("客户端连接中断: uri={}, error={}", request.getRequestURI(), e.getMessage());
+        return null;
+    }
+
     @ExceptionHandler(Exception.class)
     @ExceptionHandler(Exception.class)
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
         log.error("未捕获异常: {} - {} | uri={}", e.getClass().getName(), e.getMessage(), request.getRequestURI(), e);
         log.error("未捕获异常: {} - {} | uri={}", e.getClass().getName(), e.getMessage(), request.getRequestURI(), e);

+ 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.common.Result;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.service.HealthPlanService;
 import com.etotem.cfc.service.HealthPlanService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -122,9 +123,25 @@ public class HealthPlanController {
         if (planId == null) return Result.error("planId不能为空");
         if (planId == null) return Result.error("planId不能为空");
         HealthPlan plan = healthPlanService.getPlanDetail(planId);
         HealthPlan plan = healthPlanService.getPlanDetail(planId);
         if (plan == null) return Result.error("方案不存在");
         if (plan == null) return Result.error("方案不存在");
+        plan.setTasks(healthPlanService.getPlanTasks(planId));
         return Result.success(plan);
         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 = "获取可用规划师列表")
     @Operation(summary = "获取可用规划师列表")
     @PostMapping("/available-teachers")
     @PostMapping("/available-teachers")
     public Result<List<Map<String, Object>>> availableTeachers(
     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
     @Resource
     private com.etotem.cfc.service.PurchaseService purchaseService;
     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);
             return Result.success(result);
         }
         }
 
 
-        if (file.isEmpty()) {
+        if (file == null || file.isEmpty()) {
             return Result.error("文件不能为空");
             return Result.error("文件不能为空");
         }
         }
 
 
@@ -1150,6 +1153,9 @@ public class HealthReportController {
             if (subjectId != null) {
             if (subjectId != null) {
                 detail.put("subjectId", subjectId);
                 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);
             return Result.success(detail);
 
 
         } catch (Exception e) {
         } 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();
         Long familyId = user.getFamilyId();
         Map<String, Object> stats = new HashMap<>();
         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);
         stats.put("totalPoints", totalPoints);
 
 
-        // 2. 连续打卡天数(取所有孩子中最高值)
+        // 2. 连续打卡天数(取家庭成员中最高值)
         Integer maxStreakDays = 0;
         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);
         stats.put("streakDays", maxStreakDays);
@@ -240,11 +244,7 @@ public class UserController {
         // 4. 心愿兑换次数(按家庭成员统计)
         // 4. 心愿兑换次数(按家庭成员统计)
         Long rewardsEarned = 0L;
         Long rewardsEarned = 0L;
         if (familyId != null) {
         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(
                 Long cnt = pointsExchangeRecordMapper.selectCount(
                     new LambdaQueryWrapper<com.etotem.cfc.entity.PointsExchangeRecord>()
                     new LambdaQueryWrapper<com.etotem.cfc.entity.PointsExchangeRecord>()
                         .eq(com.etotem.cfc.entity.PointsExchangeRecord::getFamilyMemberId, m.getId())
                         .eq(com.etotem.cfc.entity.PointsExchangeRecord::getFamilyMemberId, m.getId())
@@ -256,21 +256,15 @@ public class UserController {
 
 
         // 5. 收集勋章数(所有家庭成员)
         // 5. 收集勋章数(所有家庭成员)
         Integer badgesCollected = 0;
         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);
         stats.put("badgesCollected", badgesCollected);
@@ -284,27 +278,22 @@ public class UserController {
 
 
         // 7. 参加挑战数(按家庭成员childId统计)
         // 7. 参加挑战数(按家庭成员childId统计)
         Integer challengesJoined = 0;
         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);
         stats.put("challengesJoined", challengesJoined);
 
 
-        // 8. 上传健康报告数
+        // 8. 上传健康报告数(与 /api/health/report/list 一致,仅统计 active)
         Long healthReports = 0L;
         Long healthReports = 0L;
         if (familyId != null) {
         if (familyId != null) {
             healthReports = healthReportMapper.selectCount(
             healthReports = healthReportMapper.selectCount(
                 new LambdaQueryWrapper<com.etotem.cfc.entity.HealthReport>()
                 new LambdaQueryWrapper<com.etotem.cfc.entity.HealthReport>()
                     .eq(com.etotem.cfc.entity.HealthReport::getFamilyId, familyId)
                     .eq(com.etotem.cfc.entity.HealthReport::getFamilyId, familyId)
+                    .eq(com.etotem.cfc.entity.HealthReport::getStatus, "active")
             );
             );
         }
         }
         stats.put("healthReports", healthReports);
         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.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 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.etotem.cfc.service.UserService;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 
 
 import org.springframework.util.DigestUtils;
 import org.springframework.util.DigestUtils;
@@ -58,6 +62,9 @@ public class AdminController {
     @Resource
     @Resource
     private UserService userService;
     private UserService userService;
 
 
+    @Resource
+    private TaskService taskService;
+
     @Resource
     @Resource
     private GuidePackageMapper guidePackageMapper;
     private GuidePackageMapper guidePackageMapper;
 
 
@@ -285,8 +292,8 @@ public class AdminController {
         // 为每条任务填充执行者名称
         // 为每条任务填充执行者名称
         java.util.List<Map<String, Object>> records = new java.util.ArrayList<>();
         java.util.List<Map<String, Object>> records = new java.util.ArrayList<>();
         for (Task t : result.getRecords()) {
         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 查昵称
             // 根据 executorType 查昵称
             String executorType = t.getExecutorType();
             String executorType = t.getExecutorType();
             Long executorId = t.getExecutorId();
             Long executorId = t.getExecutorId();
@@ -324,6 +331,24 @@ public class AdminController {
         return Result.success(stats);
         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")
     @PostMapping("/rewards")
     public Result<Page<Reward>> getRewards(@RequestBody Map<String, Object> params) {
     public Result<Page<Reward>> getRewards(@RequestBody Map<String, Object> params) {

+ 36 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -59,6 +59,9 @@ public class AIChatController {
     @Resource
     @Resource
     private AiGateway aiGateway;
     private AiGateway aiGateway;
 
 
+    @Resource
+    private com.etotem.cfc.service.PortraitService portraitService;
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
     public Result<Map<String, Object>> sendMessage(
@@ -109,6 +112,15 @@ public class AIChatController {
             inputs.put("mascot_persona", "");
             inputs.put("mascot_persona", "");
         }
         }
 
 
+        // 画像注入
+        String chatMemberIdStr = params.get("memberId");
+        Long chatMemberId = chatMemberIdStr != null && !chatMemberIdStr.trim().isEmpty()
+                ? Long.valueOf(chatMemberIdStr) : null;
+        String chatPortrait = portraitService.buildPortraitPrompt(userId, chatMemberId);
+        if (chatPortrait != null) {
+            inputs.put("portrait_prompt", chatPortrait);
+        }
+
         // AI记忆层注入(会话摘要+关键事实)
         // AI记忆层注入(会话摘要+关键事实)
         inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
         inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
 
 
@@ -272,11 +284,19 @@ public class AIChatController {
             inputs.put("mascot_persona", "");
             inputs.put("mascot_persona", "");
         }
         }
 
 
-        // 3. 调用精准营养助手
-        Map<String, Object> difyResp = aiService.sendNutritionMessage(
-                query, String.valueOf(userId), conversationId, inputs);
+        // 3. 注入用户画像
+        String memberIdStr = params.get("memberId");
+        Long memberId = memberIdStr != null && !memberIdStr.trim().isEmpty()
+                ? Long.valueOf(memberIdStr) : null;
+        String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+        if (portraitPrompt != null) {
+            inputs.put("portrait_prompt", portraitPrompt);
+        }
+
+        // 4. 调用营养助手(走 LangGraph)
+        Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
 
 
-        String answer = (String) difyResp.getOrDefault("answer", "");
+        String answer = (String) resp.getOrDefault("answer", "");
 
 
         // 4. 解析 [RECOMMEND] 标记
         // 4. 解析 [RECOMMEND] 标记
         List<RecommendationResult> recommendations = null;
         List<RecommendationResult> recommendations = null;
@@ -340,7 +360,7 @@ public class AIChatController {
     // 5. 组装结果
     // 5. 组装结果
     Map<String, Object> result = new LinkedHashMap<>();
     Map<String, Object> result = new LinkedHashMap<>();
     result.put("answer", cleanAnswer);
     result.put("answer", cleanAnswer);
-    result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+    result.put("conversationId", resp != null ? resp.getOrDefault("conversationId", "") : "");
     result.put("recommendations", recommendations != null ? recommendations : Collections.emptyList());
     result.put("recommendations", recommendations != null ? recommendations : Collections.emptyList());
     result.put("tasks", tasks);
     result.put("tasks", tasks);
     return Result.success(result);
     return Result.success(result);
@@ -448,6 +468,11 @@ public class AIChatController {
             inputs.put("mascot_gender", "");
             inputs.put("mascot_gender", "");
             inputs.put("mascot_persona", "");
             inputs.put("mascot_persona", "");
         }
         }
+        // 画像注入
+        String coachPortrait = portraitService.buildPortraitPrompt(userId, memberId);
+        if (coachPortrait != null) {
+            inputs.put("portrait_prompt", coachPortrait);
+        }
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         Map<String, Object> result = new LinkedHashMap<>();
         Map<String, Object> result = new LinkedHashMap<>();
@@ -480,6 +505,12 @@ public class AIChatController {
         if (params.get("focus") != null) {
         if (params.get("focus") != null) {
             inputs.put("focus", params.get("focus").toString());
             inputs.put("focus", params.get("focus").toString());
         }
         }
+        // 画像注入
+        Long butlerMemberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+        String butlerPortrait = portraitService.buildPortraitPrompt(userId, butlerMemberId);
+        if (butlerPortrait != null) {
+            inputs.put("portrait_prompt", butlerPortrait);
+        }
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         Map<String, Object> result = new LinkedHashMap<>();
         Map<String, Object> result = new LinkedHashMap<>();

+ 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.GrowthGuidanceService;
 import com.etotem.cfc.service.ReportParseService;
 import com.etotem.cfc.service.ReportParseService;
 import com.etotem.cfc.service.StorageService;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.service.TaskPlanService;
+import com.etotem.cfc.entity.TaskPlanInstance;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -52,6 +54,32 @@ public class AssessmentReportController {
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
+    @Resource
+    private TaskPlanService taskPlanService;
+
+    @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 = taskPlanService.getChildPlans(childId);
+            } else {
+                plans = taskPlanService.getFamilyPlans(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 = "家长上传测评报告")
     @Operation(summary = "家长上传测评报告")
     @PostMapping("/upload")
     @PostMapping("/upload")
     public Result<DanAssessmentResult> uploadReport(
     public Result<DanAssessmentResult> uploadReport(
@@ -121,8 +149,8 @@ public class AssessmentReportController {
         return Result.success(list);
         return Result.success(list);
     }
     }
 
 
-    @Operation(summary = "规划师确认家长上传报告")
-    @PostMapping("/{resultId}/confirm-upload")
+    @Operation(summary = "用户确认报告入库并自动生成方案")
+    @PostMapping("/{resultId}/confirm-inbox")
     public Result<DanAssessmentResult> confirmUpload(
     public Result<DanAssessmentResult> confirmUpload(
             @PathVariable Long resultId,
             @PathVariable Long resultId,
             @RequestBody Map<String, Object> body) {
             @RequestBody Map<String, Object> body) {
@@ -140,16 +168,16 @@ public class AssessmentReportController {
         }
         }
         resultMapper.updateById(result);
         resultMapper.updateById(result);
 
 
-        // 自动生成测评方案
+        // 用户确认后立即自动生成测评方案(无需规划师审核)
         try {
         try {
             User child = userMapper.selectById(result.getChildId());
             User child = userMapper.selectById(result.getChildId());
             Long familyId = child != null ? child.getFamilyId() : null;
             Long familyId = child != null ? child.getFamilyId() : null;
             if (familyId != 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) {
         } catch (Exception e) {
-            log.error("自动生成方案失败: resultId={}", resultId, e);
+            log.error("自动生成方案失败resultId={}", resultId, e);
         }
         }
 
 
         return Result.success(result);
         return Result.success(result);

+ 67 - 11
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.List;
 import java.util.Map;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
 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 = "成长方案创建、更新、复盘等接口")
 @Tag(name = "成长方案", description = "成长方案创建、更新、复盘等接口")
 @RestController
 @RestController
@@ -20,17 +24,11 @@ public class GrowthPlanController {
     @Resource
     @Resource
     private GrowthPlanService growthPlanService;
     private GrowthPlanService growthPlanService;
 
 
-    @Operation(summary = "获取成长方案列表(规划师/管理员)")
-    @PostMapping("/list")
-    public Result<List<Map<String, Object>>> listPlans(@RequestBody(required = false) Map<String, Object> body,
-                                                       @RequestAttribute("userId") Long userId,
-                                                       @RequestAttribute("role") String role) {
-        if (!"teacher".equals(role) && !"admin".equals(role)) {
-            return Result.error("无权限");
-        }
-        List<Map<String, Object>> list = growthPlanService.listVos("admin".equals(role), userId);
-        return Result.success(list);
-    }
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private GrowthPlanMapper growthPlanMapper;
 
 
     @Operation(summary = "创建成长方案")
     @Operation(summary = "创建成长方案")
     @PostMapping("/create")
     @PostMapping("/create")
@@ -104,4 +102,62 @@ public class GrowthPlanController {
         }
         }
         return Result.success(true);
         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>()
                         new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Task>()
                             .eq(Task::getFamilyMemberId, child.getId())
                             .eq(Task::getFamilyMemberId, child.getId())
                             .eq(Task::getNeedReview, 1)
                             .eq(Task::getNeedReview, 1)
-                            .eq(Task::getStatus, "completed"));
+                            .eq(Task::getStatus, "pending_review"));
                     pendingReview += pending;
                     pendingReview += pending;
                 }
                 }
             }
             }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideManagementController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.GuideApplication;
 import com.etotem.cfc.entity.GuideApplicationRecord;
 import com.etotem.cfc.entity.GuideApplicationRecord;
 import com.etotem.cfc.entity.GuideInviteCode;
 import com.etotem.cfc.entity.GuideInviteCode;
 import com.etotem.cfc.entity.GuidePackage;
 import com.etotem.cfc.entity.GuidePackage;
+import com.etotem.cfc.entity.GuidePackageTemplate;
 import com.etotem.cfc.entity.GuideHierarchy;
 import com.etotem.cfc.entity.GuideHierarchy;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
@@ -15,6 +16,7 @@ import com.etotem.cfc.service.GuideApplicationService;
 import com.etotem.cfc.service.GuideHierarchyService;
 import com.etotem.cfc.service.GuideHierarchyService;
 import com.etotem.cfc.service.GuideInviteCodeService;
 import com.etotem.cfc.service.GuideInviteCodeService;
 import com.etotem.cfc.service.GuidePackageService;
 import com.etotem.cfc.service.GuidePackageService;
+import com.etotem.cfc.service.GuidePackageTemplateService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.UserService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +46,9 @@ public class GuideManagementController {
     @Resource
     @Resource
     private GuidePackageService guidePackageService;
     private GuidePackageService guidePackageService;
 
 
+    @Resource
+    private GuidePackageTemplateService guidePackageTemplateService;
+
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
@@ -266,6 +271,12 @@ public class GuideManagementController {
         return Result.success(parentGuide);
         return Result.success(parentGuide);
     }
     }
 
 
+    @Operation(summary = "获取套餐模板列表")
+    @PostMapping("/package/templates")
+    public Result<List<GuidePackageTemplate>> getGuidePackageTemplates() {
+        return Result.success(guidePackageTemplateService.list());
+    }
+
     /**
     /**
      * 添加成长规划师任务模板
      * 添加成长规划师任务模板
      */
      */

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

@@ -41,17 +41,21 @@ public class PlanReviewController {
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
                                                            @RequestAttribute("userId") Long userId,
                                                            @RequestAttribute("userId") Long userId,
                                                            @RequestAttribute("role") String role,
                                                            @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)) {
         if (!"teacher".equals(role) && !"admin".equals(role)) {
             return Result.error("无权限");
             return Result.error("无权限");
         }
         }
         String status = (String) body.get("status");
         String status = (String) body.get("status");
-        Long filterFamilyId = familyId;
+        // 合并逻辑:admin 未指定家庭时查看全部方案;teacher 优先 body.familyId,JWT 作为默认值
+        Long filterFamilyId;
         if ("admin".equals(role)) {
         if ("admin".equals(role)) {
-            // 管理员可通过 body.familyId 指定家庭筛选;不指定则查看全部方案
             filterFamilyId = ParamUtils.getLong(body.get("familyId"));
             filterFamilyId = ParamUtils.getLong(body.get("familyId"));
-        } else if (familyId == null) {
-            return Result.noFamily("请先创建或加入家庭");
+        } else {
+            Object bodyFamilyId = body.get("familyId");
+            filterFamilyId = bodyFamilyId != null ? ParamUtils.getLong(bodyFamilyId) : jwtFamilyId;
+            if (filterFamilyId == null) {
+                return Result.noFamily("请先创建或加入家庭");
+            }
         }
         }
         LambdaQueryWrapper<TaskPlanInstance> w = new LambdaQueryWrapper<TaskPlanInstance>()
         LambdaQueryWrapper<TaskPlanInstance> w = new LambdaQueryWrapper<TaskPlanInstance>()
                 .orderByDesc(TaskPlanInstance::getCreatedAt);
                 .orderByDesc(TaskPlanInstance::getCreatedAt);

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.controller.user;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.PortraitService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/user/portrait")
+public class PortraitController {
+
+    @Resource
+    private PortraitService portraitService;
+
+    @PostMapping("/get")
+    public Result<Map<String, Object>> getPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long targetUserId = params.get("userId") != null
+                ? Long.valueOf(params.get("userId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        return Result.success(portraitService.getPortrait(userId, targetUserId, memberId));
+    }
+
+    @PostMapping("/edit")
+    public Result<String> editPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Object promptObj = params.get("portraitPrompt");
+        String portraitPrompt = promptObj != null ? promptObj.toString() : null;
+        if (portraitPrompt != null && portraitPrompt.length() > 2000) {
+            return Result.error("画像描述最多 2000 字符");
+        }
+        portraitService.updatePortraitPrompt(userId, portraitPrompt);
+        return Result.success("保存成功");
+    }
+}

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

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -113,6 +113,10 @@ public class User implements Serializable {
     // AI助手形象: xibao(浠宝-女孩)/fubao(福宝-男孩)/null(未选择)
     // AI助手形象: xibao(浠宝-女孩)/fubao(福宝-男孩)/null(未选择)
     private String mascot;
     private String mascot;
 
 
+    // 用户自定义画像 prompt
+    @TableField("portrait_prompt")
+    private String portraitPrompt;
+
     // 家庭管理员标记
     // 家庭管理员标记
     private Boolean isFamilyAdmin;
     private Boolean isFamilyAdmin;
 
 

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
@@ -21,4 +22,13 @@ public interface HealthPlanService {
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     List<Map<String, Object>> getAvailableTeachers(Long familyId);
     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 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
     @Resource
     private HealthReportDraftMapper draftMapper;
     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 com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
@@ -48,6 +49,9 @@ public class HealthReportService {
 
 
     @Resource
     @Resource
     private HealthReportMapper healthReportMapper;
     private HealthReportMapper healthReportMapper;
+    
+    @Resource
+    protected JdbcTemplate jdbcTemplate;
 
 
     @Resource
     @Resource
     private HealthIndicatorMapper healthIndicatorMapper;
     private HealthIndicatorMapper healthIndicatorMapper;
@@ -1118,10 +1122,13 @@ public class HealthReportService {
     }
     }
 
 
     public void bindReportToMember(Long reportId, Long subjectId) {
     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);
         }
         }
     }
     }
 
 

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

@@ -68,6 +68,7 @@ public class PlanActivationService {
         Task guideTask = new Task();
         Task guideTask = new Task();
         guideTask.setFamilyId(plan.getFamilyId());
         guideTask.setFamilyId(plan.getFamilyId());
         guideTask.setChildId(plan.getChildId());
         guideTask.setChildId(plan.getChildId());
+        guideTask.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
         guideTask.setCreatorId(operatorId);
         guideTask.setCreatorId(operatorId);
         guideTask.setExecutorType("child");
         guideTask.setExecutorType("child");
         guideTask.setExecutorId(plan.getChildId());
         guideTask.setExecutorId(plan.getChildId());
@@ -98,6 +99,7 @@ public class PlanActivationService {
                 Task t = new Task();
                 Task t = new Task();
                 t.setFamilyId(plan.getFamilyId());
                 t.setFamilyId(plan.getFamilyId());
                 t.setChildId(plan.getChildId());
                 t.setChildId(plan.getChildId());
+                t.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
                 t.setCreatorId(operatorId);
                 t.setCreatorId(operatorId);
                 t.setExecutorType("child");
                 t.setExecutorType("child");
                 t.setExecutorId(plan.getChildId());
                 t.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<>();
         Map<String, Object> result = new HashMap<>();
         result.put("balance", child.getTotalPoints());
         result.put("balance", child.getTotalPoints());
+        result.put("totalPoints", child.getTotalPoints());
         // 下个月1日0点
         // 下个月1日0点
         java.util.Calendar cal = java.util.Calendar.getInstance();
         java.util.Calendar cal = java.util.Calendar.getInstance();
         cal.set(java.util.Calendar.DAY_OF_MONTH, 1);
         cal.set(java.util.Calendar.DAY_OF_MONTH, 1);

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface PortraitService {
+    /** 获取用户画像文本(自定义 prompt + 快照渲染) */
+    String buildPortraitPrompt(Long userId, Long memberId);
+    /** 更新用户自定义画像 prompt */
+    void updatePortraitPrompt(Long userId, String portraitPrompt);
+    /** 读取用户画像(含渲染快照,用于前端展示) */
+    Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
+}

+ 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)
             .eq(Task::getFamilyMemberId, memberId)
             .ge(Task::getDeadline, startOfDay)
             .ge(Task::getDeadline, startOfDay)
             .lt(Task::getDeadline, endOfDay)
             .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);
             .orderByAsc(Task::getDeadline);
 
 
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
@@ -386,7 +386,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
                 .eq(Task::getCategory, category)
                 .eq(Task::getCategory, category)
                 .ge(Task::getDeadline, startOfDay)
                 .ge(Task::getDeadline, startOfDay)
                 .lt(Task::getDeadline, endOfDay)
                 .lt(Task::getDeadline, endOfDay)
-                .in(Task::getStatus, Arrays.asList("pending", "completed"))
+                .in(Task::getStatus, Arrays.asList("pending", "completed", "pending_review"))
                 .orderByAsc(Task::getDeadline)));
                 .orderByAsc(Task::getDeadline)));
     }
     }
 
 
@@ -505,8 +505,8 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             return null;
             return null;
         }
         }
 
 
-        if ("completed".equals(task.getStatus())) {
-            return null; // 任务已完成,不能重复提交
+        if ("completed".equals(task.getStatus()) || "pending_review".equals(task.getStatus())) {
+            return null; // 任务已完成或已提交待审核,不能重复提交
         }
         }
 
 
         // TASK-SYS-248: 前置任务校验——前置未完成则拒绝
         // TASK-SYS-248: 前置任务校验——前置未完成则拒绝
@@ -554,200 +554,57 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         }
         }
 
 
         Date now = new Date();
         Date now = new Date();
-        int pointsEarned;
 
 
         // 检查是否为小游戏任务
         // 检查是否为小游戏任务
         if ("小游戏类".equals(task.getCategory()) && task.getMinigameCode() != null) {
         if ("小游戏类".equals(task.getCategory()) && task.getMinigameCode() != null) {
-            // 小游戏任务不通过此接口完成,需要通过小游戏界面完成
-            // 这里只返回null,实际完成逻辑在completeMinigameTask方法中
             return null;
             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<>();
         Map<String, Object> result = new HashMap<>();
         result.put("pointsEarned", pointsEarned);
         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) {
     public boolean reviewTask(Long taskId, TaskReviewDTO dto) {
         Task task = taskMapper.selectById(taskId);
         Task task = taskMapper.selectById(taskId);
         if (task == null) {
         if (task == null) {
             return false;
             return false;
         }
         }
 
 
-        // 兼容两种审核字段:approved 和 result
+        if (!"pending_review".equals(task.getStatus())) {
+            return "completed".equals(task.getStatus());
+        }
+
         boolean isApproved;
         boolean isApproved;
         if (dto.getApproved() != null) {
         if (dto.getApproved() != null) {
-            // 使用 approved 字段(推荐)
             isApproved = dto.getApproved();
             isApproved = dto.getApproved();
         } else if ("pass".equals(dto.getResult())) {
         } else if ("pass".equals(dto.getResult())) {
-            // 兼容 result 字段
             isApproved = true;
             isApproved = true;
         } else {
         } else {
             isApproved = false;
             isApproved = false;
         }
         }
 
 
-        // 更新任务状态
         if (isApproved) {
         if (isApproved) {
-            task.setStatus("completed");
+            Date completionTime = task.getCompletedAt() != null ? task.getCompletedAt() : new Date();
+            awardTaskCompletionRewards(task, task.getFamilyMemberId(), completionTime);
         } else {
         } 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;
         return true;
     }
     }
@@ -793,7 +650,7 @@ return result;
     LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<>();
     LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<>();
     wrapper.eq(Task::getFamilyId, user.getFamilyId())
     wrapper.eq(Task::getFamilyId, user.getFamilyId())
         .eq(Task::getNeedReview, 1)
         .eq(Task::getNeedReview, 1)
-        .eq(Task::getStatus, "completed")
+        .eq(Task::getStatus, "pending_review")
         .orderByDesc(Task::getCompletedAt);
         .orderByDesc(Task::getCompletedAt);
 
 
     SortUtil.applySort(wrapper);
     SortUtil.applySort(wrapper);
@@ -904,6 +761,186 @@ return result;
             .set(Task::getUpdatedAt, new Date()));
             .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) {
     private boolean isSameDay(Date date1, Date date2) {
     Calendar cal1 = Calendar.getInstance();
     Calendar cal1 = Calendar.getInstance();
     cal1.setTime(date1);
     cal1.setTime(date1);
@@ -920,7 +957,7 @@ return result;
     return taskMapper.selectList(new LambdaQueryWrapper<Task>()
     return taskMapper.selectList(new LambdaQueryWrapper<Task>()
         .eq(Task::getFamilyMemberId, memberId)
         .eq(Task::getFamilyMemberId, memberId)
         .eq(Task::getNeedReview, 1)
         .eq(Task::getNeedReview, 1)
-        .eq(Task::getStatus, "completed")
+        .eq(Task::getStatus, "pending_review")
         .orderByDesc(Task::getCompletedAt));
         .orderByDesc(Task::getCompletedAt));
   }
   }
 
 
@@ -1151,8 +1188,8 @@ return 0; // 达到每日扣分上限
         return taskMapper.selectList(new LambdaQueryWrapper<Task>()
         return taskMapper.selectList(new LambdaQueryWrapper<Task>()
                 .eq(Task::getFamilyMemberId, familyMemberId)
                 .eq(Task::getFamilyMemberId, familyMemberId)
                 .eq(Task::getNeedReview, 1)
                 .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<>();
         List<Map<String, Object>> byChild = new ArrayList<>();
         for (FamilyMember child : children) {
         for (FamilyMember child : children) {
             List<Task> childTasks = tasks.stream()
             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());
                 .collect(Collectors.toList());
             if (!childTasks.isEmpty()) {
             if (!childTasks.isEmpty()) {
                 Map<String, Object> childStat = new HashMap<>();
                 Map<String, Object> childStat = new HashMap<>();
@@ -98,13 +98,19 @@ public class TaskStatsService {
             wrapper.eq(Task::getFamilyId, familyId);
             wrapper.eq(Task::getFamilyId, familyId);
         }
         }
         if (childId != null) {
         if (childId != null) {
-            wrapper.eq(Task::getChildId, childId);
+            wrapper.and(w -> w.eq(Task::getChildId, childId).or().eq(Task::getFamilyMemberId, childId));
         }
         }
         if (startDate != null) {
         if (startDate != null) {
             wrapper.ge(Task::getCreatedAt, startDate);
             wrapper.ge(Task::getCreatedAt, startDate);
         }
         }
         if (endDate != null) {
         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);
         wrapper.orderByDesc(Task::getCreatedAt);
 
 
@@ -119,23 +125,27 @@ public class TaskStatsService {
         Date now = new Date();
         Date now = new Date();
         int total = tasks.size();
         int total = tasks.size();
         long completed = tasks.stream().filter(t -> "completed".equals(t.getStatus())).count();
         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()
         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();
             .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<>();
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("totalTasks", total);
         result.put("totalTasks", total);
         result.put("completedTasks", completed);
         result.put("completedTasks", completed);
         result.put("pendingTasks", pending);
         result.put("pendingTasks", pending);
+        result.put("inReviewTasks", inReview);
         result.put("overdueTasks", overdue);
         result.put("overdueTasks", overdue);
         result.put("completionRate", rate);
         result.put("completionRate", rate);
 
 
-        // 待完成任务列表(含真实标题)
         List<Map<String, Object>> pendingTaskList = tasks.stream()
         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(t -> {
                 Map<String, Object> item = new HashMap<>();
                 Map<String, Object> item = new HashMap<>();
                 item.put("id", t.getId());
                 item.put("id", t.getId());
@@ -145,6 +155,19 @@ public class TaskStatsService {
             .collect(Collectors.toList());
             .collect(Collectors.toList());
         result.put("pendingTaskList", pendingTaskList);
         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;
         return result;
     }
     }
 }
 }

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

@@ -141,13 +141,16 @@ private FamilyInvitationService familyInvitationService;
         }
         }
 
 
         ensureUserHasFamily(user);
         ensureUserHasFamily(user);
-
-        // 管理员不能登录小程序
+        
+        // 🔧 本地调试:允许 admin 登录小程序(仅用于测试!)
         if ("admin".equals(user.getRole())) {
         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());
         String token = jwtConfig.generateToken(user.getId(), user.getRole());
 
 
         LoginResultDTO result = new LoginResultDTO();
         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;
 package com.etotem.cfc.service.impl;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 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.HealthPlan;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -17,6 +19,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.client.RestTemplate;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
@@ -44,7 +47,10 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
-    @Value("${cfc.langgraph.base-url:}")
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Value("${langgraph.base-url:}")
     private String langgraphBaseUrl;
     private String langgraphBaseUrl;
 
 
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
@@ -125,6 +131,22 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return healthPlanMapper.selectById(planId);
         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
     @Override
     public Long confirmPlan(Long planId) {
     public Long confirmPlan(Long planId) {
         HealthPlan plan = healthPlanMapper.selectById(planId);
         HealthPlan plan = healthPlanMapper.selectById(planId);
@@ -249,15 +271,13 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         Long familyId = plan.getFamilyId();
         Long familyId = plan.getFamilyId();
         String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
         String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
             ? plan.getMemberIds().split(",") : new String[0];
             ? 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();
         Date now = new Date();
 
 
@@ -303,6 +323,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
 
                 Task task = new Task();
                 Task task = new Task();
                 task.setFamilyId(familyId);
                 task.setFamilyId(familyId);
+                task.setFamilyMemberId(familyMemberId);
                 task.setChildId(childId);
                 task.setChildId(childId);
                 task.setExecutorType("child");
                 task.setExecutorType("child");
                 task.setExecutorId(executorId);
                 task.setExecutorId(executorId);
@@ -314,7 +335,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 task.setStatus("pending");
                 task.setStatus("pending");
                 task.setSourceType("plan");
                 task.setSourceType("plan");
                 task.setSourceId(plan.getId());
                 task.setSourceId(plan.getId());
-                task.setNeedReview(0);
+                task.setNeedReview(1);
                 task.setCreatedAt(now);
                 task.setCreatedAt(now);
                 task.setUpdatedAt(now);
                 task.setUpdatedAt(now);
 
 
@@ -576,4 +597,176 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         }
         }
         return result;
         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();
+    }
 }
 }

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PortraitService;
+import com.etotem.cfc.service.ProfileReadService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class PortraitServiceImpl implements PortraitService {
+
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private ProfileReadService profileReadService;
+
+    @Override
+    public String buildPortraitPrompt(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) return null;
+        String custom = user.getPortraitPrompt();
+        if (custom == null || custom.trim().isEmpty()) {
+            return renderSnapshot(memberId);
+        }
+        String snapshot = renderSnapshot(memberId);
+        if (snapshot == null || snapshot.trim().isEmpty()) {
+            return custom.trim();
+        }
+        return custom.trim() + "\n\n---参考指标---\n" + snapshot;
+    }
+
+    @Override
+    public void updatePortraitPrompt(Long userId, String portraitPrompt) {
+        User user = new User();
+        user.setId(userId);
+        user.setPortraitPrompt(portraitPrompt);
+        userMapper.updateById(user);
+    }
+
+    @Override
+    public Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId) {
+        Long effectiveUserId = (targetUserId != null) ? targetUserId : userId;
+        User user = userMapper.selectById(effectiveUserId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("portraitPrompt", user != null ? user.getPortraitPrompt() : null);
+        if (memberId != null) {
+            Map<String, Object> profile = profileReadService.getProfile(memberId);
+            result.put("renderedSnapshot", renderSnapshotFull(profile));
+        }
+        return result;
+    }
+
+    private String renderSnapshot(Long memberId) {
+        if (memberId == null) return null;
+        Map<String, Object> profile = profileReadService.getProfile(memberId);
+        return renderSnapshotFull(profile);
+    }
+
+    private String renderSnapshotFull(Map<String, Object> profile) {
+        if (profile == null || profile.containsKey("error")) return null;
+        StringBuilder sb = new StringBuilder();
+        Map<String, Object> member = (Map<String, Object>) profile.get("member");
+        if (member != null) {
+            sb.append("## 画像对象:");
+            sb.append(member.getOrDefault("name", "用户"));
+            sb.append("(").append(member.getOrDefault("age", "?")).append("岁,");
+            String gender = String.valueOf(member.getOrDefault("gender", ""));
+            sb.append("男".equals(gender) ? "男" : "女".equals(gender) ? "女" : "未知");
+            sb.append(")\n");
+        }
+        Map<String, Object> dims = (Map<String, Object>) profile.get("dimension_scores");
+        if (dims != null && !dims.isEmpty()) {
+            sb.append(String.format("五维评分:身 %s 智 %s 心 %s 行 %s 富 %s\n",
+                    dims.getOrDefault("body", "?"), dims.getOrDefault("wisdom", "?"),
+                    dims.getOrDefault("mind", "?"), dims.getOrDefault("action", "?"),
+                    dims.getOrDefault("wealth", "?")));
+        }
+        Map<String, Object> body = (Map<String, Object>) profile.get("body_metrics");
+        if (body != null) {
+            if (body.get("sleep_dur_avg") != null)
+                sb.append(String.format("平均睡眠:%s小时/天\n", body.get("sleep_dur_avg")));
+            if (body.get("exercise_count_week") != null)
+                sb.append(String.format("周运动频次:%s次\n", body.get("exercise_count_week")));
+        }
+        Map<String, Object> mind = (Map<String, Object>) profile.get("mind_metrics");
+        if (mind != null && mind.get("stress_avg") != null)
+            sb.append(String.format("平均压力:%s/10\n", mind.get("stress_avg")));
+        @SuppressWarnings("unchecked")
+        java.util.List<String> problems = (java.util.List<String>) profile.get("problem_domains");
+        if (problems != null && !problems.isEmpty())
+            sb.append("关注问题域:").append(String.join(", ", problems.subList(0, Math.min(5, problems.size())))).append("\n");
+        return sb.toString().trim().isEmpty() ? null : sb.toString().trim();
+    }
+}

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

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

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

@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
     gender ENUM('male','female'),
     gender ENUM('male','female'),
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
+    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
     province VARCHAR(50) DEFAULT NULL COMMENT '省份',
     province VARCHAR(50) DEFAULT NULL COMMENT '省份',
     city VARCHAR(50) DEFAULT NULL COMMENT '城市',
     city VARCHAR(50) DEFAULT NULL COMMENT '城市',
     district VARCHAR(50) DEFAULT NULL COMMENT '区县',
     district VARCHAR(50) DEFAULT NULL COMMENT '区县',
@@ -87,7 +88,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     category VARCHAR(32),
     category VARCHAR(32),
     need_review TINYINT DEFAULT 0 COMMENT '是否需要审核',
     need_review TINYINT DEFAULT 0 COMMENT '是否需要审核',
     review_by_category 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,
     completed_at DATETIME,
     bonus_rule JSON COMMENT '积分奖励规则',
     bonus_rule JSON COMMENT '积分奖励规则',
     points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
     points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
@@ -1818,6 +1819,24 @@ CREATE TABLE IF NOT EXISTS health_report_edit_logs (
     INDEX idx_report_id (report_id)
     INDEX idx_report_id (report_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕';
 ) 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 (
 CREATE TABLE IF NOT EXISTS education_systems (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
@@ -4390,6 +4409,38 @@ CREATE TABLE IF NOT EXISTS user_data_permissions (
 
 
 -- ===== 饮食模块 =====
 -- ===== 饮食模块 =====
 
 
+-- 食材库表
+CREATE TABLE IF NOT EXISTS foods (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '食材名称',
+    category VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他',
+    unit VARCHAR(20) DEFAULT 'g' COMMENT '计量单位',
+    calories DECIMAL(8,2) COMMENT '热量(kcal/100g)',
+    protein DECIMAL(8,2) COMMENT '蛋白质(g/100g)',
+    fat DECIMAL(8,2) COMMENT '脂肪(g/100g)',
+    carbs DECIMAL(8,2) COMMENT '碳水(g/100g)',
+    fiber DECIMAL(8,2) COMMENT '膳食纤维(g/100g)',
+    energy_kj DECIMAL(8,2) COMMENT '能量(kJ/100g)',
+    starch DECIMAL(8,2) COMMENT '淀粉(g/100g)',
+    cholesterol DECIMAL(8,2) COMMENT '胆固醇(mg/100g)',
+    price_level TINYINT COMMENT '价格等级 1-5',
+    nutrition_tags VARCHAR(500) COMMENT '营养标签JSON',
+    suitable_for VARCHAR(200) COMMENT '适宜人群JSON',
+    allergens VARCHAR(200) COMMENT '过敏原JSON',
+    external_source VARCHAR(100) COMMENT '数据来源',
+    external_id VARCHAR(100) COMMENT '外部ID',
+    cover_image VARCHAR(500) COMMENT '食材图片',
+    report_id BIGINT COMMENT '关联报告ID(从PDF解析的食物推荐)',
+    score INT COMMENT '食物评分',
+    status VARCHAR(20) DEFAULT 'active',
+    sort_order INT DEFAULT 0,
+    diet_type VARCHAR(50) COMMENT '饮食类型',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_category (category),
+    INDEX idx_price (price_level)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材库表';
+
 -- 饮食偏好调研表(每成员一份)
 -- 饮食偏好调研表(每成员一份)
 CREATE TABLE IF NOT EXISTS diet_preferences (
 CREATE TABLE IF NOT EXISTS diet_preferences (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 0 - 137
cfc-backend/src/test/java/com/etotem/cfc/service/DifySyncServiceTest.java

@@ -1,137 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.entity.DanKnowledgeBase;
-import com.etotem.cfc.entity.ProductDimensionConfig;
-import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import javax.annotation.Resource;
-import java.util.*;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.*;
-
-@SpringBootTest
-public class DifySyncServiceTest {
-
-    @Resource
-    private DifySyncService difySyncService;
-
-    @MockBean
-    private ProductDimensionConfigMapper productDimensionConfigMapper;
-
-    @Test
-    void syncToDify_skips_whenDimensionCodesEmpty() {
-        DanKnowledgeBase knowledge = new DanKnowledgeBase();
-        knowledge.setTitle("测试");
-        knowledge.setContent("内容");
-
-        difySyncService.syncToDify(knowledge, Collections.emptyList());
-        difySyncService.syncToDify(knowledge, null);
-
-        verify(productDimensionConfigMapper, never()).selectOne(any());
-    }
-
-    @Test
-    void syncToDify_skips_whenNoConfigFound() {
-        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
-        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
-
-        DanKnowledgeBase knowledge = new DanKnowledgeBase();
-        knowledge.setTitle("测试知识点");
-        knowledge.setContent("测试内容");
-
-        when(productDimensionConfigMapper.selectOne(any()))
-            .thenReturn(null);
-
-        difySyncService.syncToDify(knowledge, Arrays.asList("growth", "sleep"));
-
-        verify(productDimensionConfigMapper, times(2)).selectOne(any());
-    }
-
-    @Test
-    void syncToDify_skips_whenDifyDatasetIdNull() {
-        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
-        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
-
-        DanKnowledgeBase knowledge = new DanKnowledgeBase();
-        knowledge.setTitle("测试");
-        knowledge.setContent("内容");
-
-        ProductDimensionConfig config = new ProductDimensionConfig();
-        config.setId(1L);
-        config.setDimensionCode("growth");
-        config.setDifyDatasetId(null);
-
-        when(productDimensionConfigMapper.selectOne(any())).thenReturn(config);
-
-        difySyncService.syncToDify(knowledge, Arrays.asList("growth"));
-
-        verify(productDimensionConfigMapper, times(1)).selectOne(any());
-    }
-
-    @Test
-    void syncToDify_skips_whenDifyDatasetIdEmpty() {
-        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
-        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
-
-        DanKnowledgeBase knowledge = new DanKnowledgeBase();
-        knowledge.setTitle("测试");
-        knowledge.setContent("内容");
-
-        ProductDimensionConfig config = new ProductDimensionConfig();
-        config.setId(1L);
-        config.setDimensionCode("growth");
-        config.setDifyDatasetId("");
-
-        when(productDimensionConfigMapper.selectOne(any())).thenReturn(config);
-
-        difySyncService.syncToDify(knowledge, Arrays.asList("growth"));
-
-        verify(productDimensionConfigMapper, times(1)).selectOne(any());
-    }
-
-    @Test
-    void removeFromDify_skips_whenDatasetIdNull() {
-        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
-        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
-
-        difySyncService.removeFromDify(100L, null);
-        difySyncService.removeFromDify(100L, "");
-
-        verify(productDimensionConfigMapper, never()).selectOne(any());
-    }
-
-    @Test
-    void syncToDify_onlyProcessesConfiguredDimensions() {
-        ReflectionTestUtils.setField(difySyncService, "difyBaseUrl", "http://localhost");
-        ReflectionTestUtils.setField(difySyncService, "difyApiKey", "test-key");
-
-        DanKnowledgeBase knowledge = new DanKnowledgeBase();
-        knowledge.setTitle("测试");
-        knowledge.setContent("内容");
-
-        ProductDimensionConfig growthConfig = new ProductDimensionConfig();
-        growthConfig.setDimensionCode("growth");
-        growthConfig.setDifyDatasetId("dataset-growth-123");
-
-        ProductDimensionConfig noConfig = new ProductDimensionConfig();
-        noConfig.setDimensionCode("sleep");
-        noConfig.setDifyDatasetId(null);
-
-        when(productDimensionConfigMapper.selectOne(
-            argThat(q -> "growth".equals(q.getSqlSegment()))))
-            .thenReturn(growthConfig);
-        when(productDimensionConfigMapper.selectOne(
-            argThat(q -> "sleep".equals(q.getSqlSegment()))))
-            .thenReturn(noConfig);
-
-        difySyncService.syncToDify(knowledge, Arrays.asList("growth", "sleep"));
-
-        verify(productDimensionConfigMapper, times(2)).selectOne(any());
-    }
-}

+ 2 - 1
cfc-backend/startup.sh

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

+ 13 - 2
cfc-frontend/components/wuxing-sandbox.vue

@@ -10,7 +10,7 @@
   </slot>
   </slot>
 
 
   <!-- 个人/全家切换开关(仅 familyUnlocked 时显示) -->
   <!-- 个人/全家切换开关(仅 familyUnlocked 时显示) -->
-  <view class="sandbox-toggle" v-if="familyUnlocked && mode !== 'preview'">
+  <view class="sandbox-toggle" v-if="familyUnlocked && hasMultipleMembers && mode !== 'preview'">
     <view class="toggle-item" :class="activeClassName" @click="onToggle('personal')">
     <view class="toggle-item" :class="activeClassName" @click="onToggle('personal')">
       <text class="toggle-icon">👤</text>
       <text class="toggle-icon">👤</text>
       <text class="toggle-text">个人</text>
       <text class="toggle-text">个人</text>
@@ -169,7 +169,7 @@ export default {
   },
   },
   data: function() {
   data: function() {
     return {
     return {
-      viewMode: 'family',
+      viewMode: 'personal',
       selectedMemberIndex: 0,
       selectedMemberIndex: 0,
       previewScores: [0, 0, 0, 0, 0],
       previewScores: [0, 0, 0, 0, 0],
       canvasReady: false,
       canvasReady: false,
@@ -317,6 +317,16 @@ export default {
         var dimOrder = newVal.map(function(d) { return d.code })
         var dimOrder = newVal.map(function(d) { return d.code })
         this._flowAnim.refreshPaths({ dimOrder: dimOrder })
         this._flowAnim.refreshPaths({ dimOrder: dimOrder })
       }
       }
+    },
+    currentView: function(newVal) {
+      if (newVal && newVal !== this.viewMode) {
+        this.viewMode = newVal
+      }
+    },
+    hasMultipleMembers: function(newVal) {
+      if (!newVal && this.viewMode === 'family') {
+        this.viewMode = 'personal'
+      }
     }
     }
   },
   },
   mounted: function() {
   mounted: function() {
@@ -400,6 +410,7 @@ export default {
 
 
     /** 个人/全家切换(由父组件控制数据源) */
     /** 个人/全家切换(由父组件控制数据源) */
     onToggle: function(view) {
     onToggle: function(view) {
+      this.viewMode = view
       if (view === this.currentView) return
       if (view === this.currentView) return
       this.$emit('view-change', view)
       this.$emit('view-change', view)
     },
     },

+ 3 - 2
cfc-frontend/config.js

@@ -19,10 +19,11 @@ try {
   if (env) {
   if (env) {
     switch (env) {
     switch (env) {
       case 'develop':
       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
         break
       case 'trial':
       case 'trial':
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
+        API_BASE_URL = 'https://cfc.iwintrue.com'
         break
         break
       case 'release':
       case 'release':
         API_BASE_URL = 'https://cfc.etotem.com.cn'
         API_BASE_URL = 'https://cfc.etotem.com.cn'

+ 35 - 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",
       "root": "pages/dan-assessment",
       "pages": [
       "pages": [
@@ -1138,6 +1155,18 @@
         }
         }
       ]
       ]
     },
     },
+    {
+      "root": "pages/wealth-detail",
+      "pages": [
+        {
+          "path": "member-wealth-detail",
+          "style": {
+            "navigationBarTitleText": "财富能量详情",
+            "navigationStyle": "custom"
+          }
+        }
+      ]
+    },
     {
     {
       "root": "pages/action-detail",
       "root": "pages/action-detail",
       "pages": [
       "pages": [
@@ -1548,6 +1577,12 @@
           "style": {
           "style": {
             "navigationBarTitleText": "报告详情"
             "navigationBarTitleText": "报告详情"
           }
           }
+        },
+        {
+          "path": "portrait-edit",
+          "style": {
+            "navigationBarTitleText": "我的画像"
+          }
         }
         }
       ]
       ]
     },
     },

+ 11 - 11
cfc-frontend/pages/body-detail/index.vue

@@ -81,12 +81,12 @@
               v-for="(ind, i) in group.items"
               v-for="(ind, i) in group.items"
               :key="i"
               :key="i"
               class="gut-item"
               class="gut-item"
-              :class="{ 'gut-abnormal': ind.status === '偏高' || ind.status === '偏低' }"
+              :class="{ 'gut-abnormal': ind.status === 'high' || ind.status === 'low' }"
             >
             >
               <text class="gut-item-name">{{ ind.name }}</text>
               <text class="gut-item-name">{{ ind.name }}</text>
               <view class="gut-item-right">
               <view class="gut-item-right">
                 <text class="gut-item-value" v-if="ind.value">{{ ind.value }}</text>
                 <text class="gut-item-value" v-if="ind.value">{{ ind.value }}</text>
-                <text class="gut-item-status" :class="'status-' + (ind.status || 'normal')">{{ ind.status || '正常' }}</text>
+                <text class="gut-item-status" :class="'status-' + (ind.status || 'normal')">{{ ind.status === 'high' ? '偏高' : ind.status === 'low' ? '偏低' : (ind.status || '正常') }}</text>
               </view>
               </view>
             </view>
             </view>
           </view>
           </view>
@@ -114,7 +114,7 @@
           <text class="risk-name">{{ risk.name }}</text>
           <text class="risk-name">{{ risk.name }}</text>
           <view class="risk-right">
           <view class="risk-right">
             <text class="risk-value" v-if="risk.value">{{ risk.value }}</text>
             <text class="risk-value" v-if="risk.value">{{ risk.value }}</text>
-            <text class="risk-level">{{ risk.level || '未知' }}</text>
+            <text class="risk-level">{{ risk.level === 'severe' ? '高风险' : risk.level === 'attention' ? '需注意' : (risk.level || '未知') }}</text>
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
@@ -266,7 +266,7 @@ export default {
     },
     },
     gutSummary: function() {
     gutSummary: function() {
       if (!this.gutFloraIndicators.length) return ''
       if (!this.gutFloraIndicators.length) return ''
-      var abnormal = this.gutFloraIndicators.filter(function(i) { return i.status === '偏高' || i.status === '偏低' }).length
+      var abnormal = this.gutFloraIndicators.filter(function(i) { return i.status === 'high' || i.status === 'low' }).length
       if (abnormal === 0) return '菌群整体平衡良好'
       if (abnormal === 0) return '菌群整体平衡良好'
       if (abnormal <= 2) return '部分菌群指标略有波动'
       if (abnormal <= 2) return '部分菌群指标略有波动'
       return '多项菌群指标异常,建议咨询专业营养师'
       return '多项菌群指标异常,建议咨询专业营养师'
@@ -524,7 +524,7 @@ export default {
     goToBodyDetail: function() {
     goToBodyDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       if (!id) return
       if (!id) return
-      uni.navigateTo({ url: '/pages/body/member-body-detail?memberId=' + id })
+      uni.navigateTo({ url: '/pages/body-detail/member-body-detail?memberId=' + id })
     },
     },
     goIndicatorDetail: function(ind) {
     goIndicatorDetail: function(ind) {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
@@ -670,8 +670,8 @@ export default {
 .gut-item-right { display: flex; align-items: center; gap: 12rpx; }
 .gut-item-right { display: flex; align-items: center; gap: 12rpx; }
 .gut-item-value { font-size: 26rpx; color: #666; }
 .gut-item-value { font-size: 26rpx; color: #666; }
 .gut-item-status { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
 .gut-item-status { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
-.gut-item-status.status-偏高 { background: #FEE2E2; color: #DC2626; }
-.gut-item-status.status-偏低 { background: #FEF3C7; color: #D97706; }
+.gut-item-status.status-high { background: #FEE2E2; color: #DC2626; }
+.gut-item-status.status-low { background: #FEF3C7; color: #D97706; }
 .gut-item-status.status-normal { background: #D1FAE5; color: #059669; }
 .gut-item-status.status-normal { background: #D1FAE5; color: #059669; }
 .gut-empty-hint { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; background: #fff; border-radius: 16rpx; margin-bottom: 16rpx; }
 .gut-empty-hint { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; background: #fff; border-radius: 16rpx; margin-bottom: 16rpx; }
 .gut-upload-link { color: #3B82F6; margin-left: 8rpx; }
 .gut-upload-link { color: #3B82F6; margin-left: 8rpx; }
@@ -681,14 +681,14 @@ export default {
 .risk-list { background: #fff; border-radius: 16rpx; padding: 8rpx 0; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
 .risk-list { background: #fff; border-radius: 16rpx; padding: 8rpx 0; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
 .risk-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #F3F4F6; }
 .risk-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #F3F4F6; }
 .risk-item:last-child { border-bottom: none; }
 .risk-item:last-child { border-bottom: none; }
-.risk-item.risk-高风险 { background: #FEF2F2; }
-.risk-item.risk-需注意 { background: #FFFBEB; }
+.risk-item.risk-severe { background: #FEF2F2; }
+.risk-item.risk-attention { background: #FFFBEB; }
 .risk-name { font-size: 28rpx; font-weight: 500; color: #333; flex: 1; }
 .risk-name { font-size: 28rpx; font-weight: 500; color: #333; flex: 1; }
 .risk-right { display: flex; align-items: center; gap: 12rpx; }
 .risk-right { display: flex; align-items: center; gap: 12rpx; }
 .risk-value { font-size: 24rpx; color: #666; }
 .risk-value { font-size: 24rpx; color: #666; }
 .risk-level { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
 .risk-level { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
-.risk-level.高风险 { background: #FEE2E2; color: #DC2626; }
-.risk-level.需注意 { background: #FEF3C7; color: #D97706; }
+.risk-level.risk-severe { background: #FEE2E2; color: #DC2626; }
+.risk-level.risk-attention { background: #FEF3C7; color: #D97706; }
 
 
 .bottom-spacer {
 .bottom-spacer {
   height: 120rpx;
   height: 120rpx;

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

@@ -236,6 +236,13 @@
           <text class="empty-text">暂无报告和测评结果</text>
           <text class="empty-text">暂无报告和测评结果</text>
           <text class="empty-sub">上传报告或完成测评开启健康之旅</text>
           <text class="empty-sub">上传报告或完成测评开启健康之旅</text>
         </view>
         </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>
 
 
       <view class="section section-plan">
       <view class="section section-plan">
@@ -508,6 +515,15 @@ export default {
       }
       }
       uni.navigateTo({ url: url })
       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) {
     _reportTypeLabel: function(type) {
       var map = { gut_flora: '肠道', physical_exam: '体检', tongue: '舌诊', blood: '血液' }
       var map = { gut_flora: '肠道', physical_exam: '体检', tongue: '舌诊', blood: '血液' }
       return map[type] || '健康报告'
       return map[type] || '健康报告'
@@ -682,6 +698,22 @@ export default {
 .quiz-score { display: flex; align-items: baseline; margin-right: 16rpx; }
 .quiz-score { display: flex; align-items: baseline; margin-right: 16rpx; }
 .score-value { font-size: 36rpx; font-weight: bold; color: #F97316; }
 .score-value { font-size: 36rpx; font-weight: bold; color: #F97316; }
 .score-unit { font-size: 22rpx; color: #999; margin-left: 4rpx; }
 .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; }
 .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-data-view { margin: 16rpx 24rpx; }
 .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
 .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }

+ 353 - 66
cfc-frontend/pages/health/health-plan-summary.vue

@@ -16,15 +16,13 @@
         <text class="selected-count-badge" v-if="selectedMemberIds.length > 0">{{ selectedMemberIds.length }} 人已选</text>
         <text class="selected-count-badge" v-if="selectedMemberIds.length > 0">{{ selectedMemberIds.length }} 人已选</text>
       </view>
       </view>
       <view class="hint-text">选择方案针对的家庭成员</view>
       <view class="hint-text">选择方案针对的家庭成员</view>
-      <view class="member-list">
+      <view class="member-grid">
         <view v-for="(m, idx) in executors" :key="getMemberKey(m, idx)"
         <view v-for="(m, idx) in executors" :key="getMemberKey(m, idx)"
-              class="member-card" :class="{ active: isMemberSelected(m.memberId) }"
-              @click="toggleMember(m.memberId)">
-          <view class="member-card-left">
-            <view class="member-avatar">{{ m.name.charAt(0) }}</view>
-            <text class="member-card-name">{{ m.name }}</text>
-          </view>
-          <view class="member-check">{{ isMemberSelected(m.memberId) ? '✓' : '' }}</view>
+              class="member-tile" :class="{ active: isMemberSelected(m.id) }"
+              @click="toggleMember(m.id)">
+          <view class="member-tile-avatar">{{ (m.nickname || m.name || '成').charAt(0) }}</view>
+          <text class="member-tile-name">{{ m.nickname || m.name || '成员' }}</text>
+          <view class="member-tile-check" v-if="isMemberSelected(m.id)">✓</view>
         </view>
         </view>
         <view v-if="executors.length === 0" class="empty-state">
         <view v-if="executors.length === 0" class="empty-state">
           <text class="empty-icon">👨‍👩‍👧</text>
           <text class="empty-icon">👨‍👩‍👧</text>
@@ -255,6 +253,39 @@
       <view v-if="planDetail.planContent" class="plan-preview">
       <view v-if="planDetail.planContent" class="plan-preview">
         <text class="plan-preview-text">{{ planDetail.planContent }}</text>
         <text class="plan-preview-text">{{ planDetail.planContent }}</text>
       </view>
       </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">
       <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-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>
         <button class="btn-done plan-done-btn" @click="goBack">返回</button>
@@ -274,7 +305,7 @@
 </template>
 </template>
 
 
 <script>
 <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'
 import { getHealthStatus, saveHealthStatus } from '../../utils/api'
 
 
 // v2: 预置疾病清单(菌群报告病症风险 ★ + 互联网统计常见病 两源合并去重,8 组 54 项)
 // v2: 预置疾病清单(菌群报告病症风险 ★ + 互联网统计常见病 两源合并去重,8 组 54 项)
@@ -393,6 +424,8 @@ export default {
       viewMode: false,
       viewMode: false,
       planDetail: null,
       planDetail: null,
       planId: null,
       planId: null,
+      planTasks: [],
+      planTasksLoading: false,
       // UI 状态
       // UI 状态
       loading: false,
       loading: false,
       taskSubmitted: false
       taskSubmitted: false
@@ -429,13 +462,14 @@ export default {
     selectedNames: function() {
     selectedNames: function() {
       return this.selectedDiseases.map(function(d) { return d.name })
       return this.selectedDiseases.map(function(d) { return d.name })
     },
     },
-    selectedMembers: function() {
+selectedMembers: function() {
       var self = this
       var self = this
       var out = []
       var out = []
       this.selectedMemberIds.forEach(function(id) {
       this.selectedMemberIds.forEach(function(id) {
         var found = null
         var found = null
         for (var i = 0; i < self.executors.length; i++) {
         for (var i = 0; i < self.executors.length; i++) {
-          if (String(self.executors[i].memberId) === String(id)) { found = self.executors[i]; break }
+          var mid = self.executors[i].memberId || self.executors[i].id
+          if (String(mid) === String(id)) { found = self.executors[i]; break }
         }
         }
         if (found) out.push(found)
         if (found) out.push(found)
       })
       })
@@ -444,18 +478,25 @@ export default {
     currentMemberName: function() {
     currentMemberName: function() {
       var members = this.selectedMembers
       var members = this.selectedMembers
       for (var i = 0; i < members.length; i++) {
       for (var i = 0; i < members.length; i++) {
-        if (String(members[i].memberId) === String(this.activeFormMemberId)) {
-          return members[i].name
+        if (String(members[i].memberId || members[i].id) === String(this.activeFormMemberId)) {
+          return members[i].name || members[i].nickname || '成员'
         }
         }
       }
       }
       return '成员'
       return '成员'
+    },
+    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) {
   onLoad: function(options) {
     var self = this
     var self = this
     // 处理初始成员ID
     // 处理初始成员ID
     if (options.initialMemberIds) {
     if (options.initialMemberIds) {
-      this.selectedMemberIds = options.initialMemberIds.split(',').filter(function(id) { return id })
+      this.selectedMemberIds = options.initialMemberIds.split(',').filter(function(id) { return id }).map(function(id) { return parseInt(id) || id })
     }
     }
     // 处理 planId 查看模式
     // 处理 planId 查看模式
     this.planId = options.planId ? parseInt(options.planId) : null
     this.planId = options.planId ? parseInt(options.planId) : null
@@ -486,19 +527,21 @@ export default {
   },
   },
   methods: {
   methods: {
     // ===== 成员选择 (保持原有) =====
     // ===== 成员选择 (保持原有) =====
-    getMemberKey: function(m, idx) { return m.memberId || idx },
+    getMemberKey: function(m, idx) { return m.id || idx },
     loadExecutors: function() {
     loadExecutors: function() {
+      if (this._executorsLoading) return
+      this._executorsLoading = true
       var self = this
       var self = this
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res && res.code === 200 && res.data) {
         if (res && res.code === 200 && res.data) {
           var list = res.data instanceof Array ? res.data : []
           var list = res.data instanceof Array ? res.data : []
-          self.executors = list.filter(function(m) { return m.memberId || m.id }).map(function(m) {
-            return { memberId: String(m.memberId || m.id), name: m.nickname || m.name || '成员' }
+          // 兼容双模型:多成员问卷用 memberId/name,方案打卡用 id
+          self.executors = list.filter(function(m) { return m.id }).map(function(m) {
+            var nm = m.nickname || m.name || '成员'
+            return { id: m.id, memberId: String(m.id), name: nm, nickname: nm }
           })
           })
           if (self.selectedMemberIds.length === 0 && self.executors.length > 0) {
           if (self.selectedMemberIds.length === 0 && self.executors.length > 0) {
-            self.selectedMemberIds = [self.executors[0].memberId]
-          } else {
-            self.selectedMemberIds = self.selectedMemberIds.map(function(id) { return String(id) })
+            self.selectedMemberIds = [String(self.executors[0].id)]
           }
           }
           // 默认当前填写成员为第一个选中成员
           // 默认当前填写成员为第一个选中成员
           if (!self.activeFormMemberId || self.selectedMemberIds.indexOf(String(self.activeFormMemberId)) < 0) {
           if (!self.activeFormMemberId || self.selectedMemberIds.indexOf(String(self.activeFormMemberId)) < 0) {
@@ -507,16 +550,17 @@ export default {
           self.loadHealthForm(self.activeFormMemberId)
           self.loadHealthForm(self.activeFormMemberId)
         }
         }
         self._executorsLoaded = true
         self._executorsLoaded = true
-      }).catch(function() { self.executors = []; self._executorsLoaded = true })
+        self._executorsLoading = false
+      }).catch(function() { self.executors = []; self._executorsLoaded = true; self._executorsLoading = false })
     },
     },
-    isMemberSelected: function(memberId) {
-      return this.selectedMemberIds.indexOf(String(memberId)) >= 0
+    isMemberSelected: function(id) {
+      return this.selectedMemberIds.indexOf(String(id)) >= 0
     },
     },
-    toggleMember: function(memberId) {
-      var id = String(memberId)
-      var idx = this.selectedMemberIds.indexOf(id)
+    toggleMember: function(id) {
+      var strId = String(id)
+      var idx = this.selectedMemberIds.indexOf(strId)
       if (idx >= 0) this.selectedMemberIds.splice(idx, 1)
       if (idx >= 0) this.selectedMemberIds.splice(idx, 1)
-      else this.selectedMemberIds.push(id)
+      else this.selectedMemberIds.push(strId)
     },
     },
 
 
     // ===== 时间格式化 =====
     // ===== 时间格式化 =====
@@ -1000,7 +1044,7 @@ export default {
           self.planDetail = res.data
           self.planDetail = res.data
           self.userGoal = res.data.goal || ''
           self.userGoal = res.data.goal || ''
           if (res.data.memberIds) {
           if (res.data.memberIds) {
-            self.selectedMemberIds = res.data.memberIds.split(',').filter(function(id) { return id })
+            self.selectedMemberIds = res.data.memberIds.split(',').filter(function(id) { return id }).map(function(id) { return parseInt(id) || id })
           }
           }
           try {
           try {
             var pj = JSON.parse(res.data.planJson || '{}')
             var pj = JSON.parse(res.data.planJson || '{}')
@@ -1019,12 +1063,123 @@ export default {
               self.planData.overview = res.data.planContent.substring(0, 200)
               self.planData.overview = res.data.planContent.substring(0, 200)
             }
             }
           }
           }
+          self.loadPlanTasksFromDetail(res.data)
         }
         }
       }).catch(function() {
       }).catch(function() {
         uni.showToast({ title: '方案加载失败', icon: 'none' })
         uni.showToast({ title: '方案加载失败', icon: 'none' })
         setTimeout(function() { uni.navigateBack() }, 1500)
         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() {
     reCreate: function() {
       this.step = 1
       this.step = 1
       this.viewMode = false
       this.viewMode = false
@@ -1138,73 +1293,72 @@ export default {
 }
 }
 
 
 /* 成员卡片 */
 /* 成员卡片 */
-.member-list {
+.member-grid {
   display: flex;
   display: flex;
-  flex-direction: column;
-  gap: 12rpx;
+  flex-wrap: wrap;
+  gap: 20rpx;
   margin-bottom: 24rpx;
   margin-bottom: 24rpx;
 }
 }
-.member-card {
+.member-tile {
+  position: relative;
+  width: calc(33.333% - 14rpx);
   display: flex;
   display: flex;
+  flex-direction: column;
   align-items: center;
   align-items: center;
-  justify-content: space-between;
-  padding: 20rpx 24rpx;
-  border-radius: 16rpx;
+  padding: 28rpx 12rpx 20rpx;
+  border-radius: 20rpx;
   border: 3rpx solid #e8e8e8;
   border: 3rpx solid #e8e8e8;
-  border-left: 8rpx solid transparent;
   background: #FAFAFA;
   background: #FAFAFA;
   transition: all 0.2s;
   transition: all 0.2s;
 }
 }
-.member-card.active {
+.member-tile.active {
   border-color: #F97316;
   border-color: #F97316;
-  border-left: 8rpx solid #F97316;
   background: #FFFBF0;
   background: #FFFBF0;
+  box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.15);
 }
 }
-.member-card-left {
-  display: flex;
-  align-items: center;
-  gap: 16rpx;
-}
-.member-avatar {
-  width: 60rpx;
-  height: 60rpx;
-  border-radius: 30rpx;
+.member-tile-avatar {
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 50%;
   background: #ececec;
   background: #ececec;
-  border: 2rpx solid #ddd;
+  border: 3rpx solid #ddd;
   color: #888;
   color: #888;
-  font-size: 28rpx;
+  font-size: 36rpx;
   font-weight: bold;
   font-weight: bold;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  flex-shrink: 0;
+  margin-bottom: 12rpx;
 }
 }
-.member-card.active .member-avatar {
+.member-tile.active .member-tile-avatar {
   background: #F97316;
   background: #F97316;
   border-color: #F97316;
   border-color: #F97316;
   color: #fff;
   color: #fff;
 }
 }
-.member-card-name {
-  font-size: 28rpx;
+.member-tile-name {
+  font-size: 26rpx;
   font-weight: 600;
   font-weight: 600;
   color: #333;
   color: #333;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 }
-.member-card.active .member-card-name { color: #F97316; }
-.member-check {
-  width: 44rpx;
-  height: 44rpx;
-  border-radius: 50%;
-  border: 3rpx solid #d5d5d5;
-  background: #fff;
-  color: transparent;
-  font-size: 24rpx;
-  line-height: 38rpx;
-  text-align: center;
+.member-tile.active .member-tile-name {
+  color: #F97316;
 }
 }
-.member-card.active .member-check {
+.member-tile-check {
+  position: absolute;
+  top: 8rpx;
+  right: 8rpx;
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
   background: #F97316;
   background: #F97316;
-  border-color: #F97316;
   color: #fff;
   color: #fff;
+  font-size: 20rpx;
+  line-height: 36rpx;
+  text-align: center;
 }
 }
 
 
 /* 目标输入 */
 /* 目标输入 */
@@ -1429,6 +1583,139 @@ export default {
   line-height: 1.8;
   line-height: 1.8;
   white-space: pre-wrap;
   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 { display: flex; gap: 16rpx; }
 .plan-actions .btn-edit { flex: 1; margin-bottom: 0; }
 .plan-actions .btn-edit { flex: 1; margin-bottom: 0; }
 .plan-done-btn {
 .plan-done-btn {

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

@@ -794,7 +794,7 @@ export default {
       if (!this.canConfirm) return
       if (!this.canConfirm) return
       var self = this
       var self = this
       self.confirming = true
       self.confirming = true
-      uni.showLoading({ title: '确认入库...' })
+      uni.showLoading({ title: '确认入库并生成方案...' })
 
 
       var postData = {
       var postData = {
         draftId: self.draftId,
         draftId: self.draftId,
@@ -815,19 +815,23 @@ export default {
       confirmFn(postData).then(function(res) {
       confirmFn(postData).then(function(res) {
         uni.hideLoading()
         uni.hideLoading()
         if (res.code === 200) {
         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 {
         } else {
           uni.showToast({ title: res.message || '确认失败', icon: 'none' })
           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.parsingStep = (self.parsingStep + 1) % messages.length
         self.parsingText = messages[self.parsingStep]
         self.parsingText = messages[self.parsingStep]
       }, 2500)
       }, 2500)
+      // 调用 parse-report-preview,需要文件路径;从 upload 页传来的 draftId 需要重新获取文件路径
+      // 这里使用后端解析接口:先上传文件,然后解析
+      // 由于只传了 draftId,回调服务器已有的草稿进行解析
+      // 使用上传接口模式:需要文件路径
+      // 这里通过 draftId 请求后端确认,后端会使用已存储的文件进行解析
+      // 兼容模式:直接请求确认,由后端决定如何解析
       parseReportDraft(self.draftId).then(function(res) {
       parseReportDraft(self.draftId).then(function(res) {
         self.parsing = false
         self.parsing = false
         if (self.parsingTimer) { clearInterval(self.parsingTimer); self.parsingTimer = null }
         if (self.parsingTimer) { clearInterval(self.parsingTimer); self.parsingTimer = null }
@@ -695,6 +701,7 @@ export default {
       })
       })
     },
     },
     applyParseResult(detail) {
     applyParseResult(detail) {
+      console.log('[报告入库] applyParseResult', detail)
       var self = this
       var self = this
       self.payload = detail.payload || self.defaultPayload()
       self.payload = detail.payload || self.defaultPayload()
       self.extractedName = detail.extractedName || ''
       self.extractedName = detail.extractedName || ''
@@ -706,6 +713,13 @@ export default {
       self.needBind = !!detail.needBind
       self.needBind = !!detail.needBind
       self.candidates = detail.candidates || []
       self.candidates = detail.candidates || []
       self.loadFamilyMembers()
       self.loadFamilyMembers()
+      console.log('[报告入库] applyParseResult 后状态', {
+        needBind: self.needBind,
+        matchedMemberId: self.matchedMemberId,
+        candidates: self.candidates.length,
+        selectedMemberId: self.selectedMemberId,
+        canConfirm: self.canConfirm
+      })
       if (!self.needBind) {
       if (!self.needBind) {
         self.selectedMemberId = self.matchedMemberId
         self.selectedMemberId = self.matchedMemberId
       }
       }
@@ -714,6 +728,17 @@ export default {
         self.selectedMemberId = best.id || best.userId
         self.selectedMemberId = best.id || best.userId
         self.selectedMemberObj = best
         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() {
     loadFamilyMembers() {
       var self = this
       var self = this
@@ -925,8 +950,27 @@ export default {
       else this.cancelEdit()
       else this.cancelEdit()
     },
     },
     handleRightAction() {
     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() {
     doDiscard() {
       var self = this
       var self = this
@@ -948,22 +992,35 @@ export default {
       })
       })
     },
     },
     doConfirm() {
     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
       var self = this
       self.confirming = true
       self.confirming = true
-      uni.showLoading({ title: '确认入库...' })
+      uni.showLoading({ title: '确认入库并生成方案...' })
       var postData = { draftId: self.draftId, payload: self.payload }
       var postData = { draftId: self.draftId, payload: self.payload }
       if (self.selectedMemberId) postData.subjectId = self.selectedMemberId
       if (self.selectedMemberId) postData.subjectId = self.selectedMemberId
       else if (self.createdMemberId) postData.subjectId = self.createdMemberId
       else if (self.createdMemberId) postData.subjectId = self.createdMemberId
       else if (self.matchedMemberId) postData.subjectId = self.matchedMemberId
       else if (self.matchedMemberId) postData.subjectId = self.matchedMemberId
       else if (self.memberId) postData.subjectId = self.memberId
       else if (self.memberId) postData.subjectId = self.memberId
+      console.log('[报告入库] 准备调用 API', postData)
       var confirmFn = self.payload.type === 'tongue' ? confirmTongue : confirmReportPreview
       var confirmFn = self.payload.type === 'tongue' ? confirmTongue : confirmReportPreview
       confirmFn(postData).then(function(res) {
       confirmFn(postData).then(function(res) {
+        console.log('[报告入库] API 响应', res)
         uni.hideLoading()
         uni.hideLoading()
         self.confirming = false
         self.confirming = false
         if (res.code === 200) {
         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() {
             setTimeout(function() {
               uni.redirectTo({ url: '/pages/health/report-detail?reportType=' + (self.payload.type || 'gut_flora') + '&reportId=' + reportId })
               uni.redirectTo({ url: '/pages/health/report-detail?reportType=' + (self.payload.type || 'gut_flora') + '&reportId=' + reportId })
               uni.showToast({ title: '入库成功', icon: 'success' })
               uni.showToast({ title: '入库成功', icon: 'success' })

+ 23 - 5
cfc-frontend/pages/index-home/index.vue

@@ -71,6 +71,9 @@
       :dimensions="energyDimensions"
       :dimensions="energyDimensions"
       :totalEnergy="totalEnergy"
       :totalEnergy="totalEnergy"
       :totalHealthIndex="totalHealthIndex"
       :totalHealthIndex="totalHealthIndex"
+      :currentView="currentSandboxView"
+      :familyUnlocked="familyUnlocked"
+      @view-change="onSandboxViewChange"
       showDimensionBar
       showDimensionBar
       starHeight="480rpx" />
       starHeight="480rpx" />
 
 
@@ -363,7 +366,7 @@ import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
-import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getEnergyOverview, getFamilyMemberList, getChildren, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus } from '../../utils/api.js'
+import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChildren, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
@@ -430,6 +433,8 @@ export default {
       sandboxData: null,
       sandboxData: null,
       sandboxLocked: false,
       sandboxLocked: false,
       sandboxLockGate: null,
       sandboxLockGate: null,
+      currentSandboxView: 'personal',
+      familyUnlocked: false,
       energyDimensions: [],
       energyDimensions: [],
       totalEnergy: 0,
       totalEnergy: 0,
       totalHealthIndex: 0,
       totalHealthIndex: 0,
@@ -608,7 +613,7 @@ export default {
     },
     },
     loadSandboxData: function() {
     loadSandboxData: function() {
       var self = this
       var self = this
-      getFamilyEnergySandbox().then(function(res) {
+      getEnergySandbox(this.currentSandboxView).then(function(res) {
         if (res && res.data) {
         if (res && res.data) {
           var d = res.data
           var d = res.data
           if (d.locked) {
           if (d.locked) {
@@ -619,13 +624,24 @@ export default {
           } else {
           } else {
             self.sandboxLocked = false
             self.sandboxLocked = false
             self.sandboxLockGate = null
             self.sandboxLockGate = null
-            self.sandboxData = d
+            if (d.view === 'personal') {
+              self.sandboxData = d.personalData || null
+              self.familyUnlocked = d.familyUnlocked || false
+            } else {
+              self.sandboxData = d.familyData || null
+            }
           }
           }
           self._splashReady = true
           self._splashReady = true
           self._tryHideSplash()
           self._tryHideSplash()
         }
         }
       }).catch(function() {})
       }).catch(function() {})
     },
     },
+    /** 切换沙盘个人/全家视角 */
+    onSandboxViewChange: function(view) {
+      if (view === this.currentSandboxView) return
+      this.currentSandboxView = view
+      this.loadSandboxData()
+    },
     loadActionArticles: function() {
     loadActionArticles: function() {
       var self = this
       var self = this
       getFeaturedArticles({ size: 5 }).then(function(res) {
       getFeaturedArticles({ size: 5 }).then(function(res) {
@@ -729,8 +745,10 @@ export default {
           var notices = res.data.map(function(n) {
           var notices = res.data.map(function(n) {
             var timeStr = ''
             var timeStr = ''
             if (n.createdAt) {
             if (n.createdAt) {
-              var d = new Date(n.createdAt)
-              timeStr = (d.getMonth() + 1) + '月' + d.getDate() + '日'
+              var d = parseDate(n.createdAt)
+              if (d) {
+                timeStr = (d.getMonth() + 1) + '月' + d.getDate() + '日'
+              }
             }
             }
             return {
             return {
               text: '📢 ' + n.title,
               text: '📢 ' + n.title,

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

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

+ 1 - 1
cfc-frontend/pages/mind-detail/index.vue

@@ -818,7 +818,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
     goMindDetail: function() {
     goMindDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       if (!id) return
       if (!id) return
-      uni.navigateTo({ url: '/pages/mind/member-mind-detail?memberId=' + id })
+      uni.navigateTo({ url: '/pages/mind-detail/member-mind-detail?memberId=' + id })
     },
     },
     formatNumeric: function(v) {
     formatNumeric: function(v) {
       if (v === null || v === undefined) return ''
       if (v === null || v === undefined) return ''

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

@@ -347,14 +347,7 @@ export default {
       }).catch(function() {})
       }).catch(function() {})
     },
     },
     loadPoints() {
     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() {
     loadFamily() {
       var self = this
       var self = this
@@ -444,7 +437,14 @@ export default {
       uni.navigateTo({ url: '/pages/points/points' })
       uni.navigateTo({ url: '/pages/points/points' })
     },
     },
     goToStreak: function() {
     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() {
     goToTasks: function() {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
       uni.navigateTo({ url: '/pages/tasks/tasks' })

+ 127 - 0
cfc-frontend/pages/profile/portrait-edit.vue

@@ -0,0 +1,127 @@
+<template>
+  <view class="portrait-edit-page">
+    <view class="section">
+      <view class="section-title">我的画像描述</view>
+      <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
+      <textarea
+        class="portrait-textarea"
+        :value="portraitPrompt"
+        placeholder="例如:我家孩子 8 岁,偏瘦,挑食,容易积食,睡眠不足..."
+        maxlength="2000"
+        @input="onInput"
+      ></textarea>
+      <view class="char-count">{{ portraitPrompt.length }}/2000</view>
+    </view>
+
+    <view class="section" v-if="renderedSnapshot">
+      <view class="section-title">参考指标(自动生成)</view>
+      <view class="snapshot-text">{{ renderedSnapshot }}</view>
+    </view>
+
+    <button class="save-btn" :loading="saving" @click="onSave">保存</button>
+  </view>
+</template>
+
+<script>
+import { getPortrait, editPortrait } from '../../utils/api.js'
+export default {
+  data() {
+    return {
+      portraitPrompt: '',
+      renderedSnapshot: '',
+      saving: false
+    }
+  },
+  onLoad() {
+    this.loadPortrait()
+  },
+  methods: {
+    async loadPortrait() {
+      try {
+        const res = await getPortrait({ memberId: this.$store.state.currentMemberId })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+        }
+      } catch (e) {
+        console.error('加载画像失败', e)
+      }
+    },
+    onInput(e) {
+      this.portraitPrompt = e.detail.value
+    },
+    async onSave() {
+      this.saving = true
+      try {
+        const res = await editPortrait({ portraitPrompt: this.portraitPrompt })
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          this.loadPortrait()
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.portrait-edit-page {
+  padding: 24rpx;
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  margin-bottom: 12rpx;
+  color: #333;
+}
+.hint {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 16rpx;
+}
+.portrait-textarea {
+  width: 100%;
+  height: 300rpx;
+  border: 1rpx solid #eee;
+  border-radius: 8rpx;
+  padding: 16rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+  line-height: 1.6;
+}
+.char-count {
+  text-align: right;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.snapshot-text {
+  font-size: 26rpx;
+  color: #555;
+  white-space: pre-wrap;
+  line-height: 1.6;
+}
+.save-btn {
+  background: #F97316;
+  color: #fff;
+  border-radius: 44rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  font-size: 32rpx;
+  margin-top: 32rpx;
+}
+</style>

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

@@ -129,6 +129,19 @@
           <text class="empty-title">暂无任务数据</text>
           <text class="empty-title">暂无任务数据</text>
           <text class="empty-hint">创建任务后将在这里展示完成率分析</text>
           <text class="empty-hint">创建任务后将在这里展示完成率分析</text>
         </view>
         </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>
       </template>
     </view>
     </view>
   </view>
   </view>
@@ -153,10 +166,12 @@ export default {
         totalTasks: 0,
         totalTasks: 0,
         completedTasks: 0,
         completedTasks: 0,
         pendingTasks: 0,
         pendingTasks: 0,
+        inReviewTasks: 0,
         overdueTasks: 0,
         overdueTasks: 0,
         completionRate: 0,
         completionRate: 0,
         byChild: [],
         byChild: [],
-        categoryBreakdown: []
+        categoryBreakdown: [],
+        completedTaskList: []
       }
       }
     }
     }
   },
   },
@@ -208,10 +223,12 @@ export default {
             totalTasks: data.totalTasks || 0,
             totalTasks: data.totalTasks || 0,
             completedTasks: data.completedTasks || 0,
             completedTasks: data.completedTasks || 0,
             pendingTasks: data.pendingTasks || 0,
             pendingTasks: data.pendingTasks || 0,
+            inReviewTasks: data.inReviewTasks || 0,
             overdueTasks: data.overdueTasks || 0,
             overdueTasks: data.overdueTasks || 0,
             completionRate: data.completionRate || 0,
             completionRate: data.completionRate || 0,
             byChild: data.byChild || [],
             byChild: data.byChild || [],
-            categoryBreakdown: data.categoryBreakdown || []
+            categoryBreakdown: data.categoryBreakdown || [],
+            completedTaskList: data.completedTaskList || []
           }
           }
         }
         }
       } catch (e) {
       } catch (e) {
@@ -589,6 +606,36 @@ export default {
   flex-shrink: 0;
   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 {
 .empty-section {
   display: flex;
   display: flex;

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

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

+ 257 - 0
cfc-frontend/pages/wealth-detail/member-wealth-detail.vue

@@ -0,0 +1,257 @@
+<template>
+  <view class="detail-container">
+    <!-- 五维能量条 -->
+    <FamilyEnergyBar
+      dimensionCode="wealth"
+      :sandboxData="sandboxData"
+      :dualDimension="dualDimension" />
+
+    <!-- 成员信息卡 -->
+    <view class="member-card" v-if="memberInfo">
+      <view class="member-avatar wealth-avatar">
+        <text class="avatar-text">{{ memberInfo.name && memberInfo.name.charAt(0) || '孩' }}</text>
+      </view>
+      <view class="member-info">
+        <text class="member-name">{{ memberInfo.name || '孩子' }}</text>
+        <text class="member-role">财富维度</text>
+      </view>
+      <view class="member-score" v-if="dualDimension">
+        <text class="score-value wealth-score">{{ dualDimension.energy || 0 }}</text>
+        <text class="score-label">能量值</text>
+      </view>
+    </view>
+
+    <!-- 家庭成员关系图谱(只读) -->
+    <FamilyRelationGraph
+      dimensionCode="wealth"
+      :selfId="selfId"
+      :members="graphMembers"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      :interactive="false" />
+
+    <!-- 今日任务 -->
+    <DimensionTasks
+      :memberId="memberId"
+      @taskClick="onTaskClick"
+      @moreTasks="goTasks" />
+
+    <!-- 活动 -->
+    <DimensionActivities
+      :isLoggedIn="true"
+      @activityClick="goActivityDetail"
+      @moreActivities="goMoreActivities" />
+
+    <!-- 商品 -->
+    <DimensionProducts
+      :isLoggedIn="true"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
+
+    <!-- 底部占位 -->
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+import DimensionTasks from '../../components/DimensionTasks.vue'
+import DimensionActivities from '../../components/DimensionActivities.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
+import { getEnergyOverview, getFamilyEnergySandbox, getChildren } from '../../utils/api.js'
+
+export default {
+  components: { FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts },
+  data() {
+    return {
+      memberId: null,
+      selfId: null,
+      memberInfo: null,
+      sandboxData: null,
+      dualDimension: null
+    }
+  },
+  computed: {
+    graphMembers: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      return this.sandboxData.members.map(function(m) {
+        return {
+          id: m.memberId || m.id,
+          nickname: m.name || m.nickname || '成员',
+          memberType: m.memberType || 'child'
+        }
+      })
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    }
+  },
+  onLoad: function(options) {
+    this.memberId = options.memberId || uni.getStorageSync('currentChildId') || null
+  },
+  onShow: function() {
+    this.selfId = uni.getStorageSync('userId') || null
+    this.loadChildren()
+    this.loadSandboxData()
+    if (this.memberId) {
+      this.loadEnergyData()
+    }
+  },
+  methods: {
+    loadChildren: function() {
+      var self = this
+      getChildren().then(function(res) {
+        if (res.code === 200 && res.data) {
+          for (var i = 0; i < res.data.length; i++) {
+            if (res.data[i].memberId === self.memberId) {
+              self.memberInfo = res.data[i]
+              break
+            }
+          }
+          if (!self.memberInfo && res.data.length > 0) {
+            self.memberInfo = res.data[0]
+            self.memberId = res.data[0].memberId
+          }
+        }
+      }).catch(function() {})
+    },
+    loadSandboxData: function() {
+      var self = this
+      getFamilyEnergySandbox().then(function(res) {
+        if (res && res.data) self.sandboxData = res.data
+      }).catch(function() {})
+    },
+    loadEnergyData: function() {
+      var self = this
+      getEnergyOverview(this.memberId).then(function(res) {
+        if (res && res.data) {
+          var dims = res.data.dimensions
+          if (dims && dims.length > 0) {
+            for (var i = 0; i < dims.length; i++) {
+              if (dims[i].code === 'wealth') {
+                self.dualDimension = dims[i]
+                break
+              }
+            }
+          }
+        }
+      }).catch(function() {})
+    },
+    onTaskClick: function(task) {
+      uni.navigateTo({ url: '/pages/tasks/tasks' })
+    },
+    goActivityDetail: function(act) {
+      if (act && act.id) uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
+    },
+    goMoreActivities: function() {
+      uni.navigateTo({ url: '/pages/activity/index' })
+    },
+    goProductDetail: function(prod) {
+      if (prod && prod.id) uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + prod.id })
+    },
+    goMoreProducts: function() {
+      uni.navigateTo({ url: '/pages/shop/index/index' })
+    },
+    goTasks: function() { uni.navigateTo({ url: '/pages/tasks/tasks' }) },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+</script>
+
+<style scoped>
+.detail-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 40rpx;
+}
+
+.member-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+
+.member-avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 40rpx;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+}
+
+.wealth-avatar {
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+}
+
+.avatar-text {
+  font-size: 32rpx;
+  color: #fff;
+  font-weight: bold;
+}
+
+.member-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.member-name {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.member-role {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+
+.member-score {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.score-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #FFD700;
+}
+
+.wealth-score {
+  color: #B8860B;
+}
+
+.score-label {
+  font-size: 22rpx;
+  color: #999;
+}
+
+.bottom-spacer {
+  height: 40rpx;
+}
+</style>

+ 43 - 1
cfc-frontend/pages/wealth/index.vue

@@ -54,6 +54,13 @@
         @select="onFamilyMemberSelect"
         @select="onFamilyMemberSelect"
         @removed="loadFamilyMembersVisible" />
         @removed="loadFamilyMembersVisible" />
 
 
+      <!-- 五维能量条 -->
+      <FamilyEnergyBar
+        v-if="isLoggedIn && sandboxData"
+        dimensionCode="wealth"
+        :sandboxData="sandboxData"
+        :dualDimension="dualDimension" />
+
       <!-- 身克富杠杆警告 -->
       <!-- 身克富杠杆警告 -->
       <body-wealth-alert
       <body-wealth-alert
         :status="bodyWealthStatus"
         :status="bodyWealthStatus"
@@ -230,6 +237,7 @@ import WealthEnergyRing from '../../components/WealthEnergyRing.vue'
 import WealthSubScores from '../../components/WealthSubScores.vue'
 import WealthSubScores from '../../components/WealthSubScores.vue'
 import BodyWealthAlert from '../../components/BodyWealthAlert.vue'
 import BodyWealthAlert from '../../components/BodyWealthAlert.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
 import {
 import {
@@ -240,13 +248,15 @@ import {
   getCommissionSummary,
   getCommissionSummary,
   getFamilyMemberList,
   getFamilyMemberList,
   getInsuranceList,
   getInsuranceList,
+  getFamilyEnergySandbox,
+  getEnergyOverview,
   productList
   productList
 } from '../../utils/api.js'
 } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import nav from '../../utils/nav.js'
 import config from '../../config.js'
 import config from '../../config.js'
 
 
 export default {
 export default {
-  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, DimensionIntroCard, DimensionSubDims },
+  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, FamilyEnergyBar, DimensionIntroCard, DimensionSubDims },
   data() {
   data() {
     return {
     return {
       showTabTransition: true,
       showTabTransition: true,
@@ -299,6 +309,9 @@ export default {
       familyMembersVisible: [],
       familyMembersVisible: [],
       selectedMemberId: null,
       selectedMemberId: null,
       guestProducts: [],
       guestProducts: [],
+      // 五维能量条数据
+      sandboxData: null,
+      dualDimension: null,
       // 保单列表
       // 保单列表
       insuranceList: [],
       insuranceList: [],
       insuranceLoaded: false,
       insuranceLoaded: false,
@@ -368,6 +381,8 @@ export default {
     this.loadWealthData()
     this.loadWealthData()
     this.loadFamilyMembersVisible()
     this.loadFamilyMembersVisible()
     this.loadInsuranceList()
     this.loadInsuranceList()
+    this.loadSandboxData()
+    this.loadEnergyData()
     this._watchPageReady()
     this._watchPageReady()
   },
   },
     /** 每月引导语 */
     /** 每月引导语 */
@@ -483,6 +498,33 @@ export default {
         console.error('获取富维度详情失败', e)
         console.error('获取富维度详情失败', e)
       }
       }
     },
     },
+    loadSandboxData: function() {
+      var self = this
+      getFamilyEnergySandbox().then(function(res) {
+        if (res && res.data) self.sandboxData = res.data
+      }).catch(function() {})
+    },
+    loadEnergyData: function() {
+      var self = this
+      var memberId = uni.getStorageSync('userId') || null
+      if (!memberId) return
+      getEnergyOverview(memberId).then(function(res) {
+        if (res && res.data) {
+          var dims = res.data.dimensions
+          if (dims && dims.length > 0) {
+            for (var i = 0; i < dims.length; i++) {
+              if (dims[i].code === 'wealth') {
+                self.dualDimension = dims[i]
+                break
+              }
+            }
+          }
+        }
+      }).catch(function() {})
+    },
+    goToMemberDetail: function(memberId) {
+      uni.navigateTo({ url: '/pages/wealth-detail/member-wealth-detail?memberId=' + memberId })
+    },
     async loadWealthData() {
     async loadWealthData() {
       if (this.role !== 'parent') return
       if (this.role !== 'parent') return
       try {
       try {

+ 1 - 1
cfc-frontend/pages/wisdom-detail/index.vue

@@ -440,7 +440,7 @@ export default {
     goWisdomDetail: function() {
     goWisdomDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       if (!id) return
       if (!id) return
-      uni.navigateTo({ url: '/pages/wisdom/member-wisdom-detail?memberId=' + id })
+      uni.navigateTo({ url: '/pages/wisdom-detail/member-wisdom-detail?memberId=' + id })
     },
     },
     formatNumeric: function(v) {
     formatNumeric: function(v) {
       if (v === null || v === undefined) return ''
       if (v === null || v === undefined) return ''

+ 19 - 6
cfc-frontend/utils/api.js

@@ -184,9 +184,9 @@ export const wechatPhoneLogin = (data) => {
   return request('/api/auth/wechat-phone-login', 'POST', data)
   return request('/api/auth/wechat-phone-login', 'POST', data)
 }
 }
 
 
-// 获取平台公告
+// 获取平台公告(旧 /api/notices/list 已废弃返回410,改用已发布公告接口)
 export const getNotices = () => {
 export const getNotices = () => {
-  return request('/api/notices/list', 'POST', {})
+  return request('/api/admin/notices/all', 'POST', {})
 }
 }
 
 
 // 微信静默登录
 // 微信静默登录
@@ -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 confirmReportPreview = (data) => request('/api/health/report/confirm', 'POST', data)
 
 
-// 鏀惧純鑽夌
+// 放弃草稿
 export const discardReportDraft = (draftId) => request('/api/health/report/discard', 'POST', { draftId })
 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 rejectPlan = (data) => request('/api/health/plan/pending-review/reject', 'POST', data)
 export const getHealthPlanList = (data) => request('/api/health/plan/list', '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 getHealthPlanDetail = (data) => request('/api/health/plan/detail', 'POST', data)
+export const getHealthPlanTasks = (data) => request('/api/health/plan/tasks', 'POST', data)
 
 
 // ===== 閭€璇烽噷绋嬬 =====
 // ===== 閭€璇烽噷绋嬬 =====
 export const getInviteMilestones = () => {
 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 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 confirmTongue = (data) => request('/api/health/report/confirm?type=tongue', 'POST', data)
 export const discardTongue = (data) => request('/api/health/report/discard?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)
 export const getIndicatorDefinitions = (data) => request('/api/admin/indicator/list', 'POST', data)
 
 
@@ -2725,3 +2730,11 @@ export function getMyButler() {
 export function selectButler(butlerUserId) {
 export function selectButler(butlerUserId) {
   return request('/api/butler/select', 'POST', { butlerUserId })
   return request('/api/butler/select', 'POST', { butlerUserId })
 }
 }
+
+// ── 用户画像 ──
+export function getPortrait(params = {}) {
+  return request('/api/user/portrait/get', 'POST', params)
+}
+export function editPortrait(data) {
+  return request('/api/user/portrait/edit', 'POST', data)
+}

+ 54 - 0
cfc-langgraph/app/api/adapter.py

@@ -5,6 +5,7 @@ from app.graphs.chat_graph import create_chat_graph
 from app.graphs.analysis_graph import create_analysis_graph
 from app.graphs.analysis_graph import create_analysis_graph
 from app.graphs.health_coach_graph import create_health_coach_graph
 from app.graphs.health_coach_graph import create_health_coach_graph
 from app.graphs.health_butler_graph import create_health_butler_graph
 from app.graphs.health_butler_graph import create_health_butler_graph
+from app.graphs.nutrition_graph import create_nutrition_graph
 from app.tools.java_client import JavaClient
 from app.tools.java_client import JavaClient
 from app.rag.retriever import RagRetriever
 from app.rag.retriever import RagRetriever
 from app.config import settings
 from app.config import settings
@@ -801,3 +802,56 @@ async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
     except Exception as e:
     except Exception as e:
         logger.error("重新生成 section 失败: %s", e)
         logger.error("重新生成 section 失败: %s", e)
         return {"success": False, "error": str(e)}
         return {"success": False, "error": str(e)}
+
+
+@router.post("/nutrition/send", response_model=DifyResponse)
+async def nutrition_send(req: DifyChatRequest):
+    """AI 营养助手 — 基于健康报告的个性化营养建议"""
+    query = _extract_query(req)
+    if not query:
+        raise HTTPException(status_code=400, detail="query 为空")
+
+    graph = create_nutrition_graph()
+    initial_state = {
+        "query": query,
+        "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
+        "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
+        "conversation_id": req.conversation_id or None,
+        "context": _to_langgraph_context(req),
+        "answer": None,
+        "sources": [],
+        "tasks": [],
+        "messages": None,
+    }
+    config = {
+        "configurable": {"thread_id": req.conversation_id or f"nutrition_{req.user_id}"},
+    }
+    result = await graph.ainvoke(initial_state, config)
+    answer = result.get("answer") or ""
+
+    sources = result.get("sources") or []
+    metadata = {}
+    if sources:
+        metadata["sources"] = [
+            {"title": s.get("name", ""), "type": s.get("type", "tool")}
+            for s in sources
+        ]
+
+    return DifyResponse(
+        id=f"nutrition-{uuid.uuid4().hex[:24]}",
+        created=_now_ts(),
+        model="langgraph-nutrition",
+        choices=[
+            DifyChoice(
+                index=0,
+                message={"role": "assistant", "content": answer},
+                finish_reason="stop",
+            )
+        ],
+        usage=DifyUsage(
+            prompt_tokens=len(query.split()),
+            completion_tokens=len(answer.split()),
+            total_tokens=len(query.split()) + len(answer.split()),
+        ),
+        metadata=metadata,
+    )

+ 9 - 0
cfc-langgraph/app/graphs/chat_graph.py

@@ -13,6 +13,8 @@ from app.tools.product_tools import (
 from app.memory.store import MemoryManager
 from app.memory.store import MemoryManager
 from app.config import settings
 from app.config import settings
 from app.prompt_service import get_prompt
 from app.prompt_service import get_prompt
+from app.portrait_service import get_user_portrait_text
+from app.tools.java_client import JavaClient
 import logging
 import logging
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -60,6 +62,7 @@ def create_chat_graph():
     from app.rag.embeddings import get_embeddings
     from app.rag.embeddings import get_embeddings
     embeddings = get_embeddings()
     embeddings = get_embeddings()
     memory_mgr = MemoryManager(embeddings)
     memory_mgr = MemoryManager(embeddings)
+    java = JavaClient()
 
 
     llm_with_tools = llm.bind_tools([
     llm_with_tools = llm.bind_tools([
         search_product_by_keyword,
         search_product_by_keyword,
@@ -87,6 +90,12 @@ def create_chat_graph():
         chat_prompt = await get_prompt("chat_assistant") or DEFAULT_CHAT_PROMPT
         chat_prompt = await get_prompt("chat_assistant") or DEFAULT_CHAT_PROMPT
         messages = [SystemMessage(content=chat_prompt)]
         messages = [SystemMessage(content=chat_prompt)]
 
 
+        # 画像注入
+        member_id = state.get("child_id")
+        portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
+        if portrait_text:
+            messages.insert(1, SystemMessage(content=portrait_text))
+
         # 注入家庭上下文 + 用户健康现状
         # 注入家庭上下文 + 用户健康现状
         ctx = state.get("context", {})
         ctx = state.get("context", {})
         if isinstance(ctx, dict):
         if isinstance(ctx, dict):

+ 7 - 0
cfc-langgraph/app/graphs/health_butler_graph.py

@@ -11,6 +11,7 @@ from app.tools.product_tools import (
 from app.memory.store import MemoryManager
 from app.memory.store import MemoryManager
 from app.config import settings
 from app.config import settings
 from app.prompt_service import get_prompt
 from app.prompt_service import get_prompt
+from app.portrait_service import get_user_portrait_text
 import logging
 import logging
 import json
 import json
 
 
@@ -139,6 +140,12 @@ def create_health_butler_graph():
         """阶段3: LLM 生成个性化健康建议"""
         """阶段3: LLM 生成个性化健康建议"""
         messages = [SystemMessage(content=await get_prompt("health_butler") or DEFAULT_BUTLER_PROMPT)]
         messages = [SystemMessage(content=await get_prompt("health_butler") or DEFAULT_BUTLER_PROMPT)]
 
 
+        # 画像注入
+        member_id = state.get("child_id")
+        portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
+        if portrait_text:
+            messages.insert(1, SystemMessage(content=portrait_text))
+
         # 注入健康知识库上下文
         # 注入健康知识库上下文
         kb_ctx = state.get("kb_context", {})
         kb_ctx = state.get("kb_context", {})
         knowledge_results = state.get("knowledge_results", [])
         knowledge_results = state.get("knowledge_results", [])

+ 9 - 0
cfc-langgraph/app/graphs/health_coach_graph.py

@@ -11,6 +11,8 @@ from app.rag.retriever import RagRetriever
 from app.memory.store import MemoryManager
 from app.memory.store import MemoryManager
 from app.config import settings
 from app.config import settings
 from app.prompt_service import get_prompt
 from app.prompt_service import get_prompt
+from app.portrait_service import get_user_portrait_text
+from app.tools.java_client import JavaClient
 import logging
 import logging
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -70,6 +72,7 @@ def create_health_coach_graph():
     embeddings = get_embeddings()
     embeddings = get_embeddings()
     retriever = RagRetriever(collection_name="health_knowledge")
     retriever = RagRetriever(collection_name="health_knowledge")
     memory_mgr = MemoryManager(embeddings)
     memory_mgr = MemoryManager(embeddings)
+    java = JavaClient()
 
 
     # 带工具的 LLM(健康知识库检索)
     # 带工具的 LLM(健康知识库检索)
     async def health_retrieve(query: str, user_id: int) -> list[dict]:
     async def health_retrieve(query: str, user_id: int) -> list[dict]:
@@ -95,6 +98,12 @@ def create_health_coach_graph():
         _base_prompt = await get_prompt("health_coach")
         _base_prompt = await get_prompt("health_coach")
         messages = [SystemMessage(content=_persona_prompt or _base_prompt or DEFAULT_COACH_PROMPT)]
         messages = [SystemMessage(content=_persona_prompt or _base_prompt or DEFAULT_COACH_PROMPT)]
 
 
+        # 画像注入
+        member_id = _ctx.get("child_id")
+        portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
+        if portrait_text:
+            messages.insert(1, SystemMessage(content=portrait_text))
+
         # 家庭上下文
         # 家庭上下文
         ctx = state.get("context") or {}
         ctx = state.get("context") or {}
         if ctx:
         if ctx:

+ 159 - 0
cfc-langgraph/app/graphs/nutrition_graph.py

@@ -0,0 +1,159 @@
+"""AI 营养助手 LangGraph — 基于健康报告的个性化营养建议"""
+from typing import TypedDict
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint.memory import MemorySaver
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.java_client import JavaClient
+from app.tools.product_tools import (
+    search_product_by_keyword,
+    search_article_by_keyword,
+)
+from app.memory.store import MemoryManager
+from app.config import settings
+from app.prompt_service import get_prompt
+from app.portrait_service import get_user_portrait_text
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class NutritionState(TypedDict):
+    query: str
+    user_id: int
+    conversation_id: str
+    child_id: int | None
+    report_id: int | None
+    context: dict | None
+    answer: str | None
+    tasks: list[dict]
+    sources: list[dict]
+    messages: list | None
+
+
+DEFAULT_NUTRITION_PROMPT = """你是一个儿童营养健康顾问,专注于基于检测报告提供精准的营养改善建议。
+
+你的能力:
+1. 解读儿童健康报告(菌群、营养指标、体检数据)
+2. 基于报告数据推荐合适的营养产品和膳食方案
+3. 结合孩子体质给出个性化的饮食建议
+4. 推荐相关的健康活动和科普文章
+
+回答原则:
+1. 用中文,语气温暖专业,像营养师一样亲切
+2. 必须基于报告数据给出建议,不凭空猜测
+3. 引用知识库中的营养素/食物信息作为证据
+4. 需要产品推荐时使用搜索工具,说明推荐理由
+5. 生成 [TASK: {"title": "任务名", "dimension": "身", "points": 10, "frequency": "每天"}] 标记创建营养行动任务
+6. 严重健康问题建议咨询专业医生或营养师
+7. 不要编造医学建议,不夸大效果
+"""
+
+
+def create_nutrition_graph():
+    """创建营养助手 StateGraph"""
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=settings.llm_temperature,
+    )
+
+    from app.rag.embeddings import get_embeddings
+    embeddings = get_embeddings()
+    memory_mgr = MemoryManager(embeddings)
+    java = JavaClient()
+
+    llm_with_tools = llm.bind_tools([
+        search_product_by_keyword,
+        search_article_by_keyword,
+    ])
+
+    builder = StateGraph(NutritionState)
+
+    async def generate_answer(state: NutritionState) -> dict:
+        """核心 LLM 调用 + 画像注入"""
+        # 加载营养助手 prompt
+        nutrition_prompt = await get_prompt("nutrition_assistant") or DEFAULT_NUTRITION_PROMPT
+        messages = [SystemMessage(content=nutrition_prompt)]
+
+        # 画像注入(在人格 prompt 之后)
+        member_id = state.get("child_id")
+        portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
+        if portrait_text:
+            messages.insert(1, SystemMessage(content=portrait_text))
+
+        # 家庭上下文
+        ctx = state.get("context") or {}
+        if ctx:
+            ctx_parts = []
+            if ctx.get("child_id"):
+                ctx_parts.append(f"孩子ID: {ctx['child_id']}")
+            if ctx.get("family_id"):
+                ctx_parts.append(f"家庭ID: {ctx['family_id']}")
+            if ctx.get("report_id"):
+                ctx_parts.append(f"报告ID: {ctx['report_id']}")
+            if ctx_parts:
+                messages.append(SystemMessage(content="用户背景信息: " + ", ".join(ctx_parts)))
+
+        # 长期记忆
+        try:
+            memories = await memory_mgr.recall(state["user_id"], state["query"])
+            if memories:
+                mem_text = "\n".join([f"- {m}" for m in memories[:3]])
+                messages.append(SystemMessage(content=f"该用户的历史咨询记录:\n{mem_text}"))
+        except Exception as e:
+            logger.warning("召回营养记忆失败: %s", e)
+
+        # 用户消息
+        messages.append(HumanMessage(content=state["query"]))
+
+        response = await llm_with_tools.ainvoke(messages)
+        answer = response.content
+
+        # 提取任务和来源
+        tasks = []
+        sources = []
+        if response.response_metadata.get("tool_calls"):
+            for tc in response.response_metadata["tool_calls"]:
+                sources.append({
+                    "type": "tool",
+                    "name": tc.get("name", ""),
+                    "input": tc.get("args", {}),
+                })
+
+        return {
+            "answer": answer,
+            "tasks": tasks,
+            "sources": sources,
+            "messages": [
+                {"role": "user", "content": state["query"]},
+                {"role": "assistant", "content": answer},
+            ],
+        }
+
+    async def save_memory(state: NutritionState) -> dict:
+        """保存对话到长期记忆"""
+        try:
+            if state.get("messages"):
+                await memory_mgr.save_conversation(
+                    state["user_id"],
+                    state.get("conversation_id") or f"nutrition_{state['user_id']}",
+                    state["messages"],
+                )
+        except Exception as e:
+            logger.warning("保存营养记忆失败: %s", e)
+        return {}
+
+    # 构建图
+    builder.add_node("generate_answer", generate_answer)
+    builder.add_node("save_memory", save_memory)
+
+    builder.add_edge(START, "generate_answer")
+    builder.add_edge("generate_answer", "save_memory")
+    builder.add_edge("save_memory", END)
+
+    checkpointer = MemorySaver()
+    graph = builder.compile(checkpointer=checkpointer)
+
+    return graph

+ 110 - 0
cfc-langgraph/app/portrait_service.py

@@ -0,0 +1,110 @@
+"""用户画像 prompt 组装服务
+
+职责:
+1. 拉取用户自定义画像文本(600s 缓存)
+2. 拉取 profile_snapshot 并渲染为文本(900s 缓存)
+3. 拼装成 SystemMessage 内容;无数据时返回 None
+"""
+import time
+import logging
+from app.tools.java_client import JavaClient
+
+logger = logging.getLogger(__name__)
+
+_TTL_PROMPT = 600    # 用户自定义 prompt 缓存 10 分钟
+_TTL_SNAPSHOT = 900  # 画像快照缓存 15 分钟
+
+_cache_prompt: dict = {}
+_cache_snapshot: dict = {}
+
+
+def _render_snapshot(profile: dict) -> str:
+    """将 profile Map 渲染为中文指标清单文本"""
+    if not profile or "error" in profile:
+        return ""
+    lines = []
+    member = profile.get("member") or {}
+    name = member.get("name", "用户")
+    age = member.get("age", "?")
+    gender_raw = str(member.get("gender", ""))
+    gender = "男" if gender_raw == "male" else "女" if gender_raw == "female" else "未知"
+    lines.append(f"## 画像对象:{name}({age}岁,{gender})")
+
+    dims = profile.get("dimension_scores") or {}
+    if dims:
+        lines.append(f"五维评分:身 {dims.get('body', '?')} 智 {dims.get('wisdom', '?')} "
+                     f"心 {dims.get('mind', '?')} 行 {dims.get('action', '?')} 富 {dims.get('wealth', '?')}")
+
+    body = profile.get("body_metrics") or {}
+    if body.get("sleep_dur_avg"):
+        lines.append(f"平均睡眠:{body['sleep_dur_avg']}小时/天")
+    if body.get("exercise_count_week"):
+        lines.append(f"周运动频次:{body['exercise_count_week']}次")
+
+    mind = profile.get("mind_metrics") or {}
+    if mind.get("stress_avg"):
+        lines.append(f"平均压力:{mind['stress_avg']}/10")
+
+    problems = profile.get("problem_domains") or []
+    if problems:
+        lines.append(f"关注问题域:{', '.join(problems[:5])}")
+
+    return "\n".join(lines)
+
+
+async def get_user_portrait_text(java: JavaClient, user_id: int, member_id) -> str | None:
+    """返回画像 SystemMessage 内容;无画像数据时返回 None"""
+    parts = []
+
+    # 1. 用户自定义 prompt(600s 缓存)
+    now = time.time()
+    cached_text, cached_ts = _cache_prompt.get(user_id, (None, 0))
+    if now - cached_ts < _TTL_PROMPT and cached_text is not None:
+        user_text = cached_text
+    else:
+        try:
+            client = await java._get_client()
+            resp = await client.post("/api/user/portrait/get", json={"userId": user_id}, timeout=5.0)
+            data = resp.json()
+            user_text = (data.get("data") or {}).get("portraitPrompt") or ""
+            _cache_prompt[user_id] = (user_text, now)
+        except Exception as e:
+            logger.warning("拉取用户画像 prompt 失败: %s", e)
+            user_text = ""
+
+    if user_text.strip():
+        parts.append(user_text)
+
+    # 2. 快照渲染文本(仅当有 member_id 时)
+    if member_id:
+        snap_now = time.time()
+        cached_snap, cached_snap_ts = _cache_snapshot.get(int(member_id), (None, 0))
+        if snap_now - cached_snap_ts < _TTL_SNAPSHOT and cached_snap is not None:
+            snap_text = cached_snap
+        else:
+            try:
+                profile = await java.get_member_profile(int(member_id))
+                snap_text = _render_snapshot(profile) if profile else ""
+                _cache_snapshot[int(member_id)] = (snap_text, snap_now)
+            except Exception as e:
+                logger.warning("拉取成员画像快照失败: %s", e)
+                snap_text = ""
+
+        if snap_text.strip():
+            if user_text.strip():
+                parts.append("\n---参考指标---\n" + snap_text)
+            else:
+                parts.append("以下是用户画像数据(系统自动生成),请结合这些数据给出更针对性的建议:\n" + snap_text)
+
+    return "\n\n".join(parts) if parts else None
+
+
+def clear_cache(user_id: int | None = None, member_id: int | None = None):
+    """清除缓存"""
+    if user_id is not None:
+        _cache_prompt.pop(user_id, None)
+    if member_id is not None:
+        _cache_snapshot.pop(member_id, None)
+    if user_id is None and member_id is None:
+        _cache_prompt.clear()
+        _cache_snapshot.clear()

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-76cbcb144073aeb2016abd35cd85473ee37ef950
+197a9fc98c91fbebb68d7c768bd08258c7ff214c

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1216",
+  "version": "1.0.1227",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1216",
+      "version": "1.0.1227",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1217",
+  "version": "1.0.1228",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 106 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,112 @@
 
 
 ---
 ---
 
 
+## v1.0.1228 (2026-08-27)
+
+### 新功能
+- 五维沙盘个人/全家视角切换 + 食物表字段扩展 + 忽略报告解析临时文件
+
+
+## v1.0.1227 (2026-08-27)
+
+### 其他
+- 修改体验版地址
+
+
+## v1.0.1226 (2026-08-27)
+
+### 其他
+- 修改体验版地址
+
+
+## v1.0.1225 (2026-08-27)
+
+### 其他
+- 健康报告上传-解析-生成方案-分解任务-打卡,完成链路流程
+- 修改小程序的开发环境
+- 修复编译报错
+- 修复编译报错
+
+
+## v1.0.1224 (2026-08-26)
+
+### Bug 修复
+- 五维成员详情页导航路径修复 + 财富维度成员详情页补齐
+
+
+## v1.0.1223 (2026-08-25)
+
+### Bug 修复
+- 全局异常处理器捕获 ClientAbortException,避免连接中断时二次抛异常
+
+
+## v1.0.1222 (2026-08-24)
+
+### 新功能
+- 小程序新增画像编辑页(portrait-edit.vue+api封装+pages注册)
+- Java 4个对话端点注入portrait_prompt(chat/coach/butler/nutrition)
+- Python侧画像prompt注入(4个graph+nutrition端点)
+
+
+## v1.0.1221 (2026-08-23)
+
+### 新功能
+- 用户画像数据层+PortraitService(迁移263+实体+接口+Controller)
+
+### Bug 修复
+- ensure member IDs are always strings for WeChat mini-program event system
+
+### 其他
+- - Template expressions pass raw numbers, causing type mismatch in indexOf
+- - Always convert to String() in isMemberSelected, toggleMember, loadExecutors
+- - Prevent double loadExecutors with synchronous _executorsLoading flag
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1220 (2026-08-23)
+
+### 文档
+- 对齐 TabBar 重设计为实际实现(4Tab+中间⭐扇形菜单)
+
+### 其他
+- - 行跳转:pages/index-home/index→pages/action-detail/index
+- - 四维跳转路径:pages/X/index→pages/X-detail/index
+- - 健康Tab:pages/health/index→pages/health-main/index(5子Tab+雷达图)
+- - 同步更新技术方案、边界情况、实施状态表
+- - PROJECT-OVERVIEW v2.8→v2.9
+- 
+
+
+## v1.0.1219 (2026-08-23)
+
+### Bug 修复
+- energy_daily_limit迁移编号255改258避免撞号
+- 全局异常处理兜底路径参数类型转换错误
+- 恢复被误删的套餐模板列表接口 /package/templates
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1218 (2026-08-23)
+
+### 文档
+- 用户画像 prompt 拼装实现计划
+- 用户画像 prompt 拼装设计规范
+
+### Bug 修复
+- 后端添加405处理器兼容旧GET请求
+- 修复中文CSS类名WXSS编译错误及公告接口更新
+- body-detail/index.vue 中文CSS类名导致WXSS编译错误
+
+
 ## v1.0.1217 (2026-08-23)
 ## v1.0.1217 (2026-08-23)
 
 
 ### Bug 修复
 ### Bug 修复

+ 107 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1217
+> 当前版本: v1.0.1228
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,112 @@
 
 
 ---
 ---
 
 
+## v1.0.1228 (2026-08-27)
+
+### 新功能
+- 五维沙盘个人/全家视角切换 + 食物表字段扩展 + 忽略报告解析临时文件
+
+
+## v1.0.1227 (2026-08-27)
+
+### 其他
+- 修改体验版地址
+
+
+## v1.0.1226 (2026-08-27)
+
+### 其他
+- 修改体验版地址
+
+
+## v1.0.1225 (2026-08-27)
+
+### 其他
+- 健康报告上传-解析-生成方案-分解任务-打卡,完成链路流程
+- 修改小程序的开发环境
+- 修复编译报错
+- 修复编译报错
+
+
+## v1.0.1224 (2026-08-26)
+
+### Bug 修复
+- 五维成员详情页导航路径修复 + 财富维度成员详情页补齐
+
+
+## v1.0.1223 (2026-08-25)
+
+### Bug 修复
+- 全局异常处理器捕获 ClientAbortException,避免连接中断时二次抛异常
+
+
+## v1.0.1222 (2026-08-24)
+
+### 新功能
+- 小程序新增画像编辑页(portrait-edit.vue+api封装+pages注册)
+- Java 4个对话端点注入portrait_prompt(chat/coach/butler/nutrition)
+- Python侧画像prompt注入(4个graph+nutrition端点)
+
+
+## v1.0.1221 (2026-08-23)
+
+### 新功能
+- 用户画像数据层+PortraitService(迁移263+实体+接口+Controller)
+
+### Bug 修复
+- ensure member IDs are always strings for WeChat mini-program event system
+
+### 其他
+- - Template expressions pass raw numbers, causing type mismatch in indexOf
+- - Always convert to String() in isMemberSelected, toggleMember, loadExecutors
+- - Prevent double loadExecutors with synchronous _executorsLoading flag
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1220 (2026-08-23)
+
+### 文档
+- 对齐 TabBar 重设计为实际实现(4Tab+中间⭐扇形菜单)
+
+### 其他
+- - 行跳转:pages/index-home/index→pages/action-detail/index
+- - 四维跳转路径:pages/X/index→pages/X-detail/index
+- - 健康Tab:pages/health/index→pages/health-main/index(5子Tab+雷达图)
+- - 同步更新技术方案、边界情况、实施状态表
+- - PROJECT-OVERVIEW v2.8→v2.9
+- 
+
+
+## v1.0.1219 (2026-08-23)
+
+### Bug 修复
+- energy_daily_limit迁移编号255改258避免撞号
+- 全局异常处理兜底路径参数类型转换错误
+- 恢复被误删的套餐模板列表接口 /package/templates
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1218 (2026-08-23)
+
+### 文档
+- 用户画像 prompt 拼装实现计划
+- 用户画像 prompt 拼装设计规范
+
+### Bug 修复
+- 后端添加405处理器兼容旧GET请求
+- 修复中文CSS类名WXSS编译错误及公告接口更新
+- body-detail/index.vue 中文CSS类名导致WXSS编译错误
+
+
 ## v1.0.1217 (2026-08-23)
 ## v1.0.1217 (2026-08-23)
 
 
 ### Bug 修复
 ### Bug 修复

+ 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) {
 export function getRewardsList(params) {
   return request({
   return request({

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

@@ -85,4 +85,10 @@ export function savePlanRule(data) {
 
 
 export function deletePlanRule(data) {
 export function deletePlanRule(data) {
   return request({ url: '/api/admin/plan-rules/delete', method: 'post', 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-form-item label="状态">
           <el-select v-model="searchForm.status" placeholder="全部" clearable @change="loadChildTasks">
           <el-select v-model="searchForm.status" placeholder="全部" clearable @change="loadChildTasks">
             <el-option label="待完成" value="pending"></el-option>
             <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="completed"></el-option>
             <el-option label="已超时" value="overdue"></el-option>
             <el-option label="已超时" value="overdue"></el-option>
           </el-select>
           </el-select>
@@ -125,8 +126,8 @@
               </el-button>
               </el-button>
               <el-dropdown-menu slot="dropdown">
               <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 !== '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-menu>
             </el-dropdown>
             </el-dropdown>
           </template>
           </template>
@@ -312,11 +313,11 @@ taskLoading: false,
       this.loadChildTasks()
       this.loadChildTasks()
     },
     },
     getStatusType(status) {
     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'
       return map[status] || 'info'
     },
     },
     getStatusText(status) {
     getStatusText(status) {
-      const map = { pending: '待完成', completed: '已完成', overdue: '已超时', cancelled: '已取消' }
+      const map = { pending: '待完成', pending_review: '待审核', completed: '已完成', overdue: '已超时', cancelled: '已取消' }
       return map[status] || status
       return map[status] || status
     },
     },
     formatDate(date) {
     formatDate(date) {

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

@@ -27,6 +27,7 @@
         <el-form-item label="状态">
         <el-form-item label="状态">
           <el-select v-model="searchForm.status" placeholder="全部状态" clearable @change="handleSearch">
           <el-select v-model="searchForm.status" placeholder="全部状态" clearable @change="handleSearch">
             <el-option label="进行中" value="pending"></el-option>
             <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="completed"></el-option>
             <el-option label="已逾期" value="overdue"></el-option>
             <el-option label="已逾期" value="overdue"></el-option>
             <el-option label="已取消" value="cancelled"></el-option>
             <el-option label="已取消" value="cancelled"></el-option>
@@ -128,6 +129,15 @@
             {{ formatDate(scope.row.createdAt) }}
             {{ formatDate(scope.row.createdAt) }}
           </template>
           </template>
         </el-table-column>
         </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>
       </el-table>
 
 
       <!-- 分页 -->
       <!-- 分页 -->
@@ -142,11 +152,26 @@
         layout="total, sizes, prev, pager, next, jumper"
         layout="total, sizes, prev, pager, next, jumper"
       ></el-pagination>
       ></el-pagination>
     </el-card>
     </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>
   </div>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getTasksList, getTaskStats, getFamilyList } from '@/api/admin'
+import { getTasksList, getTaskStats, getFamilyList, reviewAdminTask } from '@/api/admin'
 import SortMixin from '@/mixins/SortMixin'
 import SortMixin from '@/mixins/SortMixin'
 
 
 export default {
 export default {
@@ -186,7 +211,11 @@ export default {
         current: 1,
         current: 1,
         size: 10,
         size: 10,
         total: 0
         total: 0
-      }
+      },
+      reviewDialogVisible: false,
+      reviewAction: true,
+      reviewComment: '',
+      currentTask: {}
     }
     }
   },
   },
   mounted() {
   mounted() {
@@ -282,6 +311,7 @@ export default {
     getStatusType(status) {
     getStatusType(status) {
       const types = {
       const types = {
         pending: 'warning',
         pending: 'warning',
+        pending_review: 'warning',
         completed: 'success',
         completed: 'success',
         overdue: 'danger',
         overdue: 'danger',
         cancelled: 'info'
         cancelled: 'info'
@@ -291,12 +321,35 @@ export default {
     getStatusText(status) {
     getStatusText(status) {
       const texts = {
       const texts = {
         pending: '进行中',
         pending: '进行中',
+        pending_review: '待审核',
         completed: '已完成',
         completed: '已完成',
         overdue: '已逾期',
         overdue: '已逾期',
         cancelled: '已取消'
         cancelled: '已取消'
       }
       }
       return texts[status] || status
       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) {
     formatDate(dateStr) {
       if (!dateStr) return '-'
       if (!dateStr) return '-'
       const date = new Date(dateStr)
       const date = new Date(dateStr)

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

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

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

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

+ 1 - 0
cfc.pid

@@ -0,0 +1 @@
+1772153

+ 1 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -2,7 +2,7 @@
 
 
 **文档版本:** v2.9
 **文档版本:** v2.9
 **日期:** 2026-08-23
 **日期:** 2026-08-23
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间五维启动器,已提交)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)**
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)**
 **维护:** 所有需求变更需更新本文档
 **维护:** 所有需求变更需更新本文档
 
 
 ---
 ---

+ 801 - 0
docs/superpowers/plans/2026-08-24-user-portrait-prompt.md

@@ -0,0 +1,801 @@
+# 用户画像 prompt 拼装实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 在所有 AI 对话端点(chat、health-coach、butler、nutrition)中注入用户画像 prompt,实现个性化对话体验。
+
+**架构:**
+- Java 侧:`users` 表加 `portrait_prompt` 列,新增 `PortraitService` 和 `PortraitController`,在 4 个对话 Controller 方法中统一注入 portrait_prompt 到 inputs
+- Python 侧:新增 `portrait_service.py`,在 3 个 LangGraph(chat、health_coach、health_butler)的 generate_answer 节点中读取 portrait_prompt 并组装 SystemMessage;nutrition 端点走 Dify,由 Java 直接注入
+- 前端:小程序新增画像编辑页
+
+**技术栈:** Java 8 + Spring Boot、Python 3.10 + FastAPI + LangGraph、uni-app Vue 2
+
+---
+
+## 文件清单
+
+### Java 后端
+| 操作 | 路径 | 职责 |
+|------|------|------|
+| 修改 | `cfc-backend/src/main/resources/schema.sql` | users 表加 portrait_prompt 列 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 迁移脚本 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/entity/User.java` | 实体加 portraitPrompt 字段 |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java` | 接口 |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java` | 实现(拉 portrait + 渲染快照) |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java` | 两个 REST 端点 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | 4 个方法注入 portrait_prompt |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java` | sendNutritionMessage 注入 portrait_prompt |
+
+### Python LangGraph
+| 操作 | 路径 | 职责 |
+|------|------|------|
+| 新增 | `cfc-langgraph/app/portrait_service.py` | get_user_portrait_text 函数 + 缓存 |
+| 修改 | `cfc-langgraph/app/graphs/chat_graph.py` | 注入画像段 |
+| 修改 | `cfc-langgraph/app/graphs/health_coach_graph.py` | 注入画像段 |
+| 修改 | `cfc-langgraph/app/graphs/health_butler_graph.py` | 注入画像段 |
+
+### 小程序前端
+| 操作 | 路径 | 职责 |
+|------|------|------|
+| 新增 | `cfc-frontend/pages/profile/portrait-edit.vue` | 画像编辑页 |
+| 修改 | `cfc-frontend/utils/api.js` | 新增 getPortrait/editPortrait 封装 |
+| 修改 | `cfc-frontend/pages.json` | 注册新页面 |
+
+---
+
+## 任务分解
+
+### 任务 1:Java 数据层(users 表加列)
+
+**文件:**
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/User.java`
+
+- [ ] **步骤 1:在 schema.sql 追加 users 表列定义**
+
+找到 schema.sql 中 users 表的 CREATE TABLE 语句末尾(在 mascot 列之后),追加:
+```sql
+    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
+```
+
+先 grep 确认 mascot 列位置:
+```bash
+grep -n "mascot" cfc-backend/src/main/resources/schema.sql
+```
+
+- [ ] **步骤 2:在 DatabaseInitializer.runMigrations() 末尾追加迁移**
+
+在 DatabaseInitializer.java 中搜索 `// 迁移` 找最新编号,追加:
+```java
+// 迁移N: users 表添加 portrait_prompt 列(用户画像 prompt 拼装需求)
+try {
+    jdbcTemplate.execute("ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt'");
+    log.info("已添加 portrait_prompt 列到 users 表");
+} catch (Exception e) {
+    // 列已存在,忽略错误
+}
+```
+
+- [ ] **步骤 3:在 User.java 实体加字段**
+
+在 `private String mascot;` 之后追加:
+```java
+@TableField("portrait_prompt")
+private String portraitPrompt;
+```
+同时生成 getter/setter(IntelliJ 快捷键 Alt+Insert 或手写)。
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t1.log 2>&1; M=$?; tail -3 /tmp/opencode/t1.log; test $M -eq 0 && echo MVN_OK
+```
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-backend/src/main/resources/schema.sql \
+  cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+  cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
+GIT_MASTER=1 git commit -m "feat: users 表添加 portrait_prompt 列"
+```
+
+---
+
+### 任务 2:Java PortraitService + Controller
+
+**文件:**
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java`
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java`
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java`
+
+- [ ] **步骤 1:创建 PortraitService 接口**
+
+```java
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface PortraitService {
+    /** 获取用户画像文本(自定义 prompt + 快照渲染) */
+    String buildPortraitPrompt(Long userId, Long memberId);
+    /** 更新用户自定义画像 prompt */
+    void updatePortraitPrompt(Long userId, String portraitPrompt);
+    /** 读取用户画像(含渲染快照,用于前端展示) */
+    Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
+}
+```
+
+- [ ] **步骤 2:创建 PortraitServiceImpl**
+
+```java
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PortraitService;
+import com.etotem.cfc.service.ProfileReadService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class PortraitServiceImpl implements PortraitService {
+
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private ProfileReadService profileReadService;
+
+    @Override
+    public String buildPortraitPrompt(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) return null;
+        String custom = user.getPortraitPrompt();
+        if (custom == null || custom.trim().isEmpty()) {
+            return renderSnapshot(memberId);
+        }
+        String snapshot = renderSnapshot(memberId);
+        if (snapshot == null || snapshot.trim().isEmpty()) {
+            return custom.trim();
+        }
+        return custom.trim() + "\n\n---参考指标---\n" + snapshot;
+    }
+
+    @Override
+    public void updatePortraitPrompt(Long userId, String portraitPrompt) {
+        User user = new User();
+        user.setId(userId);
+        user.setPortraitPrompt(portraitPrompt);
+        userMapper.updateById(user);
+    }
+
+    @Override
+    public Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId) {
+        Long effectiveUserId = (targetUserId != null) ? targetUserId : userId;
+        User user = userMapper.selectById(effectiveUserId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("portraitPrompt", user != null ? user.getPortraitPrompt() : null);
+        if (memberId != null) {
+            Map<String, Object> profile = profileReadService.getProfile(memberId);
+            result.put("renderedSnapshot", renderSnapshotFull(profile));
+        }
+        return result;
+    }
+
+    private String renderSnapshot(Long memberId) {
+        if (memberId == null) return null;
+        Map<String, Object> profile = profileReadService.getProfile(memberId);
+        return renderSnapshotFull(profile);
+    }
+
+    private String renderSnapshotFull(Map<String, Object> profile) {
+        if (profile == null || profile.containsKey("error")) return null;
+        StringBuilder sb = new StringBuilder();
+        Map<String, Object> member = (Map<String, Object>) profile.get("member");
+        if (member != null) {
+            sb.append("## 画像对象:");
+            sb.append(member.getOrDefault("name", "用户"));
+            sb.append("(").append(member.getOrDefault("age", "?")).append("岁,");
+            String gender = String.valueOf(member.getOrDefault("gender", ""));
+            sb.append("男".equals(gender) ? "男" : "女".equals(gender) ? "女" : "未知");
+            sb.append(")\n");
+        }
+        Map<String, Object> dims = (Map<String, Object>) profile.get("dimension_scores");
+        if (dims != null && !dims.isEmpty()) {
+            sb.append(String.format("五维评分:身 %s 智 %s 心 %s 行 %s 富 %s\n",
+                    dims.getOrDefault("body", "?"), dims.getOrDefault("wisdom", "?"),
+                    dims.getOrDefault("mind", "?"), dims.getOrDefault("action", "?"),
+                    dims.getOrDefault("wealth", "?")));
+        }
+        Map<String, Object> body = (Map<String, Object>) profile.get("body_metrics");
+        if (body != null) {
+            if (body.get("sleep_dur_avg") != null)
+                sb.append(String.format("平均睡眠:%s小时/天\n", body.get("sleep_dur_avg")));
+            if (body.get("exercise_count_week") != null)
+                sb.append(String.format("周运动频次:%s次\n", body.get("exercise_count_week")));
+        }
+        Map<String, Object> mind = (Map<String, Object>) profile.get("mind_metrics");
+        if (mind != null && mind.get("stress_avg") != null)
+            sb.append(String.format("平均压力:%s/10\n", mind.get("stress_avg")));
+        @SuppressWarnings("unchecked")
+        java.util.List<String> problems = (java.util.List<String>) profile.get("problem_domains");
+        if (problems != null && !problems.isEmpty())
+            sb.append("关注问题域:").append(String.join(", ", problems.subList(0, Math.min(5, problems.size())))).append("\n");
+        return sb.toString().trim().isEmpty() ? null : sb.toString().trim();
+    }
+}
+```
+
+- [ ] **步骤 3:创建 PortraitController**
+
+```java
+package com.etotem.cfc.controller.user;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.PortraitService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/user/portrait")
+public class PortraitController {
+
+    @Resource
+    private PortraitService portraitService;
+
+    @PostMapping("/get")
+    public Result<Map<String, Object>> getPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long targetUserId = params.get("userId") != null
+                ? Long.valueOf(params.get("userId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        // 普通用户不能查他人
+        if (targetUserId != null && !targetUserId.equals(userId)) {
+            // 可加管理员权限检查,此处暂放行(前端只传自己)
+        }
+        return Result.success(portraitService.getPortrait(userId, targetUserId, memberId));
+    }
+
+    @PostMapping("/edit")
+    public Result<String> editPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Object promptObj = params.get("portraitPrompt");
+        String portraitPrompt = promptObj != null ? promptObj.toString() : null;
+        if (portraitPrompt != null && portraitPrompt.length() > 2000) {
+            return Result.error("画像描述最多 2000 字符");
+        }
+        portraitService.updatePortraitPrompt(userId, portraitPrompt);
+        return Result.success("保存成功");
+    }
+}
+```
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t2.log 2>&1; M=$?; tail -3 /tmp/opencode/t2.log; test $M -eq 0 && echo MVN_OK
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+GIT_MASTER=1 git add \
+  cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java \
+  cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java \
+  cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
+GIT_MASTER=1 git commit -m "feat: 新增 PortraitService 和 PortraitController"
+```
+
+---
+
+### 任务 3:Python portrait_service.py
+
+**文件:**
+- 新增:`cfc-langgraph/app/portrait_service.py`
+
+- [ ] **步骤 1:创建 portrait_service.py**
+
+```python
+"""用户画像 prompt 组装服务
+
+职责:
+1. 拉取用户自定义画像文本(600s 缓存)
+2. 拉取 profile_snapshot 并渲染为文本(900s 缓存)
+3. 拼装成 SystemMessage 内容;无数据时返回 None
+"""
+import time
+import logging
+from app.tools.java_client import JavaClient
+
+logger = logging.getLogger(__name__)
+
+_TTL_PROMPT = 600
+_TTL_SNAPSHOT = 900
+
+_cache_prompt: dict = {}
+_cache_snapshot: dict = {}
+
+
+def _render_snapshot(profile: dict) -> str:
+    """将 profile Map 渲染为中文指标清单文本"""
+    if not profile or "error" in profile:
+        return ""
+    lines = []
+    member = profile.get("member") or {}
+    name = member.get("name", "用户")
+    age = member.get("age", "?")
+    gender_raw = str(member.get("gender", ""))
+    gender = "男" if gender_raw == "male" else "女" if gender_raw == "female" else "未知"
+    lines.append(f"## 画像对象:{name}({age}岁,{gender})")
+
+    dims = profile.get("dimension_scores") or {}
+    if dims:
+        lines.append(f"五维评分:身 {dims.get('body', '?')} 智 {dims.get('wisdom', '?')} "
+                     f"心 {dims.get('mind', '?')} 行 {dims.get('action', '?')} 富 {dims.get('wealth', '?')}")
+
+    body = profile.get("body_metrics") or {}
+    if body.get("sleep_dur_avg"):
+        lines.append(f"平均睡眠:{body['sleep_dur_avg']}小时/天")
+    if body.get("exercise_count_week"):
+        lines.append(f"周运动频次:{body['exercise_count_week']}次")
+
+    mind = profile.get("mind_metrics") or {}
+    if mind.get("stress_avg"):
+        lines.append(f"平均压力:{mind['stress_avg']}/10")
+
+    problems = profile.get("problem_domains") or []
+    if problems:
+        lines.append(f"关注问题域:{', '.join(problems[:5])}")
+
+    return "\n".join(lines)
+
+
+async def get_user_portrait_text(java: JavaClient, user_id: int, member_id) -> str | None:
+    """返回画像 SystemMessage 内容;无画像数据时返回 None"""
+    parts = []
+
+    # 1. 用户自定义 prompt(600s 缓存)
+    now = time.time()
+    cached_text, cached_ts = _cache_prompt.get(user_id, (None, 0))
+    if now - cached_ts < _TTL_PROMPT and cached_text is not None:
+        user_text = cached_text
+    else:
+        try:
+            client = await java._get_client()
+            resp = await client.post("/api/user/portrait/get", json={"userId": user_id}, timeout=5.0)
+            data = resp.json()
+            user_text = (data.get("data") or {}).get("portraitPrompt") or ""
+            _cache_prompt[user_id] = (user_text, now)
+        except Exception as e:
+            logger.warning("拉取用户画像 prompt 失败: %s", e)
+            user_text = ""
+
+    if user_text.strip():
+        parts.append(user_text)
+
+    # 2. 快照渲染文本(仅当有 member_id 时)
+    if member_id:
+        snap_now = time.time()
+        cached_snap, cached_snap_ts = _cache_snapshot.get(int(member_id), (None, 0))
+        if snap_now - cached_snap_ts < _TTL_SNAPSHOT and cached_snap is not None:
+            snap_text = cached_snap
+        else:
+            try:
+                profile = await java.get_member_profile(int(member_id))
+                snap_text = _render_snapshot(profile) if profile else ""
+                _cache_snapshot[int(member_id)] = (snap_text, snap_now)
+            except Exception as e:
+                logger.warning("拉取成员画像快照失败: %s", e)
+                snap_text = ""
+
+        if snap_text.strip():
+            if user_text.strip():
+                parts.append("\n---参考指标---\n" + snap_text)
+            else:
+                parts.append("以下是用户画像数据(系统自动生成),请结合这些数据给出更针对性的建议:\n" + snap_text)
+
+    return "\n\n".join(parts) if parts else None
+
+
+def clear_cache(user_id: int | None = None, member_id: int | None = None):
+    """清除缓存"""
+    if user_id is not None:
+        _cache_prompt.pop(user_id, None)
+    if member_id is not None:
+        _cache_snapshot.pop(member_id, None)
+    if user_id is None and member_id is None:
+        _cache_prompt.clear()
+        _cache_snapshot.clear()
+```
+
+- [ ] **步骤 2:py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/portrait_service.py && echo PS_OK
+```
+
+---
+
+### 任务 4:改造 3 个 LangGraph
+
+**文件:**
+- 修改:`cfc-langgraph/app/graphs/chat_graph.py`
+- 修改:`cfc-langgraph/app/graphs/health_coach_graph.py`
+- 修改:`cfc-langgraph/app/graphs/health_butler_graph.py`
+
+每个 graph 的改造模式相同:在 generate_answer 节点中,人格 prompt 之后插入画像段。
+
+- [ ] **步骤 1:改造 chat_graph.py**
+
+在文件顶部追加 import:
+```python
+from app.portrait_service import get_user_portrait_text
+```
+
+找到 generate_answer 函数中 `messages = [SystemMessage(content=...)]` 行(人格 prompt 处),在其后追加:
+```python
+    # 画像注入
+    member_id = state.get("child_id")
+    portrait_text = await get_user_portrait_text(java_client, state["user_id"], member_id)
+    if portrait_text:
+        messages.insert(1, SystemMessage(content=portrait_text))
+```
+
+其中 `java_client` 需要在图创建时构造(与 RagRetriever/ChatOpenAI 同级)。
+
+- [ ] **步骤 2:改造 health_coach_graph.py**
+
+与 chat_graph.py 相同模式,在 `generate_answer` 中人格 prompt 之后插入:
+```python
+    from app.portrait_service import get_user_portrait_text
+    # ... 在 messages 追加 persona 后 ...
+    portrait_text = await get_user_portrait_text(java_client, state["user_id"], state.get("child_id"))
+    if portrait_text:
+        messages.insert(1, SystemMessage(content=portrait_text))
+```
+
+注意:health_coach_graph.py 已有 `from app.prompt_service import get_prompt`,追加 `from app.portrait_service import get_user_portrait_text`。
+
+- [ ] **步骤 3:改造 health_butler_graph.py**
+
+同上模式。先 grep 确认其 generate_answer 结构:
+```bash
+grep -n "def generate_answer\|SystemMessage\|messages = " cfc-langgraph/app/graphs/health_butler_graph.py | head -15
+```
+然后在 SystemMessage 组装处插入画像段。
+
+- [ ] **步骤 4:py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/graphs/chat_graph.py app/graphs/health_coach_graph.py app/graphs/health_butler_graph.py && echo GRAPH_OK
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-langgraph/app/portrait_service.py \
+  cfc-langgraph/app/graphs/chat_graph.py \
+  cfc-langgraph/app/graphs/health_coach_graph.py \
+  cfc-langgraph/app/graphs/health_butler_graph.py
+GIT_MASTER=1 git commit -m "feat: Python 侧画像 prompt 注入(3 个 graph)"
+```
+
+---
+
+### 任务 5:Java Controller 注入 portrait_prompt
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java`
+
+- [ ] **步骤 1:在 AIChatController 中注入 PortraitService**
+
+在 AIChatController.java 顶部加:
+```java
+@Resource
+private PortraitService portraitService;
+```
+
+- [ ] **步骤 2:改造 chat/send 方法**
+
+在 `inputs = familyContextService.buildContext(...)` 之后追加:
+```java
+// 注入用户画像
+Long memberId = null;
+String memberIdStr = params.get("memberId");
+if (memberIdStr != null && !memberIdStr.trim().isEmpty()) {
+    memberId = Long.valueOf(memberIdStr);
+}
+String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+if (portraitPrompt != null) {
+    inputs.put("portrait_prompt", portraitPrompt);
+}
+```
+
+- [ ] **步骤 3:改造 health-coach/send 方法**
+
+在 inputs 构建后(已有 child_id 和 mascot 注入之后)追加:
+```java
+// 注入用户画像
+String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+if (portraitPrompt != null) {
+    inputs.put("portrait_prompt", portraitPrompt);
+}
+```
+
+- [ ] **步骤 4:改造 butler/send 方法**
+
+同上,在 inputs 构建后追加 portrait_prompt 注入。
+
+- [ ] **步骤 5:改造 AIService.sendNutritionMessage**
+
+在 `sendNutritionMessage` 方法中,构建 inputs 后追加 portrait_prompt。由于 nutrition 走 Dify(非 LangGraph),portrait_prompt 会被 Dify 作为 inputs 传入(Dify 的 system prompt 中可以引用)。
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t5.log 2>&1; M=$?; tail -3 /tmp/opencode/t5.log; test $M -eq 0 && echo MVN_OK
+```
+
+- [ ] **步骤 7:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java \
+  cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
+GIT_MASTER=1 git commit -m "feat: Java 侧 4 个对话端点注入 portrait_prompt"
+```
+
+---
+
+### 任务 6:小程序前端
+
+**文件:**
+- 新增:`cfc-frontend/pages/profile/portrait-edit.vue`
+- 修改:`cfc-frontend/utils/api.js`
+- 修改:`cfc-frontend/pages.json`
+
+- [ ] **步骤 1:在 api.js 新增画像接口封装**
+
+在 `cfc-frontend/utils/api.js` 末尾追加:
+```javascript
+// 用户画像
+export function getPortrait(params = {}) {
+  return request('/api/user/portrait/get', 'POST', params)
+}
+export function editPortrait(data) {
+  return request('/api/user/portrait/edit', 'POST', data)
+}
+```
+
+- [ ] **步骤 2:创建 portrait-edit.vue**
+
+```vue
+<template>
+  <view class="portrait-edit-page">
+    <view class="section">
+      <view class="section-title">我的画像描述</view>
+      <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
+      <textarea
+        class="portrait-textarea"
+        :value="portraitPrompt"
+        placeholder="例如:我家孩子 8 岁,偏瘦,挑食,容易积食..."
+        maxlength="2000"
+        @input="onInput"
+      ></textarea>
+      <view class="char-count">{{ portraitPrompt.length }}/2000</view>
+    </view>
+
+    <view class="section" v-if="renderedSnapshot">
+      <view class="section-title">参考指标(自动生成)</view>
+      <view class="snapshot-text">{{ renderedSnapshot }}</view>
+    </view>
+
+    <button class="save-btn" :loading="saving" @click="onSave">保存</button>
+  </view>
+</template>
+
+<script>
+import { getPortrait, editPortrait } from '../../utils/api.js'
+export default {
+  data() {
+    return {
+      portraitPrompt: '',
+      renderedSnapshot: '',
+      saving: false
+    }
+  },
+  onLoad() {
+    this.loadPortrait()
+  },
+  methods: {
+    async loadPortrait() {
+      try {
+        const res = await getPortrait({ memberId: this.$store.state.currentMemberId })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+        }
+      } catch (e) {
+        console.error('加载画像失败', e)
+      }
+    },
+    onInput(e) {
+      this.portraitPrompt = e.detail.value
+    },
+    async onSave() {
+      this.saving = true
+      try {
+        const res = await editPortrait({ portraitPrompt: this.portraitPrompt })
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          this.loadPortrait()
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.portrait-edit-page {
+  padding: 24rpx;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  margin-bottom: 12rpx;
+}
+.hint {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 16rpx;
+}
+.portrait-textarea {
+  width: 100%;
+  height: 300rpx;
+  border: 1rpx solid #eee;
+  border-radius: 8rpx;
+  padding: 16rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.char-count {
+  text-align: right;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.snapshot-text {
+  font-size: 26rpx;
+  color: #555;
+  white-space: pre-wrap;
+  line-height: 1.6;
+}
+.save-btn {
+  background: #F97316;
+  color: #fff;
+  border-radius: 44rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  font-size: 32rpx;
+  margin-top: 32rpx;
+}
+</style>
+```
+
+- [ ] **步骤 3:在 pages.json 注册新页面**
+
+在 pages.json 的 pages 数组中追加(放在 profile 相关页面附近):
+```json
+{
+  "path": "pages/profile/portrait-edit",
+  "style": {
+    "navigationBarTitleText": "我的画像"
+  }
+}
+```
+
+- [ ] **步骤 4:在「我的」页添加入口**
+
+在 `cfc-frontend/pages/profile/index.vue` 中找到合适位置(如吉祥物设置附近),添加「我的画像」入口按钮或菜单项,跳转至 `/pages/profile/portrait-edit`。
+
+- [ ] **步骤 5:语法验证**
+
+```bash
+sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/profile/portrait-edit.vue | sed '1d;$d' > /tmp/opencode/portrait_check.mjs && node --check /tmp/opencode/portrait_check.mjs && echo VUE_OK
+```
+
+- [ ] **步骤 6:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-frontend/pages/profile/portrait-edit.vue \
+  cfc-frontend/utils/api.js \
+  cfc-frontend/pages.json
+GIT_MASTER=1 git commit -m "feat: 小程序新增画像编辑页"
+```
+
+---
+
+### 任务 7:全量验收 + 提交
+
+- [ ] **步骤 1:后端编译**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/final_mvn.log 2>&1; M=$?; tail -3 /tmp/opencode/final_mvn.log; test $M -eq 0 && echo MVN_OK
+```
+
+- [ ] **步骤 2:Python 编译**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/portrait_service.py app/graphs/chat_graph.py app/graphs/health_coach_graph.py app/graphs/health_butler_graph.py && echo PY_OK
+```
+
+- [ ] **步骤 3:小程序前端验证**
+
+```bash
+for f in pages/profile/portrait-edit.vue pages/ai/chat.vue pages/membership/index.vue; do
+  cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' "$f" | sed '1d;$d' > /tmp/opencode/final_check.mjs && node --check /tmp/opencode/final_check.mjs && echo "OK: $f" && cd ..
+done
+```
+
+- [ ] **步骤 4:功能验证(可选,本地启动后测试)**
+
+- 调用 `POST /api/user/portrait/edit` 保存画像文本
+- 调用 `POST /api/user/portrait/get` 读取画像
+- 调用 `POST /api/ai/health-coach/send` 发起对话,检查 Python 日志确认画像段已注入
+
+- [ ] **步骤 5:提交所有剩余改动**
+
+```bash
+cd /sc-data/cfc && GIT_MASTER=1 git add -A && git diff --cached --stat && GIT_MASTER=1 git commit -m "feat: 用户画像 prompt 拼装全栈实现" && git log --oneline -5
+```
+
+---
+
+## 自检记录
+
+1. **规格覆盖度**:
+   - §一 核心决策 → 任务 1-6 全覆盖
+   - §二 数据层变更 → 任务 1
+   - §三 API 层设计 → 任务 2
+   - §四 Graph 改造 → 任务 4(Python 侧)+ 任务 5(Java 侧)
+   - §五 前端设计 → 任务 6
+   - §六 验收标准 → 任务 7
+
+2. **占位符扫描**:无"待定/TODO"
+
+3. **类型一致性**:
+   - Java:`PortraitService` 接口方法名与 impl 一致
+   - Python:`get_user_portrait_text(java, user_id, member_id)` 签名统一
+   - 前端:`getPortrait`/`editPortrait` 与后端 `/api/user/portrait/get`/`edit` 对应
+
+4. **边界处理**:
+   - portrait_prompt 为空时降级为快照渲染
+   - 两者皆空时返回 None(不注入)
+   - 成员不存在时 renderSnapshot 返回 null
+   - 缓存失效后自动刷新
+
+5. **待办**:运营侧 system_prompts 配置(步骤 7.3 遗留,非本次范围)

+ 171 - 208
docs/superpowers/specs/2026-08-08-tabbar-redesign.md

@@ -1,11 +1,11 @@
 # TabBar 重新设计
 # TabBar 重新设计
 
 
-> 状态:设计草案 · 待评审
+> 状态:已对齐实现(2026-08-23 更新)
 > 设计者:Sisyphus
 > 设计者:Sisyphus
 
 
-## 一、现状分析
+## 一、现状分析(改造前)
 
 
-### 前 TabBar 结构
+### 改造前 TabBar 结构
 
 
 ```
 ```
 原生 TabBar:  [行] [心] [身] [智] [富]
 原生 TabBar:  [行] [心] [身] [智] [富]
@@ -15,7 +15,7 @@
 
 
 主包 5 页:`index-home`(行)、`mind`(心)、`body`(身)、`wisdom`(智)、`wealth`(富)
 主包 5 页:`index-home`(行)、`mind`(心)、`body`(身)、`wisdom`(智)、`wealth`(富)
 
 
-### 前问题
+### 改造前问题
 
 
 | 问题 | 说明 |
 | 问题 | 说明 |
 |------|------|
 |------|------|
@@ -25,6 +25,15 @@
 | **缺少首页感** | 没有真正的"首页",index-home 同时承担首页和"行"维度两个角色 |
 | **缺少首页感** | 没有真正的"首页",index-home 同时承担首页和"行"维度两个角色 |
 | **导航损耗高** | 家长切换孩子、查看健康、管理成长需要在不同 Tab 间反复跳转 |
 | **导航损耗高** | 家长切换孩子、查看健康、管理成长需要在不同 Tab 间反复跳转 |
 
 
+### 改造后结果
+
+```
+当前 TabBar:  [首页] [健康] [成长] [我的]  + 中间⭐五维扇形
+主包 4 页:  index-home / health-main / growth-main / profile-main
+五维 detail:  body-detail / mind-detail / wisdom-detail / action-detail / wealth
+规划师 TabBar: 保持不变
+```
+
 ## 二、设计目标
 ## 二、设计目标
 
 
 1. **任务导向**:导航按用户想"做什么"组织,而非"五维是什么"
 1. **任务导向**:导航按用户想"做什么"组织,而非"五维是什么"
@@ -45,12 +54,12 @@ Tab 映射:    健康           成长            我的
 汇总视角:  首页(家庭仪表板,全貌概览)
 汇总视角:  首页(家庭仪表板,全貌概览)
 ```
 ```
 
 
-| Tab | 定位 | 内容范围 |
-|-----|------|----------|
-| **首页** | 家庭仪表板 | 五维能量总览、家庭成员、待办任务、推荐内容 |
-| **健康** | 测评与诊断 | 测评申请、健康报告、诊断评分、调研问卷 |
-| **成长** | 方案与执行 | 成长方案、计划任务、打卡执行、饮食运动作息记录、成长档案 |
-| **我的** | 成果与进展 | 成就徽章、健康等级、成长报告、会员权益、家庭管理 |
+| Tab | 定位 | 内容范围(实际实现) |
+|-----|------|-------------------|
+| **首页** | 家庭仪表板 | 五维能量沙盘、家庭成员、今日任务、家庭挑战、活动/商品/文章推荐 |
+| **健康** | 五维总入口 | 5 子 Tab(身/智/富/行/心)+ 各维度雷达图 + 五维相生相克展示 + 维度卡片 |
+| **成长** | 方案与执行 | 今日成长概览、成长档案记录、打卡任务、饮食运动作息 |
+| **我的** | 成果与进展 | 个人资料、家庭成员、成就徽章、健康等级、健康时间线、服务菜单 |
 
 
 ## 三、新 TabBar 结构
 ## 三、新 TabBar 结构
 
 
@@ -62,24 +71,24 @@ Tab 映射:    健康           成长            我的
 
 
 ### 4 个 Tab 页
 ### 4 个 Tab 页
 
 
-| Tab | 页面路径 | 来源 | 职责 |
-|-----|----------|------|------|
-| **首页** | `pages/index-home/index` | 主包已有 | 家庭仪表板:五维能量总览、待办、推荐 |
-| **健康** | `pages/health/index` | **新建** | 健康干预闭环:测评→报告→打卡→饮食→运动→作息 |
-| **成长** | `pages/growth/index` | 从分包**移入**主包 | 成长档案记录、测评结果、里程碑 |
-| **我的** | `pages/profile/profile` | 从分包**移入**主包 | 个人资料、家庭管理、会员、订单、设置 |
+| Tab | 页面路径 | 状态 | 职责 |
+|-----|----------|:----:|------|
+| **首页** | `pages/index-home/index` | ✅ | 家庭仪表板:五维能量沙盘、家庭成员、今日任务、活动/商品/文章推荐 |
+| **健康** | `pages/health-main/index` | ✅ | 五维总入口:5 子 Tab(身/智/富/行/心)+ 各维度雷达图 + 五维相生相克 |
+| **成长** | `pages/growth-main/index` | ✅ | 方案与执行:今日成长概览、成长档案、打卡任务 |
+| **我的** | `pages/profile-main/profile` | ✅ | 个人资料、家庭成员、成就徽章、健康等级、健康时间线、服务菜单 |
 
 
-### 中间五维启动器
+### 中间五维启动器
 
 
-点击弹出半屏浮层,显示 5 个维度入口
+点击弹出横排扇形(5 个圆形维度入口错落排列,弹性缩放动画)。跳转目标如下
 
 
 | 维度 | 编码 | 目标页面 | 跳转方式 |
 | 维度 | 编码 | 目标页面 | 跳转方式 |
 |------|------|----------|----------|
 |------|------|----------|----------|
-| 🌿 行 | action | `pages/index-home/index` | switchTab(同首页) |
-| 🔥 心 | mind | `pages/mind/index` | navigateTo 分包 |
-| 🌏 身 | body | `pages/body/index` | navigateTo 分包 |
-| ⚡ 智 | wisdom | `pages/wisdom/index` | navigateTo 分包 |
-| 💧 富 | wealth | `pages/wealth/index` | navigateTo 分包 |
+| 💧 富 | wealth | `pages/wealth/index` | switchTab(TabBar 页) |
+| 🌿 行 | action | `pages/action-detail/index` | navigateTo 分包 |
+| 🔥 心 | mind | `pages/mind-detail/index` | navigateTo 分包 |
+| 🌏 身 | body | `pages/body-detail/index` | navigateTo 分包 |
+| ⚡ 智 | wisdom | `pages/wisdom-detail/index` | navigateTo 分包 |
 
 
 ---
 ---
 
 
@@ -132,97 +141,72 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
 
 
 ---
 ---
 
 
-### 4.2 健康(`pages/health/index`)
+### 4.2 健康(`pages/health-main/index`)
 
 
-**状态**:**新建页面**。当前 `pages/health/` 分包下已有 19 个健康功能页面,但缺少聚合首页
+**状态**:**已实现**(787 行)。当前 `pages/health-main/index` 是健康 Tab 的实际页面
 
 
-**定位**:测评与诊断。聚焦于"评估当前状态"——申请测评、查看报告、了解诊断结果
+**定位**:健康总入口 + 五维健康体系展示。聚焦于"五维健康是什么"、"如何进入各维度"
 
 
 **页面结构**(登录态):
 **页面结构**(登录态):
 
 
 ```
 ```
-┌─ PageBanner ───────────────────────────┐
-│  theme="body"                          │
-│  tagline="科学测评 · 精准诊断"           │
-│  quote="了解是改善的第一步"              │
-├────────────────────────────────────────┤
-│  FamilyMemberStrip                     │
-│  [👩 妈妈] [👨 爸爸] [👦 小明] [+]     │
-├────────────────────────────────────────┤
-│  ┌─ 健康概览卡片 ──────────────────┐   │
-│  │  健康综合评分    85分           │   │
-│  │  待处理报告      2 份           │   │
-│  │  上次测评        {{日期}}       │   │
-│  │  连续打卡        7 天           │   │
-│  └──────────────────────────────────┘   │
-├────────────────────────────────────────┤
-│  功能入口网格(3列)                    │
-│  ┌──────┐ ┌──────┐ ┌──────┐           │
-│  │ 📋   │ │ 📊   │ │ 📝   │           │
-│  │ 申请  │ │ 健康  │ │ 健康  │           │
-│  │ 测评  │ │ 报告  │ │ 调研  │           │
-│  └──────┘ └──────┘ └──────┘           │
-│  ┌──────┐ ┌──────┐ ┌──────┐           │
-│  │ 🔬   │ │ 🧬   │ │ 🌡️   │           │
-│  │ 体检  │ │ 肠道  │ │ 体征  │           │
-│  │ 报告  │ │ 菌群  │ │ 记录  │           │
-│  └──────┘ └──────┘ └──────┘           │
-│  ┌──────┐ ┌──────┐                    │
-│  │ 📐   │ │ 🧠   │                    │
-│  │ 五维  │ │ 心智  │                    │
-│  │ 测评  │ │ 评估  │                    │
-│  └──────┘ └──────┘                    │
-├────────────────────────────────────────┤
-│  最近报告                              │
-│  ┌─ 报告卡片 ───────────────────────┐  │
-│  │ 📄 2026-08-01 体检报告           │  │
-│  │    状态:已解析 查看详情 ›        │  │
-│  ├──────────────────────────────────┤  │
-│  │ 📄 2026-07-15 肠道菌群报告       │  │
-│  │    状态:待确认 查看详情 ›        │  │
-│  └──────────────────────────────────┘  │
-│  📋 查看全部报告 ›                      │
-├────────────────────────────────────────┤
-│  诊断建议(最近报告的 AI 解读摘要)      │
-│  💡 "根据最新体检报告,建议关注…"        │
-└────────────────────────────────────────┘
+┌─ TabBar ────────────────────────────┐
+│  [🌏 身] [⚡ 智] [💧 富] [🌿 行] [🔥 心] │  ← 5 Tab 切换
+├──────────────────────────────────────┤
+│  当前 Tab 对应的雷达图                │
+│  ┌──────────────────────────────┐    │
+│  │     RadarChart              │    │
+│  │  (身=7维/智=6维/富=4子维/    │    │
+│  │   行=6维/心=13维)            │    │
+│  └──────────────────────────────┘    │
+│  无数据时显示"数据待采集"引导         │
+└──────────────────────────────────────┘
 ```
 ```
 
 
+**各 Tab 内容**:
+
+| Tab | 编码 | 雷达维度 | 数据源 |
+|:----:|:----:|---------|--------|
+| 🌏 身 | body | 7维(生长发育/睡眠/视力/免疫/营养/肠胃/运动) | `getDimensionOverview` |
+| ⚡ 智 | wisdom | 6维认知能力 | 认知测评结果 |
+| 💧 富 | wealth | 4子维(技能/利他/收入/汇率) | 财富数据 |
+| 🌿 行 | action | 6维关系质量 | 关系数据 |
+| 🔥 心 | mind | 13维情绪与性格 | EMI 心理测评 |
+
 **未登录态**:
 **未登录态**:
 
 
 ```
 ```
-┌─ PageBanner ───────────────────────────┐
-│  theme="body"                          │
-│  tagline="科学管理全家健康"              │
-│  quote="身体健康是幸福的基础"             │
+┌─ 品牌定位语 ───────────────────────────┐
+│  科学管理全家健康数据                    │
 ├────────────────────────────────────────┤
 ├────────────────────────────────────────┤
-│  LoginGuideCard                        │
-│  "登录后查看全家健康数据"               │
-│  · 申请专业测评                         │
-│  · 查看健康报告和诊断结果               │
-│  · 了解家人健康状态                     │
-│  [登录 / 注册]                         │
+│  痛点卡:指标正常 ≠ 健康                │
+│  "孩子反复生病,你扛了多少次了?"         │
+├────────────────────────────────────────┤
+│  功能预览(4个卡片)                   │
+│  · 上传健康报告                        │
+│  · 认知能力测评                        │
+│  · 心理健康测评                        │
+│  · 健康方案                            │
+├────────────────────────────────────────┤
+│  15题五维健康自检(自测入口)           │
+├────────────────────────────────────────┤
+│  五维健康体系(相生/相克关系展示)        │
+│  · 五张维度卡片(身/智/富/行/心)        │
+│    点击各卡 → 跳对应维度 detail 页       │
+├────────────────────────────────────────┤
+│  登录引导卡(LoginGuideCard)           │
 └────────────────────────────────────────┘
 └────────────────────────────────────────┘
 ```
 ```
 
 
-**功能入口跳转目标**:
+**维度卡片跳转目标**:
 
 
-| 入口 | 目标页面 | 说明 |
-|------|----------|------|
-| 📋 申请测评 | `/pages/assessment/purchase` | 购买/申请测评(对接原有测评流程) |
-| 📊 健康报告 | `/pages/health/report-list` | 所有健康报告列表 |
-| 📝 健康调研 | `/pages/health/report-survey` | 健康调研问卷 |
-| 🔬 体检报告 | `/pages/health/physical-exam-detail` | 体检报告详情 |
-| 🧬 肠道菌群 | `/pages/health/gut-index` | 肠道菌群报告 |
-| 🌡️ 体征记录 | `/pages/health/daily-checkin` | 体征打卡记录 |
-| 📐 五维测评 | `/pages/assessment/purchase` | 五维能量测评 |
-| 🧠 心智评估 | `/pages/mind-detail/emotion-report` | 心智维度评估报告 |
-
-**数据来源**:
-- `getMyMembership` — 会员身份
-- `getFamilyMembers` — 家庭成员列表
-- `getHealthScoreInfo` — 健康评分
-- `getHealthReportList` — 最近报告列表
+| 卡片 | 目标页面 |
+|------|----------|
+| 身 | `pages/body-detail/index` |
+| 智 | `pages/wisdom-detail/index` |
+| 富 | `pages/wealth/index` |
+| 行 | `pages/action-detail/member-action-detail` |
+| 心 | `pages/mind-detail/index` |
 
 
 ---
 ---
 
 
@@ -378,49 +362,47 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
 
 
 ---
 ---
 
 
-## 五、五维半屏浮层
+## 五、五维横排扇形菜单
 
 
 ### 交互流程
 ### 交互流程
 
 
 ```
 ```
 用户点击中间⭐按钮
 用户点击中间⭐按钮
-  → 底部弹出半屏浮层(slideUp 动画,300ms)
-  → 显示 5 个维度卡片(行/心/身/智/富
-  → 用户点击维度卡片
-    → 浮层关闭(slideDown 动画
+  → 全屏半透明遮罩(fadeIn,200ms)
+  → 5 个维度圆形图标横排展开(弹性缩放 cubic-bezier,0.05s 递进
+  → 用户点击维度圆形
+    → 扇形折叠(淡出 + 缩放回原点
     → 跳转对应页面
     → 跳转对应页面
-  → 点击遮罩/关闭按钮
-    → 浮层关闭
+  → 点击遮罩
+    → 扇形折叠并关闭
 ```
 ```
 
 
-### 浮层布局
+### 扇形布局
 
 
 ```
 ```
-┌────────────────────────────────────────┐
-│  五维能量                          ✕   │
-├────────────────────────────────────────┤
-│  ┌──────────┐ ┌──────────┐ ┌──────────┐│
-│  │  🌿 行   │ │  🔥 心   │ │  🌏 身   ││
-│  │ 知行合一  │ │ 大爱传承  │ │ 主动健康  ││
-│  │  #10B981 │ │  #FF6B9D │ │  #FF8C42 ││
-│  └──────────┘ └──────────┘ └──────────┘│
-│  ┌──────────┐ ┌──────────┐             │
-│  │  ⚡ 智   │ │  💧 富   │             │
-│  │ 赋能未来  │ │ 利他创富  │             │
-│  │  #6366F1 │ │  #F59E0B │             │
-│  └──────────┘ └──────────┘             │
-└────────────────────────────────────────┘
+                        🔥 心(-120rpx 高位)
+         💧 富(-60rpx)  🌿 行(-60rpx)
+                        🌏 身(-60rpx)  ⚡ 智(-60rpx)
+
+实际为 5 个横排等距排列,mind 居中最高,body 居中略低
+其余 3 个(wealth/action/wisdom)在两侧低位
 ```
 ```
 
 
-### 维度卡片视觉规格
+### 视觉规格
+
+| 维度 | 图标 | 色值 | 圆形尺寸 | 位置 |
+|:----:|:----:|------|:------:|------|
+| 💧 富 | `#F59E0B` | 88rpx 圆 | 低位(左) |
+| 🌿 行 | `#10B981` | 88rpx 圆 | 中位(中偏左) |
+| 🔥 心 | `#FF6B9D` | 88rpx 圆 | **高位**(居中) |
+| 🌏 身 | `#FF8C42` | 88rpx 圆 | 中位(中偏右) |
+| ⚡ 智 | `#6366F1` | 88rpx 圆 | 低位(右) |
 
 
-| 维度 | 图标 | 色值 | 背景色 |
-|------|------|------|--------|
-| 行 | 🌿 | `#10B981` | `rgba(16,185,129,0.15)` |
-| 心 | 🔥 | `#FF6B9D` | `rgba(255,107,157,0.15)` |
-| 身 | 🌏 | `#FF8C42` | `rgba(255,140,66,0.15)` |
-| 智 | ⚡ | `#6366F1` | `rgba(99,102,241,0.15)` |
-| 富 | 💧 | `#F59E0B` | `rgba(245,158,11,0.15)` |
+- 圆形使用维度主题色纯色填充
+- 圆形内含 emoji 图标
+- 圆形下方显示维度名称(白色文字,`#fff`)
+- 弹入动画:`transform: scale(0) → scale(1)`,`cubic-bezier(0.34, 1.56, 0.64, 1)`,每个圆形 0.05s 递增延迟
+- 弹入后圆形下方标签跟随淡入(`translateY(-10rpx) → translateY(0)`)
 
 
 ---
 ---
 
 
@@ -434,25 +416,28 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
 {
 {
   "pages": [
   "pages": [
     { "path": "pages/index-home/index", "style": { "navigationBarTitleText": "首页" } },
     { "path": "pages/index-home/index", "style": { "navigationBarTitleText": "首页" } },
-    { "path": "pages/health/index", "style": { "navigationBarTitleText": "健康" } },
-    { "path": "pages/growth/index", "style": { "navigationBarTitleText": "成长档案" } },
-    { "path": "pages/profile/profile", "style": { "navigationBarTitleText": "我的" } }
+    { "path": "pages/health-main/index", "style": { "navigationBarTitleText": "健康" } },
+    { "path": "pages/growth-main/index", "style": { "navigationBarTitleText": "成长档案" } },
+    { "path": "pages/profile-main/profile", "style": { "navigationBarTitleText": "我的" } }
   ]
   ]
 }
 }
 ```
 ```
 
 
-**移除出主包**:
-- `pages/mind/index` → 移入 `pages/mind-detail` 分包
-- `pages/body/index` → 移入 `pages/body-detail` 分包
-- `pages/wisdom/index` → 移入 `pages/wisdom-detail` 分包
-- `pages/wealth/index` → 移入新建 `pages/wealth` 分包
+**五维 detail 页路径(分包)**:
+
+| 维度 | 分包路径 | 说明 |
+|:----:|---------|------|
+| 🌏 身 | `pages/body-detail/index` | 7维雷达 + 指标 + 菌群 |
+| ⚡ 智 | `pages/wisdom-detail/index` | 6维认知雷达 + 指标 |
+| 💧 富 | `pages/wealth/index` | TabBar 页(能量环 + 子分 + 推广) |
+| 🌿 行 | `pages/action-detail/index` | 关系图谱 + 6维雷达 + Products/Activities/Tasks |
+| 🔥 心 | `pages/mind-detail/index` | 天盘 + EMI报告 + 13维雷达 + 肠脑轴 |
 
 
-**分包移除**:
-- `pages/growth` 分包:移除 `index` 页面
-- `pages/profile` 分包:移除 `profile` 页面
+**主包 4 页**:`index-home` / `health-main` / `growth-main` / `profile-main/profile`
 
 
-**分包新增**:
-- `pages/wealth` 新建分包:含 `{ "path": "index", "style": { "navigationBarTitleText": "富" } }`
+**五维 detail 页(分包)**:body-detail / mind-detail / wisdom-detail / action-detail / wealth 各自独立分包
+
+**原 5 维主包页已移除**:mind/body/wisdom/wealth 从主包移入各自 detail 分包
 
 
 ### 6.2 tabBar 配置
 ### 6.2 tabBar 配置
 
 
@@ -464,33 +449,30 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
   "backgroundColor": "#FFFFFF",
   "backgroundColor": "#FFFFFF",
   "list": [
   "list": [
     { "pagePath": "pages/index-home/index", "text": "首页", "iconPath": "static/tab-home.png", "selectedIconPath": "static/tab-home-active.png" },
     { "pagePath": "pages/index-home/index", "text": "首页", "iconPath": "static/tab-home.png", "selectedIconPath": "static/tab-home-active.png" },
-    { "pagePath": "pages/health/index", "text": "健康", "iconPath": "static/tab-health.png", "selectedIconPath": "static/tab-health-active.png" },
-    { "pagePath": "pages/growth/index", "text": "成长", "iconPath": "static/tab-growth.png", "selectedIconPath": "static/tab-growth-active.png" },
-    { "pagePath": "pages/profile/profile", "text": "我的", "iconPath": "static/tab-profile.png", "selectedIconPath": "static/tab-profile-active.png" }
+    { "pagePath": "pages/health-main/index", "text": "健康", "iconPath": "static/tab-health.png", "selectedIconPath": "static/tab-health-active.png" },
+    { "pagePath": "pages/growth-main/index", "text": "成长", "iconPath": "static/tab-growth.png", "selectedIconPath": "static/tab-growth-active.png" },
+    { "pagePath": "pages/profile-main/profile", "text": "我的", "iconPath": "static/tab-profile.png", "selectedIconPath": "static/tab-profile-active.png" }
   ]
   ]
 }
 }
 ```
 ```
 
 
-### 6.3 新建文件
+### 6.3 核心文件
 
 
-| 文件 | 说明 | 预估行数 |
-|------|------|----------|
-| `utils/nav.js` | 统一导航工具 | ~80 行 |
-| `pages/health/index.vue` | 健康 Tab 页 | ~400 行 |
+| 文件 | 说明 | 状态 |
+|------|------|:----:|
+| `custom-tab-bar/index.vue` | 4 Tab + 中间⭐按钮 + 五维横排扇形 | 已实现 |
+| `pages/health-main/index.vue` | 健康 Tab 页(5子Tab + 雷达图 + 五维体系) | 已实现 |
+| `pages/index-home/index.vue` | 首页(家庭仪表板) | 已实现 |
+| `utils/nav.js` | 统一导航工具 | 按需创建 |
 
 
 ### 6.4 修改文件
 ### 6.4 修改文件
 
 
-| 文件 | 变更内容 |
-|------|----------|
-| `pages.json` | 主包 5→4 页,tabBar 4 项,分包调整 |
-| `custom-tab-bar/index.vue` | 4 Tab + 中间按钮 + 五维浮层 |
-| `index-home/index.vue` | switchTab → nav.goPage |
-| `wisdom/index.vue` | switchTab → nav.goPage |
-| `wisdom/self-quiz.vue` | switchTab → navigateTo |
-| `wisdom-detail/self-quiz.vue` | switchTab → navigateTo |
-| `profile/profile.vue` | showServices 改为 true |
-| `pages/growth` 分包 | 移除 index 页 |
-| `pages/profile` 分包 | 移除 profile 页 |
+| 文件 | 变更内容 | 状态 |
+|------|----------|:----:|
+| `pages.json` | 主包 5→4 页,tabBar 4 项,五维页移入分包 | 已实现 |
+| `custom-tab-bar/index.vue` | 4 Tab + 中间⭐按钮 + 五维扇形 | 已实现 |
+| `pages/health-main/index.vue` | 健康 Tab 页(5 子 Tab + 雷达图) | 已实现 |
+| `profile-main/profile.vue` | 成就区增加推广收益入口 | 部分实现 |
 
 
 ### 6.5 图标资源
 ### 6.5 图标资源
 
 
@@ -501,30 +483,25 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
 | 成长 | `static/tab-growth.png` / `tab-growth-active.png` | 复用 teacher TabBar 现有 |
 | 成长 | `static/tab-growth.png` / `tab-growth-active.png` | 复用 teacher TabBar 现有 |
 | 我的 | `static/tab-profile.png` / `tab-profile-active.png` | 复用 teacher TabBar 现有 |
 | 我的 | `static/tab-profile.png` / `tab-profile-active.png` | 复用 teacher TabBar 现有 |
 
 
-### 6.6 switchTab 引用改造
+### 6.6 导航方式
 
 
-```bash
-# 搜索所有对五维页的 switchTab 调用
-grep -rn "switchTab.*/pages/\(mind\|body\|wisdom\|wealth\)" --include="*.vue" .
-```
+五维 detail 页从主包移入分包后,`switchTab` 不再适用于这些页面。跳转统一使用 `navigateTo`(分包异步加载)。唯一例外:`pages/wealth/index` 仍为 TabBar 页,使用 `switchTab`。
 
 
-| 文件 | 行号 | 原代码 | 改后 |
-|------|------|--------|------|
-| `index-home/index.vue` | onFuncClick | `uni.switchTab({ url: item.page })` | `nav.goPage(item.page)` |
-| `wisdom/index.vue` | onFuncClick | `uni.switchTab({ url: item.page })` | `nav.goPage(item.page)` |
-| `wisdom/self-quiz.vue` | ~453 | `uni.switchTab({ url: '/pages/wisdom/index' })` | `uni.navigateTo({ url: '/pages/wisdom/index' })` |
-| `wisdom-detail/self-quiz.vue` | ~453 | `uni.switchTab({ url: '/pages/wisdom/index' })` | `uni.navigateTo({ url: '/pages/wisdom/index' })` |
+| 维度 | 跳转方式 | 原因 |
+|:----:|---------|------|
+| 富 | `switchTab` | TabBar 页 |
+| 行/心/身/智 | `navigateTo` | 分包页 |
 
 
 ---
 ---
 
 
 ## 七、主包体积影响
 ## 七、主包体积影响
 
 
-| 项目 | 当前 | 变更后 |
-|------|------|--------|
-| 主包页面数 | 5 | 4 |
-| 主包体积 | ~188KB | ~75KB + 健康页(估计 < 120KB) |
-| 分包变化 | mind/body/wisdom/wealth 在主包 | 移入各自分包 |
-| 风险 | — | 主包体积降低,无超限风险 |
+| 项目 | 旧结构(5维TabBar) | 当前(4 Tab + 扇形) |
+|------|:-----------------:|:-----------------:|
+| 主包页面数 | 5(mind/body/wisdom/action/wealth) | 4(首页/健康/成长/我的) |
+| 主包体积 | ~188KB+ | ~75KB + health-main(~150KB) |
+| 分包变化 | 无 | body-detail/mind-detail/wisdom-detail/action-detail/wealth 各分包 |
+| 风险 | 主包过大风险 | 主包体积可控,无超限风险 |
 
 
 ---
 ---
 
 
@@ -532,39 +509,25 @@ grep -rn "switchTab.*/pages/\(mind\|body\|wisdom\|wealth\)" --include="*.vue" .
 
 
 | 场景 | 处理 |
 | 场景 | 处理 |
 |------|------|
 |------|------|
-| **未登录用户** | 看到原生 4 Tab + 中间按钮,无自定义 TabBar;点击功能页→引导登录 |
-| **规划师用户** | 保持独立 5 Tab(首页/家庭/团队/成长档案/我的),中间按钮不显示 |
+| **未登录用户** | 看到原生 4 Tab + 中间按钮,无自定义 TabBar;点击功能页→引导登录 |
+| **规划师用户** | 保持独立 5 Tab(首页/家庭/团队/成长档案/我的),中间按钮不显示 |
 | **孩子角色** | 与家长相同 4 Tab,内容按孩子视角展示 |
 | **孩子角色** | 与家长相同 4 Tab,内容按孩子视角展示 |
-| **从五维浮层跳转"行"** | 即首页,用 switchTab |
-| **从五维浮层跳转"心/身/智/富"** | 用 navigateTo 至分包页 |
+| **从扇形跳转"富"** | `switchTab` 到 TabBar 页 |
+| **从扇形跳转"行/心/身/智"** | `navigateTo` 到分包 detail 页 |
 | **分包异步加载** | 首次访问五维页时可能短暂 loading,微信分包机制自动处理 |
 | **分包异步加载** | 首次访问五维页时可能短暂 loading,微信分包机制自动处理 |
-| **成长/我的移到主包后** | 分包体积减小,加载更快;但主包体积增加约 2 页 |
+| **健康 Tab 内部 5 子 Tab** | `health-main` 页内维护独立 tab 状态(body/wisdom/wealth/action/mind),各自对应不同雷达维度 |
 
 
 ---
 ---
 
 
-## 九、实施步骤
-
-### 阶段 1:基础设施
-1. 创建 `utils/nav.js` 统一导航工具
-2. 准备新的 TabBar 图标(首页、健康)
-
-### 阶段 2:pages.json 重构
-3. 修改主包 pages 数组(4 页)
-4. 修改 tabBar.list(4 项)
-5. 调整分包(五维页移入、成长/我的移出)
-
-### 阶段 3:引用改造
-6. 替换所有 switchTab → navigateTo/nav.goPage
-7. 全局 grep 兜底检查
-
-### 阶段 4:自定义 TabBar 改造
-8. 重构 `custom-tab-bar/index.vue`
-9. 添加五维半屏浮层
-
-### 阶段 5:新建健康页
-10. 创建 `pages/health/index.vue`
-11. 实现健康概览、功能入口网格、最近报告
-
-### 阶段 6:构建验证
-12. `npm run build:mp-weixin`
-13. 路由完整性检查
+## 九、实施状态
+
+| # | 步骤 | 状态 |
+|:-:|------|:----:|
+| 1 | pages.json 主包 5→4 页,tabBar 4 项 | ✅ 已实现 |
+| 2 | 自定义 TabBar 重构(4 Tab + 中间⭐ + 扇形) | ✅ 已实现 |
+| 3 | 五维页从主包移入分包(body-detail/mind-detail/wisdom-detail/action-detail) | ✅ 已实现 |
+| 4 | wealth 保持 TabBar 页 | ✅ 已实现 |
+| 5 | health-main 健康 Tab 页实现 | ✅ 已实现 |
+| 6 | growth-main 成长 Tab 页 | ✅ 已实现 |
+| 7 | profile-main 我的 Tab 页 | ✅ 已实现 |
+| 8 | 规划师独立 TabBar 保留 | ✅ 已实现 |

+ 305 - 0
docs/superpowers/specs/2026-08-24-user-portrait-prompt-design.md

@@ -0,0 +1,305 @@
+# 用户画像 prompt 拼装设计规范
+
+**日期**:2026-08-24
+**状态**:设计阶段
+**关联计划**:待 writing-plans 生成
+**需求来源**:浠艾福平台「AI 健康教练人格分化与管家自助选择」功能的延伸——每个用户拥有自己的画像 prompt
+
+---
+
+## 一、核心决策
+
+| 决策项 | 结论 |
+|--------|------|
+| **适用范围** | 所有 AI 对话端点(chat/send、butler/send、nutrition/send、health-coach/send),不含 health_plan(结构化方案生成,已有独立画像注入逻辑) |
+| **画像主体** | 按 memberId 分派:有 child_id/memberId 时用该成员画像;无时不注入 |
+| **渲染深度** | 完整版:五维评分 + 睡眠/压力/运动等指标清单(复用健康方案生成渲染格式) |
+| **可编辑性** | 允许用户手动编辑自由文本画像 prompt |
+| **实现方案** | A:users 表加 portrait_prompt TEXT 列;Python 侧新增 portrait_service.py |
+| **缓存策略** | portrait_prompt 600s,profile_snapshot 900s(进程内 dict 缓存,失败降级为不注入) |
+
+---
+
+## 二、数据层变更
+
+### 2.1 users 表新增列
+
+```sql
+ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)' AFTER mascot;
+```
+
+**位置**:schema.sql 的 users 建表语句 + DatabaseInitializer.runMigrations() 迁移脚本。
+
+### 2.2 User.java 实体
+
+```java
+@TableField("portrait_prompt")
+private String portraitPrompt;
+```
+
+### 2.3 不新建表
+
+直接复用已有 `profile_snapshot`(五维指标 JSON)和 `profile_history`(历史快照),不新增表。
+
+---
+
+## 三、API 层设计(Java 侧)
+
+### 3.1 新增两个端点(POST,统一放 /api/user/*)
+
+#### `POST /api/user/portrait/get`
+
+**说明**:读取当前登录用户的 portrait_prompt,以及指定成员的 profile_snapshot 渲染文本。
+
+**请求体**:
+```json
+{
+  "userId": 12345,         // 被查询用户 ID(管理员可传任意用户;普通用户默认取 JWT userId)
+  "memberId": 67890        // 可选,成员 ID;不传则只返回 portrait_prompt
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "data": {
+    "portraitPrompt": "我家孩子偏瘦,容易积食...",   // 用户手动编辑的画像文本(可为空)
+    "renderedSnapshot": "【五维评分】身 7.2 智 6.8...  # 或 null(无 member 时)"
+  }
+}
+```
+
+**实现要点**:
+- 复用 `ProfileReadService.getProfile(memberId)` 获取快照
+- 渲染函数复用 `adapter.py` 已有的 profile 渲染逻辑,移植到 Java(`UserService` 或新增 `PortraitRenderService`)
+- 管理员角色可查任意用户;普通用户只能查自己
+
+#### `POST /api/user/portrait/edit`
+
+**说明**:更新当前用户的 portrait_prompt。
+
+**请求体**:
+```json
+{
+  "portraitPrompt": "我家孩子 8 岁,偏瘦,挑食..."   // 最多 2000 字符
+}
+```
+
+**权限**:任何登录用户可编辑自己的;管理员可编辑任意用户(通过请求体加 userId 字段)。
+
+**验证**:portraitPrompt 长度 ≤ 2000,null 视为清空。
+
+**实现**:`UserController` 新增方法 → `UserService.updatePortraitPrompt()` → Mapper 直接 UPDATE。
+
+---
+
+## 四、Python Graph 层设计
+
+### 4.1 新增 app/portrait_service.py
+
+```python
+"""用户画像 prompt 组装服务
+
+职责:
+1. 拉取用户自定义画像文本(600s 缓存)
+2. 拉取 profile_snapshot 并渲染为文本(900s 缓存)
+3. 拼装成 SystemMessage 内容;无数据时返回 None
+"""
+import time
+import logging
+from app.tools.java_client import JavaClient
+from app.prompt_service import clear_cache
+
+logger = logging.getLogger(__name__)
+
+_TTL_PROMPT = 600    # 用户自定义 prompt 缓存 10 分钟
+_TTL_SNAPSHOT = 900  # 画像快照缓存 15 分钟
+
+_cache_prompt: dict[int, tuple[str, float]] = {}  # user_id -> (text, ts)
+_cache_snapshot: dict[int, tuple[str, float]] = {}  # member_id -> (text, ts)
+
+
+def _render_snapshot(profile: dict) -> str:
+    """将 profile Map 渲染为中文指标清单文本"""
+    if not profile:
+        return ""
+    lines = []
+    member = profile.get("member") or {}
+    name = member.get("name", "用户")
+    age = member.get("age")
+    gender = member.get("gender")
+    lines.append(f"## 画像对象:{name}({age}岁,{gender})")
+
+    dims = profile.get("dimension_scores") or {}
+    if dims:
+        lines.append(f"五维评分:身 {dims.get('body', '?')} 智 {dims.get('wisdom', '?')} "
+                     f"心 {dims.get('mind', '?')} 行 {dims.get('action', '?')} 富 {dims.get('wealth', '?')}")
+
+    body = profile.get("body_metrics") or {}
+    if body.get("sleep_dur_avg"):
+        lines.append(f"平均睡眠:{body['sleep_dur_avg']}小时/天")
+    if body.get("exercise_count_week"):
+        lines.append(f"周运动频次:{body['exercise_count_week']}次")
+
+    mind = profile.get("mind_metrics") or {}
+    if mind.get("stress_avg"):
+        lines.append(f"平均压力:{mind['stress_avg']}/10")
+
+    problems = profile.get("problem_domains") or []
+    if problems:
+        lines.append(f"关注问题域:{', '.join(problems[:5])}")
+
+    return "\n".join(lines)
+
+
+async def get_user_portrait_text(java: JavaClient, user_id: int, member_id: int | None) -> str | None:
+    """返回画像 SystemMessage 内容;无画像数据时返回 None"""
+    parts = []
+
+    # 1. 用户自定义 prompt
+    now = time.time()
+    cached_text, cached_ts = _cache_prompt.get(user_id, (None, 0))
+    if now - cached_ts < _TTL_PROMPT and cached_text is not None:
+        user_text = cached_text
+    else:
+        try:
+            client = await java._get_client()
+            resp = await client.post("/api/user/portrait/get", json={"userId": user_id}, timeout=5.0)
+            data = resp.json()
+            user_text = (data.get("data") or {}).get("portraitPrompt") or ""
+            _cache_prompt[user_id] = (user_text, now)
+        except Exception as e:
+            logger.warning("拉取用户画像 prompt 失败: %s", e)
+            user_text = ""
+
+    if user_text.strip():
+        parts.append(user_text)
+
+    # 2. 快照渲染文本(仅当有 member_id 时)
+    if member_id:
+        snap_now = time.time()
+        cached_snap, cached_snap_ts = _cache_snapshot.get(member_id, (None, 0))
+        if snap_now - cached_snap_ts < _TTL_SNAPSHOT and cached_snap is not None:
+            snap_text = cached_snap
+        else:
+            try:
+                profile = await java.get_member_profile(int(member_id))
+                snap_text = _render_snapshot(profile) if profile else ""
+                _cache_snapshot[member_id] = (snap_text, snap_now)
+            except Exception as e:
+                logger.warning("拉取成员画像快照失败: %s", e)
+                snap_text = ""
+
+        if snap_text.strip():
+            if user_text.strip():
+                parts.append("\n---参考指标---\n" + snap_text)
+            else:
+                parts.append("以下是用户画像数据(系统自动生成),请结合这些数据给出更针对性的建议:\n" + snap_text)
+
+    return "\n\n".join(parts) if parts else None
+```
+
+### 4.2 各 graph 改造
+
+**待改造的 graph 文件**(需逐一确认是否存在):
+- `app/graphs/health_coach_graph.py` ✅ 已知存在
+- `app/graphs/chat_graph.py`
+- `app/graphs/butler_graph.py`
+- `app/graphs/nutrition_graph.py`
+
+**通用插入模式**(以 health_coach_graph 为例):
+
+```python
+from app.portrait_service import get_user_portrait_text
+
+async def generate_answer(state: HealthCoachState) -> dict:
+    _ctx = state.get("context") or {}
+    _coach_id = _ctx.get("coach_id")
+    # ... 现有 persona prompt 逻辑不变 ...
+    messages = [SystemMessage(content=_persona_prompt or _base_prompt or DEFAULT_COACH_PROMPT)]
+
+    # 新增:画像段
+    member_id = state.get("child_id") or (_ctx.get("member_id") if _ctx else None)
+    portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
+    if portrait_text:
+        messages.insert(1, SystemMessage(content=portrait_text))
+
+    # ... 后续 context/knowledge/memory 逻辑不变 ...
+```
+
+**注意**:
+- `java` 变量需在 graph 创建时提前构造(与 RagRetriever/ChatOpenAI 同级)
+- member_id 来源:`state.get("child_id")` 优先;若 context 中有 `member_id` 则也用
+
+### 4.3 清理缓存 hook
+
+在 `/api/user/portrait/edit` 响应成功后,调用 `portrait_service.clear_cache(user_id)` 使旧缓存失效。
+
+---
+
+## 五、前端设计
+
+### 5.1 小程序(cfc-frontend)
+
+**新增页面**:`pages/profile/portrait-edit.vue`
+
+**入口**:在现有「我的」页(profile/index.vue)或聊天页顶部导航区添加「我的画像」入口按钮。
+
+**页面内容**:
+- 标题:我的画像
+- 文本域:`<textarea :value="portraitPrompt" @input="onInput" maxlength="2000" />`
+- 显示字数统计(如 42/2000)
+- 「保存」按钮 → 调用 `/api/user/portrait/edit`
+- 「参考指标」区块:只读,展示系统渲染的画像快照文本(调用 `/api/user/portrait/get` 获取)
+
+**API 封装**:在 `src/api/user.js` 新增:
+```javascript
+export function getPortrait(userId) {
+  return request('/api/user/portrait/get', 'POST', { userId })
+}
+export function editPortrait(data) {
+  return request('/api/user/portrait/edit', 'POST', data)
+}
+```
+
+### 5.2 Web 管理端
+
+暂不新增。用户自主编辑即可;管理员如需帮用户代填,走 `/api/user/portrait/edit?userId=xxx` 接口(由技术支持操作)。
+
+### 5.3 pages.json
+
+注册新页面:
+```json
+{
+  "path": "pages/profile/portrait-edit",
+  "style": { "navigationBarTitleText": "我的画像" }
+}
+```
+
+---
+
+## 六、验收标准
+
+1. **编译**:`mvn clean compile` EXIT 0;`python3 -m py_compile` 所有涉及文件 OK
+2. **画像注入**:四个对话端点均能在 LLM 收到消息中包含画像 SystemMessage(可通过调试日志验证)
+3. **用户编辑**:edit 接口写入成功;get 接口读到最新值;缓存 5 秒内失效
+4. **无画像降级**:portrait_prompt 为空且无 member_id 时,LLM 不收到画像段(与改动前行为一致)
+5. **memberId 分派**:传 child_id=123 时渲染 123 的画像;不传时不渲染
+6. **字符限制**:portrait_prompt 超 2000 字符返回 400 错误
+7. **权限**:普通用户不能编辑他人 portrait;管理员可以
+
+---
+
+## 七、边界与注意事项
+
+- **多轮对话上下文**:画像段在每轮 `generate_answer` 中重新组装(非持久化到 State),确保每次对话都能反映最新画像
+- **性能**:画像数据缓存 TTL 较长(600s/900s),高频对话不会反复拉 Java
+- **与其他 SystemMessage 的位置关系**:画像段位于人格 prompt 之后、背景信息之前——保证 LLM 先理解人设,再理解具体用户,最后理解对话上下文
+- **与 mascot 注入的关系**:mascot(浠宝/福宝)是人格标识,portrait_prompt 是用户侧个性化数据,二者正交;均注入 SystemMessage
+
+---
+
+**文档版本**:v1
+**编写者**:Sisyphus(AI Agent)
+**审查状态**:待用户确认后进入 writing-plans 阶段

Some files were not shown because too many files changed in this diff