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

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 недель назад
Родитель
Сommit
95335b064e
73 измененных файлов с 4355 добавлено и 1129 удалено
  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/
 

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

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

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


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

@@ -1,15 +1,18 @@
 package com.etotem.cfc.config;
 
 import com.etotem.cfc.common.Result;
+import java.io.IOException;
 import javax.servlet.http.HttpServletRequest;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
 import org.springframework.web.multipart.MultipartException;
 
 @RestControllerAdvice
@@ -63,6 +66,27 @@ public class GlobalExceptionHandler {
         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)
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
         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.service.PurchaseService;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.service.HealthPlanService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -122,9 +123,25 @@ public class HealthPlanController {
         if (planId == null) return Result.error("planId不能为空");
         HealthPlan plan = healthPlanService.getPlanDetail(planId);
         if (plan == null) return Result.error("方案不存在");
+        plan.setTasks(healthPlanService.getPlanTasks(planId));
         return Result.success(plan);
     }
 
+    @Operation(summary = "查询方案分解任务列表")
+    @PostMapping("/tasks")
+    public Result<List<Task>> planTasks(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        HealthPlan plan = healthPlanService.getPlanDetail(planId);
+        if (plan == null) return Result.error("方案不存在");
+        if (familyId != null && plan.getFamilyId() != null && !familyId.equals(plan.getFamilyId())) {
+            return Result.error("无权查看该方案任务");
+        }
+        List<Task> tasks = healthPlanService.getPlanTasks(planId);
+        return Result.success(tasks);
+    }
+
     @Operation(summary = "获取可用规划师列表")
     @PostMapping("/available-teachers")
     public Result<List<Map<String, Object>>> availableTeachers(

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

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

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

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

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

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

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

@@ -59,6 +59,9 @@ public class AIChatController {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private com.etotem.cfc.service.PortraitService portraitService;
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
@@ -109,6 +112,15 @@ public class AIChatController {
             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记忆层注入(会话摘要+关键事实)
         inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
 
@@ -272,11 +284,19 @@ public class AIChatController {
             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] 标记
         List<RecommendationResult> recommendations = null;
@@ -340,7 +360,7 @@ public class AIChatController {
     // 5. 组装结果
     Map<String, Object> result = new LinkedHashMap<>();
     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("tasks", tasks);
     return Result.success(result);
@@ -448,6 +468,11 @@ public class AIChatController {
             inputs.put("mascot_gender", "");
             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);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         Map<String, Object> result = new LinkedHashMap<>();
@@ -480,6 +505,12 @@ public class AIChatController {
         if (params.get("focus") != null) {
             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);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         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.ReportParseService;
 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.tags.Tag;
 import org.slf4j.Logger;
@@ -52,6 +54,32 @@ public class AssessmentReportController {
     @Resource
     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 = "家长上传测评报告")
     @PostMapping("/upload")
     public Result<DanAssessmentResult> uploadReport(
@@ -121,8 +149,8 @@ public class AssessmentReportController {
         return Result.success(list);
     }
 
-    @Operation(summary = "规划师确认家长上传报告")
-    @PostMapping("/{resultId}/confirm-upload")
+    @Operation(summary = "用户确认报告入库并自动生成方案")
+    @PostMapping("/{resultId}/confirm-inbox")
     public Result<DanAssessmentResult> confirmUpload(
             @PathVariable Long resultId,
             @RequestBody Map<String, Object> body) {
@@ -140,16 +168,16 @@ public class AssessmentReportController {
         }
         resultMapper.updateById(result);
 
-        // 自动生成测评方案
+        // 用户确认后立即自动生成测评方案(无需规划师审核)
         try {
             User child = userMapper.selectById(result.getChildId());
             Long familyId = child != null ? child.getFamilyId() : null;
             if (familyId != null) {
-                planGenerator.generate(result, familyId, result.getChildId());
-                log.info("已为测评结果{}自动生成方案", resultId);
+                TaskPlanInstance plan = planGenerator.generate(result, familyId, result.getChildId());
+                log.info("已为测评结果{}自动生成方案 planId={}", resultId, plan.getId());
             }
         } catch (Exception e) {
-            log.error("自动生成方案失败: resultId={}", resultId, e);
+            log.error("自动生成方案失败resultId={}", resultId, e);
         }
 
         return Result.success(result);

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

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

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

+ 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.GuideInviteCode;
 import com.etotem.cfc.entity.GuidePackage;
+import com.etotem.cfc.entity.GuidePackageTemplate;
 import com.etotem.cfc.entity.GuideHierarchy;
 import com.etotem.cfc.entity.PackageOrder;
 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.GuideInviteCodeService;
 import com.etotem.cfc.service.GuidePackageService;
+import com.etotem.cfc.service.GuidePackageTemplateService;
 import com.etotem.cfc.service.UserService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +46,9 @@ public class GuideManagementController {
     @Resource
     private GuidePackageService guidePackageService;
 
+    @Resource
+    private GuidePackageTemplateService guidePackageTemplateService;
+
     @Resource
     private UserMapper userMapper;
 
@@ -266,6 +271,12 @@ public class GuideManagementController {
         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,
                                                            @RequestAttribute("userId") Long userId,
                                                            @RequestAttribute("role") String role,
-                                                           @RequestAttribute(value = "familyId", required = false) Long familyId) {
+                                                           @RequestAttribute(value = "familyId", required = false) Long jwtFamilyId) {
         if (!"teacher".equals(role) && !"admin".equals(role)) {
             return Result.error("无权限");
         }
         String status = (String) body.get("status");
-        Long filterFamilyId = familyId;
+        // 合并逻辑:admin 未指定家庭时查看全部方案;teacher 优先 body.familyId,JWT 作为默认值
+        Long filterFamilyId;
         if ("admin".equals(role)) {
-            // 管理员可通过 body.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>()
                 .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 java.io.Serializable;
 import java.util.Date;
+import java.util.List;
 
 @Data
 @TableName("health_plans")
@@ -27,4 +28,8 @@ public class HealthPlan implements Serializable {
     @TableField("teacher_id") private Long teacherId;
     @TableField("created_at") private Date createdAt;
     @TableField("updated_at") private Date updatedAt;
+
+    /** 方案分解任务(非数据库字段,detail 接口填充) */
+    @TableField(exist = false)
+    private List<Task> tasks;
 }

+ 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(未选择)
     private String mascot;
 
+    // 用户自定义画像 prompt
+    @TableField("portrait_prompt")
+    private String portraitPrompt;
+
     // 家庭管理员标记
     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;
 
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import java.util.List;
 import java.util.Map;
 
@@ -21,4 +22,13 @@ public interface HealthPlanService {
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     List<Map<String, Object>> getAvailableTeachers(Long familyId);
+
+    /**
+     * 报告确认入库后自动生成并发布健康方案(无需规划师审核)
+     * @return 已发布方案 ID,生成失败时返回 null
+     */
+    Long autoGenerateAndPublish(Long familyId, Long memberId, String reportType);
+
+    /** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
+    List<Task> getPlanTasks(Long planId);
 }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
     gender ENUM('male','female'),
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
+    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
     province VARCHAR(50) DEFAULT NULL COMMENT '省份',
     city VARCHAR(50) DEFAULT NULL COMMENT '城市',
     district VARCHAR(50) DEFAULT NULL COMMENT '区县',
@@ -87,7 +88,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     category VARCHAR(32),
     need_review TINYINT DEFAULT 0 COMMENT '是否需要审核',
     review_by_category TINYINT DEFAULT 0 COMMENT '是否按分类审核',
-    status ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', 'overdue', 'cancelled') DEFAULT 'pending',
+    status ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', 'overdue', 'cancelled', 'locked', 'pending_review') DEFAULT 'pending',
     completed_at DATETIME,
     bonus_rule JSON COMMENT '积分奖励规则',
     points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
@@ -1818,6 +1819,24 @@ CREATE TABLE IF NOT EXISTS health_report_edit_logs (
     INDEX idx_report_id (report_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕';
 
+-- 菌群检测专项数据表(针对 gut_flora 类型报告)
+CREATE TABLE IF NOT EXISTS health_gut_flora (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
+    report_id BIGINT NOT NULL COMMENT '关联报告 ID',
+    bacteria_name VARCHAR(100) NOT NULL COMMENT '菌种名称',
+    bacteria_value VARCHAR(50) COMMENT '菌种丰度值',
+    normal_range VARCHAR(100) COMMENT '正常范围',
+    status VARCHAR(50) COMMENT '状态:正常/偏低/偏高',
+    category VARCHAR(50) COMMENT '分类:core/probiotic/harmful/other/pathogen_genus/pathogen_detection/taxonomy',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    population_level VARCHAR(100) DEFAULT NULL COMMENT '处于人群%水平',
+    detection_rate VARCHAR(100) DEFAULT NULL COMMENT '%正常人有检出',
+    description TEXT COMMENT '菌种说明',
+    `level` VARCHAR(20) DEFAULT 'GENUS' COMMENT '级别:CLASS/ORDER/FAMILY/GENUS/SPECIES',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_report (report_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菌群检测专项数据(每种菌种的丰度值和评估状态)';
+
 -- 教育体系配置表
 CREATE TABLE IF NOT EXISTS education_systems (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
@@ -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 (
     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
 
 # ===== 配置 =====
-PROFILE="${1:-prod}"
+# PROFILE="${1:-prod}"
+PROFILE="${1:-dev}"
 JAR_NAME="cfc-backend-1.0.0.jar"
 CONFIG_DIR="$(cd "$(dirname "$0")" && pwd)/config"
 

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

@@ -10,7 +10,7 @@
   </slot>
 
   <!-- 个人/全家切换开关(仅 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')">
       <text class="toggle-icon">👤</text>
       <text class="toggle-text">个人</text>
@@ -169,7 +169,7 @@ export default {
   },
   data: function() {
     return {
-      viewMode: 'family',
+      viewMode: 'personal',
       selectedMemberIndex: 0,
       previewScores: [0, 0, 0, 0, 0],
       canvasReady: false,
@@ -317,6 +317,16 @@ export default {
         var dimOrder = newVal.map(function(d) { return d.code })
         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() {
@@ -400,6 +410,7 @@ export default {
 
     /** 个人/全家切换(由父组件控制数据源) */
     onToggle: function(view) {
+      this.viewMode = view
       if (view === this.currentView) return
       this.$emit('view-change', view)
     },

+ 3 - 2
cfc-frontend/config.js

@@ -19,10 +19,11 @@ try {
   if (env) {
     switch (env) {
       case 'develop':
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
+        //API_BASE_URL = 'http://cfc.iwintrue.com'
+        API_BASE_URL = 'http://192.168.1.57:9082'
         break
       case 'trial':
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
+        API_BASE_URL = 'https://cfc.iwintrue.com'
         break
       case 'release':
         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",
       "pages": [
@@ -1138,6 +1155,18 @@
         }
       ]
     },
+    {
+      "root": "pages/wealth-detail",
+      "pages": [
+        {
+          "path": "member-wealth-detail",
+          "style": {
+            "navigationBarTitleText": "财富能量详情",
+            "navigationStyle": "custom"
+          }
+        }
+      ]
+    },
     {
       "root": "pages/action-detail",
       "pages": [
@@ -1548,6 +1577,12 @@
           "style": {
             "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"
               :key="i"
               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>
               <view class="gut-item-right">
                 <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>
@@ -114,7 +114,7 @@
           <text class="risk-name">{{ risk.name }}</text>
           <view class="risk-right">
             <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>
@@ -266,7 +266,7 @@ export default {
     },
     gutSummary: function() {
       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 <= 2) return '部分菌群指标略有波动'
       return '多项菌群指标异常,建议咨询专业营养师'
@@ -524,7 +524,7 @@ export default {
     goToBodyDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       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) {
       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-value { font-size: 26rpx; color: #666; }
 .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-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; }
@@ -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-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.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-right { display: flex; align-items: center; gap: 12rpx; }
 .risk-value { font-size: 24rpx; color: #666; }
 .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 {
   height: 120rpx;

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

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

+ 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>
       </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)"
-              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 v-if="executors.length === 0" class="empty-state">
           <text class="empty-icon">👨‍👩‍👧</text>
@@ -255,6 +253,39 @@
       <view v-if="planDetail.planContent" class="plan-preview">
         <text class="plan-preview-text">{{ planDetail.planContent }}</text>
       </view>
+
+      <!-- 方案分解任务 -->
+      <view class="plan-tasks-section">
+        <view class="plan-tasks-header">
+          <text class="plan-tasks-title">📋 分解任务</text>
+          <text class="plan-tasks-count" v-if="planTasks.length > 0">{{ planTaskCompletedCount }}/{{ planTasks.length }} 已完成</text>
+        </view>
+        <view class="plan-tasks-loading" v-if="planTasksLoading">
+          <text class="plan-tasks-loading-text">加载任务中...</text>
+        </view>
+        <view class="plan-task-list" v-else-if="planTasks.length > 0">
+          <view class="plan-task-item" v-for="item in planTasks" :key="item.id">
+            <view class="plan-task-main">
+              <text class="plan-task-title">{{ item.title }}</text>
+              <view class="plan-task-tags">
+                <text class="plan-task-tag" v-if="item.category">{{ item.category }}</text>
+                <text class="plan-task-tag plan-task-tag-freq">{{ getTaskFreqText(item) }}</text>
+              </view>
+            </view>
+            <view class="plan-task-right">
+              <text class="plan-task-status" :class="'plan-task-status-' + getTaskStatusClass(item.status)">{{ getTaskStatusText(item.status) }}</text>
+              <view class="plan-task-actions" v-if="item.status !== 'completed' && item.status !== 'pending_review'">
+                <text class="plan-task-btn plan-task-btn-complete" @click.stop="completePlanTask(item)">打卡完成</text>
+                <text class="plan-task-btn plan-task-btn-go" @click.stop="goToTaskCenter">任务中心</text>
+              </view>
+            </view>
+          </view>
+        </view>
+        <view class="plan-tasks-empty" v-else>
+          <text class="plan-tasks-empty-text">{{ getPlanTasksEmptyText() }}</text>
+        </view>
+      </view>
+
       <view class="plan-actions">
         <button class="btn-edit" @click="reCreate" v-if="planDetail.status !== 'confirmed' && planDetail.status !== 'published' && planDetail.status !== 'pending_review'">重新制定</button>
         <button class="btn-done plan-done-btn" @click="goBack">返回</button>
@@ -274,7 +305,7 @@
 </template>
 
 <script>
-import { getFamilyMemberList, generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, approvePlan } from '../../utils/api'
+import { getFamilyMemberList, generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, getHealthPlanTasks, approvePlan, completeTask } from '../../utils/api'
 import { getHealthStatus, saveHealthStatus } from '../../utils/api'
 
 // v2: 预置疾病清单(菌群报告病症风险 ★ + 互联网统计常见病 两源合并去重,8 组 54 项)
@@ -393,6 +424,8 @@ export default {
       viewMode: false,
       planDetail: null,
       planId: null,
+      planTasks: [],
+      planTasksLoading: false,
       // UI 状态
       loading: false,
       taskSubmitted: false
@@ -429,13 +462,14 @@ export default {
     selectedNames: function() {
       return this.selectedDiseases.map(function(d) { return d.name })
     },
-    selectedMembers: function() {
+selectedMembers: function() {
       var self = this
       var out = []
       this.selectedMemberIds.forEach(function(id) {
         var found = null
         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)
       })
@@ -444,18 +478,25 @@ export default {
     currentMemberName: function() {
       var members = this.selectedMembers
       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 '成员'
+    },
+    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) {
     var self = this
     // 处理初始成员ID
     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 查看模式
     this.planId = options.planId ? parseInt(options.planId) : null
@@ -486,19 +527,21 @@ export default {
   },
   methods: {
     // ===== 成员选择 (保持原有) =====
-    getMemberKey: function(m, idx) { return m.memberId || idx },
+    getMemberKey: function(m, idx) { return m.id || idx },
     loadExecutors: function() {
+      if (this._executorsLoading) return
+      this._executorsLoading = true
       var self = this
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res && res.code === 200 && 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) {
-            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) {
@@ -507,16 +550,17 @@ export default {
           self.loadHealthForm(self.activeFormMemberId)
         }
         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)
-      else this.selectedMemberIds.push(id)
+      else this.selectedMemberIds.push(strId)
     },
 
     // ===== 时间格式化 =====
@@ -1000,7 +1044,7 @@ export default {
           self.planDetail = res.data
           self.userGoal = res.data.goal || ''
           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 {
             var pj = JSON.parse(res.data.planJson || '{}')
@@ -1019,12 +1063,123 @@ export default {
               self.planData.overview = res.data.planContent.substring(0, 200)
             }
           }
+          self.loadPlanTasksFromDetail(res.data)
         }
       }).catch(function() {
         uni.showToast({ title: '方案加载失败', icon: 'none' })
         setTimeout(function() { uni.navigateBack() }, 1500)
       })
     },
+    loadPlanTasksFromDetail: function(planData) {
+      if (planData && planData.tasks instanceof Array) {
+        this.planTasks = planData.tasks
+        this.planTasksLoading = false
+        return
+      }
+      this.loadPlanTasks()
+    },
+    loadPlanTasks: function() {
+      var self = this
+      if (!self.planId) {
+        self.planTasks = []
+        return
+      }
+      self.planTasksLoading = true
+      getHealthPlanTasks({ planId: self.planId }).then(function(res) {
+        if (res && res.code === 200 && res.data instanceof Array) {
+          self.planTasks = res.data
+        } else {
+          self.planTasks = []
+        }
+      }).catch(function() {
+        self.planTasks = []
+      }).finally(function() {
+        self.planTasksLoading = false
+      })
+    },
+    getTaskStatusText: function(status) {
+      var map = {
+        pending: '待完成',
+        in_progress: '进行中',
+        pending_review: '待审核',
+        completed: '已完成',
+        expired: '已过期',
+        rejected: '已驳回'
+      }
+      return map[status] || '待完成'
+    },
+    getTaskStatusClass: function(status) {
+      if (status === 'completed') return 'done'
+      if (status === 'pending_review') return 'review'
+      if (status === 'expired' || status === 'rejected') return 'warn'
+      return 'pending'
+    },
+    getTaskFreqText: function(task) {
+      if (!task) return '任务'
+      if (task.repeatType === 'none' || task.taskType === 'onetime') return '一次性'
+      if (task.frequency === 'weekly' || task.repeatType === 'weekly') return '每周'
+      if (task.frequency === 'monthly' || task.repeatType === 'monthly') return '每月'
+      return '每日'
+    },
+    getPlanTasksEmptyText: function() {
+      if (this.planDetail && (this.planDetail.status === 'published' || this.planDetail.status === 'confirmed')) {
+        return '方案已发布,暂未解析出可执行任务'
+      }
+      if (this.planDetail && this.planDetail.status === 'pending_review') {
+        return '方案待规划师审核,审核通过后将自动分解任务'
+      }
+      return '方案发布后,系统将自动分解为每日/每周任务'
+    },
+    getPlanMemberId: function(item) {
+      if (item && item.familyMemberId) {
+        return item.familyMemberId
+      }
+      if (this.planDetail && this.planDetail.memberIds) {
+        var parts = this.planDetail.memberIds.split(',')
+        if (parts.length > 0 && parts[0]) {
+          return parseInt(parts[0]) || parts[0]
+        }
+      }
+      var stored = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId')
+      return stored || null
+    },
+    completePlanTask: function(item) {
+      var self = this
+      if (!item || !item.id || item.status === 'completed' || item.status === 'pending_review') {
+        return
+      }
+      var memberId = self.getPlanMemberId(item)
+      if (!memberId) {
+        uni.showToast({ title: '未找到执行成员', icon: 'none' })
+        return
+      }
+      uni.showModal({
+        title: '完成任务',
+        content: '确定完成「' + item.title + '」吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          completeTask(item.id, memberId, '').then(function(r) {
+            if (r && r.code === 200) {
+              var needReview = r.data && r.data.needReview
+              if (needReview) {
+                uni.showToast({ title: '已提交,待审核', icon: 'none' })
+              } else {
+                var points = (r.data && r.data.pointsEarned) || item.points || 0
+                uni.showToast({ title: '获得 ' + points + ' 积分', icon: 'success' })
+              }
+              self.loadPlanTasks()
+            } else {
+              uni.showToast({ title: (r && r.message) || '完成失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '完成失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    goToTaskCenter: function() {
+      uni.navigateTo({ url: '/pages/tasks/tasks' })
+    },
     reCreate: function() {
       this.step = 1
       this.viewMode = false
@@ -1138,73 +1293,72 @@ export default {
 }
 
 /* 成员卡片 */
-.member-list {
+.member-grid {
   display: flex;
-  flex-direction: column;
-  gap: 12rpx;
+  flex-wrap: wrap;
+  gap: 20rpx;
   margin-bottom: 24rpx;
 }
-.member-card {
+.member-tile {
+  position: relative;
+  width: calc(33.333% - 14rpx);
   display: flex;
+  flex-direction: column;
   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-left: 8rpx solid transparent;
   background: #FAFAFA;
   transition: all 0.2s;
 }
-.member-card.active {
+.member-tile.active {
   border-color: #F97316;
-  border-left: 8rpx solid #F97316;
   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;
-  border: 2rpx solid #ddd;
+  border: 3rpx solid #ddd;
   color: #888;
-  font-size: 28rpx;
+  font-size: 36rpx;
   font-weight: bold;
   display: flex;
   align-items: center;
   justify-content: center;
-  flex-shrink: 0;
+  margin-bottom: 12rpx;
 }
-.member-card.active .member-avatar {
+.member-tile.active .member-tile-avatar {
   background: #F97316;
   border-color: #F97316;
   color: #fff;
 }
-.member-card-name {
-  font-size: 28rpx;
+.member-tile-name {
+  font-size: 26rpx;
   font-weight: 600;
   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;
-  border-color: #F97316;
   color: #fff;
+  font-size: 20rpx;
+  line-height: 36rpx;
+  text-align: center;
 }
 
 /* 目标输入 */
@@ -1429,6 +1583,139 @@ export default {
   line-height: 1.8;
   white-space: pre-wrap;
 }
+.plan-tasks-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.plan-tasks-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.plan-tasks-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.plan-tasks-count {
+  font-size: 22rpx;
+  color: #64748B;
+}
+.plan-tasks-loading {
+  padding: 24rpx 0;
+  display: flex;
+  justify-content: center;
+}
+.plan-tasks-loading-text {
+  font-size: 24rpx;
+  color: #999;
+}
+.plan-task-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+.plan-task-item {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  justify-content: space-between;
+  padding: 20rpx;
+  background: #F8FAFC;
+  border-radius: 12rpx;
+  border: 1rpx solid #E2E8F0;
+}
+.plan-task-right {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 12rpx;
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+.plan-task-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8rpx;
+}
+.plan-task-btn {
+  font-size: 22rpx;
+  padding: 8rpx 18rpx;
+  border-radius: 20rpx;
+}
+.plan-task-btn-complete {
+  color: #fff;
+  background: #F97316;
+}
+.plan-task-btn-go {
+  color: #0369A1;
+  background: #E0F2FE;
+}
+.plan-task-main {
+  flex: 1;
+  min-width: 0;
+  padding-right: 16rpx;
+}
+.plan-task-title {
+  font-size: 26rpx;
+  color: #1E293B;
+  line-height: 1.5;
+}
+.plan-task-tags {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 8rpx;
+  margin-top: 8rpx;
+}
+.plan-task-tag {
+  font-size: 20rpx;
+  color: #0369A1;
+  background: #E0F2FE;
+  padding: 4rpx 12rpx;
+  border-radius: 16rpx;
+}
+.plan-task-tag-freq {
+  color: #92400E;
+  background: #FEF3C7;
+}
+.plan-task-status {
+  flex-shrink: 0;
+  font-size: 22rpx;
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+}
+.plan-task-status-pending {
+  color: #B45309;
+  background: #FEF3C7;
+}
+.plan-task-status-done {
+  color: #047857;
+  background: #D1FAE5;
+}
+.plan-task-status-review {
+  color: #92400E;
+  background: #FFEDD5;
+}
+.plan-task-status-warn {
+  color: #B91C1C;
+  background: #FEE2E2;
+}
+.plan-tasks-empty {
+  padding: 24rpx 0 8rpx;
+}
+.plan-tasks-empty-text {
+  font-size: 24rpx;
+  color: #94A3B8;
+  line-height: 1.6;
+  text-align: center;
+}
 .plan-actions { display: flex; gap: 16rpx; }
 .plan-actions .btn-edit { flex: 1; margin-bottom: 0; }
 .plan-done-btn {

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

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

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

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

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

@@ -71,6 +71,9 @@
       :dimensions="energyDimensions"
       :totalEnergy="totalEnergy"
       :totalHealthIndex="totalHealthIndex"
+      :currentView="currentSandboxView"
+      :familyUnlocked="familyUnlocked"
+      @view-change="onSandboxViewChange"
       showDimensionBar
       starHeight="480rpx" />
 
@@ -363,7 +366,7 @@ import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import DailyTaskCard from '../../components/daily-task-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 config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
@@ -430,6 +433,8 @@ export default {
       sandboxData: null,
       sandboxLocked: false,
       sandboxLockGate: null,
+      currentSandboxView: 'personal',
+      familyUnlocked: false,
       energyDimensions: [],
       totalEnergy: 0,
       totalHealthIndex: 0,
@@ -608,7 +613,7 @@ export default {
     },
     loadSandboxData: function() {
       var self = this
-      getFamilyEnergySandbox().then(function(res) {
+      getEnergySandbox(this.currentSandboxView).then(function(res) {
         if (res && res.data) {
           var d = res.data
           if (d.locked) {
@@ -619,13 +624,24 @@ export default {
           } else {
             self.sandboxLocked = false
             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._tryHideSplash()
         }
       }).catch(function() {})
     },
+    /** 切换沙盘个人/全家视角 */
+    onSandboxViewChange: function(view) {
+      if (view === this.currentSandboxView) return
+      this.currentSandboxView = view
+      this.loadSandboxData()
+    },
     loadActionArticles: function() {
       var self = this
       getFeaturedArticles({ size: 5 }).then(function(res) {
@@ -729,8 +745,10 @@ export default {
           var notices = res.data.map(function(n) {
             var timeStr = ''
             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 {
               text: '📢 ' + n.title,

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

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

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

@@ -818,7 +818,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
     goMindDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       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) {
       if (v === null || v === undefined) return ''

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

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

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

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

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

+ 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"
         @removed="loadFamilyMembersVisible" />
 
+      <!-- 五维能量条 -->
+      <FamilyEnergyBar
+        v-if="isLoggedIn && sandboxData"
+        dimensionCode="wealth"
+        :sandboxData="sandboxData"
+        :dualDimension="dualDimension" />
+
       <!-- 身克富杠杆警告 -->
       <body-wealth-alert
         :status="bodyWealthStatus"
@@ -230,6 +237,7 @@ import WealthEnergyRing from '../../components/WealthEnergyRing.vue'
 import WealthSubScores from '../../components/WealthSubScores.vue'
 import BodyWealthAlert from '../../components/BodyWealthAlert.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
 import {
@@ -240,13 +248,15 @@ import {
   getCommissionSummary,
   getFamilyMemberList,
   getInsuranceList,
+  getFamilyEnergySandbox,
+  getEnergyOverview,
   productList
 } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '../../config.js'
 
 export default {
-  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, DimensionIntroCard, DimensionSubDims },
+  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, FamilyEnergyBar, DimensionIntroCard, DimensionSubDims },
   data() {
     return {
       showTabTransition: true,
@@ -299,6 +309,9 @@ export default {
       familyMembersVisible: [],
       selectedMemberId: null,
       guestProducts: [],
+      // 五维能量条数据
+      sandboxData: null,
+      dualDimension: null,
       // 保单列表
       insuranceList: [],
       insuranceLoaded: false,
@@ -368,6 +381,8 @@ export default {
     this.loadWealthData()
     this.loadFamilyMembersVisible()
     this.loadInsuranceList()
+    this.loadSandboxData()
+    this.loadEnergyData()
     this._watchPageReady()
   },
     /** 每月引导语 */
@@ -483,6 +498,33 @@ export default {
         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() {
       if (this.role !== 'parent') return
       try {

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

@@ -440,7 +440,7 @@ export default {
     goWisdomDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       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) {
       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)
 }
 
-// 获取平台公告
+// 获取平台公告(旧 /api/notices/list 已废弃返回410,改用已发布公告接口)
 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 discardReportDraft = (draftId) => request('/api/health/report/discard', 'POST', { draftId })
 
 // ===== 鎶ュ憡璋冪爺闂嵎 =====
@@ -2098,6 +2095,7 @@ export const approvePlan = (data) => request('/api/health/plan/pending-review/ap
 export const rejectPlan = (data) => request('/api/health/plan/pending-review/reject', 'POST', data)
 export const getHealthPlanList = (data) => request('/api/health/plan/list', 'POST', data)
 export const getHealthPlanDetail = (data) => request('/api/health/plan/detail', 'POST', data)
+export const getHealthPlanTasks = (data) => request('/api/health/plan/tasks', 'POST', data)
 
 // ===== 閭€璇烽噷绋嬬 =====
 export const getInviteMilestones = () => {
@@ -2251,6 +2249,13 @@ export const getSelfCheckHistory = () => {
 export const parseTonguePreview = (data) => request('/api/health/report/parse-preview?type=tongue', 'POST', data)
 export const confirmTongue = (data) => request('/api/health/report/confirm?type=tongue', 'POST', data)
 export const discardTongue = (data) => request('/api/health/report/discard?type=tongue', 'POST', data)
+
+// ===== DAN 测评报告相关 =====
+export const parseReportDraft = (draftId) => request('/api/health/report/parse', 'POST', { draftId })
+export const confirmAssessmentReportConfirmInbox = (resultId, overallScore) => 
+  request('/api/dan-assessment/result/' + resultId + '/confirm-inbox', 'POST', { overallScore })
+export const getMyAssessmentPlans = (familyId, childId) => 
+  request('/api/dan-assessment-result/plans/list', 'POST', { familyId, childId })
 // 鎸囨爣瀹氫箟
 export const getIndicatorDefinitions = (data) => request('/api/admin/indicator/list', 'POST', data)
 
@@ -2725,3 +2730,11 @@ export function getMyButler() {
 export function selectButler(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.health_coach_graph import create_health_coach_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.rag.retriever import RagRetriever
 from app.config import settings
@@ -801,3 +802,56 @@ async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
     except Exception as e:
         logger.error("重新生成 section 失败: %s", 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.config import settings
 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
 
 logger = logging.getLogger(__name__)
@@ -60,6 +62,7 @@ def create_chat_graph():
     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,
@@ -87,6 +90,12 @@ def create_chat_graph():
         chat_prompt = await get_prompt("chat_assistant") or DEFAULT_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", {})
         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.config import settings
 from app.prompt_service import get_prompt
+from app.portrait_service import get_user_portrait_text
 import logging
 import json
 
@@ -139,6 +140,12 @@ def create_health_butler_graph():
         """阶段3: LLM 生成个性化健康建议"""
         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", {})
         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.config import settings
 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
 
 logger = logging.getLogger(__name__)
@@ -70,6 +72,7 @@ def create_health_coach_graph():
     embeddings = get_embeddings()
     retriever = RagRetriever(collection_name="health_knowledge")
     memory_mgr = MemoryManager(embeddings)
+    java = JavaClient()
 
     # 带工具的 LLM(健康知识库检索)
     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")
         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 {}
         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",
-  "version": "1.0.1216",
+  "version": "1.0.1227",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1216",
+      "version": "1.0.1227",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1217",
+  "version": "1.0.1228",
   "private": true,
   "scripts": {
     "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)
 
 ### 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)
 
 ### 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) {
   return request({

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

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

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

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

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

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

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

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

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

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

+ 1 - 0
cfc.pid

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

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

@@ -2,7 +2,7 @@
 
 **文档版本:** v2.9
 **日期:** 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 重新设计
 
-> 状态:设计草案 · 待评审
+> 状态:已对齐实现(2026-08-23 更新)
 > 设计者:Sisyphus
 
-## 一、现状分析
+## 一、现状分析(改造前)
 
-### 前 TabBar 结构
+### 改造前 TabBar 结构
 
 ```
 原生 TabBar:  [行] [心] [身] [智] [富]
@@ -15,7 +15,7 @@
 
 主包 5 页:`index-home`(行)、`mind`(心)、`body`(身)、`wisdom`(智)、`wealth`(富)
 
-### 前问题
+### 改造前问题
 
 | 问题 | 说明 |
 |------|------|
@@ -25,6 +25,15 @@
 | **缺少首页感** | 没有真正的"首页",index-home 同时承担首页和"行"维度两个角色 |
 | **导航损耗高** | 家长切换孩子、查看健康、管理成长需要在不同 Tab 间反复跳转 |
 
+### 改造后结果
+
+```
+当前 TabBar:  [首页] [健康] [成长] [我的]  + 中间⭐五维扇形
+主包 4 页:  index-home / health-main / growth-main / profile-main
+五维 detail:  body-detail / mind-detail / wisdom-detail / action-detail / wealth
+规划师 TabBar: 保持不变
+```
+
 ## 二、设计目标
 
 1. **任务导向**:导航按用户想"做什么"组织,而非"五维是什么"
@@ -45,12 +54,12 @@ Tab 映射:    健康           成长            我的
 汇总视角:  首页(家庭仪表板,全貌概览)
 ```
 
-| Tab | 定位 | 内容范围 |
-|-----|------|----------|
-| **首页** | 家庭仪表板 | 五维能量总览、家庭成员、待办任务、推荐内容 |
-| **健康** | 测评与诊断 | 测评申请、健康报告、诊断评分、调研问卷 |
-| **成长** | 方案与执行 | 成长方案、计划任务、打卡执行、饮食运动作息记录、成长档案 |
-| **我的** | 成果与进展 | 成就徽章、健康等级、成长报告、会员权益、家庭管理 |
+| Tab | 定位 | 内容范围(实际实现) |
+|-----|------|-------------------|
+| **首页** | 家庭仪表板 | 五维能量沙盘、家庭成员、今日任务、家庭挑战、活动/商品/文章推荐 |
+| **健康** | 五维总入口 | 5 子 Tab(身/智/富/行/心)+ 各维度雷达图 + 五维相生相克展示 + 维度卡片 |
+| **成长** | 方案与执行 | 今日成长概览、成长档案记录、打卡任务、饮食运动作息 |
+| **我的** | 成果与进展 | 个人资料、家庭成员、成就徽章、健康等级、健康时间线、服务菜单 |
 
 ## 三、新 TabBar 结构
 
@@ -62,24 +71,24 @@ 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": [
     { "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 配置
 
@@ -464,33 +449,30 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
   "backgroundColor": "#FFFFFF",
   "list": [
     { "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 修改文件
 
-| 文件 | 变更内容 |
-|------|----------|
-| `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 图标资源
 
@@ -501,30 +483,25 @@ Tab 标签从"行"改为"首页"后,内容基本不变。Tab 图标用新设
 | 成长 | `static/tab-growth.png` / `tab-growth-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,内容按孩子视角展示 |
-| **从五维浮层跳转"行"** | 即首页,用 switchTab |
-| **从五维浮层跳转"心/身/智/富"** | 用 navigateTo 至分包页 |
+| **从扇形跳转"富"** | `switchTab` 到 TabBar 页 |
+| **从扇形跳转"行/心/身/智"** | `navigateTo` 到分包 detail 页 |
 | **分包异步加载** | 首次访问五维页时可能短暂 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 阶段

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