Explorar el Código

Merge remote-tracking branch 'origin/cfclub' into cfclub

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
#	cfc-frontend/pages/activity/activity-detail/activity-detail.vue
#	cfc-frontend/pages/activity/index.vue
#	cfc-frontend/pages/article-center/article-detail.vue
#	cfc-frontend/pages/growth/upload.vue
#	cfc-frontend/pages/guide/activities/detail.vue
#	cfc-frontend/pages/guide/activities/index.vue
#	cfc-frontend/pages/guide/packages/share.vue
#	cfc-frontend/pages/meal/recipe-detail.vue
#	cfc-frontend/pages/mind-detail/articles.vue
#	cfc-frontend/pages/promotion/material.vue
#	cfc-frontend/pages/shop/checkout/checkout.vue
#	cfc-frontend/pages/shop/order-detail/order-detail.vue
#	cfc-frontend/pages/shop/order-list/order-list.vue
#	cfc-frontend/utils/api.js
Sisyphus Agent hace 1 semana
padre
commit
6ca7b29118
Se han modificado 100 ficheros con 5581 adiciones y 1089 borrados
  1. 177 285
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 40 39
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  4. 16 8
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java
  5. 16 7
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java
  6. 4 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  7. 71 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  8. 16 7
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java
  9. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java
  10. 133 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminUnifiedKnowledgeController.java
  11. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
  12. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/nutrition/NutritionProductController.java
  13. 66 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ContentByWeightController.java
  14. 21 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java
  16. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  17. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java
  18. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java
  19. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java
  20. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MemberIndicatorCurrent.java
  21. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionAgeDosage.java
  22. 3 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionProduct.java
  23. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionUlLimit.java
  24. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  25. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserContentWeight.java
  26. 11 2
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportDraftMapper.java
  27. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MemberIndicatorCurrentMapper.java
  28. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionAgeDosageMapper.java
  29. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionUlLimitMapper.java
  30. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/UserContentWeightMapper.java
  31. 21 1
      cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java
  32. 6 1
      cfc-backend/src/main/java/com/etotem/cfc/service/EmiReportService.java
  33. 13 2
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  34. 11 11
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  35. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  36. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  37. 256 20
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  38. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MemberIndicatorCurrentService.java
  39. 177 0
      cfc-backend/src/main/java/com/etotem/cfc/service/NutritionProductService.java
  40. 203 8
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
  41. 104 17
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  42. 192 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UnifiedKnowledgeBaseService.java
  43. 7 29
      cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java
  44. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserContentWeightService.java
  45. 113 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserContentWeightServiceImpl.java
  46. 155 17
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  47. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java
  48. 54 0
      cfc-backend/src/main/resources/schema.sql
  49. 3 122
      cfc-frontend/components/ContentSharePoster.vue
  50. 113 0
      cfc-frontend/components/DimensionPageSkeleton.vue
  51. 107 21
      cfc-frontend/components/DimensionProducts.vue
  52. 2 2
      cfc-frontend/components/gate-lock.vue
  53. 122 0
      cfc-frontend/docs/dimension-pages-stage4-audit.md
  54. 94 39
      cfc-frontend/pages/action-detail/index.vue
  55. 0 1
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  56. 0 1
      cfc-frontend/pages/activity/index.vue
  57. 85 21
      cfc-frontend/pages/article-center/article-detail.vue
  58. 95 20
      cfc-frontend/pages/body-detail/index.vue
  59. 9 6
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  60. 4 1
      cfc-frontend/pages/growth/diet-checkin.vue
  61. 5 2
      cfc-frontend/pages/growth/exercise-checkin.vue
  62. 5 2
      cfc-frontend/pages/growth/sleep-checkin.vue
  63. 12 7
      cfc-frontend/pages/growth/upload.vue
  64. 12 7
      cfc-frontend/pages/guide/activities/detail.vue
  65. 12 7
      cfc-frontend/pages/guide/activities/index.vue
  66. 12 7
      cfc-frontend/pages/guide/packages/share.vue
  67. 1 1
      cfc-frontend/pages/health-main/index.vue
  68. 41 7
      cfc-frontend/pages/health/components/ConfirmStep.vue
  69. 57 3
      cfc-frontend/pages/health/components/DietHabitsStep.vue
  70. 60 19
      cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue
  71. 278 0
      cfc-frontend/pages/health/components/LifestyleStep.vue
  72. 4 0
      cfc-frontend/pages/health/components/MemberChipPicker.vue
  73. 160 41
      cfc-frontend/pages/health/health-plan-summary.vue
  74. 12 1
      cfc-frontend/pages/index-home/index.vue
  75. 12 7
      cfc-frontend/pages/meal/recipe-detail.vue
  76. 12 7
      cfc-frontend/pages/mind-detail/articles.vue
  77. 189 81
      cfc-frontend/pages/mind-detail/index.vue
  78. 2 2
      cfc-frontend/pages/profile-extra/family-members.vue
  79. 1 1
      cfc-frontend/pages/profile-extra/gates.vue
  80. 12 7
      cfc-frontend/pages/promotion/material.vue
  81. 12 7
      cfc-frontend/pages/shop/checkout/checkout.vue
  82. 35 64
      cfc-frontend/pages/shop/index/index.vue
  83. 12 7
      cfc-frontend/pages/shop/order-detail/order-detail.vue
  84. 12 7
      cfc-frontend/pages/shop/order-list/order-list.vue
  85. 11 8
      cfc-frontend/pages/tasks/create-task.vue
  86. 15 0
      cfc-frontend/pages/tasks/tasks.vue
  87. 51 27
      cfc-frontend/pages/wealth/index.vue
  88. 74 31
      cfc-frontend/pages/wisdom-detail/index.vue
  89. 61 13
      cfc-frontend/uni.scss
  90. 1 0
      cfc-frontend/utils/api.js
  91. 312 7
      cfc-langgraph/app/api/adapter.py
  92. 53 0
      cfc-langgraph/app/graphs/health_plan_graph.py
  93. 56 1
      cfc-langgraph/app/models/health_plan.py
  94. 18 0
      cfc-langgraph/app/tools/java_client.py
  95. 47 4
      cfc-langgraph/tests/test_health_plan.py
  96. 1 1
      cfc-web/.last_build_commit
  97. 2 2
      cfc-web/package-lock.json
  98. 1 1
      cfc-web/package.json
  99. 429 0
      cfc-web/public/CHANGELOG-v1.0.md
  100. 430 1
      cfc-web/public/CHANGELOG.md

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

@@ -3042,14 +3042,46 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库)
         ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'");
 
-        // 迁移366: 新增 LITE 轻享会员等级(9.9元/月,解锁健康自检问卷与个性化方案)
+        // 迁移366: 9.9 元/月健康会员复用既有 BASIC「体验会员」等级(迁移360/schema.sql 已创建),
+        // 移除早期误建的重复等级 LITE(与 BASIC 同为 9.9 元/月),并迁移可能已落库的 LITE 记录。
+        // 三段独立 try,确保任一段异常都不会阻断后续的等级删除。
         try {
-            jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " +
-                    "('LITE', '轻享会员', '9.9元/月,解锁健康自检问卷与个性化方案', 990, 2970, 11880, 2, 10, 1, 0, 0)");
-            log.info("迁移366: 已插入 LITE 轻享会员等级");
+            jdbcTemplate.update("UPDATE family_memberships SET level_code = 'BASIC' WHERE level_code = 'LITE'");
+        } catch (Exception e) {
+            log.warn("迁移366: 迁移 family_memberships 中的 LITE 记录失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.update("UPDATE users SET member_level = 'BASIC' WHERE member_level = 'LITE'");
         } catch (Exception e) {
-            log.warn("迁移366: 插入 LITE 会员等级失败或已存在: {}", e.getMessage());
+            log.warn("迁移366: 迁移 users.member_level 中的 LITE 记录失败: {}", e.getMessage());
         }
+        try {
+            int removed = jdbcTemplate.update("DELETE FROM membership_levels WHERE level_code = 'LITE'");
+            log.info("迁移366: 9.9 元/月健康会员统一使用 BASIC 体验会员,已移除重复的 LITE 等级({} 行)", removed);
+        } catch (Exception e) {
+            log.warn("迁移366: 清理 LITE 等级失败或不存在: {}", e.getMessage());
+        }
+
+        // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)
+        addNutritionProductEfficacyTags();
+
+        // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
+        addProductTagsColumn();
+
+        // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
+        addUserContentWeightsTable();
+
+        // 迁移375: 修复 article_quiz_records 表结构(旧表缺 member_id/total_questions/cf_earned/energy_earned,questions/answers 需为 TEXT)
+        fixArticleQuizRecordsTable();
+
+        // 迁移376: health_status 添加 lifestyle JSON 列(心情/睡眠/排便/抗生素/过敏表现)
+        ensureColumn("health_status", "lifestyle", "JSON DEFAULT NULL COMMENT '生活方式与肠道健康 {mood,sleepDuration,bedtime,bowelFrequency,bowelForm,antibioticHistory,allergySymptoms}'");
+
+        // 迁移377: diet_preferences 添加 diet_structure JSON 列(蔬果量/乳制品/含糖饮料频率)
+        ensureColumn("diet_preferences", "diet_structure", "JSON DEFAULT NULL COMMENT '饮食结构现状 {vegetableFruit,dairy,sugaryDrinks}'");
+
+        // 迁移378: 创建 member_indicator_current 表(成员指标当前值 + 相对上次变化 delta)
+        addMemberIndicatorCurrentTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -9291,8 +9323,6 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
             }
         }
 
-        // 迁移249: sys_menu 菜单归类调整 + 权限点拆分(《角色权限与菜单归类整合方案》第二部分/B-3,幂等)
-        migrateSysMenuReorg();
 
         // 迁移250: 清理测试脏数据(B-7:TEST-Energy-Grant 测试任务、乱码昵称)
         try {
@@ -9305,8 +9335,6 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
             log.warn("迁移250失败: {}", e.getMessage());
         }
 
-        // 迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材(《角色权限与菜单归类整合方案》第二部分,幂等)
-        migrateHealthMenuMount();
 
         // 迁移252: 创建用户画像表
         try {
@@ -9486,8 +9514,6 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
             log.warn("扩展 tasks.status 枚举失败(可能已修改): {}", e.getMessage());
         }
 
-        // 迁移267: 方案中心菜单(插在家庭运营与任务中心之间)
-        migratePlanCenterMenu();
         // 迁移268: coupon 表添加 status 列(优惠券停用/启用)
         migrateCouponStatus();
 // 迁移276: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用)
@@ -9511,64 +9537,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         ensureColumn("users", "family_inviter_id", "BIGINT DEFAULT NULL COMMENT '家庭邀请人ID(首次注册时绑定,用于追踪邀请来源)'");
     }
 
-    /**
-     * 迁移267: 存量库插入「方案中心」一级菜单及子菜单(幂等)
-     * 位置:家庭运营(sort=1) 与 任务中心 之间;任务中心及之后顶级菜单 sort+1
-     */
-    private void migratePlanCenterMenu() {
-        try {
-            Long existed = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=0 AND title='方案中心'");
-            if (existed == null) {
-                // 为任务中心及之后的顶级菜单腾出 sort=2
-                jdbcTemplate.update(
-                        "UPDATE sys_menu SET sort = sort + 1 WHERE parent_id=0 AND sort >= 2 " +
-                        "AND title <> '方案中心'");
-                jdbcTemplate.update(
-                        "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) " +
-                        "VALUES (0, '方案中心', NULL, NULL, 'el-icon-notebook-2', 'plan:*', 2, 1)");
-                existed = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
-                log.info("迁移267: 已插入方案中心一级菜单");
-            }
-            if (existed == null) {
-                return;
-            }
-            Long healthPlanId = insertPlanCenterChild(existed, "健康方案管理", "/health-plan-manage",
-                    "el-icon-first-aid-kit", "plan:health", 0);
-            Long assessmentPlanId = insertPlanCenterChild(existed, "测评方案管理", "/assessment-plan-manage",
-                    "el-icon-edit", "plan:assessment", 1);
-            Integer grantCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_role_menu", Integer.class);
-            if (grantCount != null && grantCount > 0) {
-                String[] roles = {"admin", "butler", "nutritionist", "operator"};
-                Long[] menuIds = {existed, healthPlanId, assessmentPlanId};
-                for (String role : roles) {
-                    for (Long menuId : menuIds) {
-                        grantMenuToRole(role, menuId);
-                    }
-                }
-            }
-            log.info("迁移267: 方案中心菜单挂载完成");
-        } catch (Exception e) {
-            log.warn("迁移267失败: {}", e.getMessage());
-        }
-    }
-
-    private Long insertPlanCenterChild(Long parentId, String label, String path, String icon, String perm, int sort) {
-        try {
-            Long existed = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=" + parentId + " AND path='" + path + "'");
-            if (existed != null) {
-                return existed;
-            }
-            jdbcTemplate.update(
-                    "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, NULL, ?, ?, ?, ?, ?, 1)",
-                    parentId, label, path, icon, perm, sort
-            );
-            return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
-        } catch (Exception e) {
-            log.warn("迁移267: 挂载子菜单「{}」失败: {}", label, e.getMessage());
-            return null;
-        }
-    }
-
+    
     /**
      * 迁移268: coupon 表添加 status 列(优惠券停用/启用,幂等)
      * 存量数据 status 为 NULL,查询层按 ACTIVE 处理,不影响现有券。
@@ -9582,200 +9551,6 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         }
     }
 
-    /**
-     * 迁移249: 存量库 sys_menu 菜单归类调整 + 权限点拆分(幂等,按 path/title 定位)
-     * 1. 审核中心改为分组,归集:服务角色申请审核/活动审核/待退款管理/收益提现审核
-     * 2. 能量规则/能量行为/周期性服务 → 系统配置·健康配置
-     * 3. LangGraph 管理 → 报告解析系统
-     * 4. 测评管理/测评订单/待分配规划师 → 测评咨询(删除系统配置下原"测评管理"子组)
-     * 5. 菜单组权限统一 模块:* 格式;system:config 拆分为 system:base/config:member/config:dimension/config:health/config:report-parser
-     * 6. 术语统一:七维能量配置 → 五维能量配置
-     */
-    private void migrateSysMenuReorg() {
-        try {
-            // A. 审核中心:顶级叶子 → 分组,并插入"审核中心总览"子项
-            jdbcTemplate.update("UPDATE sys_menu SET title='审核中心', label=NULL, path=NULL, perm='audit', sort=3 WHERE path='/review-center' AND parent_id=0 AND title IS NULL");
-            Long reviewGroup = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=0 AND title='审核中心' AND (path IS NULL OR path='')");
-            if (reviewGroup != null) {
-                Integer childCount = jdbcTemplate.queryForObject(
-                        "SELECT COUNT(*) FROM sys_menu WHERE parent_id=" + reviewGroup + " AND path='/review-center'", Integer.class);
-                if (childCount == null || childCount == 0) {
-                    jdbcTemplate.update(
-                            "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, NULL, '审核中心总览', '/review-center', 'el-icon-s-check', 'audit', 0, 1)",
-                            reviewGroup);
-                }
-                // 迁入审核中心
-                moveMenu("/service-role-applications", reviewGroup, "audit:service-role", 1);
-                moveMenu("/activity-review", reviewGroup, "audit:activity", 2);
-                moveMenu("/pending-refund", reviewGroup, "audit:refund", 3);
-                moveMenu("/family-earnings-withdraw", reviewGroup, "audit:withdraw", 4);
-            }
-
-            // B. 能量规则/能量行为/周期性服务 → 系统配置·健康配置
-            Long healthConfig = queryMenuId("SELECT id FROM sys_menu WHERE title='健康配置' AND (path IS NULL OR path='')");
-            if (healthConfig != null) {
-                jdbcTemplate.update("UPDATE sys_menu SET perm='config:health' WHERE id=" + healthConfig);
-                moveMenu("/energy-rule", healthConfig, "config:health", 3);
-                moveMenu("/energy-behavior-config", healthConfig, "config:health", 4);
-                moveMenu("/periodic-service-config", healthConfig, "config:health", 5);
-            }
-
-            // C. LangGraph 管理 → 报告解析系统
-            Long reportParser = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=0 AND title='报告解析系统'");
-            if (reportParser != null) {
-                jdbcTemplate.update("UPDATE sys_menu SET perm='config:report-parser' WHERE id=" + reportParser);
-                moveMenu("/langgraph-admin", reportParser, "config:report-parser", 4);
-                updateMenuPermByPath("/report-types", "config:report-parser");
-                updateMenuPermByPath("/report-parser-import", "config:report-parser");
-                updateMenuPermByPath("/report-unknown-clusters", "config:report-parser");
-                updateMenuPermByPath("/report-auto-learn", "config:report-parser");
-            }
-
-            // D. 测评管理/测评订单/待分配规划师 → 测评咨询
-            Long assessment = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=0 AND title='测评咨询'");
-            Long oldAssessmentManage = queryMenuId("SELECT id FROM sys_menu WHERE title='测评管理' AND (path IS NULL OR path='')");
-            if (assessment != null) {
-                moveMenu("/assessment-admin", assessment, "assessment:manage", 0);
-                moveMenu("/assessment-orders", assessment, "assessment:orders", 1);
-                moveMenu("/assessment-assign", assessment, "assessment:assign", 2);
-            }
-            if (oldAssessmentManage != null && assessment != null && !oldAssessmentManage.equals(assessment)) {
-                // 子项已迁走,删除系统配置下空的"测评管理"子组及其授权关联
-                jdbcTemplate.update("DELETE FROM sys_role_menu WHERE menu_id=" + oldAssessmentManage);
-                jdbcTemplate.update("DELETE FROM sys_menu WHERE id=" + oldAssessmentManage);
-            }
-
-            // E. 菜单组权限统一 模块:* 格式(废弃 operation/content/commerce/health 单段写法)
-            jdbcTemplate.update("UPDATE sys_menu SET perm='family:*' WHERE parent_id=0 AND title='家庭运营'");
-            jdbcTemplate.update("UPDATE sys_menu SET perm='articles:*' WHERE parent_id=0 AND title='知识中心'");
-            jdbcTemplate.update("UPDATE sys_menu SET perm='commerce:*' WHERE parent_id=0 AND title='商城营销'");
-            jdbcTemplate.update("UPDATE sys_menu SET perm='health:*' WHERE parent_id=0 AND title='健康饮食'");
-
-            // F. system:config 拆分
-            updateMenuPermByPath("/sys-config", "system:base");
-            updateMenuPermByPath("/gates", "system:base");
-            updateMenuPermByPath("/membership-center", "config:member");
-            updateMenuPermByPath("/badge-manage", "config:member");
-            updateMenuPermByPath("/virtual-goods-config", "config:member");
-            updateMenuPermByPath("/dimension-config", "config:dimension");
-            updateMenuPermByPath("/zodiac-configs", "config:dimension");
-            updateMenuPermByPath("/bazi-configs", "config:dimension");
-            updateMenuPermByPath("/blood-type-configs", "config:dimension");
-            updateMenuPermByPath("/health-norm-config", "config:health");
-            updateMenuPermByPath("/health-data-source", "config:health");
-            // 术语统一:七维能量配置 → 五维能量配置
-            jdbcTemplate.update("UPDATE sys_menu SET label='五维能量配置', perm='config:health' WHERE path='/health-energy-config'");
-            updateMenuPermByPath("/virtual-teams", "system:base");
-            updateMenuPermByPath("/knowledge-base", "knowledge:base");
-            updateMenuPermByPath("/health-knowledge", "knowledge:base");
-            updateMenuPermByPath("/supplier-products", "supply:manage");
-            updateMenuPermByPath("/nutrition-products", "health:products");
-            updateMenuPermByPath("/energy-sandbox", "health:energy");
-            // 子组权限
-            jdbcTemplate.update("UPDATE sys_menu SET perm='system:base' WHERE title='基础管理' AND (path IS NULL OR path='')");
-            jdbcTemplate.update("UPDATE sys_menu SET perm='config:dimension' WHERE title='配置中心' AND (path IS NULL OR path='')");
-            jdbcTemplate.update("UPDATE sys_menu SET perm='config:dimension' WHERE title='人生维度' AND (path IS NULL OR path='')");
-            jdbcTemplate.update("UPDATE sys_menu SET perm='system:base' WHERE title='虚拟团队' AND (path IS NULL OR path='')");
-
-            // G. 商城营销叶子权限统一(B-4 映射可识别)
-            updateMenuPermByPath("/product-manage", "commerce:products,commerce:self");
-            updateMenuPermByPath("/inventory", "inventory:self");
-            updateMenuPermByPath("/order-manage", "commerce:orders,order:self");
-            updateMenuPermByPath("/ecom-supplier", "commerce:supplier");
-            updateMenuPermByPath("/product-profit-rate", "finance:profit-rate");
-            updateMenuPermByPath("/coupon", "commerce:coupon");
-            updateMenuPermByPath("/coupon-grant-log", "commerce:coupon");
-            updateMenuPermByPath("/promotion", "commerce:promotion");
-            updateMenuPermByPath("/family-earnings", "finance:family-earnings");
-            updateMenuPermByPath("/activities", "activity:list,activity:self");
-            updateMenuPermByPath("/activity-registration-review", "activity:registration");
-            updateMenuPermByPath("/families", "family:list,family:read,family:bound");
-            updateMenuPermByPath("/children", "family:children,family:read,family:bound");
-            updateMenuPermByPath("/points-log", "family:points,family:bound");
-            updateMenuPermByPath("/growth-task", "task:growth");
-
-            log.info("迁移249: sys_menu 菜单归类调整与权限拆分完成");
-        } catch (Exception e) {
-            log.warn("迁移249失败: {}", e.getMessage());
-        }
-    }
-
-    /**
-     * 按 path 更新菜单权限(找不到时忽略)
-     */
-    private void updateMenuPermByPath(String path, String perm) {
-        try {
-            jdbcTemplate.update("UPDATE sys_menu SET perm=? WHERE path=?", perm, path);
-        } catch (Exception ignored) {
-        }
-    }
-
-    /**
-     * 迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材(存量库 sys_menu 幂等补齐)
-     * 对应页面 ReportManagement.vue / HealthReportAudit.vue / SeasonalFoods.vue 已开发完成但此前未挂载;
-     * 同时将健康饮食组排序对齐《浠艾福后台-角色权限与菜单归类整合方案》最终菜单树。
-     */
-    private void migrateHealthMenuMount() {
-        try {
-            Long healthGroup = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=0 AND title='健康饮食'");
-            if (healthGroup == null) {
-                log.warn("迁移251: 未找到健康饮食分组,跳过");
-                return;
-            }
-            // 1. 存量菜单排序对齐(健康报告0..五维能量11),先更新防止插入冲突
-            jdbcTemplate.update("UPDATE sys_menu SET sort=0 WHERE parent_id=" + healthGroup + " AND path='/health-reports'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=3 WHERE parent_id=" + healthGroup + " AND path='/health-indicators'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=4 WHERE parent_id=" + healthGroup + " AND path='/health-checkins'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=5 WHERE parent_id=" + healthGroup + " AND path='/emotion-alert'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=6 WHERE parent_id=" + healthGroup + " AND path='/nutrition-mappings'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=7 WHERE parent_id=" + healthGroup + " AND path='/foods'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=8 WHERE parent_id=" + healthGroup + " AND path='/recipes'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=10 WHERE parent_id=" + healthGroup + " AND path='/nutrition-products'");
-            jdbcTemplate.update("UPDATE sys_menu SET sort=11 WHERE parent_id=" + healthGroup + " AND path='/energy-sandbox'");
-            // 2. 按 path 幂等插入 3 个新页面
-            Long reportManagementId = insertHealthMenu(healthGroup, "报告管理", "/report-management", "el-icon-document", "health:reports", 1);
-            Long reportAuditId = insertHealthMenu(healthGroup, "报告审核", "/report-audit", "el-icon-document-checked", "report:audit", 2);
-            Long seasonalFoodsId = insertHealthMenu(healthGroup, "应季食材", "/seasonal-foods", "el-icon-apple", "diet:foods", 9);
-            // 3. 条件授权:仅当已配置过角色授权(sys_role_menu 非空)时补授权,
-            //    避免破坏"所有角色均未授权 → 返回全量可见树"的兼容逻辑(SysMenuService.userMenus)
-            Integer grantCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_role_menu", Integer.class);
-            if (grantCount != null && grantCount > 0) {
-                String[] roles = {"admin", "butler", "nutritionist"};
-                Long[] menuIds = {reportManagementId, reportAuditId, seasonalFoodsId};
-                for (String role : roles) {
-                    for (Long menuId : menuIds) {
-                        grantMenuToRole(role, menuId);
-                    }
-                }
-                log.info("迁移251: 已为 admin/butler/nutritionist 补授权新健康菜单");
-            }
-            log.info("迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材完成");
-        } catch (Exception e) {
-            log.warn("迁移251失败: {}", e.getMessage());
-        }
-    }
-
-    /**
-     * 按 path 幂等插入健康饮食叶子菜单,返回菜单 id(已存在返回原 id)
-     */
-    private Long insertHealthMenu(Long parentId, String label, String path, String icon, String perm, int sort) {
-        try {
-            Long existed = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=" + parentId + " AND path='" + path + "'");
-            if (existed != null) {
-                return existed;
-            }
-            jdbcTemplate.update(
-                    "INSERT INTO sys_menu (parent_id, title, label, path, icon, perm, sort, visible) VALUES (?, NULL, ?, ?, ?, ?, ?, 1)",
-                    parentId, label, path, icon, perm, sort
-            );
-            Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
-            log.info("迁移251: 已挂载健康饮食菜单「{}」", label);
-            return id;
-        } catch (Exception e) {
-            log.warn("迁移251: 挂载菜单「{}」失败: {}", label, e.getMessage());
-            return null;
-        }
-    }
 
     /**
      * 角色-菜单授权(幂等,唯一键 uk_role_menu 冲突忽略)
@@ -9790,16 +9565,6 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         }
     }
 
-    /**
-     * 将菜单(按 path)移动到指定父组并更新权限/排序(幂等)
-     */
-    private void moveMenu(String path, Long parentId, String perm, int sort) {
-        try {
-            jdbcTemplate.update("UPDATE sys_menu SET parent_id=?, perm=?, sort=? WHERE path=?", parentId, perm, sort, path);
-        } catch (Exception ignored) {
-        }
-    }
-
     /**
      * 查询单个菜单 id,不存在返回 null
      */
@@ -9872,18 +9637,17 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                 {"activity", "activityRegReview", "", "活动报名审核", "/activity-registration-review", "el-icon-document-checked", "activity:registration", "1"},
                 // 健康饮食(health:*,能量规则/行为/周期性服务已移入系统配置·健康配置,LangGraph已移入报告解析系统;报告管理/报告审核/应季食材按方案挂载)
                 {"", "health", "健康饮食", "", "", "el-icon-first-aid-kit", "health:*", "8"},
-                {"health", "healthReports", "", "健康报告", "/health-reports", "el-icon-document", "health:reports", "0"},
-                {"health", "reportManagement", "", "报告管理", "/report-management", "el-icon-document", "health:reports", "1"},
-                {"health", "reportAudit", "", "报告审核", "/report-audit", "el-icon-document-checked", "report:audit", "2"},
-                {"health", "healthIndicators", "", "健康指标", "/health-indicators", "el-icon-data-line", "health:indicators", "3"},
-                {"health", "healthCheckins", "", "健康打卡", "/health-checkins", "el-icon-s-order", "health:checkins", "4"},
-                {"health", "emotionAlert", "", "情绪告警", "/emotion-alert", "el-icon-warning", "health:checkins", "5"},
-                {"health", "nutritionMappings", "", "营养素映射", "/nutrition-mappings", "el-icon-connection", "health:mappings", "6"},
-                {"health", "foods", "", "食材管理", "/foods", "el-icon-apple", "diet:foods", "7"},
-                {"health", "recipes", "", "食谱管理", "/recipes", "el-icon-dish", "diet:recipes", "8"},
-                {"health", "seasonalFoods", "", "应季食材", "/seasonal-foods", "el-icon-apple", "diet:foods", "9"},
-                {"health", "nutritionProducts", "", "营养产品", "/nutrition-products", "el-icon-first-aid-kit", "health:products", "10"},
-                {"health", "energySandbox", "", "五维能量", "/energy-sandbox", "el-icon-data-line", "health:energy", "11"},
+                {"health", "reportManagement", "", "报告管理", "/report-management", "el-icon-document", "health:reports", "0"},
+                {"health", "proxyReportUpload", "", "代传报告", "/report-proxy-upload", "el-icon-upload", "health:reports", "1"},
+                {"health", "healthIndicators", "", "健康指标", "/health-indicators", "el-icon-data-line", "health:indicators", "2"},
+                {"health", "healthCheckins", "", "健康打卡", "/health-checkins", "el-icon-s-order", "health:checkins", "3"},
+                {"health", "emotionAlert", "", "情绪告警", "/emotion-alert", "el-icon-warning", "health:checkins", "4"},
+                {"health", "nutritionMappings", "", "营养素映射", "/nutrition-mappings", "el-icon-connection", "health:mappings", "5"},
+                {"health", "foods", "", "食材管理", "/foods", "el-icon-apple", "diet:foods", "6"},
+                {"health", "recipes", "", "食谱管理", "/recipes", "el-icon-dish", "diet:recipes", "7"},
+                {"health", "seasonalFoods", "", "应季食材", "/seasonal-foods", "el-icon-apple", "diet:foods", "8"},
+                {"health", "nutritionProducts", "", "营养产品", "/nutrition-products", "el-icon-first-aid-kit", "health:products", "9"},
+                {"health", "energySandbox", "", "五维能量", "/energy-sandbox", "el-icon-data-line", "health:energy", "10"},
                 // 报告解析系统(config:report-parser,LangGraph 管理归入)
                 {"", "reportParser", "报告解析系统", "", "", "el-icon-cpu", "config:report-parser", "9"},
                 {"reportParser", "reportTypes", "", "报告类型管理", "/report-types", "el-icon-collection", "config:report-parser", "0"},
@@ -12580,4 +12344,132 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.info("已添加 parse_finished_at 列到 health_report_drafts 表");
         }
     }
+
+    // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
+    /**
+     * 迁移378: 创建 member_indicator_current 表
+     * 成员维度「当前指标 + 相对上次变化」:
+     *   - 报告确认入库后,取该成员最新一份报告 vs 次新报告的同名指标,计算 delta
+     *   - 方案生成(LangGraph)与前端指标卡片可直接读取,无需每次跨表 diff
+     * 幂等:CREATE TABLE IF NOT EXISTS
+     */
+    private void addMemberIndicatorCurrentTable() {
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS `member_indicator_current` (" +
+                "`id` bigint NOT NULL AUTO_INCREMENT," +
+                "`member_id` bigint NOT NULL COMMENT '家庭成员ID(family_members.id)'," +
+                "`definition_id` bigint NOT NULL COMMENT '指标定义ID(indicator_definitions.id)'," +
+                "`domain` varchar(50) NOT NULL DEFAULT 'physical' COMMENT '指标域(body/mind/physical/wisdom)'," +
+                "`category` varchar(50) DEFAULT NULL COMMENT '指标分类'," +
+                "`name` varchar(100) NOT NULL COMMENT '指标名称'," +
+                "`value` varchar(200) DEFAULT NULL COMMENT '最新值'," +
+                "`numeric_value` decimal(12,4) DEFAULT NULL COMMENT '最新数值(可计算)'," +
+                "`previous_value` varchar(200) DEFAULT NULL COMMENT '上一份报告的同类指标值'," +
+                "`previous_numeric` decimal(12,4) DEFAULT NULL COMMENT '上一份报告数值'," +
+                "`delta` decimal(12,4) DEFAULT NULL COMMENT 'numeric_value - previous_numeric'," +
+                "`trend` varchar(16) DEFAULT NULL COMMENT '变化方向: up/down/flat/new/na'," +
+                "`latest_report_id` bigint DEFAULT NULL COMMENT '最新来源报告ID'," +
+                "`previous_report_id` bigint DEFAULT NULL COMMENT '上次来源报告ID'," +
+                "`report_date` date DEFAULT NULL COMMENT '最新报告日期'," +
+                "`recorded_at` datetime DEFAULT CURRENT_TIMESTAMP," +
+                "`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "PRIMARY KEY (`id`)," +
+                "UNIQUE KEY `uk_member_definition` (`member_id`, `definition_id`)," +
+                "KEY `idx_member_domain` (`member_id`, `domain`)," +
+                "KEY `idx_report` (`latest_report_id`)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员指标当前值+相对上次变化'");
+            log.info("迁移378: member_indicator_current 表就绪");
+        } catch (Exception e) {
+            log.warn("迁移378: 创建 member_indicator_current 表失败或已存在: {}", e.getMessage());
+        }
+    }
+
+    private void addUserContentWeightsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_content_weights (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
+                    "content_type VARCHAR(16) NOT NULL COMMENT '内容类型: product/activity/article'," +
+                    "item_id BIGINT NOT NULL COMMENT '内容ID(商品ID/活动ID/文章ID)'," +
+                    "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth'," +
+                    "weight DECIMAL(5,2) NOT NULL DEFAULT 1.00 COMMENT '权重值 0.00-10.00,越大越优先'," +
+                    "reason VARCHAR(200) DEFAULT NULL COMMENT '权重来源: user_set/auto_click/auto_purchase/manual/auto_profile'," +
+                    "created_by BIGINT DEFAULT NULL COMMENT '创建人ID(manual场景记录运营人员)'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "INDEX idx_user_content (user_id, content_type, item_id)," +
+                    "INDEX idx_family_dimension (family_id, dimension_code)," +
+                    "UNIQUE KEY uk_user_item (user_id, content_type, item_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表'");
+            log.info("迁移374: 已创建/确认 user_content_weights 表");
+        } catch (Exception e) {
+            log.warn("迁移374: 创建 user_content_weights 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移375: 修复 article_quiz_records 表结构(旧表由迁移299创建时已存在导致缺列)
+    private void fixArticleQuizRecordsTable() {
+        try {
+            if (!columnExists("article_quiz_records", "member_id")) {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)'");
+                log.info("迁移375: 已添加 member_id 列到 article_quiz_records");
+            }
+            if (!columnExists("article_quiz_records", "total_questions")) {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN total_questions INT DEFAULT 0 COMMENT '题目总数'");
+                log.info("迁移375: 已添加 total_questions 列到 article_quiz_records");
+            }
+            if (!columnExists("article_quiz_records", "cf_earned")) {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN cf_earned INT DEFAULT 0 COMMENT '发放的CF值'");
+                log.info("迁移375: 已添加 cf_earned 列到 article_quiz_records");
+            }
+            if (!columnExists("article_quiz_records", "energy_earned")) {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD COLUMN energy_earned INT DEFAULT 0 COMMENT '发放的能量值'");
+                log.info("迁移375: 已添加 energy_earned 列到 article_quiz_records");
+            }
+            // questions/answers 旧表为 VARCHAR(255),实体存 JSON 快照会超长,与 schema.sql 对齐为 TEXT
+            if (!columnTypeStartsWith("article_quiz_records", "questions", "text")) {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records MODIFY COLUMN questions TEXT COMMENT '题目快照JSON(含正确答案与维度)'");
+                log.info("迁移375: questions 列已改为 TEXT");
+            }
+            if (!columnTypeStartsWith("article_quiz_records", "answers", "text")) {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records MODIFY COLUMN answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL'");
+                log.info("迁移375: answers 列已改为 TEXT");
+            }
+            // 补充索引(幂等;重复创建会报 Duplicate key name,捕获忽略)
+            try {
+                jdbcTemplate.execute("ALTER TABLE article_quiz_records ADD INDEX idx_member (member_id)");
+            } catch (Exception ignored) {
+            }
+            log.info("迁移375: article_quiz_records 表结构修复完成");
+        } catch (Exception e) {
+            log.warn("迁移375: 修复 article_quiz_records 表结构失败: {}", e.getMessage());
+        }
+    }
+
+    private boolean columnTypeStartsWith(String table, String column, String typePrefix) {
+        try {
+            String t = jdbcTemplate.queryForObject(
+                    "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
+                    String.class, table, column);
+            return t != null && t.toLowerCase().startsWith(typePrefix);
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)
+    private void addNutritionProductEfficacyTags() {
+        if (!columnExists("nutrition_product", "efficacy_tags")) {
+            jdbcTemplate.execute("ALTER TABLE nutrition_product ADD COLUMN efficacy_tags TEXT COMMENT '成分功效标签JSON数组(如 [\"补钙\",\"补锌\",\"益生菌\",\"维生素D\"])'");
+            log.info("已添加 efficacy_tags 列到 nutrition_product 表");
+        }
+    }
+
+    // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
+    private void addProductTagsColumn() {
+        ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'");
+        log.info("迁移373: products.tags 已确认存在");
+    }
 }

+ 40 - 39
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -30,45 +30,46 @@ public class JwtInterceptor implements HandlerInterceptor {
     private FamilyMemberMapper familyMemberMapper;
 
 private static final String[] PUBLIC_PATHS = {
-        "/api/auth/send-code",
-        "/api/auth/phone-login",
-        "/api/auth/wechat-phone-login",
-        "/api/auth/silent-login",
-        "/api/auth/sso-login",
-        "/api/auth/auto-login",
-        "/api/auth/register-with-idcard",
-        "/api/auth/wechat-login",
-        "/api/auth/verify",
-        "/api/auth/check-phone",
-        "/api/auth/direct-register",
-        "/api/auth/register-with-invite",
-        "/api/auth/wechat-web/qr",
-        "/api/auth/wechat-web/poll",
-        "/api/internal/",
-        "/api/product/list",
-        "/api/product/detail",
-        "/api/product/type-list",
-        "/api/mini-game/list",
-        "/api/admin-auth/send-code",
-        "/api/admin-auth/login",
-        "/api/admin-auth/login-by-password",
-        "/api/admin-auth/account-roles",
-        "/api/articles/list",
-        "/api/articles/detail",
-        "/api/articles/categories",
-        "/api/articles/featured",
-        "/api/articles/record-read",
-        "/api/growth/**",
-        "/api/activity/list",
-        "/api/activity/detail",
-        "/api/activity/order/notify",
-        "/api/content-sections/visible",
-        "/api/mind/alert/active",
-        "/api/mind/alert/checkin-alerts",
-        "/api/mind/traditional/mirror",
-        "/api/mind/traditional/compatibility",
-        "/api/mcp/**"
-    };
+         "/api/auth/send-code",
+         "/api/auth/phone-login",
+         "/api/auth/wechat-phone-login",
+         "/api/auth/silent-login",
+         "/api/auth/sso-login",
+         "/api/auth/auto-login",
+         "/api/auth/register-with-idcard",
+         "/api/auth/wechat-login",
+         "/api/auth/verify",
+         "/api/auth/check-phone",
+         "/api/auth/direct-register",
+         "/api/auth/register-with-invite",
+         "/api/auth/wechat-web/qr",
+         "/api/auth/wechat-web/poll",
+         "/api/auth/internal/",
+         "/api/product/list",
+         "/api/product/detail",
+         "/api/product/type-list",
+         "/api/mini-game/list",
+         "/api/admin-auth/send-code",
+         "/api/admin-auth/login",
+         "/api/admin-auth/login-by-password",
+         "/api/admin-auth/account-roles",
+         "/api/articles/list",
+         "/api/articles/detail",
+         "/api/articles/categories",
+         "/api/articles/featured",
+         "/api/articles/record-read",
+         "/api/articles/related-products",
+         "/api/growth/**",
+         "/api/activity/list",
+         "/api/activity/detail",
+         "/api/activity/order/notify",
+         "/api/content-sections/visible",
+         "/api/mind/alert/active",
+         "/api/mind/alert/checkin-alerts",
+         "/api/mind/traditional/mirror",
+         "/api/mind/traditional/compatibility",
+         "/api/mcp/**"
+     };
 
     // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)
     private static final String[] LOGIN_REQUIRED_PATHS = {

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -117,6 +117,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/categories",
                 "/api/articles/featured",
                 "/api/articles/record-read",
+                "/api/articles/related-products",
                 "/api/growth/**",
                 "/api/articles/updated-since",
                 "/api/activity/list",
@@ -162,6 +163,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/categories",
                 "/api/articles/featured",
                 "/api/articles/record-read",
+                "/api/articles/related-products",
                 "/api/growth/**",
                 "/api/activity/list",
                 "/api/activity/detail",

+ 16 - 8
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java

@@ -92,18 +92,26 @@ public class HealthExerciseController {
         // ===== 任务回写挂钩 =====
         try {
             if (record.getTaskId() != null) {
-                // 一次性任务:直接完成
                 Task linkedTask = taskMapper.selectById(record.getTaskId());
                 if (linkedTask != null) {
-                    taskService.completeTask(
-                            record.getTaskId(),
-                            record.getMemberId(),
-                            record.getPhotoUrls() != null ? record.getPhotoUrls() : "",
-                            record.getRemark() != null ? record.getRemark() : "运动打卡",
-                            "text");
+                    if (linkedTask.getIsDailyProgress() != null && linkedTask.getIsDailyProgress() == 1) {
+                        // 每日进度型任务:累计进度(taskKey=EXERCISE)
+                        com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
+                        if (fm != null && fm.getUserId() != null) {
+                            taskService.updateProgress(fm.getUserId(), "EXERCISE", 1);
+                        }
+                    } else {
+                        // 一次性任务:直接完成
+                        taskService.completeTask(
+                                record.getTaskId(),
+                                record.getMemberId(),
+                                record.getPhotoUrls() != null ? record.getPhotoUrls() : "",
+                                record.getRemark() != null ? record.getRemark() : "运动打卡",
+                                "text");
+                    }
                 }
             } else {
-                // 每日进度型任务:累计进度
+                // 未带 taskId:累计今日每日运动打卡任务进度(无则 log.warn 跳过)
                 com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                 if (fm != null && fm.getUserId() != null) {
                     taskService.updateProgress(fm.getUserId(), "EXERCISE", 1);

+ 16 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -254,15 +254,24 @@ public class HealthMealController {
                 if (record.getTaskId() != null) {
                     Task linkedTask = taskMapper.selectById(record.getTaskId());
                     if (linkedTask != null) {
-                        taskService.completeTask(
-                                record.getTaskId(),
-                                record.getMemberId(),
-                                record.getPhotoUrls(),
-                                record.getRemark(),
-                                "text");
+                        if (linkedTask.getIsDailyProgress() != null && linkedTask.getIsDailyProgress() == 1) {
+                            // 每日进度型任务:累计进度(taskKey=DIET)
+                            com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
+                            if (fm != null && fm.getUserId() != null) {
+                                taskService.updateProgress(fm.getUserId(), "DIET", 1);
+                            }
+                        } else {
+                            // 一次性任务:直接完成
+                            taskService.completeTask(
+                                    record.getTaskId(),
+                                    record.getMemberId(),
+                                    record.getPhotoUrls(),
+                                    record.getRemark(),
+                                    "text");
+                        }
                     }
                 } else {
-                    // 每日进度型任务:累计进度
+                    // 未带 taskId:仍累计今日每日打卡任务进度(无则 log.warn 跳过)
                     com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                     if (fm != null && fm.getUserId() != null) {
                         taskService.updateProgress(fm.getUserId(), "DIET", 1);

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

@@ -31,7 +31,7 @@ public class HealthPlanController {
 
     @Operation(summary = "生成健康方案(文本)")
     @PostMapping("/generate")
-    public Result<String> generate(@RequestBody Map<String, Object> params,
+    public Result<Object> generate(@RequestBody Map<String, Object> params,
                                    @RequestAttribute("userId") Long userId,
                                    @RequestAttribute(value = "familyId", required = false) Long familyId) {
         // 付费墙拦截:家庭会员 或 健康启航计划 任一有效即可生成
@@ -44,15 +44,15 @@ public class HealthPlanController {
         if (!isMember && !Boolean.TRUE.equals(purchaseCheck.get("isActive"))) {
             return Result.error(4001, "请升级为家庭会员或购买健康启航计划");
         }
-        
+
         String memberIds = (String) params.get("memberIds");
         String dimensions = (String) params.get("dimensions");
         String goal = (String) params.get("goal");
         if (dimensions == null || goal == null || goal.trim().isEmpty()) {
             return Result.error("dimensions和goal不能为空");
         }
-        String planContent = healthPlanService.generatePlan(familyId, memberIds, dimensions, goal);
-        return Result.success(planContent);
+        Object result = healthPlanService.generatePlanResult(familyId, memberIds, dimensions, goal);
+        return Result.success(result);
     }
 
     @Operation(summary = "重新生成方案某一部分(营养/饮食/运动)")

+ 71 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -34,6 +34,7 @@ import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.MemberIndicatorCurrentService;
 import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
@@ -171,6 +172,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.PurchaseService purchaseService;
 
+    @Resource
+    private MemberIndicatorCurrentService memberIndicatorCurrentService;
+
     @Resource
     private com.etotem.cfc.service.HealthPlanService healthPlanService;
 
@@ -309,7 +313,7 @@ public class HealthReportController {
         Long memberId = ParamUtils.getLong(params.get("memberId"));
         List<HealthReport> reports;
         if (memberId != null) {
-            reports = healthReportService.getMemberReports(memberId);
+            reports = healthReportService.getMemberReports(memberId, familyId);
         } else if (familyId != null) {
             // 未指定 memberId 时,返回当前家庭的全部报告
             reports = healthReportService.getFamilyReports(familyId);
@@ -414,6 +418,55 @@ public class HealthReportController {
             return Result.success(java.util.Collections.emptyList());
         }
     }
+    /**
+     * [admin] 回填成员指标当前值 diff(对存量未走新确认流程的报告手动触发)
+     * 入参: memberId(必填);调用 healthReportService.rebuildForMember
+     */
+    @Operation(summary = "回填成员指标当前值(admin)")
+    @PostMapping("/indicators/rebuild")
+    public Result<Map<String, Object>> rebuildIndicatorCurrent(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "role", required = false) String role) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"));
+        if (memberId == null) return Result.error("memberId不能为空");
+        if (!"admin".equals(role)) return Result.error("权限不足");
+        try {
+            healthReportService.rebuildForMember(memberId);
+            Map<String, Object> out = new java.util.HashMap<>();
+            out.put("memberId", memberId);
+            out.put("rebuilt", true);
+            return Result.success(out);
+        } catch (Exception e) {
+            log.warn("回填指标当前值失败 memberId={}: {}", memberId, e.getMessage());
+            return Result.error("回填失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取成员指标当前值 + 相对上一份报告的变化 delta(用于指标卡片与方案生成)
+     * 数据来源:member_indicator_current(报告确认/编辑时由 refreshMemberIndicatorCurrent 写入)
+     */
+    @Operation(summary = "获取成员指标当前值与变化")
+    @PostMapping("/indicators/current")
+    public Result<List<Map<String, Object>>> getMemberIndicatorCurrent(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        String domain = (String) params.get("domain");
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        try {
+            List<Map<String, Object>> list = memberIndicatorCurrentService.getCurrentIndicators(memberId, domain);
+            return Result.success(list);
+        } catch (Exception e) {
+            log.warn("获取成员指标当前值失败 memberId={}: {}", memberId, e.getMessage());
+            return Result.success(java.util.Collections.emptyList());
+        }
+    }
+
 
     /**
      * 获取孩子身体维度数据详情(健康指标解读)
@@ -1266,6 +1319,12 @@ public class HealthReportController {
                 } catch (Exception e) {
                     log.warn("刷新7维健康评分失败: {}", e.getMessage());
                 }
+                try {
+                    healthReportService.refreshMemberIndicatorCurrent(subjectId, created.getId());
+                    log.info("已刷新成员{}的指标当前值diff", subjectId);
+                } catch (Exception e) {
+                    log.warn("刷新指标当前值diff失败: {}", e.getMessage());
+                }
                 try {
                     foodRecommendService.recalculateForUser(subjectId);
                     log.info("已刷新成员{}的食材推荐指数", subjectId);
@@ -1283,6 +1342,12 @@ public class HealthReportController {
                     } catch (Exception e) {
                         log.warn("刷新7维健康评分失败: {}", e.getMessage());
                     }
+                    try {
+                        healthReportService.refreshMemberIndicatorCurrent(matchedId, created.getId());
+                        log.info("已刷新成员{}的指标当前值diff", matchedId);
+                    } catch (Exception e) {
+                        log.warn("刷新指标当前值diff失败: {}", e.getMessage());
+                    }
                     try {
                         foodRecommendService.recalculateForUser(matchedId);
                         log.info("已刷新成员{}的食材推荐指数", matchedId);
@@ -2164,7 +2229,8 @@ public class HealthReportController {
         HealthReport report = new HealthReport();
         report.setUserId(draft.getUserId());
         report.setFamilyId(draft.getFamilyId());
-        report.setSubjectId(subjectId);
+        // A方案:subject_id 统一为 family_members.id 口径(users.id 归一化到成员卡)
+        report.setSubjectId(healthReportService.resolveSubjectToMemberId(subjectId, draft.getFamilyId()));
         report.setReportType(draft.getReportType());
         report.setFileUrl(draft.getFileUrl());
         report.setReportFormat(payload.getReportFormat());
@@ -2404,6 +2470,8 @@ public class HealthReportController {
      * 检查是否为授权代上传角色
      */
     private boolean isProxyUploader(String role, List<String> roles, User user) {
+        // 超级管理员(admin):平台最高权限,可代传任何报告
+        if ("admin".equals(role) || (roles != null && roles.contains("admin"))) return true;
         if ("teacher".equals(role)) return true;
         if (roles != null && roles.contains("teacher")) return true;
         if (user != null && "approved".equals(user.getNutritionistStatus())) return true;
@@ -2433,7 +2501,7 @@ public class HealthReportController {
 
         User user = userMapper.selectById(userId);
         if (!isProxyUploader(role, roles, user)) {
-            return Result.error("无权限代上传报告,仅规划师/营养师可操作");
+            return Result.error("无权限代上传报告,仅超级管理员/规划师/营养师可操作");
         }
 
         if (familyId == null) {

+ 16 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java

@@ -72,15 +72,24 @@ public class HealthSleepController {
             if (record.getTaskId() != null) {
                 Task linkedTask = taskMapper.selectById(record.getTaskId());
                 if (linkedTask != null) {
-                    taskService.completeTask(
-                            record.getTaskId(),
-                            record.getMemberId(),
-                            "",
-                            record.getRemark() != null ? record.getRemark() : "睡眠打卡",
-                            "text");
+                    if (linkedTask.getIsDailyProgress() != null && linkedTask.getIsDailyProgress() == 1) {
+                        // 每日进度型任务:累计进度(taskKey=SLEEP)
+                        com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
+                        if (fm != null && fm.getUserId() != null) {
+                            taskService.updateProgress(fm.getUserId(), "SLEEP", 1);
+                        }
+                    } else {
+                        // 一次性任务:直接完成
+                        taskService.completeTask(
+                                record.getTaskId(),
+                                record.getMemberId(),
+                                "",
+                                record.getRemark() != null ? record.getRemark() : "睡眠打卡",
+                                "text");
+                    }
                 }
             } else {
-                // 每日进度型任务:累计进度
+                // 未带 taskId:累计今日每日睡眠打卡任务进度(无则 log.warn 跳过)
                 com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                 if (fm != null && fm.getUserId() != null) {
                     taskService.updateProgress(fm.getUserId(), "SLEEP", 1);

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -62,6 +62,7 @@ public class HealthStatusController {
         if (params.get("diseaseHistory") != null) status.setDiseaseHistory(params.get("diseaseHistory").toString());
         if (params.get("medications") != null) status.setMedications(params.get("medications").toString());
         if (params.get("notes") != null) status.setNotes(params.get("notes").toString());
+        if (params.get("lifestyle") != null) status.setLifestyle(params.get("lifestyle").toString());
         if (isEmpty(status)) {
             return Result.error("请至少填写一项");
         }
@@ -79,7 +80,7 @@ public class HealthStatusController {
         return s.getHeightCm() == null && s.getWeightKg() == null
                 && s.getBloodPressure() == null && s.getBloodGlucose() == null
                 && s.getBloodLipids() == null && s.getDiseaseHistory() == null
-                && s.getMedications() == null && s.getNotes() == null;
+                && s.getMedications() == null && s.getNotes() == null && s.getLifestyle() == null;
     }
 
     /** 数值校验,返回错误信息或 null */

+ 133 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminUnifiedKnowledgeController.java

@@ -0,0 +1,133 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.UnifiedKnowledgeBaseService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+/**
+ * 统一知识库管理 Controller
+ * 管理端在线编辑 knowledge_base_section 各模块 JSON,写库后热重载(无需重启)。
+ * 权限:平台角色(admin/operator/finance) + 知识管理员 + 管家。
+ */
+@Slf4j
+@Tag(name = "统一知识库管理")
+@RestController
+@RequestMapping("/api/admin/knowledge-base/unified")
+public class AdminUnifiedKnowledgeController {
+
+    @Resource
+    private UnifiedKnowledgeBaseService unifiedKnowledgeBaseService;
+
+    /** 允许维护统一知识库的角色(knowledge_admin 门户中知识库归其管理;admin/operator 平台角色放行) */
+    private static final Set<String> ALLOWED_ROLES = new HashSet<>(Arrays.asList(
+            "admin", "operator", "finance", "knowledge_admin", "butler"));
+
+    @SuppressWarnings("unchecked")
+    private List<String> roles(HttpServletRequest request) {
+        Object rolesAttr = request.getAttribute("roles");
+        if (rolesAttr instanceof List) {
+            return (List<String>) rolesAttr;
+        }
+        Object roleAttr = request.getAttribute("role");
+        if (roleAttr != null) {
+            return Collections.singletonList(roleAttr.toString());
+        }
+        return Collections.emptyList();
+    }
+
+    private boolean allowed(HttpServletRequest request) {
+        List<String> r = roles(request);
+        if (r == null || r.isEmpty()) {
+            return false;
+        }
+        for (String raw : r) {
+            if (ALLOWED_ROLES.contains(AdminRoles.normalize(raw))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Operation(summary = "统一知识库-模块概览(管理端:键/名称/大小/版本/更新时间/加载状态)")
+    @PostMapping("/overview")
+    public Result<List<Map<String, Object>>> overview(HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        return Result.success(unifiedKnowledgeBaseService.getModuleOverviewDetailed());
+    }
+
+    @Operation(summary = "统一知识库-按模块获取原始内容")
+    @PostMapping("/section")
+    public Result<Map<String, Object>> section(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        String moduleKey = params.get("moduleKey");
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            return Result.error("moduleKey不能为空");
+        }
+        Map<String, Object> data = unifiedKnowledgeBaseService.getSection(moduleKey);
+        if (data == null) {
+            return Result.error("模块不存在或内容为空: " + moduleKey);
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "统一知识库-更新模块内容(写库+热重载)")
+    @PostMapping("/update")
+    public Result<Map<String, Object>> update(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        String moduleKey = params.get("moduleKey") != null ? params.get("moduleKey").toString() : null;
+        String moduleName = params.get("moduleName") != null ? params.get("moduleName").toString() : null;
+        Object content = params.get("content");
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            return Result.error("moduleKey不能为空");
+        }
+        if (content == null) {
+            return Result.error("content不能为空");
+        }
+        try {
+            Map<String, Object> r = unifiedKnowledgeBaseService.updateModule(moduleKey, moduleName, content);
+            log.info("统一知识库模块更新: {} 成功, size={}", moduleKey, r.get("size"));
+            return Result.success("模块已更新并热加载生效", r);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("统一知识库模块更新异常: " + moduleKey, e);
+            return Result.error("更新失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "统一知识库-从classpath种子重置模块(写库+热重载)")
+    @PostMapping("/reset")
+    public Result<Map<String, Object>> reset(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        String moduleKey = params.get("moduleKey");
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            return Result.error("moduleKey不能为空");
+        }
+        try {
+            Map<String, Object> r = unifiedKnowledgeBaseService.resetModuleFromClasspath(moduleKey);
+            log.info("统一知识库模块重置: {} 成功, size={}", moduleKey, r.get("size"));
+            return Result.success("模块已从种子重置并热加载生效", r);
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            return Result.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("统一知识库模块重置异常: " + moduleKey, e);
+            return Result.error("重置失败: " + e.getMessage());
+        }
+    }
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -9,6 +9,7 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.ArticleCategoryService;
 import com.etotem.cfc.service.ArticleAudioService;
 import com.etotem.cfc.service.ArticleService;
+import com.etotem.cfc.service.ProductRecommendationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -38,6 +39,30 @@ public class ArticleController {
     @Resource
     private ArticleAudioService articleAudioService;
 
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
+    @PostMapping("/related-products")
+    public Result<List<Map<String, Object>>> relatedProducts(@RequestBody Map<String, Object> body,
+                                                             @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long articleId = ParamUtils.getLong(body.get("articleId"));
+        if (articleId == null) {
+            return Result.error("articleId不能为空");
+        }
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 6;
+
+        Long familyId = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+            }
+        }
+
+        List<Map<String, Object>> products = productRecommendationService.getArticleRelatedProducts(articleId, userId, familyId, size);
+        return Result.success(products);
+    }
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
                                        @RequestAttribute(value = "userId", required = false) Long userId,

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/nutrition/NutritionProductController.java

@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -82,4 +83,21 @@ public class NutritionProductController {
         }
         return Result.success(nutritionProductService.priceCompare(productId));
     }
+
+    @Operation(summary = "根据主平台商品ID创建营养产品条目")
+    @PostMapping("/from-product")
+    public Result<Map<String, Object>> addNutritionProductFromProduct(@RequestBody Map<String, Long> params) {
+        Long productId = params.get("productId");
+        if (productId == null) {
+            return Result.error("productId 不能为空");
+        }
+        boolean success = nutritionProductService.addNutritionProductFromProduct(productId);
+        if (!success) {
+            return Result.error("创建失败");
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("message", "营养产品条目创建成功");
+        result.put("productId", productId);
+        return Result.success(result);
+    }
 }

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ContentByWeightController.java

@@ -0,0 +1,66 @@
+package com.etotem.cfc.controller.recommendation;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.UserContentWeight;
+import com.etotem.cfc.service.UserContentWeightService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 内容偏好权重接口
+ * 查询用户/家庭对商品、活动、文章的偏好权重记录
+ */
+@RestController
+@RequestMapping("/api/recommend")
+public class ContentByWeightController {
+
+    @Resource
+    private UserContentWeightService userContentWeightService;
+
+    /**
+     * 查询内容偏好权重
+     * 请求体: { contentType, itemId, familyId(可选) }
+     * contentType: product / activity / article
+     * 家庭级聚合: 不传 familyId 时使用登录用户所在家庭
+     */
+    @PostMapping("/content-by-weight")
+    public Result<List<UserContentWeight>> getContentByWeight(@RequestBody Map<String, Object> params,
+                                                              @RequestAttribute(value = "userId") Long userId,
+                                                              @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        String contentType = (String) params.get("contentType");
+        Object itemIdObj = params.get("itemId");
+        Long itemId = itemIdObj != null ? toLong(itemIdObj) : null;
+
+        // 家庭ID:优先请求体,其次登录态
+        Object familyIdObj = params.get("familyId");
+        Long effectiveFamilyId = familyIdObj != null ? toLong(familyIdObj) : familyId;
+
+        if (contentType == null || itemId == null || effectiveFamilyId == null) {
+            return Result.error("参数不完整: 需要 contentType / itemId / familyId");
+        }
+
+        List<UserContentWeight> weights = userContentWeightService.listWeights(
+                null, effectiveFamilyId, contentType, itemId);
+        return Result.success(weights);
+    }
+
+    private Long toLong(Object obj) {
+        if (obj == null) return null;
+        if (obj instanceof Number) return ((Number) obj).longValue();
+        if (obj instanceof String) {
+            try {
+                return Long.parseLong((String) obj);
+            } catch (Exception e) {
+                return null;
+            }
+        }
+        return null;
+    }
+}

+ 21 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java

@@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -43,8 +44,26 @@ public class ProductRecommendationController {
         Object limitObj = params.get("limit");
         int limit = limitObj != null ? toInt(limitObj) : 6;
 
-        List<Map<String, Object>> result = productRecommendationService.getDimensionRecommendations(
-                dimensionCode, familyId, memberId, excludeIds, limit, userId);
+        // 分页支持:page + size(不传 page 时保持原有 limit 行为,向后兼容)
+        Object pageObj = params.get("page");
+        int page = pageObj != null ? Math.max(1, toInt(pageObj)) : 1;
+        Object sizeObj = params.get("size");
+        int size = sizeObj != null ? Math.max(1, toInt(sizeObj)) : limit;
+
+        // 取全量候选(limit = page * size),再按页切片,保证翻页不重复、不遗漏
+        int fetchLimit = page * size;
+        List<Map<String, Object>> full = productRecommendationService.getDimensionRecommendations(
+                dimensionCode, familyId, memberId, excludeIds, fetchLimit, userId);
+
+        List<Map<String, Object>> result;
+        if (pageObj == null) {
+            // 未传 page:保持原有语义(取前 limit 条)
+            result = full.size() > limit ? full.subList(0, limit) : full;
+        } else {
+            int from = (page - 1) * size;
+            int to = Math.min(from + size, full.size());
+            result = from >= full.size() ? Collections.emptyList() : full.subList(from, to);
+        }
 
         return Result.success(result);
     }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java

@@ -19,4 +19,13 @@ public class DietPreferencesDTO {
     private Integer goalConfirmed;
     private String filledAt;
     private Boolean noPreference;
+
+    /** 每日蔬果量(单选,如 '<2份'/'2-3份'/'4份以上') */
+    private String vegetableFruit;
+
+    /** 乳制品频率(单选,如 '每天'/'每周'/'很少'/'不喝') */
+    private String dairy;
+
+    /** 含糖饮料频率(单选,如 '几乎不喝'/'每周1-2次'/'每天1次以上') */
+    private String sugaryDrinks;
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -37,6 +37,8 @@ public class ProductDTO {
     private String minDiscountLevel;       // 享受会员价所需的最低会员级别,NULL=所有会员均可
     private Boolean vendorVisible;         // 是否仅供应商体系内用户可见(列表接口生效)
     private Integer isRecommended;         // 是否推荐商品: 0=否 1=是(推荐时同步至营养产品)
+    /** 商品标签,逗号或JSON数组,用于文章详情页「相关商品」标签匹配推荐 */
+    private String tags;
     private List<String> imageList;
     private Long energyDimensionId;
     private Integer effectivePpoint;     // 有效P点(基点,如1000=10%)
@@ -129,6 +131,7 @@ public class ProductDTO {
         d.minDiscountLevel = p.getMinDiscountLevel();
         d.vendorVisible = p.getVendorVisible();
         d.isRecommended = p.getIsRecommended();
+        d.tags = p.getTags();
         d.createdAt = p.getCreatedAt();
         d.updatedAt = p.getUpdatedAt();
         // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java

@@ -1,10 +1,17 @@
 package com.etotem.cfc.dto;
 
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
 import java.util.List;
 
+/**
+ * 五行溯源建议 VO。
+ * 兼容 AI 返回的字段:生我者(upstream 系)、克我者(restrainer 系)与行动建议(action)。
+ * 未知字段忽略,避免 AI 输出新增字段导致整体解析失败。
+ */
 @Data
+@JsonIgnoreProperties(ignoreUnknown = true)
 public class WuxingSourcingAdviceVO {
 
     private String dimension;
@@ -30,4 +37,31 @@ public class WuxingSourcingAdviceVO {
     /** 是否使用降级静态建议 */
     @JsonProperty("fallbackUsed")
     private Boolean fallbackUsed;
+
+    // ===== AI 五行生克链字段(prompt 输出,VO 同步适配) =====
+
+    /** 生我者(上游)维度 */
+    private String upstreamDimension;
+    /** 生我者(上游)名称 */
+    private String upstreamName;
+    /** 生我者(上游)元素 */
+    private String upstreamElement;
+    /** 生我者(上游)颜色 */
+    private String upstreamColor;
+    /** 生我者(上游)理由 */
+    private String upstreamReason;
+
+    /** 克我者(制约)维度 */
+    private String restrainerDimension;
+    /** 克我者(制约)名称 */
+    private String restrainerName;
+    /** 克我者(制约)元素 */
+    private String restrainerElement;
+    /** 克我者(制约)颜色 */
+    private String restrainerColor;
+    /** 克我者(制约)理由 */
+    private String restrainerReason;
+
+    /** 具体行动建议 */
+    private String action;
 }

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

@@ -25,6 +25,10 @@ public class DietPreferences implements Serializable {
     private String goalSource;
     private Integer goalConfirmed;
     private Boolean noPreference;
+
+    /** 饮食结构现状:{vegetableFruit, dairy, sugaryDrinks} */
+    private String dietStructure;
+
     private Date filledAt;
     private Date updatedAt;
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java

@@ -46,6 +46,9 @@ public class HealthStatus implements Serializable {
     /** 其他补充说明 */
     private String notes;
 
+    /** 生活方式与肠道健康:{mood, sleepDuration, bedtime, bowelFrequency, bowelForm, antibioticHistory, allergySymptoms} */
+    private String lifestyle;
+
     /** 首次填写时间 */
     private Date filledAt;
 

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MemberIndicatorCurrent.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@TableName("member_indicator_current")
+public class MemberIndicatorCurrent {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long definitionId;
+    private String domain;
+    private String category;
+    private String name;
+    private String value;
+    private BigDecimal numericValue;
+    private String previousValue;
+    private BigDecimal previousNumeric;
+    private BigDecimal delta;
+    private String trend;
+    private Long latestReportId;
+    private Long previousReportId;
+    private Date reportDate;
+    private Date recordedAt;
+    private Date updatedAt;
+    // getters/setters omitted for brevity - use Lombok or explicit
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
+    public Long getDefinitionId() { return definitionId; }
+    public void setDefinitionId(Long definitionId) { this.definitionId = definitionId; }
+    public String getDomain() { return domain; }
+    public void setDomain(String domain) { this.domain = domain; }
+    public String getCategory() { return category; }
+    public void setCategory(String category) { this.category = category; }
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+    public String getValue() { return value; }
+    public void setValue(String value) { this.value = value; }
+    public BigDecimal getNumericValue() { return numericValue; }
+    public void setNumericValue(BigDecimal numericValue) { this.numericValue = numericValue; }
+    public String getPreviousValue() { return previousValue; }
+    public void setPreviousValue(String previousValue) { this.previousValue = previousValue; }
+    public BigDecimal getPreviousNumeric() { return previousNumeric; }
+    public void setPreviousNumeric(BigDecimal previousNumeric) { this.previousNumeric = previousNumeric; }
+    public BigDecimal getDelta() { return delta; }
+    public void setDelta(BigDecimal delta) { this.delta = delta; }
+    public String getTrend() { return trend; }
+    public void setTrend(String trend) { this.trend = trend; }
+    public Long getLatestReportId() { return latestReportId; }
+    public void setLatestReportId(Long latestReportId) { this.latestReportId = latestReportId; }
+    public Long getPreviousReportId() { return previousReportId; }
+    public void setPreviousReportId(Long previousReportId) { this.previousReportId = previousReportId; }
+    public Date getReportDate() { return reportDate; }
+    public void setReportDate(Date reportDate) { this.reportDate = reportDate; }
+    public Date getRecordedAt() { return recordedAt; }
+    public void setRecordedAt(Date recordedAt) { this.recordedAt = recordedAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionAgeDosage.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("nutrition_age_dosage")
+public class NutritionAgeDosage implements Serializable {
+    
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    
+    private Integer ageMinMonths;
+    private Integer ageMaxMonths;
+    
+    /** 成分名称 */
+    private String ingredientName;
+    
+    /** 每份剂量(毫升/次) */
+    private BigDecimal amountPerServing;
+    
+    /** 单位(如毫升、毫克、片、次) */
+    private String unit;
+    
+    /** 功效说明 */
+    private String purpose;
+    
+    private Date createTime;
+    private Date updateTime;
+}

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionProduct.java

@@ -68,7 +68,9 @@ public class NutritionProduct implements Serializable {
     /** 规格 */
     private String spec;
 
-    private Date createTime;
+    /** 成分功效标签(JSON数组,如 ["补钙","补锌","益生菌","维生素D"]) */
+    private String efficacyTags;
 
+    private Date createTime;
     private Date updateTime;
 }

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionUlLimit.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("nutrition_ul_limit")
+public class NutritionUlLimit implements Serializable {
+    
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    
+    private Long productId;
+    private String ingredientType;
+    private String nutrientName;
+    private String ageMinMonths;
+    private String ageMaxMonths;
+    private BigDecimal limitValue;
+    private String unit;
+    private String description;
+    private Date createTime;
+    private Date updateTime;
+    
+    public NutritionUlLimit() {}
+    
+    public NutritionUlLimit(Long productId, String ingredientType, String nutrientName, 
+                           String ageMinMonths, String ageMaxMonths, 
+                            BigDecimal limitValue, String unit, String description) {
+        this.productId = productId;
+        this.ingredientType = ingredientType;
+        this.nutrientName = nutrientName;
+        this.ageMinMonths = ageMinMonths;
+        this.ageMaxMonths = ageMaxMonths;
+        this.limitValue = limitValue;
+        this.unit = unit;
+        this.description = description;
+        this.createTime = new Date();
+        this.updateTime = new Date();
+    }
+}

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

@@ -75,6 +75,9 @@ public class Product implements Serializable {
     /** 是否推荐商品: 0=否 1=是(推荐时同步至营养产品表) */
     private Integer isRecommended;
 
+    /** JSON: ["标签1","标签2"],用于文章-商品标签匹配推荐 */
+    private String tags;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 
@@ -121,6 +124,7 @@ public class Product implements Serializable {
     public String getMinVisibleLevel() { return minVisibleLevel; }
     public String getMinDiscountLevel() { return minDiscountLevel; }
     public Boolean getVendorVisible() { return vendorVisible; }
+    public String getTags() { return tags; }
     public Date getCreatedAt() { return createdAt; }
     public Date getUpdatedAt() { return updatedAt; }
 }

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserContentWeight.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 用户内容偏好权重表(user_content_weights)
+ * 用于记录用户/家庭对某条内容(商品/活动/文章)的偏好权重,供推荐排序使用。
+ */
+@Data
+@TableName("user_content_weights")
+public class UserContentWeight implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID(users.id) */
+    private Long userId;
+
+    /** 家庭ID */
+    private Long familyId;
+
+    /** 内容类型: product/activity/article */
+    private String contentType;
+
+    /** 内容ID(商品ID/活动ID/文章ID) */
+    private Long itemId;
+
+    /** 维度编码: body/mind/wisdom/action/wealth */
+    private String dimensionCode;
+
+    /** 权重值 0.00-10.00,越大越优先 */
+    private BigDecimal weight;
+
+    /** 权重来源: user_set/auto_click/auto_purchase/manual/auto_profile */
+    private String reason;
+
+    /** 创建人ID(manual场景记录运营人员) */
+    private Long createdBy;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 11 - 2
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportDraftMapper.java

@@ -37,14 +37,23 @@ public interface HealthReportDraftMapper extends BaseMapper<HealthReportDraft> {
             "WHERE id = #{draftId} AND (next_retry_at IS NULL OR next_retry_at <= #{now})")
     int claimRetry(@Param("draftId") Long draftId, @Param("now") Date now);
 
+    /**
+     * 原子抢占报告插入槽位:仅当草稿仍在采集中且尚未确认入库时,将 parse_status 改为 'inserting',
+     * 防止并发的重跑任务在同一份草稿上重复调用 insertReport 创建多份报告。
+     * 抢到(rows==1)后由调用方执行 insertReport;insertReport 完成会标记 completed。
+     */
+    @Update("UPDATE health_report_drafts SET parse_status = 'inserting' " +
+            "WHERE id = #{draftId} AND parse_status = 'collecting' AND confirmed_report_id IS NULL")
+    int tryClaimInsertSlot(@Param("draftId") Long draftId);
+
     /**
      * 查询待重试草稿:状态 pending + 采集中 + (已到 next_retry_at,或从未有过 next_retry_at 且
-     * 最近一次解析开始超过10分钟——用于兜底卡死/丢失的采集任务),按 updated_at 升序取前 N 条。
+     * 最近一次解析开始超过30分钟——用于兜底卡死/丢失的采集任务),按 updated_at 升序取前 N 条。
      */
     @Select("SELECT * FROM health_report_drafts " +
             "WHERE status = 'pending' AND parse_status = 'collecting' " +
             "AND ((next_retry_at IS NOT NULL AND next_retry_at <= #{now}) " +
-            "OR (next_retry_at IS NULL AND (parse_started_at IS NULL OR parse_started_at < DATE_SUB(#{now}, INTERVAL 10 MINUTE)))) " +
+            "OR (next_retry_at IS NULL AND (parse_started_at IS NULL OR parse_started_at < DATE_SUB(#{now}, INTERVAL 30 MINUTE)))) " +
             "ORDER BY updated_at ASC LIMIT #{limit}")
     List<HealthReportDraft> listPendingRetry(@Param("now") Date now, @Param("limit") int limit);
 }

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MemberIndicatorCurrentMapper.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.MemberIndicatorCurrent;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+import java.util.List;
+
+@Mapper
+public interface MemberIndicatorCurrentMapper {
+    @Select("SELECT * FROM member_indicator_current WHERE member_id = #{memberId} AND domain = #{domain} ORDER BY updated_at DESC")
+    List<MemberIndicatorCurrent> selectByMemberAndDomain(@Param("memberId") Long memberId, @Param("domain") String domain);
+
+    @Select("SELECT * FROM member_indicator_current WHERE member_id = #{memberId} AND definition_id = #{definitionId}")
+    MemberIndicatorCurrent selectByMemberAndDefinition(@Param("memberId") Long memberId, @Param("definitionId") Long definitionId);
+
+    @Select("SELECT * FROM member_indicator_current WHERE member_id = #{memberId} ORDER BY domain ASC, name ASC")
+    List<MemberIndicatorCurrent> selectByMemberAll(@Param("memberId") Long memberId);
+
+    @Update("UPDATE member_indicator_current SET value=#{value}, numeric_value=#{numericValue}, previous_value=#{previousValue}, previous_numeric=#{previousNumeric}, delta=#{delta}, trend=#{trend}, latest_report_id=#{latestReportId}, previous_report_id=#{previousReportId}, report_date=#{reportDate}, updated_at=NOW() WHERE id=#{id}")
+    int updateById(MemberIndicatorCurrent item);
+
+    @Update("INSERT INTO member_indicator_current (member_id, definition_id, domain, category, name, value, numeric_value, previous_value, previous_numeric, delta, trend, latest_report_id, previous_report_id, report_date) VALUES (#{memberId}, #{definitionId}, #{domain}, #{category}, #{name}, #{value}, #{numericValue}, #{previousValue}, #{previousNumeric}, #{delta}, #{trend}, #{latestReportId}, #{previousReportId}, #{reportDate}) ON DUPLICATE KEY UPDATE value=#{value}, numeric_value=#{numericValue}, previous_value=#{previousValue}, previous_numeric=#{previousNumeric}, delta=#{delta}, trend=#{trend}, latest_report_id=#{latestReportId}, previous_report_id=#{previousReportId}, report_date=#{reportDate}, updated_at=NOW()")
+    int upsert(MemberIndicatorCurrent item);
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionAgeDosageMapper.java

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

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionUlLimitMapper.java

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

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

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

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

@@ -57,7 +57,16 @@ public class DietPreferencesService {
         entity.setGoalSource(dto.getGoalSource());
         entity.setGoalConfirmed(dto.getGoalConfirmed() != null ? dto.getGoalConfirmed() : 0);
         entity.setNoPreference(dto.getNoPreference() != null ? dto.getNoPreference() : false);
-        
+
+        // 饮食结构现状:{vegetableFruit, dairy, sugaryDrinks}
+        java.util.Map<String, Object> dietStructureMap = new java.util.LinkedHashMap<>();
+        if (dto.getVegetableFruit() != null) dietStructureMap.put("vegetableFruit", dto.getVegetableFruit());
+        if (dto.getDairy() != null) dietStructureMap.put("dairy", dto.getDairy());
+        if (dto.getSugaryDrinks() != null) dietStructureMap.put("sugaryDrinks", dto.getSugaryDrinks());
+        if (!dietStructureMap.isEmpty()) {
+            entity.setDietStructure(toJSON(dietStructureMap));
+        }
+
         if (entity.getId() == null) {
             dietPreferencesMapper.insert(entity);
         } else {
@@ -90,6 +99,17 @@ public class DietPreferencesService {
         dto.setGoalConfirmed(entity.getGoalConfirmed());
         dto.setNoPreference(entity.getNoPreference());
         dto.setFilledAt(entity.getFilledAt() != null ? entity.getFilledAt().toString() : null);
+        // diet_structure JSON → {vegetableFruit, dairy, sugaryDrinks}
+        if (entity.getDietStructure() != null) {
+            try {
+                java.util.Map<String, Object> dsMap = objectMapper.readValue(entity.getDietStructure(), java.util.Map.class);
+                dto.setVegetableFruit(dsMap != null && dsMap.get("vegetableFruit") != null ? String.valueOf(dsMap.get("vegetableFruit")) : null);
+                dto.setDairy(dsMap != null && dsMap.get("dairy") != null ? String.valueOf(dsMap.get("dairy")) : null);
+                dto.setSugaryDrinks(dsMap != null && dsMap.get("sugaryDrinks") != null ? String.valueOf(dsMap.get("sugaryDrinks")) : null);
+            } catch (Exception e) {
+                log.warn("diet_structure 反序列化失败: {}", e.getMessage());
+            }
+        }
         return dto;
     }
 

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

@@ -167,7 +167,12 @@ public class EmiReportService {
 
     private static Map<String, Object> parseStructuredAnalysis(String json) {
         if (json == null || json.isEmpty()) return null;
-        try { return JSON.parseObject(json, Map.class); }
+        String trimmed = json.trim();
+        if (trimmed.startsWith("[")) {
+            // 平铺 items 数组(无 extra 嵌套):非异常,静默返回 null
+            return null;
+        }
+        try { return JSON.parseObject(trimmed, Map.class); }
         catch (Exception e) { return null; }
     }
 }

+ 13 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -1324,12 +1324,23 @@ public class EnergyService {
         return null;
     }
 
-    /** 解析 structuredAnalysis JSON(使用 fastjson) */
+    /**
+     * 解析 structuredAnalysis JSON(使用 fastjson)。
+     * 注意:structured_analysis 实际存储的是 DAN 解析 items 平铺数组
+     * [{code,name,value,category},...](见 DanReportUploadService#confirmUpload)。
+     * 本方法只消费嵌套对象形态(extra.selfDriving/selfConcept 等),
+     * 数组形态无这些数据,静默返回 null 由调用方降级(自驱力/自我概念回 0)。
+     */
     @SuppressWarnings("unchecked")
     private Map<String, Object> parseStructuredAnalysis(String json) {
         if (json == null || json.isEmpty()) return null;
+        String trimmed = json.trim();
+        if (trimmed.startsWith("[")) {
+            // 平铺 items 数组(无 extra 嵌套):非异常,静默返回 null
+            return null;
+        }
         try {
-            return JSON.parseObject(json, Map.class);
+            return JSON.parseObject(trimmed, Map.class);
         } catch (Exception e) {
             log.warn("解析 structuredAnalysis 失败: {}", e.getMessage());
             return null;

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

@@ -780,18 +780,18 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         } catch (Exception e) {
             log.warn("迁移能量值异常: memberId={}", memberId, e);
         }
-        // 健康报告(按 userId 或 subjectId 关联)
+        // 健康报告(A方案:按 subject_id=memberId 关联迁移,members.id 不变则报告自动跟随;
+        // 兼容历史 subject_id=users.id 的报告一并迁移,保持 family_id 与成员所在家庭一致)
         try {
-            if (member.getUserId() != null) {
-                List<HealthReport> reports = healthReportMapper.selectList(
-                        new LambdaQueryWrapper<HealthReport>()
-                                .eq(HealthReport::getFamilyId, oldFamilyId)
-                                .and(w -> w.eq(HealthReport::getUserId, member.getUserId())
-                                        .or().eq(HealthReport::getSubjectId, member.getUserId())));
-                for (HealthReport r : reports) {
-                    r.setFamilyId(targetFamilyId);
-                    healthReportMapper.updateById(r);
-                }
+            List<HealthReport> reports = healthReportMapper.selectList(
+                    new LambdaQueryWrapper<HealthReport>()
+                            .eq(HealthReport::getFamilyId, oldFamilyId)
+                            .and(w -> w.eq(HealthReport::getSubjectId, memberId)
+                                    .or(w2 -> w2.isNull(HealthReport::getSubjectId)
+                                            .eq(HealthReport::getUserId, member.getUserId()))));
+            for (HealthReport r : reports) {
+                r.setFamilyId(targetFamilyId);
+                healthReportMapper.updateById(r);
             }
         } catch (Exception e) {
             log.warn("迁移健康报告异常: memberId={}", memberId, e);

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

@@ -15,6 +15,14 @@ public interface HealthPlanService {
     /** 管理端跨家庭分页查询健康方案 */
     Page<HealthPlan> adminListPlans(Long familyId, String status, String keyword, int page, int size);
     String generatePlan(Long familyId, String memberIds, String dimensions, String goal);
+
+    /**
+     * 生成健康方案并返回结构化结果:
+     * - 单人生成:返回 String(planContent 文本,向后兼容)
+     * - 多人生成:返回 Map({planContent: String, common: Map, individual: Map<memberId, Map>}),
+     *   前端可据此保存 planJson(common/individual 结构),确认时按成员分别生成任务
+     */
+    Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal);
     String regenerateSection(Long familyId, String section, String feedback, String planJsonStr);
     /** 规划师查看待审核方案列表 */
     List<HealthPlan> listPendingReviewPlans(Long familyId, Long teacherId);

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

@@ -293,6 +293,13 @@ public class HealthReportDraftService {
         return draftMapper.claimRetry(draftId, now) == 1;
     }
 
+    /**
+     * 原子抢占报告插入槽位(见 Mapper.tryClaimInsertSlot),防止并发重跑对同一草稿重复入库。
+     */
+    public boolean tryClaimInsertSlot(Long draftId) {
+        return draftMapper.tryClaimInsertSlot(draftId) == 1;
+    }
+
     /**
      * 人工重试重置:清零重试次数与错误,回到 collecting 待采集状态。
      */

+ 256 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -35,7 +35,9 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.math.BigDecimal;
 import java.util.Map;
+import com.etotem.cfc.entity.MemberIndicatorCurrent;
 import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 
@@ -112,6 +114,10 @@ public class HealthReportService {
     @Resource
     private FoodMapper foodMapper;
 
+    @Resource
+    @org.springframework.context.annotation.Lazy
+    private MemberIndicatorCurrentService memberIndicatorCurrentService;
+
     @Resource
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
 
@@ -313,12 +319,24 @@ public class HealthReportService {
      * 先通过 family_members 表归一该成员的两种 id,再用 IN 匹配 subject_id/user_id
      */
     public List<HealthReport> getMemberReports(Long memberId) {
+        return getMemberReports(memberId, null);
+    }
+
+    /**
+     * 获取成员的报告列表(家庭隔离)。
+     *
+     * @param memberId       成员ID(兼容 users.id / family_members.id)
+     * @param viewerFamilyId 查看者家庭ID(来自 JWT)。非空时以其为家庭边界,
+     *                       与 reassignReport 的权限校验保持一致,避免列表展示
+     *                       的报告在「重新分配归属」时被判为「报告不属于该家庭」。
+     */
+    public List<HealthReport> getMemberReports(Long memberId, Long viewerFamilyId) {
         if (memberId == null) {
             return Collections.emptyList();
         }
         Set<Long> ids = new HashSet<>();
         ids.add(memberId);
-        Long familyId = null;
+        Long memberFamilyId = null;
         try {
             FamilyMember fm = familyMemberMapper.selectById(memberId);
             if (fm != null) {
@@ -326,28 +344,28 @@ public class HealthReportService {
                 if (fm.getUserId() != null) {
                     ids.add(fm.getUserId());
                 }
-                familyId = fm.getFamilyId();
+                memberFamilyId = fm.getFamilyId();
             }
             // memberId 可能是 users.id,反查其关联的 family_members 行
             List<FamilyMember> linked = familyMemberMapper.selectList(
                     new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, memberId));
             for (FamilyMember item : linked) {
                 ids.add(item.getId());
-                if (familyId == null) {
-                    familyId = item.getFamilyId();
+                if (memberFamilyId == null) {
+                    memberFamilyId = item.getFamilyId();
                 }
             }
         } catch (Exception e) {
             log.warn("归一家庭成员ID失败 memberId={}: {}", memberId, e.getMessage());
         }
 
-        // 家庭隔离:报告必须归属该成员所在家庭。
-        // 此前只按 subject_id/user_id 匹配、完全不带 family_id 条件,会把其他家庭的
-        // 报告带进成员列表(实测家庭72成员返回7条中5条越界)。小程序据此显示
-        // "取消归属",点击后在 reassignReport 权限校验处抛"报告不属于该家庭"。
-        // 列表侧必须与权限侧遵守同一条家庭边界。
-        if (familyId == null) {
-            log.warn("无法解析成员所属家庭,返回空报告列表 memberId={}", memberId);
+        // 家庭隔离:报告必须归属该家庭成员。列表侧必须与权限侧(reassignReport)遵守同一条家庭边界。
+        // 查看者家庭(JWT)优先,与权限校验一致;为空时退回成员家庭。
+        // 修复:此前以成员家庭为边界,当成员为跨家庭 users.id(多家庭)时与查看者家庭不一致,
+        // 小程序列表展示后可点「取消归属」,却在 reassignReport 处抛「报告不属于该家庭」。
+        Long boundaryFamilyId = (viewerFamilyId != null) ? viewerFamilyId : memberFamilyId;
+        if (boundaryFamilyId == null) {
+            log.warn("无法解析家庭边界,返回空报告列表 memberId={}", memberId);
             return Collections.emptyList();
         }
         // 严格绑定:经 reassignReport 取消归属(subject_id 被清空)的报告不在此列出,
@@ -355,7 +373,7 @@ public class HealthReportService {
         return healthReportMapper.selectList(
                 new LambdaQueryWrapper<HealthReport>()
                         .in(HealthReport::getSubjectId, ids)
-                        .eq(HealthReport::getFamilyId, familyId)
+                        .eq(HealthReport::getFamilyId, boundaryFamilyId)
                         .eq(HealthReport::getStatus, "active")
                         .orderByDesc(HealthReport::getCreatedAt)
         );
@@ -756,7 +774,7 @@ public class HealthReportService {
             String nm = nameToMatch.trim().replaceAll("\\s+", "");
             if (nm.equals(cleanName)) {
                 log.info("User 姓名精确匹配: {} -> userId={}", cleanName, user.getId());
-                return user.getId();
+                return resolveSubjectToMemberId(user.getId(), familyId);
             }
         }
 
@@ -766,7 +784,7 @@ public class HealthReportService {
 
             if (cleanName.contains(nameToMatch) || nameToMatch.contains(cleanName)) {
                 log.info("User 姓名模糊匹配: {} -> userId={}", cleanName, user.getId());
-                return user.getId();
+                return resolveSubjectToMemberId(user.getId(), familyId);
             }
         }
 
@@ -776,7 +794,7 @@ public class HealthReportService {
 
             if (fuzzyMatch(cleanName, nameToMatch)) {
                 log.info("User 姓名fuzzy匹配: {} -> userId={}", cleanName, user.getId());
-                return user.getId();
+                return resolveSubjectToMemberId(user.getId(), familyId);
             }
         }
 
@@ -1210,9 +1228,71 @@ public class HealthReportService {
                 payloadJson, reportId);
     }
 
+    /**
+     * 归一化 subject_id 到 family_members.id 口径(A方案:报告统一挂 member_id)。
+     *
+     * <p>历史 subject_id 混用 users.id / family_members.id:确认/采集/编辑各链路都可能
+     * 把 users.id 直接写入。本方法把"users.id 或跨家庭成员 id"回查为指定家庭内的
+     * family_members.id,保证报告、维度分、食材推荐全部按成员卡统一挂接,
+     * 成员迁移(改 family_id 保留 member_id)后自动跟随。</p>
+     *
+     * @param subjectId 待归一化的被检测人 id(可能是 users.id 或 family_members.id)
+     * @param familyId  报告所属家庭
+     * @return 该家庭内的 family_members.id;无法解析时原样返回
+     */
+    public Long resolveSubjectToMemberId(Long subjectId, Long familyId) {
+        if (subjectId == null || familyId == null) {
+            return subjectId;
+        }
+        try {
+            // 已是本家庭成员 id -> 原样返回
+            FamilyMember direct = familyMemberMapper.selectById(subjectId);
+            if (direct != null && familyId.equals(direct.getFamilyId())) {
+                return subjectId;
+            }
+            // subjectId 是 users.id -> 回查该用户在本家庭的成员行
+            User u = userMapper.selectById(subjectId);
+            if (u != null) {
+                FamilyMember byUser = familyMemberMapper.selectOne(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getFamilyId, familyId)
+                                .eq(FamilyMember::getUserId, subjectId)
+                                .last("LIMIT 1"));
+                if (byUser != null) {
+                    return byUser.getId();
+                }
+            }
+            // subjectId 是跨家庭 member 行 -> 按该成员的 user_id 回查本家庭
+            if (direct != null && direct.getUserId() != null) {
+                FamilyMember same = familyMemberMapper.selectOne(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getFamilyId, familyId)
+                                .eq(FamilyMember::getUserId, direct.getUserId())
+                                .last("LIMIT 1"));
+                if (same != null) {
+                    return same.getId();
+                }
+            }
+        } catch (Exception e) {
+            log.warn("subjectId归一化失败 subjectId={}, familyId={}: {}", subjectId, familyId, e.getMessage());
+        }
+        return subjectId;
+    }
+
     public void bindReportToMember(Long reportId, Long subjectId) {
         // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
         String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
+        // A方案:写库前归一化为 family_members.id 口径
+        if (subjectId != null && subjectId > 0) {
+            HealthReport rep = healthReportMapper.selectById(reportId);
+            if (rep != null) {
+                Long resolved = resolveSubjectToMemberId(subjectId, rep.getFamilyId());
+                if (resolved != null && !resolved.equals(subjectId)) {
+                    log.info("subjectId归一化 reportId={}, {} -> {}", reportId, subjectId, resolved);
+                }
+                subjectId = resolved;
+            }
+        }
         int updatedRows = jdbcTemplate.update(sql, subjectId, reportId);
         if (updatedRows == 0) {
             log.warn("reportId={}, subjectId={} 更新失败 - 报告不存在", reportId, subjectId);
@@ -1249,7 +1329,10 @@ public class HealthReportService {
             throw new RuntimeException("报告不存在");
         }
         
-        // 权限检查:确保报告属于该家庭
+        // 权限检查:确保报告属于该家庭(familyId 为空视为未加入家庭,防 NPE)
+        if (familyId == null) {
+            throw new RuntimeException("请先加入家庭");
+        }
         if (!familyId.equals(report.getFamilyId())) {
             throw new RuntimeException("报告不属于该家庭");
         }
@@ -2072,11 +2155,18 @@ public class HealthReportService {
         List<Map<String, Object>> result = new java.util.ArrayList<>();
         for (IndicatorDefinition def : defs) {
             LambdaQueryWrapper<IndicatorValue> w = new LambdaQueryWrapper<>();
-            w.eq(IndicatorValue::getSourceId, memberId)
+            // 口径:写入端 source_id=报告ID(保留每报告快照),subject_id=成员ID;
+            // 读取端按 subject_id=memberId 查最新观测值
+            w.eq(IndicatorValue::getSubjectId, memberId)
              .eq(IndicatorValue::getDefinitionId, def.getId())
              .orderByDesc(IndicatorValue::getRecordedAt)
              .last("LIMIT 1");
             IndicatorValue latest = indicatorValueMapper.selectOne(w);
+            // B方案 diff:member_indicator_current(member_id+definition_id 唯一)
+            com.etotem.cfc.entity.MemberIndicatorCurrent mic = null;
+            try {
+                mic = memberIndicatorCurrentService.getByMemberAndDefinition(memberId, def.getId());
+            } catch (Exception ignore) { }
             Map<String, Object> item = new HashMap<>();
             item.put("code", def.getCode());
             item.put("name", def.getName());
@@ -2088,12 +2178,33 @@ public class HealthReportService {
                 item.put("numericValue", latest.getNumericValue() != null ? latest.getNumericValue().doubleValue() : null);
                 item.put("remark", latest.getRemark());
                 item.put("recordedAt", latest.getRecordedAt());
+            } else if (mic != null) {
+                // indicator_values 无观测行时回退到 member_indicator_current(B方案 diff 表)
+                item.put("value", mic.getValue());
+                item.put("numericValue", mic.getNumericValue() != null ? mic.getNumericValue().doubleValue() : null);
+                item.put("remark", null);
+                item.put("recordedAt", mic.getUpdatedAt());
             } else {
                 item.put("value", null);
                 item.put("numericValue", null);
                 item.put("remark", null);
                 item.put("recordedAt", null);
             }
+            if (mic != null) {
+                item.put("previousValue", mic.getPreviousValue());
+                java.math.BigDecimal delta = mic.getDelta();
+                item.put("delta", delta != null ? delta.doubleValue() : null);
+                item.put("trend", mic.getTrend());
+                // trendDesc: 前端展示"较上次 ±x"
+                if (delta != null && delta.compareTo(java.math.BigDecimal.ZERO) != 0) {
+                    String arrow = delta.signum() > 0 ? "+" : "";
+                    // stripTrailingZeros: 去掉 decimal(12,4) 带来的尾零 (如 -9.0000 -> -9)
+                    String plain = delta.stripTrailingZeros().toPlainString();
+                    item.put("trendDesc", "较上次 " + arrow + plain);
+                } else {
+                    item.put("trendDesc", "");
+                }
+            }
             result.add(item);
         }
         return result;
@@ -2101,7 +2212,7 @@ public class HealthReportService {
 
     /**
      * 获取成员的最新菌群报告核心指标(用于身页面"肠道菌群"卡片)
-     * 返回:益生菌(core/probiotic)、有害菌(harmful)、核心菌属(taxonomy)各取前3条
+     * 返回:核心菌属、益生菌、有害菌属、病原菌属、其它重要菌属(排除 taxonomy 门/纲/目/科级别分类构成)
      */
     public List<Map<String, Object>> getGutFloraIndicators(Long memberId) {
         LambdaQueryWrapper<HealthReport> reportW = new LambdaQueryWrapper<>();
@@ -2116,7 +2227,7 @@ public class HealthReportService {
 
         LambdaQueryWrapper<HealthGutFlora> w = new LambdaQueryWrapper<>();
         w.eq(HealthGutFlora::getReportId, report.getId())
-         .in(HealthGutFlora::getCategory, "core", "probiotic", "harmful", "other")
+         .in(HealthGutFlora::getCategory, "核心菌属", "益生菌", "有害菌属", "其它重要菌属", "病原菌属")
          .orderByAsc(HealthGutFlora::getSortOrder);
         List<HealthGutFlora> flora = healthGutFloraMapper.selectList(w);
         if (flora.isEmpty()) return Collections.emptyList();
@@ -2136,4 +2247,129 @@ public class HealthReportService {
         }
         return result;
     }
-}
+    /**
+     * 刷新成员指标当前值 diff(member_indicator_current)
+     * 逻辑:
+     *   1. 查该成员最近 2 份按 report_date desc 的确认报告(status=active)
+     *   2. 取第 1 份(最新)与第 2 份(次新)中同名指标
+     *   3. 通过 IndicatorDefinitionMapper 拿 domain/category
+     *   4. 用 numeric 做 delta/trend 计算(字符串 fallback)
+     *   5. upsert 写入 member_indicator_current
+     * 幂等:同一 member_id+definition_id 用 upsert 覆盖
+     */
+    /**
+     * 回填重载:按成员自动找最近 2 份已确认报告并刷新 member_indicator_current
+     * 供 admin 端点 / 启动任务使用;无 latestReportId 也可调用(自动解析)
+     */
+    public void rebuildForMember(Long subjectId) {
+        if (subjectId == null) return;
+        List<HealthReport> recent = healthReportMapper.selectList(
+            new LambdaQueryWrapper<HealthReport>()
+                .eq(HealthReport::getSubjectId, subjectId)
+                .eq(HealthReport::getStatus, "active")
+                .orderByDesc(HealthReport::getReportDate)
+                .orderByDesc(HealthReport::getCreatedAt)
+                .last("LIMIT 1"));
+        if (recent.isEmpty()) {
+            log.info("rebuildForMember: 成员{}无已确认报告,跳过", subjectId);
+            return;
+        }
+        refreshMemberIndicatorCurrent(subjectId, recent.get(0).getId());
+    }
+
+    public void refreshMemberIndicatorCurrent(Long subjectId, Long latestReportId) {
+        if (subjectId == null || latestReportId == null) return;
+        try {
+            // 取该成员最近 2 份报告 (按 report_date desc, 失败则 fallback 到 created_at)
+            List<HealthReport> recent = healthReportMapper.selectList(
+                new LambdaQueryWrapper<HealthReport>()
+                    .eq(HealthReport::getSubjectId, subjectId)
+                    .eq(HealthReport::getStatus, "active")
+                    .orderByDesc(HealthReport::getReportDate)
+                    .orderByDesc(HealthReport::getCreatedAt)
+                    .last("LIMIT 2"));
+            if (recent.isEmpty()) return;
+            HealthReport latest = recent.get(0);
+            HealthReport previous = recent.size() > 1 ? recent.get(1) : null;
+
+            // 最新报告的指标
+            List<HealthIndicator> latestInds = getReportIndicators(latest.getId());
+            if (latestInds.isEmpty()) return;
+
+            // 次新指标的 name→HealthIndicator 映射
+            java.util.Map<String, HealthIndicator> prevMap = new java.util.HashMap<>();
+            if (previous != null) {
+                for (HealthIndicator pi : getReportIndicators(previous.getId())) {
+                    if (pi.getIndicatorName() != null) {
+                        prevMap.put(pi.getIndicatorName().trim(), pi);
+                    }
+                }
+            }
+
+            int written = 0;
+            for (HealthIndicator li : latestInds) {
+                if (li.getIndicatorName() == null) continue;
+                String rawName = li.getIndicatorName().trim();
+
+                // 通过 mapper 找 definition(domain/category 来源)
+                com.etotem.cfc.entity.IndicatorDefinition def = indicatorDefinitionMapper.selectOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<
+                        com.etotem.cfc.entity.IndicatorDefinition>()
+                        .eq(com.etotem.cfc.entity.IndicatorDefinition::getName, rawName)
+                        .eq(com.etotem.cfc.entity.IndicatorDefinition::getStatus, 1)
+                        .last("LIMIT 1"));
+                if (def == null) continue;
+
+                MemberIndicatorCurrent item = new MemberIndicatorCurrent();
+                item.setMemberId(subjectId);
+                item.setDefinitionId(def.getId());
+                item.setDomain(def.getDomain());
+                item.setCategory(def.getCategory());
+                item.setName(rawName);
+                item.setValue(li.getIndicatorValue());
+                item.setLatestReportId(latest.getId());
+                item.setReportDate(latest.getReportDate() != null
+                        ? new java.sql.Date(latest.getReportDate().getTime()) : null);
+
+                // previous 指标(同名)
+                HealthIndicator pi = prevMap.get(rawName);
+                if (pi != null) {
+                    item.setPreviousValue(pi.getIndicatorValue());
+                    item.setPreviousReportId(previous.getId());
+                }
+
+                // numeric 计算
+                BigDecimal currentNum = parseNumericString(li.getIndicatorValue());
+                BigDecimal previousNum = pi != null ? parseNumericString(pi.getIndicatorValue()) : null;
+                item.setNumericValue(currentNum);
+                item.setPreviousNumeric(previousNum);
+                if (currentNum != null && previousNum != null) {
+                    item.setDelta(currentNum.subtract(previousNum));
+                    int cmp = currentNum.compareTo(previousNum);
+                    item.setTrend(cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat"));
+                } else {
+                    item.setTrend(pi != null ? "na" : "new");
+                }
+                item.setRecordedAt(new Date());
+                memberIndicatorCurrentService.upsert(item);
+                written++;
+            }
+            log.info("迁移378 补写完成: member={} latestReport={} prevReport={} written={}",
+                    subjectId, latest.getId(),
+                    previous != null ? previous.getId() : "none", written);
+        } catch (Exception e) {
+            log.warn("迁移378 补写失败 member={} report={}: {}", subjectId, latestReportId, e.getMessage());
+        }
+    }
+
+    /** 安全解析指标值为 BigDecimal(失败返回 null,避免整块报告因单条脏数据卡死)*/
+    private BigDecimal parseNumericString(String s) {
+        if (s == null || s.trim().isEmpty()) return null;
+        try {
+            return new BigDecimal(s.trim().replaceAll("[^0-9.eE+-]", ""));
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+}

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MemberIndicatorCurrentService.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.MemberIndicatorCurrent;
+import com.etotem.cfc.mapper.MemberIndicatorCurrentMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class MemberIndicatorCurrentService {
+    @Resource
+    private MemberIndicatorCurrentMapper mapper;
+
+    public MemberIndicatorCurrent getByMemberAndDefinition(Long memberId, Long definitionId) {
+        return mapper.selectByMemberAndDefinition(memberId, definitionId);
+    }
+
+    public List<MemberIndicatorCurrent> getByMemberAndDomain(Long memberId, String domain) {
+        return mapper.selectByMemberAndDomain(memberId, domain);
+    }
+
+    public List<Map<String, Object>> getCurrentIndicators(Long memberId, String domain) {
+        List<MemberIndicatorCurrent> list;
+        if (domain != null && !domain.isEmpty()) {
+            list = mapper.selectByMemberAndDomain(memberId, domain);
+        } else {
+            list = mapper.selectByMemberAll(memberId);
+        }
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (MemberIndicatorCurrent item : list) {
+            Map<String, Object> m = new java.util.HashMap<>();
+            m.put("name", item.getName());
+            m.put("code", item.getDomain());
+            m.put("category", item.getCategory());
+            m.put("value", item.getValue());
+            m.put("numericValue", item.getNumericValue());
+            m.put("previousValue", item.getPreviousValue());
+            m.put("previousNumeric", item.getPreviousNumeric());
+            m.put("delta", item.getDelta());
+            m.put("trend", item.getTrend());
+            m.put("latestReportId", item.getLatestReportId());
+            m.put("previousReportId", item.getPreviousReportId());
+            m.put("reportDate", item.getReportDate());
+            result.add(m);
+        }
+        return result;
+    }
+
+    public void upsert(MemberIndicatorCurrent item) {
+        mapper.upsert(item);
+    }
+
+    public void bulkUpsert(List<MemberIndicatorCurrent> items) {
+        for (MemberIndicatorCurrent item : items) {
+            try {
+                mapper.upsert(item);
+            } catch (Exception e) {
+                log.warn("指标当前值写入失败 member={}, defId={}: {}", item.getMemberId(), item.getDefinitionId(), e.getMessage());
+            }
+        }
+    }
+}

+ 177 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NutritionProductService.java

@@ -38,6 +38,15 @@ public class NutritionProductService {
     @Resource
     private NutritionProductReviewMapper reviewMapper;
 
+
+
+    @Resource
+    private NutritionUlLimitMapper ulLimitMapper;
+
+
+    @Resource
+    private NutritionAgeDosageMapper ageDosageMapper;
+
     @Resource
     private ProductMapper productMapper;
 
@@ -214,6 +223,11 @@ public class NutritionProductService {
                 }
                 existing.setStatus(1);
                 nutritionProductMapper.insert(existing);
+                // 根据成分功效说明自动生成功效标签
+                existing.setEfficacyTags(buildEfficacyTags(existing.getId()));
+                if (existing.getEfficacyTags() != null) {
+                    nutritionProductMapper.updateById(existing);
+                }
             } else {
                 existing.setName(product.getName());
                 existing.setImageUrl(product.getCoverImage());
@@ -242,6 +256,169 @@ public class NutritionProductService {
         return fen / 100;
     }
 
+    /**
+     * 根据主平台商品ID创建营养产品条目(标记为本平台商品)
+     *
+     * @param productId 主平台商品ID(products.id)
+     * @return true=成功/已存在, false=主商品不存在
+     */
+    public boolean addNutritionProductFromProduct(Long productId) {
+        Product product = productMapper.selectById(productId);
+        if (product == null) {
+            log.warn("主平台商品不存在,ID: {}", productId);
+            return false;
+        }
+        // 检查是否已存在该主平台商品的营养产品条目
+        NutritionProduct existing = nutritionProductMapper.selectOne(
+            new LambdaQueryWrapper<NutritionProduct>()
+                .eq(NutritionProduct::getLocalProductId, productId)
+        );
+        if (existing != null) {
+            log.info("营养产品条目已存在,主平台商品ID: {}", productId);
+            return true; // 已存在,视为成功
+        }
+        NutritionProduct nutritionProduct = new NutritionProduct();
+        nutritionProduct.setName(product.getName());
+        nutritionProduct.setLocalProductId(productId);
+        nutritionProduct.setStatus(1); // 1 = 上架
+        nutritionProduct.setSourcePlatform("platform"); // 标记为平台来源
+        nutritionProduct.setDescription(product.getDescription());
+        nutritionProduct.setImageUrl(product.getCoverImage());
+        nutritionProduct.setPrice(BigDecimal.valueOf(product.getPrice() != null ? product.getPrice() : 0));
+        nutritionProduct.setOriginalPrice(product.getPurchasePrice() != null ? product.getPurchasePrice().intValue() : 0);
+        nutritionProduct.setSuitableFor("全家人");
+        nutritionProduct.setCreateTime(new Date());
+        nutritionProduct.setUpdateTime(new Date());
+        // 默认分类和品牌设为空,由前端或后续填充
+        nutritionProductMapper.insert(nutritionProduct);
+        // 根据成分功效说明自动生成功效标签
+        nutritionProduct.setEfficacyTags(buildEfficacyTags(nutritionProduct.getId()));
+        if (nutritionProduct.getEfficacyTags() != null) {
+            nutritionProductMapper.updateById(nutritionProduct);
+        }
+        log.info("已创建营养产品条目,主平台商品ID: {}, 营养产品ID: {}", productId, nutritionProduct.getId());
+        return true;
+    }
+
+    /**
+     * 根据产品的成分功效说明生成成分功效标签(JSON数组字符串,如 ["补钙","补锌"])
+     *
+     * @param nutritionProductId 营养产品ID
+     * @return JSON数组字符串;无成分或无法提取时返回 null
+     */
+    private String buildEfficacyTags(Long nutritionProductId) {
+        List<NutritionIngredient> ingredients = ingredientMapper.selectList(
+            new LambdaQueryWrapper<NutritionIngredient>()
+                .eq(NutritionIngredient::getProductId, nutritionProductId)
+        );
+        if (ingredients.isEmpty()) {
+            return null;
+        }
+        List<String> tags = new ArrayList<>();
+        for (NutritionIngredient ingredient : ingredients) {
+            String purpose = ingredient.getPurpose();
+            if (purpose == null || purpose.trim().isEmpty()) {
+                continue;
+            }
+            // 按常见分隔符拆分功效描述,取有效片段作为标签(去重、去空、截断过长)
+            for (String part : purpose.split("[,,、;;/|\\s]+")) {
+                String tag = part.trim();
+                if (tag.isEmpty() || tag.length() > 20 || tags.contains(tag)) {
+                    continue;
+                }
+                tags.add(tag);
+            }
+        }
+        if (tags.isEmpty()) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < tags.size(); i++) {
+            if (i > 0) {
+                sb.append(",");
+            }
+            sb.append("\"").append(tags.get(i)).append("\"");
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
+    /**
+     * 验证营养产品是否符合年龄和剂量要求
+     *
+     * @param productId 营养产品ID
+     * @param ageInMonths 当前年龄(月)
+     * @param ageMinMonths 最小年龄(月)
+     * @param ageMaxMonths 最大年龄(月)
+     * @param dosageRequirement 剂量要求描述(可为 null)
+     * @param dosageUnit 剂量单位(可为 null)
+     * @return 是否通过验证
+     */
+    public boolean validateProductForAgeAndDosage(Long productId, Integer ageInMonths,
+                                                  Integer ageMinMonths, Integer ageMaxMonths,
+                                                  String dosageRequirement, String dosageUnit) {
+        // 1. 检查年龄是否在允许范围内
+        if (ageMinMonths != null && ageInMonths != null
+                && (ageInMonths < ageMinMonths || ageInMonths > ageMaxMonths)) {
+            log.info("产品{} 年龄{}个月不在允许范围[{}, {}]", productId, ageInMonths, ageMinMonths, ageMaxMonths);
+            return false;
+        }
+        // 2. 检查剂量是否超过 UL 限制
+        return validateDosageAgainstUlLimit(productId);
+    }
+
+    /**
+     * 检查产品剂量是否超过 UL 限制(多来源累加自动校验)
+     */
+    private boolean validateDosageAgainstUlLimit(Long productId) {
+        // 1. 获取该产品的所有成分
+        List<NutritionIngredient> ingredients = ingredientMapper.selectList(
+            new LambdaQueryWrapper<NutritionIngredient>()
+                .eq(NutritionIngredient::getProductId, productId)
+        );
+        if (ingredients.isEmpty()) {
+            return true; // 没有成分则视为通过
+        }
+
+        // 2. 按营养成分分组,累加同一成分多来源剂量
+        Map<String, BigDecimal> accumulatedDosage = new HashMap<>();
+
+        for (NutritionIngredient ingredient : ingredients) {
+            String nutrientKey = ingredient.getName() != null
+                ? ingredient.getName().toLowerCase() : ingredient.getIngredientType();
+            if (nutrientKey == null) {
+                continue;
+            }
+            // 累加同一成分剂量(假设所有成分来自同一产品内的多个成分条目)
+            BigDecimal amount = ingredient.getAmountPerServing() != null
+                ? ingredient.getAmountPerServing() : BigDecimal.ZERO;
+            accumulatedDosage.merge(nutrientKey, amount, BigDecimal::add);
+        }
+
+        // 3. 检查每个成分的 UL 限制
+        for (Map.Entry<String, BigDecimal> entry : accumulatedDosage.entrySet()) {
+            // 查找该成分的 UL 限制(按营养名称匹配)
+            List<NutritionUlLimit> ulLimits = ulLimitMapper.selectList(
+                new LambdaQueryWrapper<NutritionUlLimit>()
+                    .eq(NutritionUlLimit::getNutrientName, entry.getKey())
+            );
+            if (ulLimits.isEmpty()) {
+                continue;
+            }
+            NutritionUlLimit ulLimit = ulLimits.get(0);
+            if (ulLimit.getLimitValue() != null && entry.getValue() != null) {
+                double ulValue = ulLimit.getLimitValue().doubleValue();
+                double actualDose = entry.getValue().doubleValue();
+                if (actualDose > ulValue) {
+                    log.warn("成分{}剂量{}超过UL限制{}", entry.getKey(), actualDose, ulValue);
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
     // ---- 评分私有方法 ----
 
     private double calcIngredientScore(NutritionProduct product, NutritionRecommendDTO.RecommendQuery query) {

+ 203 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -58,6 +58,12 @@ public class ProductRecommendationService {
     @Resource
     private SupplySystemService supplySystemService;
 
+    @Resource
+    private UserContentWeightService userContentWeightService;
+
+    @Resource
+    private ArticleMapper articleMapper;
+
     /**
      * 维度页推荐
      *
@@ -142,7 +148,7 @@ public class ProductRecommendationService {
                     item.put("memberPrice", p.getPrice());
                     item.put("productType", p.getProductType());
                     item.put("source", "dimension");
-                    item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
+                    item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + p.getId());
 
                     // 推荐理由
                     String dimReason = buildRecommendationReason(p, dimensionCode, memberScores);
@@ -183,7 +189,7 @@ public class ProductRecommendationService {
                 item.put("memberPrice", p.getPrice());
                 item.put("productType", p.getProductType());
                 item.put("source", "dimension");
-                item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
+                item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + p.getId());
                 String reason = buildRecommendationReason(p, dimensionCode, memberScores);
                 item.put("reason", reason);
                 double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
@@ -207,7 +213,7 @@ public class ProductRecommendationService {
         }
 
         // 5. 最终排序并返回
-        return sortAndReturn(candidates, limit, userId);
+        return sortAndReturn(candidates, limit, userId, familyId);
     }
 
     /**
@@ -521,13 +527,14 @@ public class ProductRecommendationService {
 
     /**
      * 根据 matchScore 和供应商优先级进行最终排序
+     * 权重表有数据时按家庭权重总和排序,无权重数据时回退 matchScore
      */
-    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit, Long userId) {
+    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit, Long userId, Long familyId) {
         if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
-
+        
         // 计算供应商优先级加分(规则1~3)
         Map<Long, Double> supplierPriorityScores = getSupplierPriorityScores(userId);
-
+        
         // 将供应商优先级加入 matchScore
         if (!supplierPriorityScores.isEmpty()) {
             for (Map<String, Object> item : candidates) {
@@ -541,8 +548,33 @@ public class ProductRecommendationService {
                 item.put("supplierPriorityBoost", priorityBoost != null ? priorityBoost : 0);
             }
         }
-
+        
+        // 计算每个商品的家庭权重总和(家庭聚合:Σ成员权重,方向一致均为正向)
+        Map<Long, Double> productWeightSum = new HashMap<>();
+        if (familyId != null) {
+            for (Map<String, Object> item : candidates) {
+                Long productId = (Long) item.get("productId");
+                if (productId == null) continue;
+                // 查该家庭所有成员对该商品的权重,求和
+                List<UserContentWeight> weights = userContentWeightService.listWeights(null, familyId, "product", productId);
+                double sum = 0.0;
+                if (weights != null) {
+                    for (UserContentWeight w : weights) {
+                        sum += w.getWeight() != null ? w.getWeight().doubleValue() : 0.0;
+                    }
+                }
+                productWeightSum.put(productId, sum);
+                item.put("userWeight", sum);
+            }
+        }
+        
+        // 排序:优先按用户权重(降序),权重相同或无权重数据时回退 matchScore(降序)
         candidates.sort((a, b) -> {
+            Double weightA = productWeightSum.getOrDefault((Long) a.get("productId"), 0.0);
+            Double weightB = productWeightSum.getOrDefault((Long) b.get("productId"), 0.0);
+            int cmp = weightB.compareTo(weightA);
+            if (cmp != 0) return cmp;
+            
             Double scoreA = (Double) a.get("matchScore");
             Double scoreB = (Double) b.get("matchScore");
             if (scoreA == null && scoreB == null) return 0;
@@ -550,7 +582,7 @@ public class ProductRecommendationService {
             if (scoreB == null) return -1;
             return scoreB.compareTo(scoreA);
         });
-
+        
         // 去重(按 productId 去重)
         Set<Long> seenIds = new HashSet<>();
         List<Map<String, Object>> result = new ArrayList<>();
@@ -788,4 +820,167 @@ public class ProductRecommendationService {
 
         return result;
     }
+
+    /**
+     * 文章关联商品推荐:通过文章标签与商品标签匹配,按 标签匹配数 &gt; 用户权重 &gt; 家庭权重 &gt; 销量 排序
+     *
+     * @param articleId 文章ID
+     * @param userId    当前用户ID(可为空,用于用户权重)
+     * @param familyId  家庭ID(可为空,用于家庭权重)
+     * @param limit     返回数量
+     * @return 关联商品列表
+     */
+    public List<Map<String, Object>> getArticleRelatedProducts(Long articleId, Long userId, Long familyId, int limit) {
+        if (articleId == null || limit <= 0) return Collections.emptyList();
+
+        // 1. 获取文章标签
+        Article article = articleMapper.selectById(articleId);
+        if (article == null || article.getTags() == null || article.getTags().trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> articleTags = parseTagList(article.getTags());
+        if (articleTags.isEmpty()) return Collections.emptyList();
+        Set<String> articleTagSet = new HashSet<>(articleTags);
+
+        // 2. 查询所有在售且有库存的商品
+        List<Product> products = productMapper.selectList(
+                new LambdaQueryWrapper<Product>()
+                        .eq(Product::getStatus, "on_shelf")
+                        .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
+        );
+        if (products == null || products.isEmpty()) return Collections.emptyList();
+
+        // 3. 预加载用户权重(推荐曝光日志:购买=1.0,点击=0.6)
+        Map<Long, Double> userWeights = new HashMap<>();
+        if (userId != null) {
+            List<ProductRecommendationLog> logs = productRecommendationLogMapper.selectList(
+                    new LambdaQueryWrapper<ProductRecommendationLog>()
+                            .eq(ProductRecommendationLog::getUserId, userId));
+            if (logs != null) {
+                for (ProductRecommendationLog log : logs) {
+                    if (log.getProductId() == null) continue;
+                    double w = userWeights.getOrDefault(log.getProductId(), 0.0);
+                    if (log.getWasPurchased() != null && log.getWasPurchased() == 1) {
+                        w += 1.0;
+                    } else if (log.getWasClicked() != null && log.getWasClicked() == 1) {
+                        w += 0.6;
+                    }
+                    userWeights.put(log.getProductId(), w);
+                }
+            }
+        }
+
+        // 4. 预加载家庭权重(家庭订单数量)
+        Map<Long, Double> familyWeights = new HashMap<>();
+        if (familyId != null) {
+            List<ProductOrder> orders = orderMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrder>()
+                            .eq(ProductOrder::getFamilyId, familyId)
+                            .in(ProductOrder::getStatus, "paid", "pending_receipt", "shipped", "completed"));
+            if (orders != null) {
+                for (ProductOrder order : orders) {
+                    if (order.getProductId() == null) continue;
+                    familyWeights.merge(order.getProductId(), 1.0, Double::sum);
+                }
+            }
+        }
+
+        // 5. 计算标签匹配数与权重,构建候选列表
+        List<Map<String, Object>> candidates = new ArrayList<>();
+        for (Product product : products) {
+            if (product.getTags() == null || product.getTags().trim().isEmpty()) continue;
+            List<String> productTags = parseTagList(product.getTags());
+            if (productTags.isEmpty()) continue;
+
+            int matchCount = 0;
+            for (String tag : productTags) {
+                if (articleTagSet.contains(tag)) matchCount++;
+            }
+            if (matchCount == 0) continue;
+
+            double userWeight = userWeights.getOrDefault(product.getId(), 0.0);
+            double familyWeight = familyWeights.getOrDefault(product.getId(), 0.0);
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", product.getId());
+            item.put("productId", product.getId());
+            item.put("name", product.getName());
+            item.put("coverImage", product.getCoverImage());
+            item.put("price", product.getPrice());
+            item.put("memberPrice", product.getMemberPrice() != null ? product.getMemberPrice() : product.getPrice());
+            item.put("productType", product.getProductType());
+            item.put("tagMatchCount", matchCount);
+            item.put("userWeight", userWeight);
+            item.put("familyWeight", familyWeight);
+            item.put("reason", "与文章标签匹配 " + matchCount + " 个标签");
+            item.put("source", "article_tag");
+            item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + product.getId());
+            candidates.add(item);
+        }
+
+        // 6. 排序:标签匹配数(降序) > 用户权重(降序) > 家庭权重(降序) > 销量(降序)
+        candidates.sort((a, b) -> {
+            int matchA = (Integer) a.get("tagMatchCount");
+            int matchB = (Integer) b.get("tagMatchCount");
+            if (matchA != matchB) return matchB - matchA;
+
+            double userA = (Double) a.get("userWeight");
+            double userB = (Double) b.get("userWeight");
+            if (userA != userB) return Double.compare(userB, userA);
+
+            double familyA = (Double) a.get("familyWeight");
+            double familyB = (Double) b.get("familyWeight");
+            if (familyA != familyB) return Double.compare(familyB, familyA);
+
+            Product pa = findProductById(products, (Long) a.get("productId"));
+            Product pb = findProductById(products, (Long) b.get("productId"));
+            int salesA = pa != null && pa.getSalesCount() != null ? pa.getSalesCount() : 0;
+            int salesB = pb != null && pb.getSalesCount() != null ? pb.getSalesCount() : 0;
+            return salesB - salesA;
+        });
+
+        // 7. 截取 limit 条
+        if (candidates.size() > limit) {
+            return candidates.subList(0, limit);
+        }
+        return candidates;
+    }
+
+    /** 解析标签 JSON 数组字符串(兼容逗号分隔) */
+    private List<String> parseTagList(String tagsJson) {
+        if (tagsJson == null || tagsJson.trim().isEmpty()) return Collections.emptyList();
+        String trimmed = tagsJson.trim();
+        try {
+            if (trimmed.startsWith("[")) {
+                com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(trimmed);
+                if (arr == null) return Collections.emptyList();
+                List<String> tags = new ArrayList<>();
+                for (int i = 0; i < arr.size(); i++) {
+                    Object o = arr.get(i);
+                    if (o != null && !o.toString().trim().isEmpty()) {
+                        tags.add(o.toString().trim());
+                    }
+                }
+                return tags;
+            }
+            List<String> tags = new ArrayList<>();
+            for (String raw : trimmed.split(",")) {
+                String tag = raw.trim();
+                if (!tag.isEmpty()) tags.add(tag);
+            }
+            return tags;
+        } catch (Exception e) {
+            log.warn("解析标签失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /** 在产品列表中查找商品 */
+    private Product findProductById(List<Product> products, Long id) {
+        if (products == null || id == null) return null;
+        for (Product p : products) {
+            if (id.equals(p.getId())) return p;
+        }
+        return null;
+    }
 }

+ 104 - 17
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -25,6 +25,7 @@ import java.io.FileInputStream;
 import java.io.InputStreamReader;
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.concurrent.Executor;
@@ -149,12 +150,14 @@ public class ReportCollectService {
             log.warn("异步采集草稿不存在 draftId={}", draftId);
             return;
         }
-        if (STATUS_COMPLETED.equals(draft.getParseStatus())) {
+        if (STATUS_COMPLETED.equals(draft.getParseStatus())
+                || draft.getConfirmedReportId() != null) {
             return;
         }
+        String pdfPath = null;
         try {
             healthReportDraftService.markParseStarted(draftId);
-            String pdfPath = resolvePdfPath(draft);
+            pdfPath = resolvePdfPath(draft);
             if (pdfPath == null) {
                 healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
                 return;
@@ -185,6 +188,16 @@ public class ReportCollectService {
                 }
             }
 
+            // 加固③:指纹成功识别出的类型与用户所选报告类型不一致(非 DAN)→ 阻止自动入库,
+            // 防止认知能力测评等报告误入健康管道(如 gut_flora)后以错误类型静默入库
+            if (!"unknown".equals(typeId) && !typeId.equals(draft.getReportType())) {
+                log.warn("报告类型不匹配 draftId={}, 指纹识别={}, 所选={}", draftId, typeId, draft.getReportType());
+                healthReportDraftService.markCollectFailed(draftId,
+                        "报告内容与所选类型不符(指纹识别为「" + typeId + "」,所选「" + draft.getReportType()
+                                + "」),请核对报告类型后重新上传");
+                return;
+            }
+
             // 采集指标清单(用于驱动非 DAN 的 LangGraph LLM 解析)
             String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
             List<String> expectedIndicators = loadExpectedIndicators(validationTypeId);
@@ -223,21 +236,38 @@ public class ReportCollectService {
             }
 
             harmReportPayloadValid(payload);
+            // 加固②:已知肠道菌群家族类型必须含菌群实质数据(菌群检出列表/指标清单),
+            // 防止认知测评等非菌群报告只解析出 overview 也静默入库
+            if (isGutFloraType(validationTypeId) && !hasGutContent(payload)) {
+                log.warn("肠道菌群报告解析结果缺少菌群数据,阻止入库 draftId={}, typeId={}",
+                        draftId, validationTypeId);
+                healthReportDraftService.markCollectFailed(draftId,
+                        "解析结果缺少菌群检测数据(报告内容与所选类型不符),请在解析状态页人工处理或重新上传");
+                return;
+            }
             // 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动)
+            // 加固①:校验不通过(pass=false)阻止自动入库,标记失败由管理端人工处理
+            boolean validationPass;
             try {
-                runCollectValidation(draftId, validationTypeId, payload);
+                validationPass = runCollectValidation(draftId, validationTypeId, payload);
             } catch (Exception e) {
                 log.warn("采集指标校验失败 draftId={}, typeId={}", draftId, validationTypeId, e);
+                validationPass = true;
+            }
+            if (!validationPass) {
+                log.warn("采集指标校验未通过,阻止入库 draftId={}, typeId={}", draftId, validationTypeId);
+                healthReportDraftService.markCollectFailed(draftId,
+                        "采集指标校验未通过(报告内容与所选类型不符或解析不完整),请在解析状态页人工处理或重新上传");
+                return;
             }
             healthReportDraftService.updateParseMethod(draftId, method);
             healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
 
-            // 幂等守卫:解析期间若草稿已被确认入库或已完成(并发/外部触发),跳过本次入库
-            HealthReportDraft fresh = healthReportDraftService.getDraftById(draftId);
-            if (fresh != null && (fresh.getConfirmedReportId() != null
-                    || STATUS_COMPLETED.equals(fresh.getParseStatus()))) {
-                log.warn("草稿解析期间已被处理,跳过重复入库 draftId={}, confirmedReportId={}, parseStatus={}",
-                        draftId, fresh.getConfirmedReportId(), fresh.getParseStatus());
+            // 幂等守卫:原子抢占插入槽位(collecting -> inserting 且 confirmed_report_id 为空)。
+            // 并发重跑任务(定时重试驱动器/外部触发)解析完成后到达此处时,只有先到者能抢到槽位,
+            // 后到者因 parse_status 已变为 inserting 或 confirmed_report_id 非空而失败,避免重复入库。
+            if (!healthReportDraftService.tryClaimInsertSlot(draftId)) {
+                log.warn("草稿插入槽位已被抢占或已入库,跳过重复入库 draftId={}", draftId);
                 return;
             }
 
@@ -247,6 +277,8 @@ public class ReportCollectService {
         } catch (Exception e) {
             log.error("异步采集失败 draftId={}", draftId, e);
             healthReportDraftService.markCollectFailed(draftId, e.getMessage());
+        } finally {
+            cleanupPdfCache(pdfPath);
         }
     }
 
@@ -405,6 +437,25 @@ public class ReportCollectService {
         }
     }
 
+    /**
+     * 解析完成后清理 OSS 下载中转缓存。OSS 为持久存储,本地缓存仅为解析中转,用后即删。
+     * 仅清理 .jdcloud-cache 缓存文件,本地磁盘存储(uploadBaseDir 直存)的原文件不动。
+     */
+    private void cleanupPdfCache(String pdfPath) {
+        if (pdfPath == null) {
+            return;
+        }
+        File f = new File(pdfPath);
+        if (f.exists() && f.getAbsolutePath().contains(".jdcloud-cache")) {
+            try {
+                Files.deleteIfExists(f.toPath());
+                log.info("已清理OSS下载中转缓存 {}", pdfPath);
+            } catch (Exception e) {
+                log.warn("清理OSS下载中转缓存失败 {}", pdfPath, e);
+            }
+        }
+    }
+
     /**
      * 检查文件是否为 PDF(通过魔数 %PDF- 判断)。
      * 指纹引擎、LangGraph 解析、opencode 均要求 PDF 输入。
@@ -652,6 +703,12 @@ public class ReportCollectService {
             } catch (Exception e) {
                 log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
             }
+            try {
+                healthReportService.refreshMemberIndicatorCurrent(subjectId, created.getId());
+                log.info("已刷新成员{}的指标当前值diff", subjectId);
+            } catch (Exception e) {
+                log.warn("刷新指标当前值diff失败 memberId={}", subjectId, e);
+            }
             try {
                 foodRecommendService.recalculateForUser(subjectId);
             } catch (Exception e) {
@@ -683,7 +740,8 @@ public class ReportCollectService {
         HealthReport report = new HealthReport();
         report.setUserId(draft.getUserId());
         report.setFamilyId(draft.getFamilyId());
-        report.setSubjectId(subjectId);
+        // A方案:subject_id 统一为 family_members.id 口径(users.id 归一化到成员卡)
+        report.setSubjectId(healthReportService.resolveSubjectToMemberId(subjectId, draft.getFamilyId()));
         report.setReportType(draft.getReportType());
         report.setFileUrl(draft.getFileUrl());
         report.setFileHash(draft.getFileHash());
@@ -1045,14 +1103,14 @@ public class ReportCollectService {
     /**
      * 健康(GUT/混检)管道校验入口:汇总 payload 中全部可采集指标名称后按定义比对
      */
-    private void runCollectValidation(Long draftId, String typeId, ParsedReportPayload.Payload payload) {
-        runCollectValidation(draftId, typeId, collectHealthNames(payload));
+    private boolean runCollectValidation(Long draftId, String typeId, ParsedReportPayload.Payload payload) {
+        return runCollectValidation(draftId, typeId, collectHealthNames(payload));
     }
 
     /**
      * DAN 管道校验入口:汇总解析 items 的 name/code 后按定义比对
      */
-    private void runCollectValidation(Long draftId, String typeId, DanReportParseService.DanParsedReport parsed) {
+    private boolean runCollectValidation(Long draftId, String typeId, DanReportParseService.DanParsedReport parsed) {
         Set<String> names = new HashSet<>();
         if (parsed != null && parsed.getItems() != null) {
             for (DanReportParseService.DataItem item : parsed.getItems()) {
@@ -1060,7 +1118,7 @@ public class ReportCollectService {
                 addCollectName(names, item.getCode());
             }
         }
-        runCollectValidation(draftId, typeId, names);
+        return runCollectValidation(draftId, typeId, names);
     }
 
     /**
@@ -1068,13 +1126,18 @@ public class ReportCollectService {
      * 该类型无指标项定义(expected_indicators 为空 / 类型未注册)时跳过,不影响采集主流程。
      * 结果 JSON(typeId/expected/matched/pass/missing)写入草稿 collect_validation,缺失项记入 WARN 日志。
      */
-    private void runCollectValidation(Long draftId, String typeId, Set<String> collectedNames) {
+    private boolean runCollectValidation(Long draftId, String typeId, Set<String> collectedNames) {
         if (draftId == null || typeId == null || collectedNames == null || collectedNames.isEmpty()) {
-            return;
+            return true;
         }
         List<String> expected = loadExpectedIndicators(typeId);
         if (expected == null || expected.isEmpty()) {
-            return;
+            return true;
+        }
+        // 肠道菌群家族:肠道预测年龄/肠型 为摘要增强字段,部分报告(如体检型菌群检测)不输出,
+        // 缺失不阻断入库,避免合法报告因缺少增强字段卡在草稿
+        if (isGutFloraType(typeId)) {
+            expected.removeAll(GUT_FLORA_OPTIONAL_INDICATORS);
         }
         List<String> missing = new ArrayList<>();
         int matched = 0;
@@ -1102,6 +1165,30 @@ public class ReportCollectService {
         } catch (Exception e) {
             log.warn("记录采集校验结果失败 draftId={}, typeId={}", draftId, typeId, e);
         }
+        return missing.isEmpty();
+    }
+
+    /** 肠道菌群家族校验可选指标(report_type_registry.expected_indicators 中缺失不阻断入库) */
+    private static final Set<String> GUT_FLORA_OPTIONAL_INDICATORS =
+            new HashSet<>(Arrays.asList("肠道预测年龄", "肠型"));
+
+    /** 是否为肠道菌群家族类型(report_type_registry.family = gut_flora) */
+    private boolean isGutFloraType(String typeId) {
+        if (typeId == null) {
+            return false;
+        }
+        ReportTypeRegistry registry = reportTypeRegistryMapper.selectOne(
+                new LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getTypeId, typeId)
+                        .last("LIMIT 1"));
+        return registry != null && "gut_flora".equals(registry.getFamily());
+    }
+
+    /** 菌群实质数据判定:菌群检出列表或指标清单至少一项非空 */
+    private boolean hasGutContent(ParsedReportPayload.Payload payload) {
+        return payload != null
+                && ((payload.getGutFlora() != null && !payload.getGutFlora().isEmpty())
+                        || (payload.getIndicators() != null && !payload.getIndicators().isEmpty()));
     }
 
     /** 读取类型指标项定义(report_type_registry.expected_indicators JSON 数组);未定义返回 null */

+ 192 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UnifiedKnowledgeBaseService.java

@@ -119,6 +119,13 @@ public class UnifiedKnowledgeBaseService {
             } catch (Exception e) {
                 log.warn("classpath种子导入失败: {}", e.getMessage());
             }
+        } else if (backfillMissingModules(rows)) {
+            // DB 非空但缺少部分模块(种子新增模块后旧库未补齐),从种子回填后重读
+            try {
+                rows = knowledgeBaseSectionMapper.selectList(null);
+            } catch (Exception ignore) {
+                // 回填后重读失败也不影响:至少新增行已落库,下次启动生效
+            }
         }
 
         if (rows != null) {
@@ -151,6 +158,79 @@ public class UnifiedKnowledgeBaseService {
                 indicatorIndex.size(), nutrientIndex.size(), diseaseIndex.size());
     }
 
+    /**
+     * 回填 DB 中缺失的模块(从 classpath 种子读取),用于种子新增模块后旧库自动补齐(幂等)。
+     * 仅插入缺失键,不触碰已存在的模块。
+     *
+     * @param existingRows 当前 DB 中的行
+     * @return true=有回填(调用方应重读 DB)
+     */
+    private boolean backfillMissingModules(List<KnowledgeBaseSection> existingRows) {
+        if (existingRows == null || existingRows.isEmpty()) {
+            return false;
+        }
+        Set<String> existingKeys = new HashSet<>();
+        for (KnowledgeBaseSection r : existingRows) {
+            if (r.getModuleKey() != null) {
+                existingKeys.add(r.getModuleKey());
+            }
+        }
+        List<String> missing = new ArrayList<>();
+        for (String key : MODULE_KEYS) {
+            if (!existingKeys.contains(key)) {
+                missing.add(key);
+            }
+        }
+        if (missing.isEmpty()) {
+            return false;
+        }
+
+        Map<String, Object> root;
+        try (InputStream is = getClass().getResourceAsStream(SEED_CLASSPATH)) {
+            if (is == null) {
+                log.warn("种子文件不存在,无法回填缺失模块: {}", SEED_CLASSPATH);
+                return false;
+            }
+            root = objectMapper.readValue(is, new TypeReference<Map<String, Object>>() {});
+        } catch (Exception e) {
+            log.warn("读取种子文件失败,无法回填缺失模块: {}", e.getMessage());
+            return false;
+        }
+
+        log.info("检测到知识库缺失模块 {} 个,从classpath种子回填: {}", missing.size(), missing);
+        boolean filled = false;
+        Date now = new Date();
+        for (String key : missing) {
+            Object content = root.get(key);
+            if (content == null) {
+                log.warn("种子文件中缺少模块,跳过回填: {}", key);
+                continue;
+            }
+            try {
+                String moduleVersion = null;
+                if ("元数据".equals(key) && content instanceof Map) {
+                    Object v = ((Map<String, Object>) content).get("版本");
+                    if (v != null) {
+                        moduleVersion = String.valueOf(v);
+                    }
+                }
+                KnowledgeBaseSection row = new KnowledgeBaseSection();
+                row.setModuleKey(key);
+                row.setModuleName(key);
+                row.setContent(objectMapper.writeValueAsString(content));
+                row.setVersion(moduleVersion);
+                row.setCreatedAt(now);
+                row.setUpdatedAt(now);
+                knowledgeBaseSectionMapper.insert(row);
+                filled = true;
+                log.info("回填缺失模块到DB: {}", key);
+            } catch (Exception e) {
+                log.warn("回填模块[{}]失败: {}", key, e.getMessage());
+            }
+        }
+        return filled;
+    }
+
     /**
      * 从 classpath 种子文件导入全部模块到 DB(幂等,存在则更新)
      *
@@ -458,6 +538,118 @@ public class UnifiedKnowledgeBaseService {
         return overview;
     }
 
+    /**
+     * 获取模块详情概览(管理端使用):键 + 名称 + 内容字符数 + 版本 + 更新时间
+     */
+    public List<Map<String, Object>> getModuleOverviewDetailed() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        Map<String, KnowledgeBaseSection> dbRows = new LinkedHashMap<>();
+        try {
+            List<KnowledgeBaseSection> rows = knowledgeBaseSectionMapper.selectList(null);
+            if (rows != null) {
+                for (KnowledgeBaseSection r : rows) {
+                    dbRows.put(r.getModuleKey(), r);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("读取knowledge_base_section表失败: {}", e.getMessage());
+        }
+        for (String key : MODULE_KEYS) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("moduleKey", key);
+            Map<String, Object> content = sectionMap.get(key);
+            KnowledgeBaseSection row = dbRows.get(key);
+            item.put("moduleName", row != null && row.getModuleName() != null ? row.getModuleName() : key);
+            int size = 0;
+            try {
+                size = row != null && row.getContent() != null ? row.getContent().length() : 0;
+            } catch (Exception ignore) {
+            }
+            item.put("size", size);
+            item.put("version", row != null ? row.getVersion() : null);
+            item.put("updatedAt", row != null && row.getUpdatedAt() != null ? row.getUpdatedAt() : null);
+            item.put("loaded", content != null);
+            item.put("count", content != null ? content.size() : 0);
+            list.add(item);
+        }
+        return list;
+    }
+
+    /**
+     * 更新模块内容(写DB + 热重载)。moduleKey 必须是已注册模块。
+     */
+    public synchronized Map<String, Object> updateModule(String moduleKey, String moduleName, Object content) {
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            throw new IllegalArgumentException("moduleKey不能为空");
+        }
+        if (!MODULE_KEYS.contains(moduleKey)) {
+            throw new IllegalArgumentException("未注册模块: " + moduleKey);
+        }
+        String json;
+        try {
+            json = objectMapper.writeValueAsString(content);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("content不是合法JSON: " + e.getMessage());
+        }
+        try {
+            KnowledgeBaseSection row = knowledgeBaseSectionMapper.selectOne(
+                    new LambdaQueryWrapper<KnowledgeBaseSection>().eq(KnowledgeBaseSection::getModuleKey, moduleKey));
+            Date now = new Date();
+            if (row == null) {
+                row = new KnowledgeBaseSection();
+                row.setModuleKey(moduleKey);
+                row.setModuleName(moduleName != null ? moduleName : moduleKey);
+                row.setContent(json);
+                row.setVersion("manual-" + new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(now));
+                row.setCreatedAt(now);
+                row.setUpdatedAt(now);
+                knowledgeBaseSectionMapper.insert(row);
+            } else {
+                if (moduleName != null && !moduleName.isEmpty()) {
+                    row.setModuleName(moduleName);
+                }
+                row.setContent(json);
+                row.setUpdatedAt(now);
+                knowledgeBaseSectionMapper.updateById(row);
+            }
+            load(); // 热重载内存索引
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("moduleKey", moduleKey);
+            result.put("size", json.length());
+            result.put("updatedAt", now);
+            return result;
+        } catch (Exception e) {
+            log.error("更新模块{}失败: {}", moduleKey, e.getMessage(), e);
+            throw e;
+        }
+    }
+
+    /**
+     * 从 classpath 种子恢复单个模块(写DB + 热重载)
+     */
+    public synchronized Map<String, Object> resetModuleFromClasspath(String moduleKey) {
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            throw new IllegalArgumentException("moduleKey不能为空");
+        }
+        if (!MODULE_KEYS.contains(moduleKey)) {
+            throw new IllegalArgumentException("未注册模块: " + moduleKey);
+        }
+        try (InputStream is = getClass().getResourceAsStream(SEED_CLASSPATH)) {
+            if (is == null) {
+                throw new IllegalStateException("classpath种子不存在: " + SEED_CLASSPATH);
+            }
+            Map<String, Object> seed = objectMapper.readValue(is, new TypeReference<Map<String, Object>>() {});
+            Object moduleContent = seed.get(moduleKey);
+            if (moduleContent == null) {
+                throw new IllegalStateException("种子中无模块: " + moduleKey);
+            }
+            return updateModule(moduleKey, moduleKey, moduleContent);
+        } catch (java.io.IOException e) {
+            log.error("重置模块{}失败: {}", moduleKey, e.getMessage(), e);
+            throw new IllegalStateException("重置失败: " + e.getMessage(), e);
+        }
+    }
+
     // ==================== 食物查询 ====================
 
     /**

+ 7 - 29
cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java

@@ -36,9 +36,6 @@ public class UnlockGateService {
     @Resource
     private FiveDimensionSelfCheckMapper selfCheckMapper;
 
-    @Resource
-    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
-
     @Resource
     private MicroActionRecordMapper microActionRecordMapper;
 
@@ -148,37 +145,18 @@ public class UnlockGateService {
     }
 
     /**
-     * 家庭级判据:family 内任一成员做过五维自检,或任一维度能量值有值(>0),即解锁首页能量沙盘。
-     * 放宽自改进前的"须做完五维自检":做过任一测评/上传报告/完成任务,只要任一经落库为
-     * five_dimension_score 的能量维度 >0,即可展示沙盘。
+     * 家庭级判据:family 内任一成员完成健康自检问卷(health_status)即解锁首页能量沙盘。
+     * 严格模式:未完成健康自检问卷的家庭一律锁定沙盘。
      */
     public boolean hasFamilySelfCheck(Long familyId) {
         if (familyId == null) return false;
-        // 来源1:五维自检记录(five_dimension_self_checks)
-        List<User> familyUsers = userMapper.selectList(
-                new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId));
-        if (!familyUsers.isEmpty()) {
-            List<Long> userIds = familyUsers.stream().map(User::getId).collect(Collectors.toList());
-            long selfCheckCount = selfCheckMapper.selectCount(
-                    new LambdaQueryWrapper<FiveDimensionSelfCheck>().in(FiveDimensionSelfCheck::getUserId, userIds));
-            if (selfCheckCount >= 1) return true;
-        }
-        // 来源2:任一维度能量快照 >0(five_dimension_score)
-        long scoreCount = fiveDimensionScoreMapper.selectCount(
-                new LambdaQueryWrapper<FiveDimensionScore>()
-                        .eq(FiveDimensionScore::getFamilyId, familyId)
-                        .gt(FiveDimensionScore::getScore, 0));
-        if (scoreCount >= 1) return true;
-        // 来源3:健康自检问卷(health_status)——任一家庭成员完成问卷即解锁
         List<FamilyMember> familyMembers = familyMemberMapper.selectList(
                 new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
-        if (!familyMembers.isEmpty()) {
-            List<Long> memberIds = familyMembers.stream().map(FamilyMember::getId).collect(Collectors.toList());
-            long healthCount = healthStatusMapper.selectCount(
-                    new LambdaQueryWrapper<HealthStatus>().in(HealthStatus::getMemberId, memberIds));
-            if (healthCount >= 1) return true;
-        }
-        return false;
+        if (familyMembers.isEmpty()) return false;
+        List<Long> memberIds = familyMembers.stream().map(FamilyMember::getId).collect(Collectors.toList());
+        long healthCount = healthStatusMapper.selectCount(
+                new LambdaQueryWrapper<HealthStatus>().in(HealthStatus::getMemberId, memberIds));
+        return healthCount >= 1;
     }
 
     // ========== 当前锁定关卡 ==========

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserContentWeightService.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.UserContentWeight;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 用户内容偏好权重服务。
+ * 表为空时返回空列表,推荐排序将自动回退到 matchScore。
+ */
+public interface UserContentWeightService {
+
+    /**
+     * 查询内容偏好权重列表。
+     * 家庭聚合场景:userId 为 null 时返回该家庭下所有成员对该内容的权重。
+     *
+     * @param userId      用户ID(users.id),可为 null(不限成员)
+     * @param familyId    家庭ID
+     * @param contentType 内容类型: product/activity/article
+     * @param itemId      内容ID
+     * @return 权重列表(表为空时返回空列表)
+     */
+    List<UserContentWeight> listWeights(Long userId, Long familyId, String contentType, Long itemId);
+
+    /** 查询单个用户的内容偏好权重,不存在返回 null */
+    UserContentWeight getWeight(Long userId, Long familyId, String contentType, Long itemId);
+
+    /** 保存/更新一条内容偏好权重(id 非空则更新,否则新增) */
+    void saveWeight(UserContentWeight weight);
+
+    /** 直接更新指定内容的权重值 */
+    void updateWeight(Long userId, Long familyId, String contentType, Long itemId, BigDecimal newWeight);
+
+    /** 记录一次内容行为,按增量累加权重(上限 10.0),无记录时新建 */
+    void recordBehavior(Long userId, Long familyId, String contentType, Long itemId,
+                        String dimensionCode, BigDecimal increment, String reason);
+}

+ 113 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserContentWeightServiceImpl.java

@@ -0,0 +1,113 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.UserContentWeight;
+import com.etotem.cfc.mapper.UserContentWeightMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+@Service("userContentWeightService")
+public class UserContentWeightServiceImpl implements UserContentWeightService {
+
+    private final UserContentWeightMapper mapper;
+
+    public UserContentWeightServiceImpl(UserContentWeightMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    @Override
+    @Transactional
+    public void saveWeight(UserContentWeight weight) {
+        if (weight.getId() != null) {
+            mapper.updateById(weight);
+        } else {
+            weight.setCreatedAt(new Date());
+            weight.setUpdatedAt(new Date());
+            mapper.insert(weight);
+        }
+    }
+
+    @Override
+    public UserContentWeight getWeight(Long userId, Long familyId, String contentType, Long itemId) {
+        LambdaQueryWrapper<UserContentWeight> qw = new LambdaQueryWrapper<UserContentWeight>()
+            .eq(UserContentWeight::getUserId, userId)
+            .eq(UserContentWeight::getFamilyId, familyId)
+            .eq(UserContentWeight::getContentType, contentType)
+            .eq(UserContentWeight::getItemId, itemId);
+        return mapper.selectOne(qw);
+    }
+
+    @Override
+    public List<UserContentWeight> listWeights(Long userId, Long familyId, String contentType, Long itemId) {
+        if (familyId == null || contentType == null || itemId == null) {
+            return java.util.Collections.emptyList();
+        }
+        try {
+            LambdaQueryWrapper<UserContentWeight> qw = new LambdaQueryWrapper<UserContentWeight>()
+                .eq(UserContentWeight::getFamilyId, familyId)
+                .eq(UserContentWeight::getContentType, contentType)
+                .eq(UserContentWeight::getItemId, itemId)
+                .eq(userId != null, UserContentWeight::getUserId, userId)
+                .orderByDesc(UserContentWeight::getWeight);
+            return mapper.selectList(qw);
+        } catch (Exception e) {
+            java.util.logging.Logger.getLogger("UserContentWeightService").warning(
+                String.format("查询用户内容偏好权重失败: familyId=%s, contentType=%s, itemId=%s, error=%s",
+                    familyId, contentType, itemId, e.getMessage()));
+            return java.util.Collections.emptyList();
+        }
+    }
+
+    @Override
+    public void updateWeight(Long userId, Long familyId, String contentType, Long itemId, BigDecimal newWeight) {
+        LambdaQueryWrapper<UserContentWeight> qw = new LambdaQueryWrapper<UserContentWeight>()
+            .eq(UserContentWeight::getUserId, userId)
+            .eq(UserContentWeight::getFamilyId, familyId)
+            .eq(UserContentWeight::getContentType, contentType)
+            .eq(UserContentWeight::getItemId, itemId);
+        UserContentWeight weight = new UserContentWeight();
+        weight.setWeight(newWeight);
+        weight.setUpdatedAt(new Date());
+        mapper.update(weight, qw);
+    }
+
+    @Override
+    @Transactional
+    public void recordBehavior(Long userId, Long familyId, String contentType, Long itemId,
+                               String dimensionCode, BigDecimal increment, String reason) {
+        if (userId == null || familyId == null || contentType == null || itemId == null) {
+            return;
+        }
+        UserContentWeight existing = getWeight(userId, familyId, contentType, itemId);
+        Date now = new Date();
+        if (existing != null) {
+            BigDecimal newWeight = existing.getWeight() == null ? BigDecimal.ZERO : existing.getWeight();
+            newWeight = newWeight.add(increment == null ? BigDecimal.ZERO : increment);
+            // 权重上限 10.0
+            if (newWeight.compareTo(BigDecimal.valueOf(10.0)) > 0) {
+                newWeight = BigDecimal.valueOf(10.0);
+            }
+            existing.setWeight(newWeight);
+            existing.setDimensionCode(dimensionCode);
+            existing.setReason(reason);
+            existing.setUpdatedAt(now);
+            mapper.updateById(existing);
+        } else {
+            UserContentWeight weight = new UserContentWeight();
+            weight.setUserId(userId);
+            weight.setFamilyId(familyId);
+            weight.setContentType(contentType);
+            weight.setItemId(itemId);
+            weight.setDimensionCode(dimensionCode);
+            weight.setWeight(increment == null ? BigDecimal.ONE : increment);
+            weight.setReason(reason);
+            weight.setCreatedAt(now);
+            weight.setUpdatedAt(now);
+            mapper.insert(weight);
+        }
+    }
+}

+ 155 - 17
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -217,14 +217,20 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     @Override
     public String generatePlan(Long familyId, String memberIds, String dimensions, String goal) {
+        Object result = generatePlanResult(familyId, memberIds, dimensions, goal);
+        return (result instanceof String) ? (String) result : "";
+    }
+
+    @Override
+    public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal) {
         if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
             return buildSamplePlan(memberIds, dimensions, goal);
         }
         try {
             RestTemplate restTemplate = new RestTemplate();
             Map<String, Object> body = new HashMap<>();
-            body.put("familyId", familyId);
-            body.put("memberIds", memberIds);
+            body.put("family_id", familyId);
+            body.put("member_ids", memberIds);
             body.put("dimensions", dimensions);
             body.put("goal", goal);
             String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
@@ -233,6 +239,15 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 JsonNode root = objectMapper.readTree(response.getBody());
                 if (root.has("success") && root.get("success").asBoolean()) {
                     JsonNode data = root.get("data");
+                    // 多人方案:common(共性)+ individual(各成员个性)
+                    if (data != null && data.has("common")) {
+                        Map<String, Object> multi = new HashMap<>();
+                        multi.put("planContent", buildMultiMemberPlanContent(data));
+                        if (data.has("common")) multi.put("common", data.get("common"));
+                        if (data.has("individual")) multi.put("individual", data.get("individual"));
+                        return multi;
+                    }
+                    // 单人生成:保持原 flat 结构
                     if (data != null && data.has("overview")) {
                         StringBuilder sb = new StringBuilder();
                         sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
@@ -254,6 +269,45 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return buildSamplePlan(memberIds, dimensions, goal);
     }
 
+    /**
+     * 渲染多人方案 planContent:先共性,再各成员个性。
+     * LangGraph 返回结构:{common: {overview, sections}, individual: {memberId: {overview, sections}}}
+     */
+    private String buildMultiMemberPlanContent(JsonNode data) {
+        StringBuilder sb = new StringBuilder();
+        JsonNode common = data.get("common");
+        if (common != null && common.has("overview")) {
+            sb.append("# 共性方案\n\n").append(common.get("overview").asText()).append("\n\n");
+        }
+        if (common != null && common.has("sections")) {
+            for (JsonNode section : common.get("sections")) {
+                sb.append("## ").append(section.get("title").asText()).append("\n\n");
+                sb.append(section.get("content").asText()).append("\n\n");
+            }
+        }
+        JsonNode individual = data.get("individual");
+        if (individual != null && individual.isObject()) {
+            java.util.Iterator<java.util.Map.Entry<String, JsonNode>> fields = individual.fields();
+            while (fields.hasNext()) {
+                java.util.Map.Entry<String, JsonNode> entry = fields.next();
+                String memberId = entry.getKey();
+                JsonNode part = entry.getValue();
+                if (part == null) continue;
+                sb.append("\n---\n\n# 成员 ").append(memberId).append(" 个性方案\n\n");
+                if (part.has("overview")) {
+                    sb.append("## 概述\n\n").append(part.get("overview").asText()).append("\n\n");
+                }
+                if (part.has("sections")) {
+                    for (JsonNode section : part.get("sections")) {
+                        sb.append("## ").append(section.get("title").asText()).append("\n\n");
+                        sb.append(section.get("content").asText()).append("\n\n");
+                    }
+                }
+            }
+        }
+        return sb.toString();
+    }
+
 
     @Override
     public String regenerateSection(Long familyId, String section, String feedback, String planJsonStr) {
@@ -318,6 +372,20 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     private static final int DEFAULT_PLAN_DAYS = 14;
 
     private void generateDailyTasksFromPlan(HealthPlan plan) {
+        Long familyId = plan.getFamilyId();
+        String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
+            ? plan.getMemberIds().split(",") : new String[0];
+
+        // 多人结构:planJson 含 common + individual,且 memberIds 多于 1 人
+        // 规则:共性任务给每个成员各生成一份;个性任务只给对应成员
+        JsonNode planRoot = parsePlanJson(plan.getPlanJson());
+        if (planRoot != null && planRoot.has("common") && planRoot.has("individual")
+                && memberIdArray.length > 1) {
+            generateDailyTasksForMembers(plan, planRoot, memberIdArray);
+            return;
+        }
+
+        // 单人生成 / 旧 flat 结构:保持原有逻辑(只取第一个成员)
         String content = plan.getPlanContent();
         List<String> rawLines = new java.util.ArrayList<>();
         List<TaskDraft> structuredDrafts = buildDraftsFromPlanJson(plan);
@@ -331,18 +399,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
         }
 
-        Long familyId = plan.getFamilyId();
-        String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
-            ? plan.getMemberIds().split(",") : new String[0];
         Long rawMemberId = memberIdArray.length > 0 ? Long.valueOf(memberIdArray[0].trim()) : null;
         Long familyMemberId = resolveFamilyMemberId(rawMemberId, familyId);
-        Long childId = familyMemberId;
-        Long executorId = familyMemberId;
 
         Date now = new Date();
 
         // 方案周期终点(D3):由 health_plans.total_days 决定,缺省 14 天。
-        // 既作为周期任务模板的截止日期(其时分=每日截止时刻),也作为周期生成的终止边界。
         int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
             ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
         Date planEnd = buildPlanEnd(now, totalDays);
@@ -359,9 +421,70 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
         }
 
+        insertTasksForMember(plan, familyId, familyMemberId, drafts, now, planEnd);
+    }
+
+    /**
+     * 多人方案:按成员分别生成任务。
+     * 共性任务(common.sections.tasks)每个成员各一份;个性任务(individual.<memberId>.sections.tasks)只给对应成员。
+     */
+    private void generateDailyTasksForMembers(HealthPlan plan, JsonNode root, String[] memberIdArray) {
+        Long familyId = plan.getFamilyId();
+        List<TaskDraft> commonDrafts = buildDraftsFromSections(root.path("common").path("sections"));
+        Date now = new Date();
+        int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
+            ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
+        Date planEnd = buildPlanEnd(now, totalDays);
+
+        for (String rawMemberId : memberIdArray) {
+            if (rawMemberId == null || rawMemberId.trim().isEmpty()) continue;
+            Long rawId = Long.valueOf(rawMemberId.trim());
+            Long familyMemberId = resolveFamilyMemberId(rawId, familyId);
+            List<TaskDraft> memberDrafts = new java.util.ArrayList<>(commonDrafts);
+            memberDrafts.addAll(buildDraftsFromSections(
+                root.path("individual").path(rawMemberId.trim()).path("sections")));
+            if (memberDrafts.isEmpty()) continue;
+            insertTasksForMember(plan, familyId, familyMemberId, memberDrafts, now, planEnd);
+        }
+    }
+
+    /** 从 planJson 的 sections 数组提取任务草稿(仅读取 tasks 字段,无懒回填) */
+    private List<TaskDraft> buildDraftsFromSections(JsonNode sections) {
+        List<TaskDraft> drafts = new java.util.ArrayList<>();
+        if (sections == null || !sections.isArray()) return drafts;
+        for (JsonNode section : sections) {
+            JsonNode tasks = section.path("tasks");
+            if (!tasks.isArray()) continue;
+            for (JsonNode t : tasks) {
+                String actionType = t.path("action_type").asText("");
+                String title = t.path("title").asText("");
+                String dimension = t.path("dimension").asText("");
+                String frequency = t.path("frequency").asText("daily");
+                if (title.isEmpty()) continue;
+                TaskDraft draft = mapStructuredTask(actionType, title, dimension, frequency);
+                if (draft != null) drafts.add(draft);
+            }
+        }
+        return drafts;
+    }
+
+    private JsonNode parsePlanJson(String planJsonStr) {
+        if (planJsonStr == null || planJsonStr.trim().isEmpty()) return null;
+        try {
+            return objectMapper.readTree(planJsonStr);
+        } catch (Exception e) {
+            log.warn("解析 plan_json 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /** 为指定成员插入任务:一次性购买锚点 + 一次性/周期任务模板 */
+    private void insertTasksForMember(HealthPlan plan, Long familyId, Long familyMemberId,
+                                      List<TaskDraft> drafts, Date now, Date planEnd) {
+        Long childId = familyMemberId;
+        Long executorId = familyMemberId;
+
         // 1) 先落一次性购买任务,作为服务任务的依赖锚点(D1)
-        // 依赖必须"逐项配对":每个使用任务绑定它前面最近的购买任务。
-        // 若所有使用任务共用首个采购任务,多商品场景闸门语义就是错的(E2E 实跑发现)。
         Long buyTaskId = null;
         List<Long> draftAnchors = new java.util.ArrayList<Long>();
         for (TaskDraft draft : drafts) {
@@ -370,7 +493,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 draftAnchors.add(buyTaskId);
                 continue;
             }
-            Task existing = findPlanTask(plan, familyId, draft.title, "购买任务", 0);
+            Task existing = findPlanTask(plan, familyId, familyMemberId, draft.title, "购买任务", 0);
             if (existing != null) {
                 buyTaskId = existing.getId();
                 draftAnchors.add(existing.getId());
@@ -399,7 +522,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             boolean once = "once".equals(draft.frequency);
 
             if (once) {
-                if (findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 0) != null) continue;
+                if (findPlanTask(plan, familyId, familyMemberId, draft.title, draft.categoryLabel, 0) != null) continue;
                 Task task = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
                 task.setRepeatType("none");
                 task.setTaskType("onetime");
@@ -415,9 +538,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
 
             // 周期任务 -> 模板行(is_template=1),交 RepeatTaskGenerator 按日生成实例。
-            // 模板 deadline=planEnd(未来日期),因此不会出现在"今日任务"列表里(列表按 deadline=当天过滤),
-            // 用户只会看到生成出来的实例,天然避免模板/实例重复展示。
-            Task existingTpl = findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 1);
+            Task existingTpl = findPlanTask(plan, familyId, familyMemberId, draft.title, draft.categoryLabel, 1);
             if (existingTpl != null) {
                 // 模板已存在:仅在依赖缺失时补写(修复存量方案已生成的模板)
                 if ("使用任务".equals(draft.categoryLabel)
@@ -467,6 +588,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     /** 幂等查找:同一方案下的同名任务,templateOnly=true 只找模板行 */
     private Task findPlanTask(HealthPlan plan, Long familyId, String title, String category, int templateOnly) {
+        return findPlanTask(plan, familyId, null, title, category, templateOnly);
+    }
+
+    /** 按成员隔离的幂等查找:multi-member 下共性任务每成员各一份,需按 family_member_id 区分,避免互相拦截 */
+    private Task findPlanTask(HealthPlan plan, Long familyId, Long familyMemberId,
+                              String title, String category, int templateOnly) {
         QueryWrapper<Task> qw = new QueryWrapper<Task>()
             .eq("family_id", familyId)
             .eq("source_type", "health_plan")
@@ -474,6 +601,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             .eq("title", title)
             .eq("is_template", templateOnly);
         if (category != null) qw.eq("category", category);
+        if (familyMemberId != null) qw.eq("family_member_id", familyMemberId);
         return taskMapper.selectOne(qw.last("LIMIT 1"));
     }
 
@@ -986,8 +1114,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         try {
             RestTemplate restTemplate = new RestTemplate();
             Map<String, Object> body = new HashMap<>();
-            body.put("familyId", familyId);
-            body.put("memberIds", memberIds);
+            body.put("family_id", familyId);
+            body.put("member_ids", memberIds);
             body.put("dimensions", dimensions);
             body.put("goal", goal);
             String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
@@ -1006,6 +1134,16 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     private String buildPlanJsonFromData(JsonNode data) {
         try {
+            // 多人方案:原样透传 common + individual 结构(与前端保存的 planJson 一致),
+            // 供确认时 generateDailyTasksForMembers 按成员分别生成任务
+            if (data.has("common") && data.has("individual")) {
+                Map<String, Object> planJsonMap = new HashMap<>();
+                planJsonMap.put("common", objectMapper.convertValue(data.get("common"), Map.class));
+                planJsonMap.put("individual", objectMapper.convertValue(data.get("individual"), Map.class));
+                return objectMapper.writeValueAsString(planJsonMap);
+            }
+
+            // 单人 / 旧 flat 结构
             Map<String, Object> planJsonMap = new HashMap<>();
             planJsonMap.put("overview", data.has("overview") ? data.get("overview").asText() : "");
             List<Map<String, Object>> sections = new ArrayList<>();

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java

@@ -148,11 +148,72 @@ public class RepeatTaskGenerator {
             }
             log.info("每小时重复任务生成完成,处理{}个模板", hourlyTasks.size());
 
+            // 6. 内置健康打卡任务([DIET]/[SLEEP]/[EXERCISE])——每日为能登录用户生成
+            generateBuiltinCheckinTasks(today);
+
         } catch (Exception e) {
             log.error("生成重复任务失败", e);
         }
     }
 
+    /**
+     * 每日为所有能登录用户生成 3 条内置健康打卡任务(is_daily_progress=1)
+     * title 前缀约定:[DIET]/[SLEEP]/[EXERCISE],与 TaskService.updateProgress 的 taskKey 匹配一致
+     * 幂等:当天已有对应实例则跳过
+     */
+    private void generateBuiltinCheckinTasks(Date today) {
+        Date tomorrow = new Date(today.getTime() + 86400000L);
+        // 所有能登录用户(family_members.userId 非空)
+        List<FamilyMember> members = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .isNotNull(FamilyMember::getUserId)
+        );
+        int created = 0;
+        for (FamilyMember member : members) {
+            for (String[] tpl : CHECKIN_TEMPLATES) {
+                String title = tpl[0];
+                String actionType = tpl[1];
+                Long exists = taskMapper.selectCount(
+                        new LambdaQueryWrapper<Task>()
+                                .eq(Task::getFamilyMemberId, member.getId())
+                                .eq(Task::getTitle, title)
+                                .eq(Task::getIsDailyProgress, 1)
+                                .ge(Task::getCreatedAt, today)
+                                .lt(Task::getCreatedAt, tomorrow)
+                );
+                if (exists != null && exists > 0) {
+                    continue;
+                }
+                Task checkinTask = new Task();
+                checkinTask.setFamilyId(member.getFamilyId());
+                checkinTask.setFamilyMemberId(member.getId());
+                checkinTask.setCreatorId(member.getUserId());
+                checkinTask.setExecutorType("child");
+                checkinTask.setExecutorId(member.getId());
+                checkinTask.setChildId(member.getId());
+                checkinTask.setTitle(title);
+                checkinTask.setDescription("每日健康打卡任务");
+                checkinTask.setPoints(5);
+                checkinTask.setCategory("健康打卡");
+                checkinTask.setStatus("pending");
+                checkinTask.setNeedReview(0);
+                checkinTask.setIsTemplate(0);
+                checkinTask.setIsDailyProgress(1);
+                checkinTask.setTargetValue(1);
+                checkinTask.setActionType(actionType);
+                checkinTask.setMemberOnly(0);
+                checkinTask.setSourceType("self");
+                checkinTask.setCreatedAt(new Date());
+                checkinTask.setUpdatedAt(new Date());
+                taskMapper.insert(checkinTask);
+                created++;
+            }
+        }
+        if (created > 0) {
+            log.info("内置健康打卡任务生成完成,新增{}条(覆盖{}个能登录用户)", created, members.size());
+        }
+    }
+
     /**
      * 隔天判定:模板创建时间到今天的间隔天数为偶数 → 今天生成
      */

+ 54 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1194,6 +1194,7 @@ CREATE TABLE IF NOT EXISTS products (
     allergen_tags VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如"花生,小麦")',
     drug_interaction_flags TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)',
     is_recommended TINYINT(1) DEFAULT 0 COMMENT '是否推荐商品 0否 1是',
+    tags VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -4738,6 +4739,7 @@ CREATE TABLE IF NOT EXISTS diet_preferences (
     goal_source VARCHAR(20) DEFAULT NULL COMMENT 'manual/system_recommend',
     goal_confirmed TINYINT(1) DEFAULT 0 COMMENT '用户已确认',
     no_preference TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)',
+    diet_structure JSON DEFAULT NULL COMMENT '饮食结构现状 {vegetableFruit,dairy,sugaryDrinks}',
     filled_at TIMESTAMP NULL COMMENT '首次填写时间',
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_member (family_member_id),
@@ -5079,6 +5081,7 @@ CREATE TABLE IF NOT EXISTS nutrition_product (
     source_url VARCHAR(500) DEFAULT '' COMMENT '来源平台商品链接',
     product_images TEXT COMMENT '商品图片URL(JSON数组)',
     spec VARCHAR(200) DEFAULT '' COMMENT '规格',
+    efficacy_tags TEXT COMMENT '成分功效标签JSON数组(如 ["补钙","补锌","益生菌","维生素D"])',
     create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
     update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_brand (brand_id),
@@ -5086,6 +5089,38 @@ CREATE TABLE IF NOT EXISTS nutrition_product (
     INDEX idx_source (source_platform, source_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品';
 
+-- 营养产品年龄分层剂量建议表
+CREATE TABLE IF NOT EXISTS nutrition_age_dosage (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    age_min_months INT NOT NULL COMMENT '产品适用的最小月龄',
+    age_max_months INT NOT NULL COMMENT '产品适用的最大月龄',
+    ingredient_name VARCHAR(100) NOT NULL COMMENT '成分名称',
+    strain VARCHAR(100) DEFAULT NULL COMMENT '菌株号',
+    amount_per_serving DECIMAL(10,2) DEFAULT NULL COMMENT '每份含量',
+    unit VARCHAR(20) DEFAULT NULL COMMENT '单位',
+    daily_value VARCHAR(50) DEFAULT NULL COMMENT '每日参考值%',
+    purpose VARCHAR(200) DEFAULT NULL COMMENT '功效说明',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品按年龄分组的剂量建议';
+
+-- 营养产品剂量上限(UL)表
+CREATE TABLE IF NOT EXISTS nutrition_ul_limit (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '产品ID',
+    ingredient_type VARCHAR(20) NOT NULL COMMENT '成分类型',
+    nutrient_name VARCHAR(100) NOT NULL COMMENT '营养物质名称',
+    age_min_months VARCHAR(20) NOT NULL COMMENT '最小月龄',
+    age_max_months VARCHAR(20) NOT NULL COMMENT '最大月龄',
+    limit_value DECIMAL(10,2) DEFAULT NULL COMMENT '限值',
+    unit VARCHAR(30) DEFAULT NULL COMMENT '价格单位',
+    description VARCHAR(200) DEFAULT NULL COMMENT '说明',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id),
+    INDEX idx_platform (ingredient_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品剂量上限';
+
 -- 营养产品成分明细(迁移217)
 CREATE TABLE IF NOT EXISTS nutrition_ingredient (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -5326,6 +5361,7 @@ CREATE TABLE IF NOT EXISTS health_status (
     disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]',
     medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]',
     notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
+    lifestyle JSON DEFAULT NULL COMMENT '生活方式与肠道健康 {mood,sleepDuration,bedtime,bowelFrequency,bowelForm,antibioticHistory,allergySymptoms}',
     filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_member (user_id, member_id)
@@ -6336,3 +6372,21 @@ CREATE TABLE IF NOT EXISTS procurement_tasks (
     INDEX idx_status (status),
     INDEX idx_create_time (create_time)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)';
+
+-- 迁移374: 用户内容偏好权重表(user_content_weights)
+CREATE TABLE IF NOT EXISTS user_content_weights (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    user_id BIGINT NOT NULL COMMENT '用户ID(users.id)',
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    content_type VARCHAR(16) NOT NULL COMMENT '内容类型: product/activity/article',
+    item_id BIGINT NOT NULL COMMENT '内容ID(商品ID/活动ID/文章ID)',
+    dimension_code VARCHAR(32) DEFAULT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth',
+    weight DECIMAL(5,2) NOT NULL DEFAULT 1.00 COMMENT '权重值 0.00-10.00,越大越优先',
+    reason VARCHAR(200) DEFAULT NULL COMMENT '权重来源: user_set/auto_click/auto_purchase/manual/auto_profile',
+    created_by BIGINT DEFAULT NULL COMMENT '创建人ID(manual场景记录运营人员)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_user_content (user_id, content_type, item_id),
+    INDEX idx_family_dimension (family_id, dimension_code),
+    UNIQUE KEY uk_user_item (user_id, content_type, item_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表';

+ 3 - 122
cfc-frontend/components/ContentSharePoster.vue

@@ -2,50 +2,6 @@
   <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
     <view class="poster-container">
       <canvas canvas-id="contentPosterCanvas" class="poster-canvas" id="contentPosterCanvas"></canvas>
-      <!-- 模板选择 -->
-      <view class="poster-tpl-row">
-        <view class="tpl-label">海报模板</view>
-        <view class="tpl-options">
-          <view
-            class="tpl-option"
-            :class="{ 'tpl-active': template === 'gradient' }"
-            @click="switchTemplate('gradient')"
-          >
-            <view class="tpl-dot tpl-dot-grad"></view>
-            <text class="tpl-text">渐变</text>
-          </view>
-          <view
-            class="tpl-option"
-            :class="{ 'tpl-active': template === 'minimal' }"
-            @click="switchTemplate('minimal')"
-          >
-            <view class="tpl-dot tpl-dot-dark"></view>
-            <text class="tpl-text">深色</text>
-          </view>
-          <view
-            class="tpl-option"
-            :class="{ 'tpl-active': template === 'card' }"
-            @click="switchTemplate('card')"
-          >
-            <view class="tpl-dot tpl-dot-light"></view>
-            <text class="tpl-text">卡片</text>
-          </view>
-        </view>
-      </view>
-      <!-- 背景颜色选择(渐变模板可用) -->
-      <view class="poster-bg-row" v-if="template === 'gradient'">
-        <view class="tpl-label">背景色</view>
-        <view class="bg-options">
-          <view
-            v-for="(c, i) in bgColors"
-            :key="i"
-            class="bg-option"
-            :class="{ 'bg-active': bgColor === c }"
-            :style="{ background: c }"
-            @click="switchBgColor(c)"
-          ></view>
-        </view>
-      </view>
       <view class="poster-actions">
         <button class="poster-btn save" @click="savePoster">保存到相册</button>
       </view>
@@ -89,10 +45,7 @@ export default {
     }
   },
   data() {
-    return {
-      // 可选的背景色(渐变模板下展示)
-      bgColors: ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D', '#1E293B', '#FFFFFF']
-    }
+    return {}
   },
   methods: {
     getColors() {
@@ -624,21 +577,6 @@ export default {
       ctx.closePath()
     },
 
-    switchTemplate(tpl) {
-      this.$emit('update:template', tpl)
-      var self = this
-      this.$nextTick(function() {
-        setTimeout(self.drawPoster.bind(self), 100)
-      })
-    },
-    switchBgColor(color) {
-      this.$emit('update:bgColor', color)
-      var self = this
-      this.$nextTick(function() {
-        setTimeout(self.drawPoster.bind(self), 100)
-      })
-    },
-
     savePoster() {
       var self = this
       uni.canvasToTempFilePath({
@@ -700,6 +638,7 @@ export default {
 .poster-canvas {
   width: 620rpx;
   height: 992rpx;
+  margin-bottom: 80rpx;
   border-radius: 16rpx;
 }
 .poster-actions {
@@ -723,63 +662,5 @@ export default {
   font-weight: 600;
 }
 .poster-btn.save:active { opacity: 0.85; }
-.poster-tpl-row {
-  display: flex;
-  align-items: center;
-  margin: 12rpx 0;
-  font-size: 24rpx;
-}
-.tpl-label {
-  margin-right: 20rpx;
-  color: #666;
-}
-.tpl-options {
-  display: flex;
-}
-.tpl-option {
-  flex: 1;
-  margin: 0 4rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  cursor: pointer;
-}
-.tpl-dot {
-  width: 24rpx;
-  height: 24rpx;
-  border-radius: 999rpx;
-  margin-bottom: 4rpx;
-}
-.tpl-dot-grad { background: linear-gradient(135deg, #FF8C42, #EA580C); }
-.tpl-dot-dark { background: #1E293B; }
-.tpl-dot-light { background: #FFFFFF; }
-.tpl-text {
-  font-size: 14rpx;
-  text-align: center;
-  color: #666;
-}
-.tpl-active .tpl-text { color: #F97316; }
 
-.poster-bg-row {
-  display: flex;
-  align-items: center;
-  margin: 12rpx 0;
-  font-size: 24rpx;
-}
-.bg-label {
-  margin-right: 20rpx;
-  color: #666;
-}
-.bg-options {
-  display: flex;
-}
-.bg-option {
-  width: 32rpx;
-  height: 32rpx;
-  border-radius: 999rpx;
-  margin: 0 4rpx;
-  cursor: pointer;
-}
-.bg-active {
-  border: 2rpx solid #F97316;
-}</style>
+</style>

+ 113 - 0
cfc-frontend/components/DimensionPageSkeleton.vue

@@ -0,0 +1,113 @@
+<template>
+  <!--
+    五维页首屏加载骨架。
+    用途:已登录用户进入五维页、首屏数据请求期间的占位,避免「空白 -> 内容」硬跳变。
+    稳态视觉零变化:仅在 pageLoading && isLoggedIn 为真时渲染,真实 section 由各自的数据守卫控制。
+    触摸目标与 a11y 不适用(纯占位,不可交互)。
+  -->
+  <view class="dim-skeleton">
+    <!-- 品牌定位语占位 -->
+    <view class="dim-skeleton-strap skeleton" />
+
+    <!-- 画像卡占位 -->
+    <view class="dim-skeleton-card">
+      <view class="skeleton dim-skeleton-line dim-skeleton-line-title" />
+      <view class="dim-skeleton-row">
+        <view class="skeleton dim-skeleton-line dim-skeleton-line-w70" />
+        <view class="skeleton dim-skeleton-line dim-skeleton-line-w30" />
+      </view>
+      <view class="skeleton dim-skeleton-line dim-skeleton-line-w90" />
+      <view class="skeleton dim-skeleton-line dim-skeleton-line-w60" />
+    </view>
+
+    <!-- 概览卡占位 -->
+    <view class="dim-skeleton-card">
+      <view class="skeleton dim-skeleton-line dim-skeleton-line-title" />
+      <view class="dim-skeleton-grid">
+        <view class="skeleton dim-skeleton-tile" />
+        <view class="skeleton dim-skeleton-tile" />
+        <view class="skeleton dim-skeleton-tile" />
+        <view class="skeleton dim-skeleton-tile" />
+      </view>
+    </view>
+
+    <!-- 功能区占位 -->
+    <view class="dim-skeleton-card">
+      <view class="skeleton dim-skeleton-line dim-skeleton-line-title" />
+      <view class="dim-skeleton-grid">
+        <view class="skeleton dim-skeleton-tile" />
+        <view class="skeleton dim-skeleton-tile" />
+        <view class="skeleton dim-skeleton-tile" />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'DimensionPageSkeleton',
+  props: {
+    // 维度码:body / mind / wisdom / action / wealth。预留给后续按维度着色,当前为中性骨架。
+    dimCode: {
+      type: String,
+      default: ''
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+/* 骨架走 uni.scss §11 的 .skeleton(shimmer 动画 + 中性渐变底) */
+.dim-skeleton {
+  display: flex;
+  flex-direction: column;
+  gap: 24rpx;
+  padding: 24rpx 30rpx 0;
+}
+
+.dim-skeleton-strap {
+  height: 64rpx;
+  border-radius: $uni-radius-sm;
+}
+
+.dim-skeleton-card {
+  background: $app-surface;
+  border-radius: 16rpx;
+  padding: 28rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.dim-skeleton-line {
+  height: 28rpx;
+  border-radius: $uni-radius-sm;
+}
+
+.dim-skeleton-line-title {
+  height: 36rpx;
+  width: 45%;
+}
+
+.dim-skeleton-line-w90 { width: 90%; }
+.dim-skeleton-line-w70 { width: 70%; }
+.dim-skeleton-line-w60 { width: 60%; }
+.dim-skeleton-line-w30 { width: 30%; }
+
+.dim-skeleton-row {
+  display: flex;
+  gap: 20rpx;
+}
+
+.dim-skeleton-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.dim-skeleton-tile {
+  width: calc((100% - 40rpx) / 3);
+  height: 120rpx;
+  border-radius: $uni-radius-sm;
+}
+</style>

+ 107 - 21
cfc-frontend/components/DimensionProducts.vue

@@ -3,21 +3,27 @@
     <view v-if="!loading && displayProducts.length === 0" class="empty-state">
       <text class="empty-tip">{{ emptyText }}</text>
     </view>
-    <view v-else class="product-list">
-      <view class="product-card" v-for="prod in displayProducts" :key="prod.id" @click="onProductClick(prod)">
-        <image class="product-img" :src="getImageUrl(prod.coverImage) || '/static/default-product.png'" mode="aspectFill" />
-        <view class="product-body">
-          <text class="product-name">{{ prod.name }}</text>
+    <view v-else :class="['product-list', layout === 'mall' ? 'product-list-mall' : '']">
+      <view :class="['product-card', layout === 'mall' ? 'product-card-mall' : '']" v-for="prod in displayProducts" :key="prod.id" @click="onProductClick(prod)">
+        <image class="product-img" :class="layout === 'mall' ? 'product-img-mall' : ''" :src="getImageUrl(prod.coverImage) || '/static/default-product.png'" mode="aspectFill" />
+        <view class="product-body" :class="layout === 'mall' ? 'product-body-mall' : ''">
+          <text class="product-name" :class="layout === 'mall' ? 'product-name-mall' : ''">{{ prod.name }}</text>
           <view class="price-row">
-            <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
-            <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
-            <text class="product-price" v-else>免费</text>
+            <text class="product-price" :class="layout === 'mall' ? 'product-price-mall' : ''" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
+            <text class="product-price" :class="layout === 'mall' ? 'product-price-mall' : ''" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
+            <text class="product-price" :class="layout === 'mall' ? 'product-price-mall' : ''" v-else>免费</text>
           </view>
         </view>
       </view>
-    </view>
-    <view class="loading-state" v-if="loading">
-      <text class="loading-text">加载中...</text>
+      <view v-if="loading" class="loading-state">
+        <text class="loading-text">加载中...</text>
+      </view>
+      <view v-if="loadingMore" class="loading-more">
+        <text class="loading-text">加载中...</text>
+      </view>
+      <view v-if="noMore && displayProducts.length > 0" class="no-more">
+        <text class="no-more-text">— 没有更多了 —</text>
+      </view>
     </view>
   </view>
 </template>
@@ -35,17 +41,24 @@ export default {
     isLoggedIn: { type: Boolean, default: false },
     dimensionCode: { type: String, default: '' },
     title: { type: String, default: '推荐商品' },
-    showMore: { type: Boolean, default: true }
+    showMore: { type: Boolean, default: true },
+    // 'compact' 保持原始 4 列小卡片(member-detail 等页面);'mall' 两列大卡片(首页)
+    layout: { type: String, default: 'compact' }
   },
   data: function() {
     return {
       selfProducts: [],
-      loading: false
+      loading: false,
+      loadingMore: false,
+      page: 1,
+      size: 4,
+      noMore: false
     }
   },
   computed: {
     displayProducts: function() {
-      return (this.products && this.products.length > 0 ? this.products : this.selfProducts).slice(0, 4)
+      // 兼容模式:父组件传入 products 时原样展示(member-detail 等页面已自行截断)
+      return (this.products && this.products.length > 0 ? this.products : this.selfProducts)
     },
     emptyText: function() {
       // 有 familyId(已登录且选中孩子)但推荐接口无数据
@@ -79,21 +92,49 @@ export default {
   methods: {
     loadDimensionProducts: function() {
       var self = this
+      self.page = 1
+      self.noMore = false
       self.loading = true
+      self.loadDimensionProductsByPage(self.page).then(function(list) {
+        self.loading = false
+        self.selfProducts = list
+        if (list.length < self.size) self.noMore = true
+      }).catch(function() {
+        self.loading = false
+        self.selfProducts = []
+      })
+    },
+    // 供父页面 onReachBottom / scrolltolower 调用的加载更多
+    loadMore: function() {
+      var self = this
+      if (self.loading || self.loadingMore || self.noMore) return
+      // 兼容模式(父组件传入 products)不自加载
+      if (self.products && self.products.length > 0) return
+      self.loadingMore = true
+      var nextPage = self.page + 1
+      self.loadDimensionProductsByPage(nextPage).then(function(list) {
+        self.loadingMore = false
+        self.page = nextPage
+        self.selfProducts = self.selfProducts.concat(list)
+        if (list.length < self.size) self.noMore = true
+      }).catch(function() {
+        self.loadingMore = false
+      })
+    },
+    loadDimensionProductsByPage: function(page) {
+      var self = this
       var params = {
-        limit: 4
+        page: page,
+        size: self.size
       }
       if (self.dimensionCode) {
         params.dimensionCode = self.dimensionCode
       }
-      getDimensionProducts(params).then(function(res) {
-        self.loading = false
+      return getDimensionProducts(params).then(function(res) {
         if (res.code === 200 && res.data) {
-          self.selfProducts = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          return Array.isArray(res.data) ? res.data : (res.data.records || [])
         }
-      }).catch(function() {
-        self.loading = false
-        self.selfProducts = []
+        return []
       })
     },
     onProductClick: function(prod) {
@@ -163,6 +204,34 @@ export default {
   color: #F97316;
   font-weight: bold;
 }
+/* ===== 商城样式(首页 layout="mall")===== */
+.product-list-mall {
+  justify-content: space-between;
+}
+.product-card-mall {
+  width: 49%;
+  border-radius: 16rpx;
+  margin-right: 0;
+}
+.product-img-mall {
+  height: 320rpx;
+}
+.product-body-mall {
+  padding: 16rpx 16rpx 14rpx;
+}
+.product-name-mall {
+  display: -webkit-box;
+  font-size: 26rpx;
+  line-height: 1.4;
+  margin-bottom: 8rpx;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  white-space: normal;
+}
+.product-price-mall {
+  font-size: 28rpx;
+}
 .empty-state {
   display: flex;
   justify-content: center;
@@ -176,9 +245,26 @@ export default {
   display: flex;
   justify-content: center;
   padding: 40rpx 0;
+  width: 100%;
+}
+.loading-more {
+  display: flex;
+  justify-content: center;
+  padding: 20rpx 0;
+  width: 100%;
 }
 .loading-text {
   font-size: 24rpx;
   color: #999;
 }
+.no-more {
+  display: flex;
+  justify-content: center;
+  padding: 20rpx 0;
+  width: 100%;
+}
+.no-more-text {
+  font-size: 22rpx;
+  color: #ccc;
+}
 </style>

+ 2 - 2
cfc-frontend/components/gate-lock.vue

@@ -43,7 +43,7 @@ export default {
     },
     btnText: function() {
       var map = {
-        SELF_CHECK: '完成五维基础能量检测,解锁能量沙盘',
+        SELF_CHECK: '完成健康自检问卷,解锁能量沙盘',
         INVITE_FAMILY: '邀请家人加入,解锁全家沙盘',
         CREATE_FAMILY_MEMBER: '创建孩子账号,解锁微行动',
         PURCHASE: '去商城逛逛',
@@ -59,7 +59,7 @@ export default {
       var url = ''
       switch (type) {
         case 'SELF_CHECK':
-          url = '/pages/family/self-check'
+          url = '/pages/health/health-plan-summary'
           break
         case 'INVITE_FAMILY':
           url = '/pages/profile-extra/family-members'

+ 122 - 0
cfc-frontend/docs/dimension-pages-stage4-audit.md

@@ -0,0 +1,122 @@
+# 五维主页面 · 阶段四审计报告(UniForge verify)
+
+> 范围:`pages/body-detail` / `wisdom-detail` / `mind-detail` / `wealth` / `action-detail` 五页
+> 前置:阶段一(语义基座)→ 阶段二(灰阶归一 + 旧蓝清零)→ 阶段三(状态色阶 + 页面级局部调色板)均已合入 git
+> 性质:**只读审计 + 方案待确认**,本报告不含代码改动
+
+---
+
+## 一、阶段一~三 已完成项(复核通过)
+
+| 项 | 结果 |
+|---|---|
+| SCSS 令牌编译(dart-sass 逐页) | ALL 5 PASS |
+| 全量 `npm run build:mp-weixin` | DONE Build complete |
+| `transition: all` | 五页 **0**(阶段一修 3 处) |
+| 五页 `#5B9BD5` 旧品牌蓝 | **0** |
+| 硬编码色值(style 区块) | **0**(仅存于令牌声明行) |
+| 渐变退化(全同色) | **0**(修复 6 处,见 §四) |
+| 未解析 `$` 变量残留(产物 wxss) | **0** |
+| 尺寸左条规范 8rpx | 已统一 |
+
+---
+
+## 二、审计发现
+
+### 2.1 触摸目标不足(微信建议 ≥ 88rpx ≈ 44px)
+
+方法:提取带 `@tap`/`@click` 的元素 class,累加其 `height` + `padding` 估算。**这是上限估计**——`flex` 子项靠内容撑开的会被低估。
+
+**A 级 · 确认过小(文字/图标型,无内容撑开)**
+
+| 元素 | 页 | 估算高度 | 性质 |
+|---|---|---|---|
+| `.section-more` | body / wisdom / mind / wealth | ~24–40rpx | 纯文字「更多」 |
+| `.help-icon` | action | ~36rpx | 帮助图标 |
+| `.help-dialog-close` | action | ~48rpx | 弹窗关闭 |
+| `.overview-nav-btn` / `-secondary` | body | ~20rpx | 概览双按钮(核心入口) |
+| `.insurance-item` | wealth | ~16rpx | 列表行(靠内容撑开,可能误判) |
+
+**B 级 · 疑似误判(flex 列布局,靠内容撑开,需真机核对)**
+
+`.indicator-card`(3 页)、`.dan-card`(2 页)、`.func-item`(2 页)、`.disc-sub-card`、`.insight-btn`、`.gut-upload-btn`、`.section-link`、`.overview-card-item`
+
+### 2.2 加载态缺失(5/5 页)
+
+五页 `loading` 关键字命中 **0**。而项目侧资产是现成的:
+
+- `uni.scss` §11 已定义骨架屏令牌
+- 五页编译产物中已有 **15 处合法 shimmer 渐变**(`#E2E8F0 25%, #F5F9FC 50%, #E2E8F0 75%`)——但均在 mind 页局部使用,未形成统一加载态
+- `components/BaseLoading.vue` 已存在
+
+**结论:数据请求期间页面是「空白→内容」跳变,无过渡。**
+
+### 2.3 空态覆盖不全
+
+| 页 | 空态关键词命中 | 备注 |
+|---|---|---|
+| body | 4 | 部分分区有空态 |
+| wisdom | 16 | 覆盖最好 |
+| mind | 7 | 中等 |
+| wealth | 6 | 中等 |
+| **action** | **0** | **完全无空态** |
+
+### 2.4 错误态基本缺失
+
+五页仅 1–2 处 `error` 命中,且多在 `catch` 内。无统一「加载失败 + 重试」入口。
+
+---
+
+## 三、修复方案(**需廖总拍板范围**)
+
+### 方案对比
+
+| 方案 | 做法 | 观感影响 | 工作量 |
+|---|---|---|---|
+| **甲 · 零观感** | 伪元素扩展触摸区(`position:relative` + `::after` 外扩) | **完全不变** | 中(17 处 × 2 行) |
+| **乙 · 标准高度** | A 级加 `min-height:88rpx` + flex 居中 | 按钮/「更多」变高,header 行高变化 | 小 |
+| **丙 · 分级** | A 级用乙、B 级不动,加载态补骨架屏,action 补空态 | 局部变高 + 加载过渡 | 中大 |
+
+**我的建议:丙。** 理由:
+- A 级里 `overview-nav-btn` 是概览区核心入口,20rpx 触摸区在真机上几乎点不中,属**可用性缺陷**而非审美问题,应修;
+- `section-more` 这类文字链用**伪元素**(甲)处理即可,不必撑高 header;
+- 加载态补骨架屏是纯增益(请求期间有占位,不改变稳态视觉);
+- action 页空态补齐是**功能完整性**问题。
+
+### 加载态方案(细化)
+
+抽取页面级 `dim-skeleton` 原子类(复用 §11 令牌 + 已有 shimmer 渐变),五页 `<template>` 数据容器外加 `v-if="pageLoading"` 骨架分支。稳态视觉零变化。
+
+---
+
+## 四、阶段三遗留修复记录(已合入 git,备查)
+
+| 文件 | 问题 | 修复 |
+|---|---|---|
+| action-detail | `#34D399`/`#6EE7B7`(行主色亮阶)被误映射到同值的 `$status-success`,**4 处渐变退化成纯色** | 补 `$c-action-light`/`$c-action-lighter` 局部变量还原 |
+| body-detail | `#FFF3E8`/`#FFF9F5` 被并入 `$dim-body-tint`,**2 处渐变同色退化** | 补 `$c-tint-peach`/`$c-tint-blush` 还原 |
+| body-detail | 另 1 处 `$dim-body-tint, $dim-body-tint` | 同法还原 |
+
+**等价回归结论**:HEAD 前后 CSS 逐字节对比,全项目仅剩 **29 处刻意语义收敛**(灰阶归一化 12 / 危险色 `#FF6B6B→#EF4444` 4 / 财富 tint `#FFF7ED→#FFFBEB` 10 / 其他 tint 3),无功能缺陷。
+
+---
+
+## 五、环境异常警告(重要)
+
+本轮发现 `pages/action-detail/index.vue` **在工作区被截断为 145 行**(`</style>` 丢失、阶段三改动全失),已从 git HEAD 完整恢复(1300 行,锚点复核通过)。
+
+同期异常:
+- HEAD 从 `407e91da5` 变为 `a51bb8682`,五维页阶段三改动被**外部提交**(`d89ad7c24 feat(web): Phase 2`,本属 cfc-web 改动)顺带合入;
+- `UserContentWeightService.java` 等非本项目文件也出现改动。
+
+**这与历史异常同源**(docs/ 被清空、`DimensionVisitorCard.vue` 消失、wealth 页被截断)。建议排查是否有外部进程/清理脚本/多会话并发写入本工作区。
+
+---
+
+## 六、待办
+
+- [ ] **廖总确认 §三 修复范围**(甲 / 乙 / 丙)
+- [ ] 加载态骨架屏落地(方案丙)
+- [ ] action 页空态补齐(方案丙)
+- [ ] B 级触摸目标**真机核对**(模拟器/开发者工具元素面板)
+- [ ] `SectionCard` 共用卡片原语抽取(**需动五页模板**,改动面大,单独确认后再做)

+ 94 - 39
cfc-frontend/pages/action-detail/index.vue

@@ -9,6 +9,7 @@
     <PageBanner theme="action"
       tagline="人际和谐 · 关系滋养"
       quote="和谐关系滋养幸福家庭" />
+    <DimensionPageSkeleton v-if="pageLoading" dim-code="action" />
 
     <!-- 品牌定位语(登录前显示) -->
     <view class="brand-strap" v-if="!isLoggedIn">
@@ -271,12 +272,14 @@ import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import DimensionSubDims from './components/DimensionSubDims.vue'
+import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
 import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getOctopusTentacles, getPearlResources, deletePearlResource, getAbilityMap, getRadarData, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, DimensionVisitorCard, FloatingAvatar, RadarChart, DimensionSubDims },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, DimensionVisitorCard, FloatingAvatar, RadarChart, DimensionSubDims, DimensionPageSkeleton },
   data() {
     return {
+    pageLoading: false,
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
@@ -521,6 +524,17 @@ export default {
       return result
     }
   },
+  created() {
+    // 首屏加载骨架:已登录用户的数据请求期间占位,稳态视觉零变化
+    // 未登录走静态内容,不进 loading,故此处不读 isLoggedIn
+    var token = uni.getStorageSync('token')
+    if (token) {
+      this.pageLoading = true
+      var self = this
+      setTimeout(function() { self.pageLoading = false }, 600)
+    }
+  },
+
   onShow() {
     this.showTabTransition = true
 
@@ -843,9 +857,30 @@ export default {
 </script>
 
 <style scoped lang="scss">
+
+// 页面级局部调色板:仅本页专有色阶,跨页共性一律走 uni.scss 全局令牌
+// 信息蓝
+$c-info: #0EA5E9;
+$c-action-light: #34D399;
+$c-action-lighter: #6EE7B7;
+// 信息蓝·亮阶
+$c-info-light: #38BDF8;
+// 危险·亮阶(渐变尾)
+$c-danger-light: #F87171;
+// 次级文字
+$c-text-soft: #64748B;
+// 深次级文字
+$c-text-deep: #475569;
+// 下沉底 / 分隔
+$c-surface-sunken: #F1F5F9;
+// 成功·亮阶(渐变尾)
+$c-success-light: #A7F3D0;
+// 薄荷极浅底
+$c-tint-mint: #E6FFF2;
+
 .container {
   min-height: 100vh;
-  background: linear-gradient(180deg, #F0FFF4 0%, $app-bg 40%);
+  background: linear-gradient(180deg, $dim-action-tint 0%, $app-bg 40%);
   padding-bottom: 120rpx;
 }
 
@@ -853,7 +888,7 @@ export default {
 .brand-strap {
   margin: 24rpx 30rpx 0;
   padding: 28rpx 32rpx;
-  background: linear-gradient(135deg, $dim-action 0%, #34D399 50%, #6EE7B7 100%);
+  background: linear-gradient(135deg, $dim-action 0%, $c-action-light 50%, $c-action-lighter 100%);
   border-radius: 24rpx;
   box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.25);
   position: relative;
@@ -892,7 +927,7 @@ export default {
 .monthly-guidance {
   margin: 20rpx 30rpx 0;
   padding: 24rpx 28rpx;
-  background: linear-gradient(135deg, $app-surface 0%, #F0FFF4 100%);
+  background: linear-gradient(135deg, $app-surface 0%, $dim-action-tint 100%);
   border-radius: 20rpx;
   border: 1rpx solid rgba(16, 185, 129, 0.15);
   box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.08);
@@ -903,7 +938,7 @@ export default {
 .guidance-icon { font-size: 36rpx; }
 .guidance-text {
   font-size: 26rpx;
-  color: #065F46;
+  color: $status-success-strong;
   font-weight: 500;
 }
 
@@ -920,7 +955,7 @@ export default {
   right: 24rpx;
   height: 3rpx;
   border-radius: 3rpx 3rpx 0 0;
-  background: linear-gradient(90deg, $dim-action, #34D399);
+  background: linear-gradient(90deg, $dim-action, $c-action-light);
   opacity: 0.7;
 }
 .section-header {
@@ -939,11 +974,12 @@ export default {
   padding-left: 16rpx;
 }
 .help-icon {
+  position: relative;
   width: 36rpx;
   height: 36rpx;
   border-radius: 50%;
-  border: 2rpx solid #D1D5DB;
-  color: #9CA3AF;
+  border: 2rpx solid $app-border-strong;
+  color: $app-text-muted;
   font-size: 22rpx;
   display: flex;
   align-items: center;
@@ -952,21 +988,30 @@ export default {
   transition: opacity 0.2s $ease-standard, transform 0.2s $ease-standard;
   background: $app-surface;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):帮助图标 36x36 -> 88x88,视觉零变化 */
+.help-icon::after {
+  content: '';
+  position: absolute;
+  left: -26rpx;
+  right: -26rpx;
+  top: -26rpx;
+  bottom: -26rpx;
+}
 .help-icon:active {
   border-color: $dim-action;
   color: $dim-action;
-  background: #F0FFF4;
+  background: $dim-action-tint;
   transform: scale(1.1);
 }
 .section-title {
   font-size: 34rpx;
   font-weight: 700;
-  color: #1E293B;
+  color: $app-text;
   letter-spacing: 0.5rpx;
 }
 .section-sub {
   font-size: 24rpx;
-  color: #94A3B8;
+  color: $app-text-subtle;
   margin-top: 4rpx;
   line-height: 1.4;
 }
@@ -986,41 +1031,41 @@ export default {
   transform: scale(0.98);
 }
 .section-link-octopus {
-  background: linear-gradient(135deg, #F97316, #FB923C);
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
   color: $app-surface;
   box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.3);
 }
 .section-link-pearl {
-  background: linear-gradient(135deg, #0EA5E9, #38BDF8);
+  background: linear-gradient(135deg, $c-info, $c-info-light);
   color: $app-surface;
   box-shadow: 0 4rpx 16rpx rgba(14,165,233,0.3);
 }
 .section-link-ability {
-  background: linear-gradient(135deg, $dim-action, #34D399);
+  background: linear-gradient(135deg, $dim-action, $c-action-light);
   color: $app-surface;
   box-shadow: 0 4rpx 16rpx rgba(16,185,129,0.3);
 }
 
 /* 关系解读卡片 — 状态色 */
 .score-explain-card {
-  background: linear-gradient(135deg, $app-surface 0%, #F0FFF4 100%);
+  background: linear-gradient(135deg, $app-surface 0%, $dim-action-tint 100%);
   border-radius: 20rpx;
   padding: 28rpx 32rpx;
   border: 1rpx solid rgba(16, 185, 129, 0.12);
   box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.08);
 }
 .score-explain-good {
-  background: linear-gradient(135deg, $app-surface 0%, #D1FAE5 100%);
+  background: linear-gradient(135deg, $app-surface 0%, $status-success-soft 100%);
   border-color: rgba(16, 185, 129, 0.25);
   box-shadow: 0 4rpx 20rpx rgba(16, 185, 129, 0.15);
 }
 .score-explain-warn {
-  background: linear-gradient(135deg, $app-surface 0%, #FEF3C7 100%);
+  background: linear-gradient(135deg, $app-surface 0%, $status-warning-soft 100%);
   border-color: rgba(245, 158, 11, 0.25);
   box-shadow: 0 4rpx 20rpx rgba(245, 158, 11, 0.12);
 }
 .score-explain-bad {
-  background: linear-gradient(135deg, $app-surface 0%, #FEE2E2 100%);
+  background: linear-gradient(135deg, $app-surface 0%, $status-danger-soft 100%);
   border-color: rgba(239, 68, 68, 0.25);
   box-shadow: 0 4rpx 20rpx rgba(239, 68, 68, 0.12);
 }
@@ -1042,9 +1087,9 @@ export default {
   transition: transform 0.2s;
 }
 .explain-icon-wrap:active { transform: scale(0.95); }
-.explain-icon-good { background: linear-gradient(135deg, $dim-action, #34D399); box-shadow: 0 4rpx 12rpx rgba(16,185,129,0.3); }
-.explain-icon-warn { background: linear-gradient(135deg, #F59E0B, #FBBF24); box-shadow: 0 4rpx 12rpx rgba(245,158,11,0.3); }
-.explain-icon-bad { background: linear-gradient(135deg, #EF4444, #F87171); box-shadow: 0 4rpx 12rpx rgba(239,68,68,0.3); }
+.explain-icon-good { background: linear-gradient(135deg, $dim-action, $c-action-light); box-shadow: 0 4rpx 12rpx rgba(16,185,129,0.3); }
+.explain-icon-warn { background: linear-gradient(135deg, $dim-wealth, $status-warning); box-shadow: 0 4rpx 12rpx rgba(245,158,11,0.3); }
+.explain-icon-bad { background: linear-gradient(135deg, $status-danger, $c-danger-light); box-shadow: 0 4rpx 12rpx rgba(239,68,68,0.3); }
 .explain-icon {
   font-size: 28rpx;
   color: $app-surface;
@@ -1057,7 +1102,7 @@ export default {
 .explain-title {
   font-size: 28rpx;
   font-weight: 600;
-  color: #1E293B;
+  color: $app-text;
   line-height: 1.3;
 }
 .explain-score {
@@ -1067,18 +1112,18 @@ export default {
   letter-spacing: -2rpx;
 }
 .score-explain-good .explain-score { color: $dim-action; }
-.score-explain-warn .explain-score { color: #D97706; }
-.score-explain-bad .explain-score { color: #DC2626; }
+.score-explain-warn .explain-score { color: $status-warning-deep; }
+.score-explain-bad .explain-score { color: $status-danger-deep; }
 .explain-score-unit {
   font-size: 24rpx;
   font-weight: 500;
-  color: #94A3B8;
+  color: $app-text-subtle;
   margin-left: 2rpx;
   letter-spacing: 0;
 }
 .explain-text {
   font-size: 26rpx;
-  color: #64748B;
+  color: $c-text-soft;
   line-height: 1.7;
 }
 
@@ -1109,17 +1154,18 @@ export default {
   justify-content: space-between;
   align-items: center;
   padding: 28rpx 32rpx 20rpx;
-  border-bottom: 1rpx solid #F1F5F9;
-  background: linear-gradient(135deg, #F0FFF4, $app-surface);
+  border-bottom: 1rpx solid $c-surface-sunken;
+  background: linear-gradient(135deg, $dim-action-tint, $app-surface);
 }
 .help-dialog-title {
   font-size: 32rpx;
   font-weight: 700;
-  color: #1E293B;
+  color: $app-text;
 }
 .help-dialog-close {
+  position: relative;
   font-size: 36rpx;
-  color: #94A3B8;
+  color: $app-text-subtle;
   cursor: pointer;
   width: 48rpx;
   height: 48rpx;
@@ -1129,13 +1175,22 @@ export default {
   border-radius: 50%;
   transition: background 0.2s;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):关闭按钮 48x48 -> 88x88,视觉零变化 */
+.help-dialog-close::after {
+  content: '';
+  position: absolute;
+  left: -20rpx;
+  right: -20rpx;
+  top: -20rpx;
+  bottom: -20rpx;
+}
 .help-dialog-close:active {
-  background: #F1F5F9;
+  background: $c-surface-sunken;
 }
 .help-dialog-content {
   padding: 28rpx 32rpx;
   font-size: 28rpx;
-  color: #64748B;
+  color: $c-text-soft;
   line-height: 1.8;
 }
 
@@ -1147,7 +1202,7 @@ export default {
   display: flex;
   flex-direction: row;
   justify-content: space-around;
-  background: linear-gradient(135deg, $app-surface 0%, #F0FFF4 100%);
+  background: linear-gradient(135deg, $app-surface 0%, $dim-action-tint 100%);
   border-radius: 24rpx;
   padding: 32rpx 16rpx;
   box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.1);
@@ -1164,7 +1219,7 @@ export default {
   width: 96rpx;
   height: 96rpx;
   border-radius: 28rpx;
-  background: linear-gradient(135deg, #D1FAE5, #A7F3D0);
+  background: linear-gradient(135deg, $status-success-soft, $c-success-light);
   display: flex;
   align-items: center;
   justify-content: center;
@@ -1181,7 +1236,7 @@ export default {
 }
 .func-label {
   font-size: 24rpx;
-  color: #475569;
+  color: $c-text-deep;
   font-weight: 500;
 }
 
@@ -1233,7 +1288,7 @@ export default {
   justify-content: center;
   margin-right: 10rpx;
   font-size: 20rpx;
-  background: linear-gradient(135deg, #D1FAE5, #A7F3D0);
+  background: linear-gradient(135deg, $status-success-soft, $c-success-light);
 }
 .portrait-dim-name {
   font-size: 24rpx;
@@ -1248,7 +1303,7 @@ export default {
 }
 .portrait-dim-bar-track {
   height: 10rpx;
-  background: #D1FAE5;
+  background: $status-success-soft;
   border-radius: 5rpx;
   overflow: hidden;
   margin-bottom: 4rpx;
@@ -1266,14 +1321,14 @@ export default {
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
-  background: linear-gradient(135deg, #F0FFF4, #E6FFF2);
+  background: linear-gradient(135deg, $dim-action-tint, $c-tint-mint);
   border-radius: 12rpx;
   border-left: 8rpx solid $dim-action;
   margin-top: 16rpx;
 }
 .portrait-tip-text {
   font-size: 22rpx;
-  color: #065F46;
+  color: $status-success-strong;
   line-height: 1.5;
 }
 </style>

+ 0 - 1
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -363,7 +363,6 @@ export default {
   methods: {
     // ===== 工具方法 =====
     getImageUrl: function(path) {
-
       if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
       var s = String(path).trim()
       if (!s || s === 'null' || s === '/null') return ''

+ 0 - 1
cfc-frontend/pages/activity/index.vue

@@ -376,7 +376,6 @@ export default {
   },
   methods: {
     getImageUrl: function(path) {
-
       if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
       var s = String(path).trim()
       if (!s || s === 'null' || s === '/null') return ''

+ 85 - 21
cfc-frontend/pages/article-center/article-detail.vue

@@ -41,6 +41,15 @@
         <view class="divider"></view>
         <view class="detail-body"><mp-html :content="article.content" :domain="config.API_BASE_URL" :tag-style="contentTagStyle" /></view>
 
+        <!-- 关联商品推荐(文章标签 ↔ 商品标签匹配,登录/匿名均可返回) -->
+        <view v-if="relatedProducts.length > 0" class="related-section">
+          <view class="related-header">
+            <text class="related-title">相关商品</text>
+            <text class="related-subtitle">根据文章内容推荐</text>
+          </view>
+          <dimension-products :products="relatedProducts" @productClick="goRelatedProduct" />
+        </view>
+
         <!-- 评论区(已屏蔽) -->
         <view style="height: 200rpx;"></view>
       </scroll-view>
@@ -60,21 +69,30 @@
 
     <ContentSharePoster :show="showPoster" :title="(article && article.title) || ''" :coverImage="(article && article.coverImage) || ''" :qrCodeBase64="posterQrCode || ''" typeLabel="好文推荐" :dimensionCode="posterDimension || ''" :summary="posterSummary || ''" :inviteText="posterInviteText || ''" @close="closePoster" />
 
-    <!-- 浠宝答题弹窗 -->
+    <!-- 浠宝答题弹窗(一题一页) -->
     <view class="quiz-mask" v-if="showQuiz" @click="closeQuiz">
       <view class="quiz-dialog" @click.stop>
         <view class="quiz-mascot">
           <text class="mascot-icon">{{ mascotIcon }}</text>
           <text class="mascot-name">{{ mascotName }}</text>
         </view>
-        <text class="quiz-intro">阅读完成!考考你三个问题~</text>
-        <view class="quiz-question" v-for="(q, qi) in quizQuestions" :key="qi">
-          <text class="q-title">问题{{ qi+1 }}: {{ q.question }}</text>
+        <template v-if="currentQuestion">
+          <view class="quiz-step-bar">
+            <text class="quiz-step-text">第 {{ currentQuizIndex + 1 }} / {{ quizQuestions.length }} 题</text>
+            <view class="quiz-dots">
+              <view v-for="(q, qi) in quizQuestions" :key="qi" class="quiz-dot" :class="{ active: qi === currentQuizIndex, done: quizAnswers[qi] }"></view>
+            </view>
+          </view>
+          <text class="q-title">{{ currentQuestion.question }}</text>
           <view class="q-options">
-            <text v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ selected: quizAnswers[qi] === String.fromCharCode(65 + oi) }" @click="selectQuizAnswer(qi, oi)">{{ opt }}</text>
+            <text v-for="(opt, oi) in currentQuestion.options" :key="oi" class="q-option" :class="{ selected: quizAnswers[currentQuizIndex] === String.fromCharCode(65 + oi) }" @click="selectQuizAnswer(currentQuizIndex, oi)">{{ opt }}</text>
           </view>
-        </view>
-        <button class="quiz-submit" @click="submitQuiz">提交答案</button>
+          <view class="quiz-nav">
+            <button v-if="currentQuizIndex > 0" class="quiz-prev" @click="goPrevQuestion">上一题</button>
+            <button v-if="currentQuizIndex < quizQuestions.length - 1" class="quiz-submit" @click="goNextQuestion">下一题</button>
+            <button v-else class="quiz-submit" @click="submitQuiz">提交答案</button>
+          </view>
+        </template>
       </view>
     </view>
 
@@ -91,19 +109,21 @@
 </template>
 
 <script>
-import { getArticleDetail, reportReadingTime, getArticleAudio, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz } from '@/utils/api.js'
+import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
+import DimensionProducts from '@/components/DimensionProducts.vue'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import config from '@/config.js'
 import { getFontScale } from '@/utils/fontScale.js'
 
 export default {
   mixins: [shareMixin],
-  components: { ContentSharePoster, MpHtml },
+  components: { ContentSharePoster, DimensionProducts, MpHtml },
   data() {
     return {
       articleId: '', article: null, loading: true, error: false, errorMsg: '',
+      relatedProducts: [],
       readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
       isTimerRunning: false, timerHandle: null, syncHandle: null,
       currentChildId: null, isLogin: false,
@@ -113,7 +133,7 @@ export default {
       posterDimension: '', posterSummary: '', posterInviteText: '',
       fontScale: 1,
       // 答题
-      showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {}, quizRecordId: null,
+      showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {}, quizRecordId: null, currentQuizIndex: 0,
       mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
       mascotIcon: '🐶', mascotName: '浠宝'
     }
@@ -123,6 +143,11 @@ export default {
       var scale = this.fontScale || 1
       var fontSize = 32 * scale
       return { p: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', div: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', section: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', li: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', blockquote: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;' }
+    },
+    // 当前展示的一题(一题一页)
+    currentQuestion: function() {
+      if (!this.quizQuestions || !this.quizQuestions.length) return null
+      return this.quizQuestions[this.currentQuizIndex] || null
     }
   },
   onLoad(options) {
@@ -169,19 +194,35 @@ export default {
           this.article.content = this.resolveContentUrls(this.article.content)
           this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
           this.startReadingTimer()
+          // 关联商品:放在 loadDetail 内只走一次(onShow 不重复请求,避免双发)
+          this.loadRelatedProducts(id)
         } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
       } catch (e) { this.error = true; this.errorMsg = '加载失败' }
       finally { this.loading = false }
     },
+    // 加载文章关联商品(后端按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序)
+    loadRelatedProducts: function(articleId) {
+      var self = this
+      if (!articleId) return
+      getArticleRelatedProducts({ articleId: articleId, size: 4 }).then(function(res) {
+        if (res && res.code === 200 && res.data && Array.isArray(res.data)) {
+          self.relatedProducts = res.data
+        }
+      }).catch(function() { self.relatedProducts = [] })
+    },
+    // 跳转商品详情(优先使用后端返回的 url)
+    goRelatedProduct: function(prod) {
+      if (!prod) return
+      var pid = prod.productId || prod.id
+      var url = prod.url || ('/pages/discover-detail/product-detail/product-detail?id=' + (pid || ''))
+      if (url.indexOf('/pages/') !== 0) return
+      uni.navigateTo({ url: url })
+    },
     formatDate(d) { return d ? d.slice(0, 10) : '' },
     getImageUrl: function(path) {
       if (!path) return ''
       if (path.indexOf('http://') === 0 || path.indexOf('https://') === 0) return path
-      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-      var s = String(path).trim()
-      if (!s || s === 'null' || s === '/null') return ''
-      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+      return config.API_BASE_URL + path
     },
     resolveContentUrls: function(html) {
       if (!html) return html
@@ -348,6 +389,7 @@ export default {
           this.quizRecordId = res.data.recordId
           this.quizQuestions = res.data.questions
           this.quizAnswers = []
+          this.currentQuizIndex = 0
           this.showQuiz = true
         }
       } catch (e) {}
@@ -378,6 +420,13 @@ export default {
     },
     selectQuizAnswer: function(qi, oi) {
       this.quizAnswers[qi] = String.fromCharCode(65 + oi)
+    },
+    // 一题一页:上一题 / 下一题
+    goPrevQuestion: function() {
+      if (this.currentQuizIndex > 0) this.currentQuizIndex--
+    },
+    goNextQuestion: function() {
+      if (this.currentQuizIndex < this.quizQuestions.length - 1) this.currentQuizIndex++
     }
   }
 }
@@ -416,6 +465,12 @@ export default {
 .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
 .detail-body { padding: 0 30rpx; font-size: calc(32rpx * var(--font-scale, 1)); color: #444; line-height: 1.8; }
 
+/* 关联商品推荐 */
+.related-section { margin-top: 20rpx; padding: 0 30rpx 8rpx; }
+.related-header { display: flex; align-items: baseline; margin-bottom: 16rpx; }
+.related-title { font-size: 30rpx; font-weight: bold; color: #333; }
+.related-subtitle { font-size: 20rpx; color: #999; margin-left: 12rpx; }
+
 .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
 .share-btn { height: 72rpx; padding: 0 30rpx; background: linear-gradient(135deg, #F97316, #FB923C); border-radius: 36rpx; display: flex; align-items: center; justify-content: center; border: none; }
 .share-btn::after { border: none; }
@@ -433,13 +488,22 @@ export default {
 .quiz-mascot { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin-bottom: 16rpx; }
 .mascot-icon { font-size: 60rpx; }
 .mascot-name { font-size: 32rpx; font-weight: 700; color: #F97316; }
-.quiz-intro { text-align: center; font-size: 28rpx; color: #666; margin-bottom: 24rpx; }
-.quiz-question { margin-bottom: 20rpx; }
-.q-title { font-size: 26rpx; font-weight: 600; color: #333; margin-bottom: 12rpx; }
-.q-options { display: flex; flex-direction: column; gap: 8rpx; }
-.q-option { padding: 14rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; font-size: 24rpx; color: #333; }
+.quiz-step-bar { display: flex; align-items: center; justify-content: center; margin-bottom: 24rpx; }
+.quiz-step-text { font-size: 24rpx; color: #999; margin-right: 12rpx; }
+.quiz-dots { display: flex; align-items: center; gap: 8rpx; }
+.quiz-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #E5E7EB; }
+.quiz-dot.active { background: #F97316; }
+.quiz-dot.done { background: #FDBA74; }
+.q-title { font-size: 30rpx; font-weight: 600; color: #333; line-height: 1.5; margin-bottom: 20rpx; }
+.q-options { display: flex; flex-direction: column; gap: 12rpx; }
+.q-option { padding: 16rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; font-size: 26rpx; color: #333; }
 .q-option.selected { border-color: #F97316; background: #FFF7ED; color: #92400E; }
-.quiz-submit, .result-btn { width: 100%; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; margin-top: 20rpx; }
+.quiz-nav { display: flex; gap: 16rpx; margin-top: 28rpx; }
+.quiz-prev { flex: 1; padding: 20rpx; background: #fff; color: #F97316; border: 2rpx solid #F97316; font-size: 28rpx; border-radius: 12rpx; }
+.quiz-prev::after { border: none; }
+.quiz-submit { flex: 1; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; }
+.quiz-submit::after { border: none; }
+.result-btn { width: 100%; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; margin-top: 20rpx; }
 .result-dialog { text-align: center; }
 .result-icon { font-size: 80rpx; }
 .result-text { font-size: 32rpx; font-weight: 700; color: #333; margin: 16rpx 0; }

+ 95 - 20
cfc-frontend/pages/body-detail/index.vue

@@ -7,6 +7,7 @@
 
     <!-- 品牌头部 -->
     <PageBanner theme="body" tagline="科学管理全家健康" quote="身体健康是幸福的基础" />
+    <DimensionPageSkeleton v-if="pageLoading" dim-code="body" />
 
     <!-- 品牌定位语(登录前显示) -->
     <view class="brand-strap" v-if="!isLoggedIn">
@@ -142,6 +143,10 @@
                 {{ ind.value || formatNumeric(ind.numericValue) }}
               </text>
               <text class="indicator-placeholder" v-else>暂无数据</text>
+              <text class="indicator-delta" v-if="ind.trendDesc"
+                    :class="ind.trend === 'up' ? 'trend-up' : ind.trend === 'down' ? 'trend-down' : 'trend-stable'">
+                {{ ind.trend === 'up' ? '▲' : ind.trend === 'down' ? '▼' : '─' }}{{ ind.trendDesc }}
+              </text>
             </view>
             <view class="indicator-footer">
               <text class="indicator-range" v-if="ind.refRange">参考范围: {{ ind.refRange }}</text>
@@ -241,6 +246,7 @@ import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
 
 import {
   getVisibleSections,
@@ -257,9 +263,10 @@ import {
 } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
+    pageLoading: false,
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
@@ -379,6 +386,17 @@ export default {
       return messages[month] || messages[0]
     }
   },
+  created() {
+    // 首屏加载骨架:已登录用户的数据请求期间占位,稳态视觉零变化
+    // 未登录走静态内容,不进 loading,故此处不读 isLoggedIn
+    var token = uni.getStorageSync('token')
+    if (token) {
+      this.pageLoading = true
+      var self = this
+      setTimeout(function() { self.pageLoading = false }, 600)
+    }
+  },
+
   onShow() {
     this.showTabTransition = true
     var token = uni.getStorageSync('token')
@@ -625,6 +643,23 @@ export default {
 </script>
 
 <style scoped lang="scss">
+
+// 页面级局部调色板:仅本页专有色阶,跨页共性一律走 uni.scss 全局令牌
+// 身·亮阶(渐变尾)
+$c-body-light: #FFB366;
+$c-tint-peach: #FFF3E8;
+$c-tint-blush: #FFF9F5;
+// 身·深阶(渐变尾)
+$c-body-deep: #FF6B1A;
+// 暖调极浅底
+$c-tint-warm: #FFF5EE;
+// 暖调浅底
+$c-tint-soft: #FFF1E6;
+// 暖调描边
+$c-edge-warm: #FFE4CC;
+// 发丝分隔线
+$c-hairline: #eee;
+
 .body-container {
   min-height: 100vh;
   background-color: $app-bg;
@@ -643,7 +678,7 @@ export default {
 
 .overview-header {
   padding: 24rpx 24rpx 16rpx;
-  border-bottom: 1rpx solid #FFF0E4;
+  border-bottom: 1rpx solid $dim-body-tint;
 }
 
 .overview-title {
@@ -670,7 +705,7 @@ export default {
   margin: 0 0 20rpx 0;
   margin-right: 16rpx;
   padding: 20rpx;
-  background: linear-gradient(135deg, #FFF8F0 0%, #FFF3E8 100%);
+  background: linear-gradient(135deg, $dim-body-tint 0%, $c-tint-peach 100%);
   border-radius: 20rpx;
   box-shadow: 0 2rpx 8rpx rgba(255, 140, 66, 0.08);
 }
@@ -683,7 +718,7 @@ export default {
   width: 52rpx;
   height: 52rpx;
   border-radius: 50%;
-  background: linear-gradient(135deg, $dim-body, #FFB366);
+  background: linear-gradient(135deg, $dim-body, $c-body-light);
   display: flex;
   align-items: center;
   justify-content: center;
@@ -729,8 +764,8 @@ export default {
   flex-shrink: 0;
 }
 
-.trend-up { color: #10B981; }
-.trend-down { color: #FF6B6B; }
+.trend-up { color: $status-success; }
+.trend-down { color: $status-danger; }
 .trend-stable { color: $app-text-muted; }
 
 .trend-desc {
@@ -749,15 +784,25 @@ export default {
 }
 
 .overview-nav-btn {
+  position: relative;
   flex: 1;
   display: flex;
   align-items: center;
   justify-content: center;
-  background: linear-gradient(135deg, $dim-body, #FF6B1A);
+  background: linear-gradient(135deg, $dim-body, $c-body-deep);
   border-radius: 48rpx;
   padding: 20rpx 0;
   box-shadow: 0 4rpx 16rpx rgba(255, 107, 26, 0.35);
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):概览主按钮 ~76rpx -> 88rpx,视觉零变化 */
+.overview-nav-btn::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: -6rpx;
+  bottom: -6rpx;
+}
 
 .overview-nav-btn-text {
   color: $app-surface;
@@ -766,6 +811,7 @@ export default {
 }
 
 .overview-nav-btn-secondary {
+  position: relative;
   flex: 1;
   display: flex;
   align-items: center;
@@ -775,6 +821,15 @@ export default {
   padding: 20rpx 0;
   border: 2rpx solid $dim-body;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):概览次按钮 ~76rpx -> 88rpx,视觉零变化 */
+.overview-nav-btn-secondary::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: -6rpx;
+  bottom: -6rpx;
+}
 
 .overview-nav-btn-secondary-text {
   color: $dim-body;
@@ -809,12 +864,22 @@ export default {
 }
 
 .section-more {
+  position: relative;
   font-size: 24rpx;
   color: $dim-body;
   font-weight: 500;
   margin-left: auto;
   padding-left: 16rpx;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):文字链 ~34rpx -> 88rpx,视觉零变化 */
+.section-more::after {
+  content: '';
+  position: absolute;
+  left: -8rpx;
+  right: -16rpx;
+  top: -27rpx;
+  bottom: -27rpx;
+}
 
 .avg-badge {
   display: block;
@@ -835,7 +900,7 @@ export default {
   margin: 0 0 20rpx 0;
   margin-right: 16rpx;
   padding: 20rpx;
-  background: linear-gradient(135deg, #FFF9F5 0%, #FFF5EE 100%);
+  background: linear-gradient(135deg, $c-tint-blush 0%, $c-tint-warm 100%);
   border-radius: 20rpx;
   border: 2rpx solid rgba(255, 140, 66, 0.15);
 }
@@ -846,7 +911,7 @@ export default {
 
 .indicator-card.no-data {
   background: $app-surface-subtle;
-  border-color: #eee;
+  border-color: $c-hairline;
 }
 
 .indicator-header {
@@ -868,6 +933,16 @@ export default {
 
 .indicator-body {
   margin-bottom: 6rpx;
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+}
+
+.indicator-delta {
+  font-size: 20rpx;
+  font-weight: 500;
+  flex-shrink: 0;
+  margin-left: 8rpx;
 }
 
 .indicator-value {
@@ -898,8 +973,8 @@ export default {
 
 .gut-summary {
   padding: 20rpx 24rpx;
-  background-color: #FFF8F0;
-  border-bottom: 1rpx solid #FFF0E4;
+  background-color: $dim-body-tint;
+  border-bottom: 1rpx solid $dim-body-tint;
   text-align: center;
 }
 
@@ -965,7 +1040,7 @@ export default {
 }
 
 .gut-abnormal .gut-item-status {
-  color: #FF6B6B;
+  color: $status-danger;
 }
 
 .gut-empty-hint {
@@ -1018,7 +1093,7 @@ export default {
 .risk-value {
   font-size: 26rpx;
   font-weight: 600;
-  color: #FF6B6B;
+  color: $status-danger;
 }
 
 .risk-level {
@@ -1027,7 +1102,7 @@ export default {
   font-weight: 500;
 }
 
-.risk-severe .risk-level { color: #FF6B6B; }
+.risk-severe .risk-level { color: $status-danger; }
 .risk-attention .risk-level { color: $dim-body; }
 .risk-normal .risk-level { color: $app-text-muted; }
 
@@ -1089,7 +1164,7 @@ export default {
 }
 .portrait-dim-bar-track {
   height: 12rpx;
-  background: #FFF0E4;
+  background: $dim-body-tint;
   border-radius: 6rpx;
   overflow: hidden;
   margin-bottom: 6rpx;
@@ -1106,13 +1181,13 @@ export default {
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
-  background: linear-gradient(135deg, #FFF7ED, #FFF0E4);
+  background: linear-gradient(135deg, $dim-wealth-tint, $dim-body-tint);
   border-radius: 12rpx;
   border-left: 8rpx solid $dim-body;
 }
 .portrait-tip-text {
   font-size: 24rpx;
-  color: #C2410C;
+  color: $brand-primary-deep;
   line-height: 1.5;
 }
 .monthly-guidance {
@@ -1120,9 +1195,9 @@ export default {
   align-items: center;
   padding: 20rpx 24rpx;
   margin: 0 20rpx 16rpx;
-  background: linear-gradient(135deg, #FFF8F0, #FFF3E8);
+  background: linear-gradient(135deg, $dim-body-tint, $c-tint-peach);
   border-radius: 16rpx;
-  border: 1rpx solid #FFE4CC;
+  border: 1rpx solid $c-edge-warm;
 }
 
 .guidance-icon {
@@ -1138,7 +1213,7 @@ export default {
 /* ─── 品牌定位语(登录前) ─── */
 .brand-strap {
   margin: 0 24rpx 16rpx;
-  background: linear-gradient(135deg, #FFF1E6, rgba(255, 140, 66, 0.10));
+  background: linear-gradient(135deg, $c-tint-soft, rgba(255, 140, 66, 0.10));
   border-radius: 16rpx;
   padding: 20rpx 24rpx;
 }

+ 9 - 6
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -70,7 +70,7 @@
             <view
               v-for="option in group.options"
               :key="option.id"
-              :class="selectedSpecs[group.groupId] === option.id ? 'spec-option spec-option-selected' : (!option.enabled || (option.stock !== undefined && option.stock <= 0) ? 'spec-option spec-option-disabled' : 'spec-option')"
+              :class="getSpecOptionClass(group.groupId, option)"
               @click="selectSpec(group.groupId, option)"
             >
               <text class="spec-option-text">{{ option.name }}</text>
@@ -353,12 +353,9 @@ export default {
       })
     },
     getImageUrl: function(path) {
-      if (!path || path === 'null' || path === 'undefined' || path === '/null') return '/static/default-product.png'
+      if (!path) return '/static/default-product.png'
       if (path.startsWith('http') || path.startsWith('/static/')) return path
-      var s = String(path).trim()
-      if (!s || s === 'null' || s === 'undefined' || s === '/null') return ''
-      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+      return config.API_BASE_URL + path
     },
     onSwiperImageError: function(idx) {
       // 单张图片加载失败时,替换为占位图
@@ -529,6 +526,12 @@ export default {
       return desc
     },
 
+    getSpecOptionClass: function(groupId, option) {
+      if (this.selectedSpecs[groupId] === option.id) return 'spec-option spec-option-selected'
+      if (!option.enabled || (option.stock !== undefined && option.stock <= 0)) return 'spec-option spec-option-disabled'
+      return 'spec-option'
+    },
+
     goUpgrade: function() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },

+ 4 - 1
cfc-frontend/pages/growth/diet-checkin.vue

@@ -169,6 +169,7 @@ export default {
   data() {
     return {
       memberId: null,
+      taskId: null,
       submitting: false,
       pendingSubmit: false,
       isRecording: false,
@@ -206,6 +207,7 @@ export default {
     }
   },
     onLoad: function(options) {
+    this.taskId = options.taskId || null
     this.todayText = this.getToday()
     this.setShareInfo('我在用浠艾福记录孩子的每日饮食,一起来打卡吧', '/pages/growth/diet-checkin')
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
@@ -449,6 +451,7 @@ export default {
           method: 'POST',
           data: {
             memberId: self.memberId,
+            taskId: self.taskId || undefined,
             mealType: self.form.mealType,
             foodItems: self.form.foodItems || '',
             remark: self.form.remark || '',
@@ -462,7 +465,7 @@ export default {
 success: function(res) {
               self.submitting = false
               if (res.data && res.data.code === 200) {
-                uni.showToast({ title: '打卡成功', icon: 'success' })
+                uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
                 setTimeout(function() {
                   uni.navigateBack()
                 }, 800)

+ 5 - 2
cfc-frontend/pages/growth/exercise-checkin.vue

@@ -161,6 +161,7 @@ export default {
   data() {
     return {
       memberId: null,
+      taskId: null,
       isSelfCheckin: false,
       submitting: false,
       pendingSubmit: false,
@@ -206,7 +207,8 @@ export default {
       return '录音 ' + this.recordSeconds + '秒'
     }
   },
-  onLoad: function() {
+  onLoad: function(options) {
+    this.taskId = options.taskId || null
     this.todayText = this.getToday()
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     if (role !== 'child') {
@@ -445,6 +447,7 @@ export default {
           method: 'POST',
           data: {
             memberId: self.memberId,
+            taskId: self.taskId || undefined,
             exerciseType: self.form.exerciseType,
             durationMinutes: self.form.durationMinutes ? parseInt(self.form.durationMinutes) : null,
             intensity: self.form.intensity,
@@ -458,7 +461,7 @@ export default {
           header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
           success: function(res) {
             if (res.data && res.data.code === 200) {
-              uni.showToast({ title: '打卡成功', icon: 'success' })
+              uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
               self.syncedStepText = ''
               setTimeout(function() {
                 uni.navigateBack()

+ 5 - 2
cfc-frontend/pages/growth/sleep-checkin.vue

@@ -96,6 +96,7 @@ export default {
   data() {
     return {
       memberId: null,
+      taskId: null,
       submitting: false,
       history: [],
       todayText: '',
@@ -107,7 +108,8 @@ export default {
       }
     }
   },
-  onLoad: function() {
+  onLoad: function(options) {
+    this.taskId = options.taskId || null
     this.todayText = this.getToday()
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     if (role !== 'child') {
@@ -165,6 +167,7 @@ export default {
           method: 'POST',
           data: {
             memberId: self.memberId,
+            taskId: self.taskId || undefined,
             sleepTime: self.form.sleepTime,
             wakeTime: self.form.wakeTime,
             durationMinutes: duration,
@@ -175,7 +178,7 @@ export default {
           success: function(res) {
               self.submitting = false
               if (res.data && res.data.code === 200) {
-                uni.showToast({ title: '打卡成功', icon: 'success' })
+                uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
                 setTimeout(function() {
                   uni.navigateBack()
                 }, 800)

+ 12 - 7
cfc-frontend/pages/growth/upload.vue

@@ -273,13 +273,18 @@ export default {
     formatPrice: function(cents) {
       if (cents === null || cents === undefined) return '¥0'
       return '¥' + (cents / 100).toFixed(2)
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 12 - 7
cfc-frontend/pages/guide/activities/detail.vue

@@ -318,13 +318,18 @@ export default {
       var h = ('0' + d.getHours()).slice(-2)
       var mi = ('0' + d.getMinutes()).slice(-2)
       return y + '-' + m + '-' + day + ' ' + h + ':' + mi
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 12 - 7
cfc-frontend/pages/guide/activities/index.vue

@@ -207,13 +207,18 @@ export default {
       var h = ('0' + d.getHours()).slice(-2)
       var mi = ('0' + d.getMinutes()).slice(-2)
       return y + '-' + m + '-' + day + ' ' + h + ':' + mi
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 12 - 7
cfc-frontend/pages/guide/packages/share.vue

@@ -154,13 +154,18 @@ export default {
     },
     closePoster() {
       this.$refs.posterPopup.close()
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 1 - 1
cfc-frontend/pages/health-main/index.vue

@@ -515,7 +515,7 @@ export default {
     _statusClass: function(status) {
       return status === 'resolved' ? 'resolved' : 'pending'
     },
-    goSelfTest: function() { uni.navigateTo({ url: '/pages/family/self-check' }) },
+    goSelfTest: function() { uni.navigateTo({ url: '/pages/health/health-plan-summary' }) },
     // 拉取本期待填调研(服务端按 cycle_type 计算当前周期)
     loadPendingSurveys: function() {
       var self = this

+ 41 - 7
cfc-frontend/pages/health/components/ConfirmStep.vue

@@ -56,6 +56,22 @@
       </view>
     </view>
 
+    <!-- 生活方式与肠道健康 -->
+    <view class="form-card" v-if="hasLifestyle">
+      <view class="card-head">
+        <text class="card-title">生活方式与肠道健康</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.mood"><text class="preview-label">心情</text><text class="preview-value">{{ healthStatus.mood }}</text></view>
+      <view class="preview-row" v-if="healthStatus.sleepDuration"><text class="preview-label">睡眠时长</text><text class="preview-value">{{ healthStatus.sleepDuration }}</text></view>
+      <view class="preview-row" v-if="healthStatus.bedtime"><text class="preview-label">入睡时间</text><text class="preview-value">{{ healthStatus.bedtime }}</text></view>
+      <view class="preview-row" v-if="healthStatus.bowelFrequency"><text class="preview-label">排便频率</text><text class="preview-value">{{ healthStatus.bowelFrequency }}</text></view>
+      <view class="preview-row" v-if="healthStatus.bowelForm"><text class="preview-label">粪便性状</text><text class="preview-value">{{ healthStatus.bowelForm }}</text></view>
+      <view class="preview-row" v-if="healthStatus.antibioticHistory"><text class="preview-label">近2年抗生素</text><text class="preview-value">{{ healthStatus.antibioticHistory }}</text></view>
+      <view class="preview-row" v-if="healthStatus.allergySymptoms && healthStatus.allergySymptoms.length > 0">
+        <text class="preview-label">过敏表现</text><text class="preview-value">{{ healthStatus.allergySymptoms.join('、') }}</text>
+      </view>
+    </view>
+
     <!-- 饮食习惯 -->
     <view class="form-card">
       <view class="card-head">
@@ -85,6 +101,18 @@
         <text class="preview-label">健康目标</text>
         <text class="preview-value">{{ dietPreferences.healthGoals.join('、') }}</text>
       </view>
+      <view class="preview-row" v-if="dietPreferences.vegetableFruit">
+        <text class="preview-label">每日蔬果量</text>
+        <text class="preview-value">{{ dietPreferences.vegetableFruit }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.dairy">
+        <text class="preview-label">乳制品</text>
+        <text class="preview-value">{{ dietPreferences.dairy }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.sugaryDrinks">
+        <text class="preview-label">含糖饮料</text>
+        <text class="preview-value">{{ dietPreferences.sugaryDrinks }}</text>
+      </view>
       <text class="empty-hint" v-if="!hasDiet && noPreference">无特殊偏好</text>
       <text class="empty-hint" v-else-if="!hasDiet">未填写饮食习惯</text>
     </view>
@@ -96,13 +124,13 @@
         {{ submitting ? '保存中...' : '保存并生成方案' }}
       </button>
     </view>
-  </view>
-</template>
+   </view>
+ </template>
 
-<script>
-export default {
-  name: 'ConfirmStep',
-  props: {
+ <script>
+ export default {
+   name: 'ConfirmStep',
+   props: {
     healthStatus: {
       type: Object,
       default: function() { return {} }
@@ -124,6 +152,11 @@ export default {
     hasMeds: function() {
       return this.medRows.length > 0
     },
+    hasLifestyle: function() {
+      var hs = this.healthStatus || {}
+      return hs.mood || hs.sleepDuration || hs.bedtime || hs.bowelFrequency || hs.bowelForm || hs.antibioticHistory ||
+        (hs.allergySymptoms && hs.allergySymptoms.length > 0)
+    },
     hasDiet: function() {
       var d = this.dietPreferences
       return !!(d &&
@@ -131,7 +164,8 @@ export default {
         (d.absoluteAvoid && d.absoluteAvoid.length > 0) ||
         (d.mustEat && d.mustEat.length > 0) ||
         (d.flavorPref && d.flavorPref.length > 0) ||
-        (d.healthGoals && d.healthGoals.length > 0)))
+        (d.healthGoals && d.healthGoals.length > 0) ||
+        d.vegetableFruit || d.dairy || d.sugaryDrinks))
     },
     noPreference: function() {
       return !!(this.dietPreferences && this.dietPreferences.noPreference)

+ 57 - 3
cfc-frontend/pages/health/components/DietHabitsStep.vue

@@ -14,6 +14,41 @@
       >{{ inner.noPreference ? '已选:无特殊偏好' : '无特殊偏好,直接下一步' }}</button>
     </view>
 
+    <!-- 饮食结构现状:蔬果量/乳制品/含糖饮料频率 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">饮食结构现状</text>
+        <text class="card-hint">单选</text>
+      </view>
+      <view class="struct-row">
+        <text class="struct-label">每日蔬果量</text>
+        <view class="tag-wrap">
+          <view v-for="opt in vegFruitOptions" :key="opt" class="tag-item"
+                :class="{ 'tag-active': inner.vegetableFruit === opt }" @click="setSingle('vegetableFruit', opt)">
+            <text>{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="struct-row">
+        <text class="struct-label">乳制品</text>
+        <view class="tag-wrap">
+          <view v-for="opt in dairyOptions" :key="opt" class="tag-item"
+                :class="{ 'tag-active': inner.dairy === opt }" @click="setSingle('dairy', opt)">
+            <text>{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="struct-row">
+        <text class="struct-label">含糖饮料频率</text>
+        <view class="tag-wrap">
+          <view v-for="opt in sugaryDrinkOptions" :key="opt" class="tag-item"
+                :class="{ 'tag-active': inner.sugaryDrinks === opt }" @click="setSingle('sugaryDrinks', opt)">
+            <text>{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
     <!-- 过敏原 -->
     <view class="form-card">
       <view class="card-head">
@@ -221,7 +256,10 @@ export default {
         cuisinePref: [],
         cookingMethods: [],
         healthGoals: [],
-        noPreference: false
+        noPreference: false,
+        vegetableFruit: '',
+        dairy: '',
+        sugaryDrinks: ''
       },
       customInput: '',
       spiceLabels: ['不辣', '微辣', '中辣', '偏辣', '很辣', '特辣']
@@ -242,7 +280,10 @@ export default {
         { value: 'vegetarian', label: '素食' },
         { value: 'vegan', label: '纯素' }
       ]
-    }
+    },
+    vegFruitOptions: function() { return ['<2份', '2-3份', '4份以上'] },
+    dairyOptions: function() { return ['每天', '每周3-5次', '很少', '不喝'] },
+    sugaryDrinkOptions: function() { return ['几乎不喝', '每周1-2次', '每周3-5次', '每天1次以上'] }
   },
   watch: {
     value: {
@@ -266,6 +307,9 @@ export default {
       self.inner.religiousDiet = val.religiousDiet || 'none'
       self.inner.spiceLevel = val.spiceLevel != null ? val.spiceLevel : 0
       self.inner.noPreference = !!val.noPreference
+      self.inner.vegetableFruit = val.vegetableFruit || ''
+      self.inner.dairy = val.dairy || ''
+      self.inner.sugaryDrinks = val.sugaryDrinks || ''
     },
     emitInput: function() {
       this.$emit('input', {
@@ -278,9 +322,16 @@ export default {
         cuisinePref: this.inner.cuisinePref.slice(),
         cookingMethods: this.inner.cookingMethods.slice(),
         healthGoals: this.inner.healthGoals.slice(),
-        noPreference: this.inner.noPreference
+        noPreference: this.inner.noPreference,
+        vegetableFruit: this.inner.vegetableFruit,
+        dairy: this.inner.dairy,
+        sugaryDrinks: this.inner.sugaryDrinks
       })
     },
+    setSingle: function(field, value) {
+      this.inner[field] = (this.inner[field] === value) ? '' : value
+      this.emitInput()
+    },
     toggleChip: function(field, value) {
       var list = this.inner[field]
       if (!list) return
@@ -325,6 +376,9 @@ export default {
       this.inner.cuisinePref = []
       this.inner.cookingMethods = []
       this.inner.healthGoals = []
+      this.inner.vegetableFruit = ''
+      this.inner.dairy = ''
+      this.inner.sugaryDrinks = ''
       this.inner.noPreference = true
       this.emitInput()
       this.$emit('skip-no-preference')

+ 60 - 19
cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue

@@ -34,8 +34,18 @@
         />
       </view>
 
-      <!-- Step 3: 饮食习惯 -->
+      <!-- Step 3: 生活方式与肠道健康 -->
       <view v-if="currentStep === 3">
+        <lifestyle-step
+          ref="stepRef"
+          :value="currentMemberForm"
+          :profile="currentMemberProfile"
+          @input="onLifestyleInput"
+        />
+      </view>
+
+      <!-- Step 4: 饮食习惯 -->
+      <view v-if="currentStep === 4">
         <diet-habits-step
           ref="stepRef"
           :value="currentMemberDiet"
@@ -44,8 +54,8 @@
         />
       </view>
 
-      <!-- Step 4: 确认 -->
-      <view v-if="currentStep === 4">
+      <!-- Step 5: 确认 -->
+      <view v-if="currentStep === 5">
         <confirm-step
           ref="stepRef"
           :health-status="currentMemberForm"
@@ -58,7 +68,7 @@
     </view>
 
     <!-- 底部导航:上一步 / 下一步(最后一步为保存并生成) -->
-    <view class="wizard-footer" v-if="currentStep < 4">
+    <view class="wizard-footer" v-if="currentStep < 5">
       <button class="btn-prev" :disabled="currentStep === 1" @click="prev">
         <text>上一步</text>
       </button>
@@ -72,6 +82,7 @@
 <script>
 import BasicVitalsStep from './BasicVitalsStep.vue'
 import MedicalHistoryStep from './MedicalHistoryStep.vue'
+import LifestyleStep from './LifestyleStep.vue'
 import DietHabitsStep from './DietHabitsStep.vue'
 import ConfirmStep from './ConfirmStep.vue'
 
@@ -80,6 +91,7 @@ export default {
   components: {
     BasicVitalsStep,
     MedicalHistoryStep,
+    LifestyleStep,
     DietHabitsStep,
     ConfirmStep
   },
@@ -94,6 +106,11 @@ export default {
       type: Object,
       default: function() { return {} }
     },
+    // 成员基本信息:memberId → { gender, age }(问卷按 profile 动态调整问题)
+    memberProfileMap: {
+      type: Object,
+      default: function() { return {} }
+    },
     // 当前填写成员
     activeFormMemberId: {
       type: String,
@@ -113,7 +130,7 @@ export default {
   },
   computed: {
     wizardSteps: function() {
-      return ['基础体征', '疾病史', '饮食习惯', '确认']
+      return ['基础体征', '疾病史', '生活方式', '饮食习惯', '确认']
     },
     // 当前成员的健康表单(来自父组件快照)
     currentMemberForm: function() {
@@ -129,7 +146,22 @@ export default {
         lipidCholesterol: snapshot.lipidCholesterol || '',
         selectedDiseases: snapshot.selectedDiseases || [],
         medRows: snapshot.medRows || [],
-        noMedication: !!snapshot.noMedication
+        noMedication: !!snapshot.noMedication,
+        mood: snapshot.lifestyle && snapshot.lifestyle.mood || '',
+        sleepDuration: snapshot.lifestyle && snapshot.lifestyle.sleepDuration || '',
+        bedtime: snapshot.lifestyle && snapshot.lifestyle.bedtime || '',
+        bowelFrequency: snapshot.lifestyle && snapshot.lifestyle.bowelFrequency || '',
+        bowelForm: snapshot.lifestyle && snapshot.lifestyle.bowelForm || '',
+        antibioticHistory: snapshot.lifestyle && snapshot.lifestyle.antibioticHistory || '',
+        allergySymptoms: snapshot.lifestyle && Array.isArray(snapshot.lifestyle.allergySymptoms) ? snapshot.lifestyle.allergySymptoms : [],
+        menstrualCycle: snapshot.lifestyle && snapshot.lifestyle.menstrualCycle || '',
+        specialPeriod: snapshot.lifestyle && snapshot.lifestyle.specialPeriod || '',
+        growthDevelopment: snapshot.lifestyle && snapshot.lifestyle.growthDevelopment || '',
+        pickyEating: snapshot.lifestyle && snapshot.lifestyle.pickyEating || '',
+        osteoporosisRisk: snapshot.lifestyle && snapshot.lifestyle.osteoporosisRisk || '',
+        fallRisk: snapshot.lifestyle && snapshot.lifestyle.fallRisk || '',
+        screenTime: snapshot.lifestyle && snapshot.lifestyle.screenTime || '',
+        teenMood: snapshot.lifestyle && snapshot.lifestyle.teenMood || ''
       }
     },
     // 当前成员的饮食习惯
@@ -137,6 +169,12 @@ export default {
       var map = this.dietMap || {}
       var key = this.activeFormMemberId || '__account'
       return map[key] || {}
+    },
+    // 当前成员的基本信息(性别/年龄),供 LifestyleStep 动态渲染
+    currentMemberProfile: function() {
+      var map = this.memberProfileMap || {}
+      var key = this.activeFormMemberId || '__account'
+      return map[key] || {}
     }
   },
   watch: {
@@ -148,13 +186,11 @@ export default {
   methods: {
     // —— 步骤导航 ——
     goToStep: function(n) {
-      if (n < 1 || n > 4) return
+      if (n < 1 || n > 5) return
       if (n <= this.currentStep) {
-        // 回退到已完成步骤,无需校验
         this.currentStep = n
         return
       }
-      // 向前跳需逐级校验中间每一步(保证"每步都校验")
       for (var s = this.currentStep; s < n; s++) {
         if (!this.validateStep(s)) return
       }
@@ -168,17 +204,16 @@ export default {
     next: function() {
       var pass = this.validateStep(this.currentStep)
       if (!pass) return
-      if (this.currentStep < 4) {
+      if (this.currentStep < 5) {
         this.currentStep += 1
       }
     },
-    // —— 每步校验(用户在确认需求时明确要求"每步都校验") ——
     validateStep: function(step) {
-      var self = this
       var validatorMap = {
         1: this.validateVitals,
         2: this.validateMedical,
-        3: this.validateDiet
+        3: this.validateLifestyle,
+        4: this.validateDiet
       }
       var fn = validatorMap[step]
       if (fn) {
@@ -223,31 +258,37 @@ export default {
       }
       return ''
     },
-    // Step3: 饮食习惯至少填写一项(忌口/偏好/必吃/菜系/烹饪/辣度/宗教饮食/目标任一)
+    // Step3: 生活方式 — 心情/睡眠/排便/抗生素/过敏,所有字段可选,无强制校验
+    validateLifestyle: function() {
+      return ''
+    },
+    // Step4: 饮食习惯至少填写一项(忌口/偏好/必吃/菜系/烹饪/辣度/宗教饮食/目标/饮食结构任一)
     validateDiet: function() {
       var diet = this.currentMemberDiet || {}
-      // 已明确选择「无特殊偏好」→ 直接通过
       if (diet.noPreference) return ''
       var arrays = [diet.allergies, diet.absoluteAvoid, diet.mustEat, diet.flavorPref, diet.cuisinePref, diet.cookingMethods, diet.healthGoals]
       var hasAny = arrays.some(function(a) { return a && a.length > 0 })
+      if (diet.vegetableFruit || diet.dairy || diet.sugaryDrinks) hasAny = true
       if (!hasAny && !(diet.spiceLevel && Number(diet.spiceLevel) > 0) && !(diet.religiousDiet && diet.religiousDiet !== 'none')) {
-        return '请至少选择一项饮食习惯偏好'
+        return '请至少选择一项饮食习惯偏好或填写饮食结构'
       }
       return ''
     },
-    // —— 子组件数据同步回父组件 ——
     onVitalsInput: function(payload) {
       this.$emit('update-vitals', payload)
     },
     onMedicalInput: function(payload) {
       this.$emit('update-medical', payload)
     },
+    onLifestyleInput: function(payload) {
+      this.$emit('update-lifestyle', payload)
+    },
     onDietInput: function(payload) {
       this.$emit('update-diet', payload)
     },
-    // 第3步「无特殊偏好」:跳过校验直接进入确认步
+    // 第4步「无特殊偏好」:跳过校验直接进入确认步
     onDietSkipNoPreference: function() {
-      this.currentStep = 4
+      this.currentStep = 5
     },
     // —— 确认步骤提交:聚合所有成员数据后交给父组件统一保存+生成 ——
     handleSubmit: function() {

+ 278 - 0
cfc-frontend/pages/health/components/LifestyleStep.vue

@@ -0,0 +1,278 @@
+<template>
+  <view class="lifestyle-step">
+    <view class="step-title-row">
+      <text class="step-title">生活方式与肠道健康</text>
+      <text class="step-sub">问卷核心:心情/睡眠/排便/抗生素/过敏,帮助精准识别肠道状态</text>
+    </view>
+
+    <!-- 心情状态 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">心情状态</text>
+        <text class="card-hint">单选</text>
+      </view>
+      <view class="chip-row">
+        <view v-for="opt in moodOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.mood === opt }" @click="setField('mood', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+
+    <!-- 睡眠 -->
+    <view class="form-card">
+      <view class="card-head"><text class="card-title">睡眠时长</text><text class="card-hint">单选</text></view>
+      <view class="chip-row">
+        <view v-for="opt in sleepOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.sleepDuration === opt }" @click="setField('sleepDuration', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+    <view class="form-card">
+      <view class="card-head"><text class="card-title">入睡时间</text><text class="card-hint">单选</text></view>
+      <view class="chip-row">
+        <view v-for="opt in bedtimeOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.bedtime === opt }" @click="setField('bedtime', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+
+    <!-- 排便情况 -->
+    <view class="form-card">
+      <view class="card-head"><text class="card-title">排便频率</text><text class="card-hint">单选</text></view>
+      <view class="chip-row">
+        <view v-for="opt in bowelFreqOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.bowelFrequency === opt }" @click="setField('bowelFrequency', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+    <view class="form-card">
+      <view class="card-head"><text class="card-title">粪便性状</text><text class="card-hint">单选</text></view>
+      <view class="chip-row">
+        <view v-for="opt in bowelFormOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.bowelForm === opt }" @click="setField('bowelForm', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+
+    <!-- 抗生素用药史 -->
+    <view class="form-card">
+      <view class="card-head"><text class="card-title">近 2 年抗生素用药史</text><text class="card-hint">单选</text></view>
+      <view class="chip-row">
+        <view v-for="opt in antibioticOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.antibioticHistory === opt }" @click="setField('antibioticHistory', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+
+    <!-- 过敏表现 -->
+    <view class="form-card">
+      <view class="card-head"><text class="card-title">过敏表现</text><text class="card-hint">多选(可多个)</text></view>
+      <view class="chip-row-wrap">
+        <view v-for="opt in allergyOptions" :key="opt" class="chip-item-wrap" :class="{ 'chip-active': inner.allergySymptoms && inner.allergySymptoms.indexOf(opt) >= 0 }" @click="toggleChip('allergySymptoms', opt)"><text>{{ opt }}</text></view>
+      </view>
+    </view>
+
+    <!-- ===== 按性别动态:女性专属 ===== -->
+    <view v-if="isFemale">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">月经周期</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in menstrualOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.menstrualCycle === opt }" @click="setField('menstrualCycle', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">特殊时期</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in specialPeriodOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.specialPeriod === opt }" @click="setField('specialPeriod', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 按年龄动态:儿童专属 ===== -->
+    <view v-if="isChild">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">生长发育</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in growthOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.growthDevelopment === opt }" @click="setField('growthDevelopment', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">挑食情况</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in pickyEatingOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.pickyEating === opt }" @click="setField('pickyEating', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 按年龄动态:老年专属 ===== -->
+    <view v-if="isElderly">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">骨质疏松风险</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in osteoporosisOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.osteoporosisRisk === opt }" @click="setField('osteoporosisRisk', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">跌倒风险</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in fallRiskOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.fallRisk === opt }" @click="setField('fallRisk', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 按年龄动态:青少年专属 ===== -->
+    <view v-if="isTeenager">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">屏幕时间</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in screenTimeOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.screenTime === opt }" @click="setField('screenTime', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">情绪状态</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in teenMoodOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.teenMood === opt }" @click="setField('teenMood', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'LifestyleStep',
+  props: {
+    value: { type: Object, default: function() { return {} } },
+    profile: { type: Object, default: function() { return {} } }
+  },
+  data: function() {
+    return {
+      inner: {
+        mood: '', sleepDuration: '', bedtime: '', bowelFrequency: '', bowelForm: '', antibioticHistory: '', allergySymptoms: [],
+        menstrualCycle: '', specialPeriod: '', growthDevelopment: '', pickyEating: '', osteoporosisRisk: '', fallRisk: '', screenTime: '', teenMood: ''
+      }
+    }
+  },
+  computed: {
+    isFemale: function() {
+      var g = this.profile && this.profile.gender
+      return g === 'female' || g === '女'
+    },
+    isChild: function() {
+      var age = this.profile && this.profile.age
+      return age != null && Number(age) > 0 && Number(age) < 12
+    },
+    isTeenager: function() {
+      var age = this.profile && this.profile.age
+      return age != null && Number(age) >= 12 && Number(age) < 18
+    },
+    isElderly: function() {
+      var age = this.profile && this.profile.age
+      return age != null && Number(age) >= 60
+    },
+    moodOptions: function() { return ['良好', '一般', '低落', '焦虑'] },
+    sleepOptions: function() { return ['<6小时', '6-7小时', '7-8小时', '>8小时'] },
+    bedtimeOptions: function() { return ['22:00前', '22:00-24:00', '24:00后'] },
+    bowelFreqOptions: function() { return ['每天1次(正常)', '隔天1次', '每周2-3次', '每周<2次'] },
+    bowelFormOptions: function() { return ['干硬颗粒', '条状干硬', '正常香蕉状', '软便', '水样'] },
+    antibioticOptions: function() { return ['无', '曾使用过(未用完疗程)', '按疗程使用完', '频繁使用(近2年≥3次)'] },
+    allergyOptions: function() { return ['无', '反复皮疹', '鼻痒/打喷嚏', '眼痒/流泪', '哮喘样症状', '食物过敏'] },
+    menstrualOptions: function() { return ['规律(28-32天)', '不规律', '已停经', '尚未开始'] },
+    specialPeriodOptions: function() { return ['无', '备孕中', '怀孕', '哺乳期'] },
+    growthOptions: function() { return ['正常(在生长曲线内)', '偏慢', '偏快', '不清楚'] },
+    pickyEatingOptions: function() { return ['无挑食', '轻微挑食', '明显挑食', '严重挑食'] },
+    osteoporosisOptions: function() { return ['无风险', '有风险(曾骨折/骨密度低)', '不清楚'] },
+    fallRiskOptions: function() { return ['近1年无跌倒', '近1年跌倒1次', '近1年跌倒2次及以上'] },
+    screenTimeOptions: function() { return ['<1小时/天', '1-3小时/天', '3-5小时/天', '>5小时/天'] },
+    teenMoodOptions: function() { return ['开朗', '一般', '易怒', '低落'] }
+  },
+  watch: {
+    value: { deep: true, handler: function(val) { this.syncFromValue(val) } },
+    profile: { deep: true, handler: function(val) { this.syncProfile(val) } }
+  },
+  created: function() {
+    this.syncFromValue(this.value)
+    this.syncProfile(this.profile)
+  },
+  methods: {
+    // 切换成员/更新 profile 时:仅清除当前性别/年龄不再适用的专属字段,
+    // 保留适用字段的已存答案(先由 syncFromValue 回填,这里只做减法)
+    syncProfile: function(p) {
+      if (!p) return
+      var g = p.gender
+      var age = p.age != null ? Number(p.age) : null
+      var isFemale = g === 'female' || g === '女'
+      var isChild = age != null && age > 0 && age < 12
+      var isTeen = age != null && age >= 12 && age < 18
+      var isElderly = age != null && age >= 60
+      if (!isFemale) {
+        this.inner.menstrualCycle = ''
+        this.inner.specialPeriod = ''
+      }
+      if (!isChild) {
+        this.inner.growthDevelopment = ''
+        this.inner.pickyEating = ''
+      }
+      if (!isTeen) {
+        this.inner.screenTime = ''
+        this.inner.teenMood = ''
+      }
+      if (!isElderly) {
+        this.inner.osteoporosisRisk = ''
+        this.inner.fallRisk = ''
+      }
+    },
+    syncFromValue: function(val) {
+      if (!val) return
+      this.inner.mood = val.mood || ''
+      this.inner.sleepDuration = val.sleepDuration || ''
+      this.inner.bedtime = val.bedtime || ''
+      this.inner.bowelFrequency = val.bowelFrequency || ''
+      this.inner.bowelForm = val.bowelForm || ''
+      this.inner.antibioticHistory = val.antibioticHistory || ''
+      this.inner.allergySymptoms = Array.isArray(val.allergySymptoms) ? val.allergySymptoms.slice() : []
+      // 兼容:按成员加载时若已有答案则回填(不在此处重置)
+      this.inner.menstrualCycle = val.menstrualCycle || ''
+      this.inner.specialPeriod = val.specialPeriod || ''
+      this.inner.growthDevelopment = val.growthDevelopment || ''
+      this.inner.pickyEating = val.pickyEating || ''
+      this.inner.osteoporosisRisk = val.osteoporosisRisk || ''
+      this.inner.fallRisk = val.fallRisk || ''
+      this.inner.screenTime = val.screenTime || ''
+      this.inner.teenMood = val.teenMood || ''
+    },
+    setField: function(field, value) {
+      this.inner[field] = value
+      this.emitInput()
+    },
+    toggleChip: function(field, value) {
+      var list = this.inner[field] || []
+      var idx = list.indexOf(value)
+      if (idx >= 0) { this.inner[field] = list.slice(0, idx).concat(list.slice(idx + 1)) } else { this.inner[field] = list.concat([value]) }
+      this.emitInput()
+    },
+    emitInput: function() {
+      this.$emit('input', {
+        mood: this.inner.mood,
+        sleepDuration: this.inner.sleepDuration,
+        bedtime: this.inner.bedtime,
+        bowelFrequency: this.inner.bowelFrequency,
+        bowelForm: this.inner.bowelForm,
+        antibioticHistory: this.inner.antibioticHistory,
+        allergySymptoms: (this.inner.allergySymptoms || []).slice(),
+        menstrualCycle: this.inner.menstrualCycle,
+        specialPeriod: this.inner.specialPeriod,
+        growthDevelopment: this.inner.growthDevelopment,
+        pickyEating: this.inner.pickyEating,
+        osteoporosisRisk: this.inner.osteoporosisRisk,
+        fallRisk: this.inner.fallRisk,
+        screenTime: this.inner.screenTime,
+        teenMood: this.inner.teenMood
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.lifestyle-step { padding: 10rpx 0 30rpx; }
+.step-title-row { margin-bottom: 20rpx; }
+.step-title { font-size: 34rpx; font-weight: 700; color: #333; }
+.step-sub { display: block; font-size: 24rpx; color: #999; margin-top: 8rpx; }
+.form-card { background: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
+.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
+.card-title { font-size: 28rpx; font-weight: 600; color: #333; }
+.card-hint { font-size: 22rpx; color: #94A3B8; }
+.chip-row { display: flex; flex-wrap: wrap; }
+.chip-row-wrap { display: flex; flex-wrap: wrap; }
+.chip-item, .chip-item-wrap { padding: 12rpx 28rpx; border-radius: 999rpx; background: #F1F5F9; font-size: 24rpx; color: #334155; margin: 0 16rpx 14rpx 0; }
+.chip-active { background: #FFF3E0; color: #F97316; font-weight: 600; }
+</style>

+ 4 - 0
cfc-frontend/pages/health/components/MemberChipPicker.vue

@@ -80,6 +80,8 @@ export default {
           memberId: m.memberId,
           name: m.name,
           nickname: m.nickname,
+          gender: m.gender || '',
+          age: m.age != null ? m.age : null,
           selected: !!selected[String(m.id)]
         })
       }
@@ -135,6 +137,8 @@ export default {
           memberId: String(mid),
           name: String(nm),
           nickname: String(nm),
+          gender: m.gender || '',
+          age: m.age != null ? m.age : null,
           selected: false
         })
       }

+ 160 - 41
cfc-frontend/pages/health/health-plan-summary.vue

@@ -56,10 +56,12 @@
         ref="wizard"
         :member-form-map="memberFormMap"
         :diet-map="memberDietMap"
+        :member-profile-map="memberProfileMap"
         :active-form-member-id="activeFormMemberId"
         :submitting="loading"
         @update-vitals="onWizardVitals"
         @update-medical="onWizardMedical"
+        @update-lifestyle="onWizardLifestyle"
         @update-diet="onWizardDiet"
         @submit="onWizardSubmit"
       />
@@ -91,9 +93,9 @@
         <view class="section-title" style="margin-top:20rpx">方案结构</view>
         <view class="section-preview-list">
           <view v-for="(sec, i) in sectionKeys" :key="i" class="section-preview-item">
-            <view class="sec-icon">{{ secIcons[i] }}</view>
+            <view class="sec-icon">{{ sectionIcons[i] }}</view>
             <view class="sec-info">
-              <text class="sec-title">{{ secTitles[i] }}</text>
+              <text class="sec-title">{{ sectionTitles[i] }}</text>
               <text class="sec-status" :class="{ done: sections[sec].confirmed }">{{ sections[sec].confirmed ? '已确认' : '待确认' }}</text>
             </view>
           </view>
@@ -135,7 +137,7 @@
       <view class="hint-text">所有板块已确认,点击保存生成最终方案</view>
       <view class="final-review">
         <view v-for="(sec, i) in sectionKeys" :key="i" class="review-item">
-          <text class="review-title">{{ secTitles[i] }}</text>
+          <text class="review-title">{{ sectionTitles[i] }}</text>
           <text class="review-preview">{{ sections[sec].content.substring(0, 80) }}{{ sections[sec].content.length > 80 ? '...' : '' }}</text>
         </view>
       </view>
@@ -207,13 +209,17 @@ export default {
       activeFormMemberId: '',
       // 方案数据
       planData: {},
+      // 多人方案结构化数据(common/individual),保存时原样写入 planJson
+      multiMemberPlanJson: null,
       sections: {
-        nutrition: { content: '', confirmed: false },
-        diet: { content: '', confirmed: false },
-        exercise: { content: '', confirmed: false }
+        nutrition: { content: '', confirmed: false, items: [], tasks: [] },
+        diet: { content: '', confirmed: false, items: [], tasks: [] },
+        exercise: { content: '', confirmed: false, items: [], tasks: [] }
       },
       sectionFeedback: '',
       editing: false,
+      // 当前正在编辑的板块下标(0=nutrition,1=diet,2=exercise),逐板块确认时递增
+      editIndex: 0,
       // 查看模式
       viewMode: false,
       planDetail: null,
@@ -244,16 +250,13 @@ export default {
       '包含有氧运动、力量训练等运动计划'
     ]},
     currentSectionKey: function() {
-      var idx = this.step - 3
-      return this.sectionKeys[idx] || ''
+      return this.sectionKeys[this.editIndex] || ''
     },
     currentSectionTitle: function() {
-      var idx = this.step - 3
-      return this.sectionTitles[idx] || ''
+      return this.sectionTitles[this.editIndex] || ''
     },
     currentSectionHint: function() {
-      var idx = this.step - 3
-      return this.sectionHints[idx] || ''
+      return this.sectionHints[this.editIndex] || ''
     },
 selectedMembers: function() {
       var self = this
@@ -282,6 +285,22 @@ selectedMembers: function() {
         }
       }
       return '成员'
+    },
+    // 成员基本信息(性别/年龄),供问卷按 profile 动态调整问题
+    memberProfileMap: function() {
+      var out = {}
+      var self = this
+      this.selectedMemberIds.forEach(function(id) {
+        var found = null
+        for (var i = 0; i < self.pickerMembers.length; i++) {
+          var mid = self.pickerMembers[i].memberId || self.pickerMembers[i].id
+          if (String(mid) === String(id)) { found = self.pickerMembers[i]; break }
+        }
+        if (found) {
+          out[String(id)] = { gender: found.gender || '', age: found.age != null ? found.age : null }
+        }
+      })
+      return out
     }
   },
   onLoad: function(options) {
@@ -391,7 +410,8 @@ selectedMembers: function() {
             lipidCholesterol: self.parseLipidCholesterol(d.bloodLipids),
             selectedDiseases: self.parseDiseases(d.diseaseHistory),
             medRows: self.parseMeds(d.medications),
-            noMedication: false
+            noMedication: false,
+            lifestyle: d.lifestyle ? self.parseLifestyle(d.lifestyle) : {}
           })
         }
       }).catch(function() {})
@@ -420,7 +440,10 @@ selectedMembers: function() {
         cuisinePref: (d && d.cuisinePref) || [],
         cookingMethods: (d && d.cookingMethods) || [],
         healthGoals: (d && d.healthGoals) || [],
-        noPreference: !!(d && d.noPreference)
+        noPreference: !!(d && d.noPreference),
+        vegetableFruit: (d && d.vegetableFruit) || '',
+        dairy: (d && d.dairy) || '',
+        sugaryDrinks: (d && d.sugaryDrinks) || ''
       }
     },
     hasDietContent: function(diet) {
@@ -429,6 +452,7 @@ selectedMembers: function() {
       for (var i = 0; i < arrs.length; i++) {
         if (arrs[i] && arrs[i].length > 0) return true
       }
+      if (diet.vegetableFruit || diet.dairy || diet.sugaryDrinks) return true
       if (diet.spiceLevel && Number(diet.spiceLevel) > 0) return true
       if (diet.religiousDiet && diet.religiousDiet !== 'none') return true
       return false
@@ -436,7 +460,7 @@ selectedMembers: function() {
     // 保存用饮食对象:noPreference 为前端标记,但随表单落库(后端 diet_preferences.no_preference 持久化)
     dietForSave: function(diet) {
       var out = {}
-      var keys = ['allergies', 'absoluteAvoid', 'mustEat', 'religiousDiet', 'spiceLevel', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'noPreference']
+      var keys = ['allergies', 'absoluteAvoid', 'mustEat', 'religiousDiet', 'spiceLevel', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'noPreference', 'vegetableFruit', 'dairy', 'sugaryDrinks']
       keys.forEach(function(k) { out[k] = diet[k] })
       return out
     },
@@ -492,6 +516,11 @@ selectedMembers: function() {
     onWizardMedical: function(flat) {
       this.mergeWizardForm(flat)
     },
+    onWizardLifestyle: function(lifestyle) {
+      var key = String(this.activeFormMemberId || '__account')
+      var prev = this.memberFormMap[key] || {}
+      this.$set(this.memberFormMap, key, Object.assign({}, prev, { lifestyle: lifestyle }))
+    },
     onWizardDiet: function(diet) {
       var key = String(this.activeFormMemberId || '__account')
       this.$set(this.memberDietMap, key, diet)
@@ -516,7 +545,8 @@ selectedMembers: function() {
         lipidCholesterol: flat.lipidCholesterol != null ? flat.lipidCholesterol : (prev.lipidCholesterol || ''),
         selectedDiseases: flat.selectedDiseases != null ? flat.selectedDiseases : (prev.selectedDiseases || []),
         medRows: flat.medRows != null ? flat.medRows : (prev.medRows || []),
-        noMedication: flat.noMedication != null ? flat.noMedication : (prev.noMedication || false)
+        noMedication: flat.noMedication != null ? flat.noMedication : (prev.noMedication || false),
+        lifestyle: prev.lifestyle || {}
       })
     },
     buildHealthPayload: function(snapshot) {
@@ -550,6 +580,16 @@ selectedMembers: function() {
       if (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
       if (meds.length > 0) payload.medications = JSON.stringify(meds)
       if (s.form.notes) payload.notes = s.form.notes
+      var lifestyle = s.lifestyle || {}
+      var lifestyleKeys = ['mood', 'sleepDuration', 'bedtime', 'bowelFrequency', 'bowelForm', 'antibioticHistory', 'allergySymptoms', 'menstrualCycle', 'specialPeriod', 'growthDevelopment', 'pickyEating', 'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood']
+      var hasLifestyle = lifestyleKeys.some(function(k) {
+        return lifestyle[k] && (Array.isArray(lifestyle[k]) ? lifestyle[k].length > 0 : true)
+      })
+      if (hasLifestyle) {
+        var lsObj = {}
+        lifestyleKeys.forEach(function(k) { if (lifestyle[k]) lsObj[k] = lifestyle[k] })
+        payload.lifestyle = JSON.stringify(lsObj)
+      }
       return payload
     },
     // 当前成员快照(无数据时返回 null)
@@ -562,7 +602,31 @@ selectedMembers: function() {
     hasPayload: function(payload) {
       if (!payload) return false
       return payload.heightCm || payload.weightKg || payload.bloodPressure || payload.bloodGlucose ||
-             payload.bloodLipids || payload.diseaseHistory || payload.medications || payload.notes
+             payload.bloodLipids || payload.diseaseHistory || payload.medications || payload.notes || payload.lifestyle
+    },
+    parseLifestyle: function(json) {
+      var obj = {}
+      try {
+        var parsed = JSON.parse(json)
+        if (parsed && typeof parsed === 'object') {
+          obj.mood = parsed.mood || ''
+          obj.sleepDuration = parsed.sleepDuration || ''
+          obj.bedtime = parsed.bedtime || ''
+          obj.bowelFrequency = parsed.bowelFrequency || ''
+          obj.bowelForm = parsed.bowelForm || ''
+          obj.antibioticHistory = parsed.antibioticHistory || ''
+          obj.allergySymptoms = Array.isArray(parsed.allergySymptoms) ? parsed.allergySymptoms.slice() : []
+          obj.menstrualCycle = parsed.menstrualCycle || ''
+          obj.specialPeriod = parsed.specialPeriod || ''
+          obj.growthDevelopment = parsed.growthDevelopment || ''
+          obj.pickyEating = parsed.pickyEating || ''
+          obj.osteoporosisRisk = parsed.osteoporosisRisk || ''
+          obj.fallRisk = parsed.fallRisk || ''
+          obj.screenTime = parsed.screenTime || ''
+          obj.teenMood = parsed.teenMood || ''
+        }
+      } catch (e) {}
+      return obj
     },
     // 逐个成员保存健康档案 + 饮食习惯,最后生成方案
     saveAndGenerate: function() {
@@ -622,11 +686,43 @@ selectedMembers: function() {
         self.loading = false
         if (res && res.code === 200 && res.data) {
           var data = res.data
-          if (typeof data === 'string') {
+          // 多人方案:后端返回 {planContent, common, individual} 结构化对象
+          if (data && typeof data === 'object' && data.common && data.individual) {
+            // 用 common 填充三大板块;再把每位成员的 individual 内容追加到同名板块后
+            var commonPart = data.common || {}
+            var commonSections = (commonPart.sections instanceof Array) ? commonPart.sections : []
+            commonSections.forEach(function(sec) {
+              if (sec && sec.key && self.sections[sec.key]) {
+                self.sections[sec.key].content = sec.content || ''
+                self.sections[sec.key].items = sec.items || []
+                self.sections[sec.key].tasks = sec.tasks || []
+              }
+            })
+            var individualPart = data.individual || {}
+            Object.keys(individualPart).forEach(function(mid) {
+              var part = individualPart[mid] || {}
+              var secs = (part.sections instanceof Array) ? part.sections : []
+              secs.forEach(function(sec) {
+                if (sec && sec.key && self.sections[sec.key]) {
+                  var block = '### 成员 ' + mid + '\n\n' + (sec.content || '')
+                  var prev = self.sections[sec.key].content
+                  self.sections[sec.key].content = prev ? (prev + '\n\n' + block) : block
+                  if (sec.tasks && sec.tasks.length) {
+                    self.sections[sec.key].tasks = (self.sections[sec.key].tasks || []).concat(sec.tasks)
+                  }
+                }
+              })
+            })
+            self.planData = { overview: commonPart.overview || ((commonSections.length || Object.keys(individualPart).length) ? '' : (data.planContent || '')) }
+            self.multiMemberPlanJson = { common: data.common, individual: data.individual }
+            self.step = 3
+          } else if (typeof data === 'string') {
             self.planData = { overview: data }
+            self.multiMemberPlanJson = null
             self.step = 3
           } else if (data.overview) {
             self.planData = data
+            self.multiMemberPlanJson = null
             if (data.sections && data.sections instanceof Array) {
               data.sections.forEach(function(sec) {
                 if (sec.key && self.sections[sec.key]) {
@@ -638,6 +734,7 @@ selectedMembers: function() {
             self.step = 3
           } else if (data.raw) {
             self.planData = { overview: data.raw.substring(0, 200) }
+            self.multiMemberPlanJson = null
             self.step = 3
           } else {
             uni.showToast({ title: '生成失败,请重试', icon: 'none' })
@@ -662,13 +759,13 @@ selectedMembers: function() {
         var level = m.memberLevel || m.levelCode || ''
         if (level && level !== 'FREE') return
         uni.showModal({
-          title: '开通 9.9 元/月健康会员',
-          content: '填写健康自检问卷需先开通月度健康会员(9.9 元/月),开通后可填写问卷并生成个性化健康方案。',
+          title: '开通 9.9 元/月体验会员',
+          content: '填写健康自检问卷需先开通体验会员(9.9 元/月),开通后可填写问卷并生成个性化健康方案。',
           confirmText: '去开通',
           cancelText: '暂不',
           success: function(r) {
             if (r.confirm) {
-              uni.navigateTo({ url: '/pages/membership/pay?levelCode=LITE&period=monthly' })
+              uni.navigateTo({ url: '/pages/membership/pay?levelCode=BASIC&period=monthly' })
             } else {
               uni.navigateBack({ delta: 1 })
             }
@@ -728,15 +825,29 @@ selectedMembers: function() {
     },
     confirmCurrentSection: function() {
       var self = this
-      self.sections[self.currentSectionKey].confirmed = true
-      self.editing = false
+      var key = self.currentSectionKey
+      if (!key || !self.sections[key]) return
+      self.sections[key].confirmed = true
       self.sectionFeedback = ''
-      // 检查是否全部确认
-      if (self.allConfirmed) {
-        // 保持在 step 3,显示最终确认区域
+      // 定位下一个尚未确认的板块;有则继续编辑,全部确认后退出编辑显示「确认保存」
+      var nextIdx = -1
+      for (var i = 0; i < self.sectionKeys.length; i++) {
+        if (!self.sections[self.sectionKeys[i]].confirmed) { nextIdx = i; break }
+      }
+      if (nextIdx >= 0) {
+        self.editIndex = nextIdx
+        self.editing = true
+      } else {
+        self.editing = false
       }
     },
     startEdit: function() {
+      // 进入编辑:定位第一个未确认的板块(全部已确认时从第一个开始,便于重改)
+      var idx = 0
+      for (var i = 0; i < this.sectionKeys.length; i++) {
+        if (!this.sections[this.sectionKeys[i]].confirmed) { idx = i; break }
+      }
+      this.editIndex = idx
       this.editing = true
       this.sectionFeedback = ''
     },
@@ -747,12 +858,14 @@ selectedMembers: function() {
     submitPlan: function() {
       var self = this
       self.loading = true
-      var planJson = JSON.stringify({
-        overview: self.planData.overview || '',
-        sections: self.sectionKeys.map(function(key) {
-          return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
+      var planJson = self.multiMemberPlanJson
+        ? JSON.stringify(self.multiMemberPlanJson)
+        : JSON.stringify({
+          overview: self.planData.overview || '',
+          sections: self.sectionKeys.map(function(key) {
+            return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
+          })
         })
-      })
       // 读取前端规划的师选择(teacher-service.vue 已写入本地缓存)
       var teacherId = uni.getStorageSync('healthPlan_teacherId') || null
       var saveData = {
@@ -806,20 +919,26 @@ selectedMembers: function() {
         if (res && res.code === 200 && res.data) {
           self.planDetail = res.data
           self.userGoal = res.data.goal != null ? String(res.data.goal) : ''
-          if (res.data.memberIds) {
+            if (res.data.memberIds) {
             self.selectedMemberIds = res.data.memberIds.split(',').filter(function(id) { return id }).map(function(id) { return String(parseInt(id) || id) })
           }
           try {
             var pj = JSON.parse(res.data.planJson || '{}')
-            if (pj.overview) self.planData.overview = pj.overview
-            if (pj.sections && pj.sections instanceof Array) {
-              pj.sections.forEach(function(sec) {
-                if (sec.key && self.sections[sec.key]) {
-                  self.sections[sec.key].content = sec.content || ''
-                  self.sections[sec.key].items = sec.items || []
-                  self.sections[sec.key].confirmed = true
-                }
-              })
+            // 多人方案:planJson 为 {common: {...}, individual: {memberId: {...}}}
+            if (pj.common && pj.individual) {
+              self.planData = { overview: res.data.planContent || (pj.common.overview || '') }
+              self.multiMemberPlanJson = pj
+            } else {
+              if (pj.overview) self.planData.overview = pj.overview
+              if (pj.sections && pj.sections instanceof Array) {
+                pj.sections.forEach(function(sec) {
+                  if (sec.key && self.sections[sec.key]) {
+                    self.sections[sec.key].content = sec.content || ''
+                    self.sections[sec.key].items = sec.items || []
+                    self.sections[sec.key].confirmed = true
+                  }
+                })
+              }
             }
           } catch (e) {
             if (res.data.planContent) {

+ 12 - 1
cfc-frontend/pages/index-home/index.vue

@@ -219,8 +219,10 @@
         <text class="section-more" @click="goMoreProducts">更多 ›</text>
       </view>
       <DimensionProducts
+        ref="dimensionProducts"
         :isLoggedIn="true"
-        :showMore="false"
+        :showMore="true"
+        layout="mall"
         @productClick="goProductDetail" />
     </view>
 
@@ -838,7 +840,16 @@ title: '添加家庭成员',
     // 定期调研待办角标(内部自检登录态,未登录静默清空)
     this._loadSurveyBadge()
   },
+  onReachBottom() {
+    // 滚动到页面底部时自动加载推荐商品(委托给 DimensionProducts.loadMore())
+    this.loadMoreProducts()
+  },
   methods: {
+    loadMoreProducts() {
+      if (this.$refs.dimensionProducts) {
+        this.$refs.dimensionProducts.loadMore()
+      }
+    },
     _checkSplashReady() {
       this._splashLoadCount++
       this._tryHideSplash()

+ 12 - 7
cfc-frontend/pages/meal/recipe-detail.vue

@@ -147,13 +147,18 @@ export default {
       } catch (e) {
         this.stepList = str.split(/[;;\n]/).filter(function(s) { return s.trim() })
       }
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 12 - 7
cfc-frontend/pages/mind-detail/articles.vue

@@ -254,13 +254,18 @@ onShow: function() {
         .replace(/&quot;/gi, '"')
         .replace(/\s+/g, ' ')
         .trim()
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 189 - 81
cfc-frontend/pages/mind-detail/index.vue

@@ -9,6 +9,7 @@
     <PageBanner theme="mind-wisdom"
       tagline="心智成长 · 智慧人生"
       quote="知己知彼,百战不殆" />
+    <DimensionPageSkeleton v-if="pageLoading" dim-code="mind" />
 
     <!-- 品牌定位语(登录前显示) -->
     <view class="brand-strap" v-if="!isLoggedIn">
@@ -149,6 +150,27 @@
           </view>
         </view>
 
+
+        <!-- ===== DAN 报告(登录后) ===== -->
+        <view class="section" v-if="isLoggedIn">
+          <view class="section-header">
+            <text class="section-title">📊 DAN 报告</text>
+            <text class="section-more" @click="goDanUpload('mind')">上传 +</text>
+          </view>
+          <view class="dan-card" v-for="report in danReports" :key="report.id" @click="viewDanReport(report)">
+            <view class="dan-card-header">
+              <text class="dan-report-date">{{ formatDate(report.assessmentDate) }}</text>
+              <text :class="['dan-status', report.draftStatus === 'confirmed' ? 'confirmed' : 'pending']">
+                {{ report.draftStatus === 'confirmed' ? '已确认' : '待确认' }}
+              </text>
+            </view>
+            <text class="dan-report-desc">DAN A2 心维度测评报告</text>
+          </view>
+          <view class="dan-empty" v-if="danReports.length === 0">
+            <text class="dan-empty-text">暂无 DAN 报告,点击上方「上传 +」上传测评报告</text>
+          </view>
+        </view>
+
         <!-- 肠脑轴情绪卡片 -->
         <view class="section" v-if="gutIndicators && gutIndicators.length > 0">
           <view class="section-header">
@@ -241,15 +263,17 @@ import PsychCrisisBanner from '@/pages/mind-detail/components/PsychCrisisBanner.
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyTianpanCard from '@/pages/mind-detail/components/FamilyTianpanCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
 import { getVisibleSections, getEmiReport, getFamilyEnergySandbox, getEnergySandbox, getChildren, getFamilyMemberList, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild, getFamilyFortune, getLatestIndicators, getRadarData } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '../../config.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
-components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
+components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
+    pageLoading: false,
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
@@ -606,6 +630,17 @@ var descList = descMap[this.dominantElement] || descMap.wood
       return result
     }
   },
+  created() {
+    // 首屏加载骨架:已登录用户的数据请求期间占位,稳态视觉零变化
+    // 未登录走静态内容,不进 loading,故此处不读 isLoggedIn
+    var token = uni.getStorageSync('token')
+    if (token) {
+      this.pageLoading = true
+      var self = this
+      setTimeout(function() { self.pageLoading = false }, 600)
+    }
+  },
+
   onShow() {
     this.showTabTransition = true
 
@@ -1188,6 +1223,69 @@ var descList = descMap[this.dominantElement] || descMap.wood
 </script>
 
 <style scoped lang="scss">
+
+// 页面级局部调色板:仅本页专有色阶,跨页共性一律走 uni.scss 全局令牌
+// 页面强调色(情绪 / 成就)
+$c-accent: #FF6B35;
+// 金(与强调色成渐变)
+$c-gold: #FFD700;
+// 紫主
+$c-violet: #7C3AED;
+// 紫·亮阶
+$c-violet-light: #A855F7;
+// 心·极深文字
+$c-mind-ink: #9D174D;
+// 信息蓝
+$c-info: #3B82F6;
+// 洞察文案(棕金)
+$c-insight-ink: #8B6914;
+// 夜间渐变 起
+$c-night-1: #1a1a2e;
+// 夜间渐变 中
+$c-night-2: #16213e;
+// 夜间渐变 止
+$c-night-3: #0f3460;
+// 运势 0 前景
+$f0: #D32F2F;
+// 运势 0 背景
+$f0-bg: #FFEBEE;
+// 运势 1 前景
+$f1: #E65100;
+// 运势 1 背景
+$f1-bg: #FFF3E0;
+// 运势 2 前景
+$f2: #F9A825;
+// 运势 2 背景
+$f2-bg: #FFFDE7;
+// 运势 3 前景
+$f3: #7B1FA2;
+// 运势 3 背景
+$f3-bg: #F3E5F5;
+// 运势 4 前景
+$f4: #B71C1C;
+// 运势 4 背景
+$f4-bg: #FFCDD2;
+// 玫瑰浅底
+$tint-rose: #FDF2F7;
+// 紫浅底
+$tint-violet: #F5F3FF;
+// 淡紫浅底
+$tint-lilac: #F5F0FF;
+// 紫罗兰浅底
+$tint-purple: #F3E8FF;
+// 蓝浅底
+$tint-blue: #EFF6FF;
+// 天蓝浅底
+$tint-sky: #EBF2FA;
+// 天蓝浅底·深
+$tint-sky-deep: #D6EAF8;
+// 薄荷浅底
+$tint-mint: #ECFDF5;
+// 中性浅底
+$tint-gray: #F8F9FA;
+// 青灰浅底
+$tint-cyan: #F0F6F7;
+
 .container {
   min-height: 100vh;
   background: $app-bg;
@@ -1201,7 +1299,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   align-items: center;
   margin: 20rpx 30rpx 10rpx;
   padding: 16rpx 24rpx;
-  background: linear-gradient(135deg, #F97316, #FB923C);
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
   border-radius: 20rpx;
   box-shadow: 0 4rpx 16rpx rgba(139,92,246,0.25);
 }
@@ -1224,7 +1322,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   display: flex;
   flex-direction: row;
   align-items: center;
-  background: linear-gradient(135deg, #F3E8FF, #FCE4EC);
+  background: linear-gradient(135deg, $tint-purple, $dim-mind-tint);
   border-radius: 20rpx;
   padding: 28rpx 24rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
@@ -1249,13 +1347,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .mind-report-title {
   font-size: 30rpx;
   font-weight: bold;
-  color: #7C3AED;
+  color: $c-violet;
   display: block;
   margin-bottom: 6rpx;
 }
 .mind-report-desc {
   font-size: 22rpx;
-  color: #A855F7;
+  color: $c-violet-light;
   line-height: 1.5;
   display: block;
   margin-bottom: 10rpx;
@@ -1267,14 +1365,14 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .mind-tag {
   font-size: 20rpx;
-  color: #7C3AED;
+  color: $c-violet;
   background: rgba(255,255,255,0.6);
   padding: 4rpx 14rpx;
   border-radius: 20rpx;
 }
 .mind-report-arrow {
   font-size: 36rpx;
-  color: #FB923C;
+  color: $brand-primary-light;
   margin-left: 10rpx;
   flex-shrink: 0;
 }
@@ -1317,8 +1415,8 @@ var descList = descMap[this.dominantElement] || descMap.wood
   border-radius: 20rpx;
 }
 .mind-report-item-status.parsed {
-  color: #7C3AED;
-  background: #F3E8FF;
+  color: $c-violet;
+  background: $tint-purple;
 }
 .mind-report-item-status.pending {
   color: $app-text-muted;
@@ -1338,7 +1436,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .mind-report-empty-text {
   font-size: 24rpx;
-  color: #bbb;
+  color: $app-border;
 }
 
 /* ===== 心理能量概况 ===== */
@@ -1397,7 +1495,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   margin-bottom: 24rpx;
 }
 .test-btn {
-  background: #F97316;
+  background: $brand-primary;
   padding: 8rpx 28rpx;
   border-radius: 30rpx;
 }
@@ -1421,7 +1519,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .emi-bar-track {
   flex: 1;
   height: 20rpx;
-  background: #f0f0f0;
+  background: $app-surface-subtle;
   border-radius: 10rpx;
   overflow: hidden;
   margin: 0 16rpx;
@@ -1445,10 +1543,10 @@ var descList = descMap[this.dominantElement] || descMap.wood
   text-align: right;
   flex-shrink: 0;
 }
-.level-excellent { color: #10B981; }
-.level-good { color: #3B82F6; }
-.level-average { color: #F97316; }
-.level-low { color: #EF4444; }
+.level-excellent { color: $status-success; }
+.level-good { color: $c-info; }
+.level-average { color: $brand-primary; }
+.level-low { color: $status-danger; }
 .emi-footer {
   display: flex;
   flex-direction: row;
@@ -1456,12 +1554,12 @@ var descList = descMap[this.dominantElement] || descMap.wood
   align-items: center;
   margin-top: 20rpx;
   padding-top: 20rpx;
-  border-top: 1rpx solid #f0f0f0;
+  border-top: 1rpx solid $app-surface-subtle;
 }
 .emi-overall {
   font-size: 28rpx;
   font-weight: bold;
-  color: #FF6B35;
+  color: $c-accent;
 }
 .emi-link {
   display: flex;
@@ -1470,12 +1568,12 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .emi-link-text {
   font-size: 26rpx;
-  color: #FF6B35;
+  color: $c-accent;
   font-weight: 500;
 }
 .emi-link-arrow {
   font-size: 26rpx;
-  color: #FF6B35;
+  color: $c-accent;
   margin-left: 8rpx;
 }
 
@@ -1501,7 +1599,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   flex-direction: column;
   align-items: center;
   padding: 32rpx 0;
-  background: linear-gradient(135deg, #FF6B35, #FFD700);
+  background: linear-gradient(135deg, $c-accent, $c-gold);
   border-radius: 16rpx;
   margin: 20rpx 0;
   color: $app-surface;
@@ -1549,7 +1647,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .emi-dim-score {
   font-size: 28rpx;
   font-weight: bold;
-  color: #FF6B35;
+  color: $c-accent;
   margin-right: 12rpx;
 }
 .emi-dim-level {
@@ -1559,7 +1657,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .emi-dim-bar {
   height: 14rpx;
-  background: #f0f0f0;
+  background: $app-surface-subtle;
   border-radius: 7rpx;
   overflow: hidden;
 }
@@ -1573,22 +1671,22 @@ var descList = descMap[this.dominantElement] || descMap.wood
   justify-content: flex-end;
   align-items: center;
   padding-top: 20rpx;
-  border-top: 1rpx solid #f0f0f0;
+  border-top: 1rpx solid $app-surface-subtle;
   margin-top: 20rpx;
 }
 .emi-footer-text {
   font-size: 24rpx;
-  color: #F97316;
+  color: $brand-primary;
   font-weight: 500;
 }
 .emi-footer-icon {
   font-size: 24rpx;
   margin-left: 8rpx;
 }
-.level-excellent { color: #10B981; background: #ECFDF5; }
-.level-good { color: #3B82F6; background: #EFF6FF; }
-.level-average { color: #F97316; background: #FFF7ED; }
-.level-low { color: #EF4444; background: #FEF2F2; }
+.level-excellent { color: $status-success; background: $tint-mint; }
+.level-good { color: $c-info; background: $tint-blue; }
+.level-average { color: $brand-primary; background: $dim-wealth-tint; }
+.level-low { color: $status-danger; background: $status-danger-tint; }
 
 /* 雷达图内的空状态 */
 .placeholder-content {
@@ -1627,7 +1725,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   margin-bottom: 30rpx;
 }
 .placeholder-btn {
-  background: linear-gradient(135deg, #FF6B35, #FFD700);
+  background: linear-gradient(135deg, $c-accent, $c-gold);
   color: $app-surface;
   font-size: 28rpx;
   font-weight: bold;
@@ -1685,8 +1783,8 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .bazi-pillar-value {
   font-size: 32rpx;
   font-weight: 700;
-  color: #F97316;
-  background: #F5F0FF;
+  color: $brand-primary;
+  background: $tint-lilac;
   padding: 10rpx 14rpx;
   border-radius: 10rpx;
 }
@@ -1710,7 +1808,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .wuxing-bar-track {
   flex: 1;
   height: 20rpx;
-  background: #F0F0F0;
+  background: $app-surface-subtle;
   border-radius: 10rpx;
   overflow: hidden;
 }
@@ -1727,7 +1825,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .energy-card {
   margin-top: 20rpx;
   padding: 24rpx 20rpx;
-  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
+  background: linear-gradient(135deg, $c-night-1 0%, $c-night-2 50%, $c-night-3 100%);
   border-radius: 20rpx;
   position: relative;
   overflow: hidden;
@@ -1771,7 +1869,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .energy-label {
   font-size: 28rpx;
   font-weight: 700;
-  background: linear-gradient(135deg, #F59E0B, $dim-mind);
+  background: linear-gradient(135deg, $dim-wealth, $dim-mind);
   -webkit-background-clip: text;
   -webkit-text-fill-color: transparent;
   background-clip: text;
@@ -1804,13 +1902,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
   top: 4rpx;
   bottom: 4rpx;
   width: 4rpx;
-  background: linear-gradient(180deg, #F59E0B, $dim-mind);
+  background: linear-gradient(180deg, $dim-wealth, $dim-mind);
   border-radius: 2rpx;
 }
 
 .energy-advice {
   font-size: 24rpx;
-  color: #94A3B8;
+  color: $app-text-subtle;
   line-height: 1.7;
   display: block;
   position: relative;
@@ -1869,12 +1967,12 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .culture-dual-value {
   font-size: 28rpx;
   font-weight: 700;
-  color: #F97316;
+  color: $brand-primary;
 }
 .culture-empty {
   text-align: center;
   padding: 30rpx 0;
-  color: #bbb;
+  color: $app-border;
   font-size: 24rpx;
 }
 
@@ -1917,7 +2015,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .tip-source {
   font-size: 22rpx;
-  color: #bbb;
+  color: $app-border;
 }
 
 /* ===== 通用区块 ===== */
@@ -1988,11 +2086,11 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .meta-text {
   font-size: 22rpx;
-  color: #bbb;
+  color: $app-border;
 }
 .article-date {
   font-size: 22rpx;
-  color: #bbb;
+  color: $app-border;
   margin-left: auto;
 }
 
@@ -2024,7 +2122,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   width: 80rpx;
   height: 80rpx;
   border-radius: 40rpx;
-  background: linear-gradient(135deg, #D6EAF8, #EBF2FA);
+  background: linear-gradient(135deg, $tint-sky-deep, $tint-sky);
   display: flex;
   align-items: center;
   justify-content: center;
@@ -2053,7 +2151,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .indicator-body { margin-bottom: 8rpx; }
 .indicator-value { font-size: 40rpx; font-weight: bold; color: $dim-mind; }
 .indicator-placeholder { font-size: 26rpx; color: $app-border; }
-.indicator-range { font-size: 20rpx; color: #bbb; }
+.indicator-range { font-size: 20rpx; color: $app-border; }
 .bottom-spacer { height: 120rpx; }
 
 /* ===== 肠脑轴 ===== */
@@ -2062,7 +2160,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   color: $app-text-secondary;
   margin-bottom: 16rpx;
   padding: 12rpx 16rpx;
-  background: #F0FFF4;
+  background: $dim-action-tint;
   border-radius: 12rpx;
   line-height: 1.6;
 }
@@ -2095,7 +2193,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .gut-indicator-bar-track {
   flex: 1;
   height: 16rpx;
-  background: #F0F0F0;
+  background: $app-surface-subtle;
   border-radius: 8rpx;
   overflow: hidden;
 }
@@ -2118,16 +2216,16 @@ var descList = descMap[this.dominantElement] || descMap.wood
   text-align: center;
 }
 .status-normal {
-  background: #D1FAE5;
-  color: #059669;
+  background: $status-success-soft;
+  color: $status-success-deep;
 }
 .status-abnormal {
-  background: #FEE2E2;
-  color: #DC2626;
+  background: $status-danger-soft;
+  color: $status-danger-deep;
 }
 .status-warning {
-  background: #FEF3C7;
-  color: #D97706;
+  background: $status-warning-soft;
+  color: $status-warning-deep;
 }
 
 /* ===== 关键信息(家庭+个人) ===== */
@@ -2159,7 +2257,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .key-info-col {
   flex: 1;
-  background: #F8F9FA;
+  background: $tint-gray;
   border-radius: 14rpx;
   padding: 16rpx;
   min-width: 0;
@@ -2223,31 +2321,31 @@ var descList = descMap[this.dominantElement] || descMap.wood
   border-radius: 10rpx;
 }
 .ki-fortune-0 {
-  color: #D32F2F;
-  background: #FFEBEE;
+  color: $f0;
+  background: $f0-bg;
 }
 .ki-fortune-1 {
-  color: #E65100;
-  background: #FFF3E0;
+  color: $f1;
+  background: $f1-bg;
 }
 .ki-fortune-2 {
-  color: #F9A825;
-  background: #FFFDE7;
+  color: $f2;
+  background: $f2-bg;
 }
 .ki-fortune-3 {
-  color: #7B1FA2;
-  background: #F3E5F5;
+  color: $f3;
+  background: $f3-bg;
 }
 .ki-fortune-4 {
-  color: #B71C1C;
-  background: #FFCDD2;
+  color: $f4;
+  background: $f4-bg;
 }
 .ki-insight {
   display: flex;
   align-items: flex-start;
   margin-top: 16rpx;
   padding: 16rpx;
-  background: #FFF8F0;
+  background: $dim-body-tint;
   border-radius: 12rpx;
 }
 .ki-insight-icon {
@@ -2257,7 +2355,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .ki-insight-text {
   font-size: 22rpx;
-  color: #8B6914;
+  color: $c-insight-ink;
   line-height: 1.5;
 }
 .ki-footer {
@@ -2265,9 +2363,9 @@ var descList = descMap[this.dominantElement] || descMap.wood
   text-align: center;
   padding: 12rpx;
   font-size: 24rpx;
-  color: #F97316;
+  color: $brand-primary;
   font-weight: 500;
-  background: #F5F3FF;
+  background: $tint-violet;
   border-radius: 12rpx;
 }
 .ki-footer:active {
@@ -2298,12 +2396,12 @@ var descList = descMap[this.dominantElement] || descMap.wood
   border-radius: 8rpx;
 }
 .dan-status.confirmed {
-  background: #D1FAE5;
-  color: #065F46;
+  background: $status-success-soft;
+  color: $status-success-strong;
 }
 .dan-status.pending {
-  background: #FEF3C7;
-  color: #92400E;
+  background: $status-warning-soft;
+  color: $status-warning-strong;
 }
 .dan-report-desc {
   font-size: 28rpx;
@@ -2317,7 +2415,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .dan-view-link {
   font-size: 26rpx;
-  color: #6366F1;
+  color: $dim-wisdom;
 }
 .section-header {
   display: flex;
@@ -2331,22 +2429,32 @@ var descList = descMap[this.dominantElement] || descMap.wood
   color: $app-text;
 }
 .section-more {
+  position: relative;
   font-size: 26rpx;
-  color: #6366F1;
+  color: $dim-wisdom;
   padding: 8rpx 16rpx;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):文字链 ~52rpx -> 88rpx,视觉零变化 */
+.section-more::after {
+  content: '';
+  position: absolute;
+  left: -8rpx;
+  right: -16rpx;
+  top: -18rpx;
+  bottom: -18rpx;
+}
 
 /* 品牌定位语 */
 .brand-strap {
   margin: 16rpx 30rpx 0;
   padding: 20rpx 28rpx;
-  background: linear-gradient(135deg, #FFF0F5, rgba(255,107,157,0.12));
+  background: linear-gradient(135deg, $dim-mind-tint, rgba(255,107,157,0.12));
   border-radius: 16rpx;
   border-left: 8rpx solid $dim-mind;
 }
 .brand-strap-text {
   font-size: 26rpx;
-  color: #9D174D;
+  color: $c-mind-ink;
   font-weight: 500;
 }
 
@@ -2354,7 +2462,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .monthly-guidance {
   margin: 16rpx 30rpx 0;
   padding: 20rpx 28rpx;
-  background: linear-gradient(135deg, #FFF0F5, rgba(255,107,157,0.08));
+  background: linear-gradient(135deg, $dim-mind-tint, rgba(255,107,157,0.08));
   border-radius: 16rpx;
   border-left: 8rpx solid $dim-mind;
   display: flex;
@@ -2364,7 +2472,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .guidance-icon { font-size: 32rpx; }
 .guidance-text {
   font-size: 26rpx;
-  color: #9D174D;
+  color: $c-mind-ink;
   font-weight: 500;
 }
 
@@ -2373,7 +2481,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .radar-group-label {
   font-size: 28rpx;
   font-weight: bold;
-  color: #9D174D;
+  color: $c-mind-ink;
   margin-bottom: 16rpx;
   padding-left: 10rpx;
 }
@@ -2436,7 +2544,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .portrait-dim-bar-track {
   height: 10rpx;
-  background: #F0F6F7;
+  background: $tint-cyan;
   border-radius: 5rpx;
   overflow: hidden;
   margin-bottom: 4rpx;
@@ -2454,14 +2562,14 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
-  background: linear-gradient(135deg, #FFF0F5, #FDF2F7);
+  background: linear-gradient(135deg, $dim-mind-tint, $tint-rose);
   border-radius: 12rpx;
   border-left: 8rpx solid $dim-mind;
   margin-top: 16rpx;
 }
 .portrait-tip-text {
   font-size: 22rpx;
-  color: #C2410C;
+  color: $brand-primary-deep;
   line-height: 1.5;
 }
 </style>

+ 2 - 2
cfc-frontend/pages/profile-extra/family-members.vue

@@ -326,8 +326,8 @@ export default {
       this.showMemberDetailModal = true
     },
     canEditMember(member) {
-      if (!this.isAdmin) return false
-      // 家长账号(users 表成员)与手写家庭成员均可编辑资料(后端 updateMember/updateParentMember 分别处理)
+      // 家长账号(users 表成员)与手写家庭成员均可编辑资料
+      // 后端 updateMember 会自行验证是否关联登录账号/手机,若不可编辑会返回错误
       return member.source === 'family_member' || member.source === 'parent'
     },
     onAddMember() {

+ 1 - 1
cfc-frontend/pages/profile-extra/gates.vue

@@ -127,7 +127,7 @@ export default {
       var url = ''
       switch (type) {
         case 'SELF_CHECK':
-          url = '/pages/family/self-check'
+          url = '/pages/health/health-plan-summary'
           break
         case 'INVITE_FAMILY':
           url = '/pages/family/address-edit'

+ 12 - 7
cfc-frontend/pages/promotion/material.vue

@@ -220,13 +220,18 @@ export default {
           uni.showToast({ title: '下载失败', icon: 'none' })
         }
         })
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 12 - 7
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -1115,13 +1115,18 @@ showOnlineSlotPicker: function() {
     selectServicePerson: function(person) {
       this.selectedServicePerson = person
       this.showServicePersonModal = false
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 35 - 64
cfc-frontend/pages/shop/index/index.vue

@@ -24,21 +24,22 @@
       </view>
     </view>
 
-    <!-- Level 1 分类:2列横排网格 -->
-    <view class="cat-level1">
-      <view
-        v-for="cat in categories"
-        :key="cat.id"
-        :class="['cat1-item', currentCat1 && currentCat1.id === cat.id ? 'active' : '']"
-        @click="onCat1Select(cat)"
-      >
-        <image v-if="cat.image" class="cat1-icon" :src="cat.image" mode="aspectFit" />
-        <view v-else class="cat1-icon-placeholder">
-          <text class="cat1-icon-text">{{ cat.name.substr(0, 1) }}</text>
-        </view>
-        <text class="cat1-name">{{ cat.name }}</text>
+    <!-- Level 1 分类:横向滚动chips -->
+    <scroll-view
+      scroll-x
+      enable-flex
+      show-scrollbar="false"
+      class="cat-level1-scroll"
+    >
+      <view class="cat1-chips">
+        <view
+          v-for="cat in categories"
+          :key="cat.id"
+          :class="['cat1-chip', currentCat1 && currentCat1.id === cat.id ? 'active' : '']"
+          @click="onCat1Select(cat)"
+        >{{ cat.name }}</view>
       </view>
-    </view>
+    </scroll-view>
 
     <!-- Level 2 子分类:横向滚动chips -->
     <scroll-view
@@ -340,61 +341,31 @@ export default {
   background: #f5f5f5;
 }
 /* ==================== 分类布局 ==================== */
-/* Level 1 分类:2列横排网格 */
-.cat-level1 {
-  display: flex;
-  flex-wrap: wrap;
-  padding: 16rpx 16rpx 8rpx;
+/* Level 1 分类:横向滚动chips */
+.cat-level1-scroll {
   background: #fff;
+  white-space: nowrap;
+  padding: 12rpx 16rpx;
   border-bottom: 1rpx solid #eee;
 }
-.cat1-item {
-  width: 25%;
-  box-sizing: border-box;
-  padding: 16rpx 8rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  text-align: center;
-  border-radius: 16rpx;
-  margin-bottom: 8rpx;
-}
-.cat1-item.active {
-  background: rgba(249, 115, 22, 0.08);
-}
-.cat1-icon {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-  margin-bottom: 6rpx;
-  background: #f5f5f5;
+.cat1-chips {
+  display: inline-flex;
+  gap: 12rpx;
+  padding: 0 4rpx;
 }
-.cat1-icon-placeholder {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-  margin-bottom: 6rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
-  display: flex;
-  align-items: center;
-  justify-content: center;
+.cat1-chip {
+  display: inline-block;
+  padding: 10rpx 28rpx;
+  font-size: 26rpx;
+  color: #666;
+  background: #f0f0f0;
+  border-radius: 30rpx;
+  flex-shrink: 0;
 }
-.cat1-icon-text {
-  font-size: 32rpx;
-  font-weight: bold;
+.cat1-chip.active {
+  background: #F97316;
   color: #fff;
-}
-.cat1-name {
-  font-size: 24rpx;
-  color: #333;
-  max-width: 140rpx;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.cat1-item.active .cat1-name {
-  color: #F97316;
-  font-weight: bold;
+  font-weight: 500;
 }
 
 /* 搜索栏 + 购物车 */
@@ -568,7 +539,7 @@ export default {
 /* ==================== 商品列表 ==================== */
 .product-list {
   flex: 1;
-  height: calc(100vh - 220rpx);
+  min-height: 0;
 }
 .login-hint-bar {
   text-align: center;

+ 12 - 7
cfc-frontend/pages/shop/order-detail/order-detail.vue

@@ -290,13 +290,18 @@ export default {
     formatPriceWithSymbol(price) {
       if (!price && price !== 0) return '-'
       return '¥' + (price / 100).toFixed(2)
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     },
     // 履约状态标签
     fulfillStatusLabel: function(status) {

+ 12 - 7
cfc-frontend/pages/shop/order-list/order-list.vue

@@ -356,13 +356,18 @@ export default {
     },
     onImgError(e, item) {
       item.coverImage = ''
-        getImageUrl: function(path) {
-          if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
-          var s = String(path).trim()
-          if (!s || s === 'null' || s === '/null') return ''
-          if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
-          return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
-        }
+    },getImageUrl: function(path) {
+
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+
+      var s = String(path).trim()
+
+      if (!s || s === 'null' || s === '/null') return ''
+
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+
     }
   }
 }

+ 11 - 8
cfc-frontend/pages/tasks/create-task.vue

@@ -183,14 +183,17 @@ repeatTypes: [
 { value: 'monthly', label: '每月' }
 ],
 actionTypes: [
-{ value: '', label: '无(手动完成)' },
-{ value: 'minigame', label: '小游戏' },
-{ value: 'focus', label: '专注训练' },
-{ value: 'survey', label: '问卷/测评' },
-{ value: 'article', label: '文章阅读' },
-{ value: 'activity', label: '活动报名' },
-{ value: 'cart', label: '购物(加购)' },
-{ value: 'order', label: '购物(支付/收货)' }
+  { value: '', label: '无(手动完成)' },
+  { value: 'minigame', label: '小游戏' },
+  { value: 'focus', label: '专注训练' },
+  { value: 'survey', label: '问卷/测评' },
+  { value: 'article', label: '文章阅读' },
+  { value: 'activity', label: '活动报名' },
+  { value: 'cart', label: '购物(加购)' },
+  { value: 'order', label: '购物(支付/收货)' },
+  { value: 'diet', label: '饮食打卡' },
+  { value: 'sleep', label: '睡眠打卡' },
+  { value: 'exercise', label: '运动打卡' }
 ],
 dateTimeRange: [[], [], [], [], []],
 dateTimeIndex: [0, 0, 0, 0, 0],

+ 15 - 0
cfc-frontend/pages/tasks/tasks.vue

@@ -420,6 +420,9 @@ export default {
       if (at === 'article') return '去阅读'
       if (at === 'activity') return '去报名'
       if (at === 'cart' || at === 'order') return '去购物'
+      if (at === 'diet') return '去打卡'
+      if (at === 'sleep') return '去打卡'
+      if (at === 'exercise') return '去打卡'
       return '完成'
     },
     /** 统一任务动作入口:开始/联动跳转/填写/直接完成 */
@@ -455,6 +458,12 @@ export default {
         this.showInputModal = true
         return
       }
+      // 打卡/联动类任务(含每日进度型打卡任务):跳转打卡页执行,
+      // 打卡提交后由后端回写(一次性任务直接完成 / 每日任务累计进度)
+      if (task.actionType === 'diet' || task.actionType === 'sleep' || task.actionType === 'exercise') {
+        this.navigateByAction(task)
+        return
+      }
       // 已开始的两阶段任务或普通任务:直接完成
       this.completeTask(task)
     },
@@ -477,6 +486,12 @@ export default {
         uni.navigateTo({ url: '/pages/activity/list?taskId=' + taskId })
       } else if (at === 'cart' || at === 'order') {
         uni.navigateTo({ url: '/pages/shop/cart?taskId=' + taskId })
+      } else if (at === 'diet') {
+        uni.navigateTo({ url: '/pages/growth/diet-checkin?taskId=' + taskId })
+      } else if (at === 'sleep') {
+        uni.navigateTo({ url: '/pages/growth/sleep-checkin?taskId=' + taskId })
+      } else if (at === 'exercise') {
+        uni.navigateTo({ url: '/pages/growth/exercise-checkin?taskId=' + taskId })
       }
     },
     closeInputModal() {

+ 51 - 27
cfc-frontend/pages/wealth/index.vue

@@ -7,6 +7,7 @@
 
     <!-- 品牌头部 -->
     <PageBanner theme="wealth" tagline="家庭财富能量 · 利他创富" quote="利他创富 · 富足人生" />
+    <DimensionPageSkeleton v-if="pageLoading" dim-code="wealth" />
 
     <!-- 未登录时显示精简内容 -->
     <template v-if="!isLoggedIn">
@@ -270,6 +271,7 @@ import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import DimensionSubDims from './components/DimensionSubDims.vue'
+import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
 import {
   getChildren,
   getWealthDetail,
@@ -289,9 +291,10 @@ import nav from '../../utils/nav.js'
 import config from '../../config.js'
 
 export default {
-  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, FamilyEnergyBar, DimensionIntroCard, DimensionVisitorCard, DimensionSubDims },
+  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, FamilyEnergyBar, DimensionIntroCard, DimensionVisitorCard, DimensionSubDims, DimensionPageSkeleton },
   data() {
     return {
+    pageLoading: false,
       showTabTransition: true,
       isLoggedIn: false,
       role: 'parent',
@@ -436,6 +439,17 @@ export default {
       return result
     }
   },
+  created() {
+    // 首屏加载骨架:已登录用户的数据请求期间占位,稳态视觉零变化
+    // 未登录走静态内容,不进 loading,故此处不读 isLoggedIn
+    var token = uni.getStorageSync('token')
+    if (token) {
+      this.pageLoading = true
+      var self = this
+      setTimeout(function() { self.pageLoading = false }, 600)
+    }
+  },
+
   onShow() {
     reportAppEnter('wealth');
     
@@ -822,9 +836,19 @@ export default {
   color: $app-text;
 }
 .section-more {
+  position: relative;
   font-size: 24rpx;
   color: $app-text-muted;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):文字链 ~34rpx -> 88rpx,视觉零变化 */
+.section-more::after {
+  content: '';
+  position: absolute;
+  left: -8rpx;
+  right: -16rpx;
+  top: -27rpx;
+  bottom: -27rpx;
+}
 
 /* 财富成长 */
 .stream-section {
@@ -849,8 +873,8 @@ export default {
   align-items: center;
 }
 .stage-card.active {
-  background: #FFF7ED;
-  border: 2rpx solid #FBBF24;
+  background: $dim-wealth-tint;
+  border: 2rpx solid $status-warning;
 }
 .stage-icon {
   font-size: 44rpx;
@@ -874,13 +898,13 @@ export default {
 .stream-water {
   height: 20rpx;
   border-radius: 10rpx;
-  background: #FEF3C7;
+  background: $status-warning-soft;
   overflow: hidden;
 }
 .stream-water-fill {
   height: 100%;
   border-radius: 10rpx;
-  background: linear-gradient(90deg, $dim-wealth, #FBBF24);
+  background: linear-gradient(90deg, $dim-wealth, $status-warning);
   transition: width 0.8s ease;
 }
 .stream-percent {
@@ -894,13 +918,13 @@ export default {
   display: block;
   text-align: center;
   font-size: 24rpx;
-  color: #92400E;
+  color: $status-warning-strong;
   margin-top: 12rpx;
 }
 
 /* 财富数据主卡 */
 .disc-card {
-  background: linear-gradient(135deg, #FFF7ED, #FEF3C7);
+  background: linear-gradient(135deg, $dim-wealth-tint, $status-warning-soft);
   border-radius: 20rpx;
   padding: 30rpx;
   margin-bottom: 16rpx;
@@ -909,7 +933,7 @@ export default {
 }
 .disc-label {
   font-size: 26rpx;
-  color: #92400E;
+  color: $status-warning-strong;
 }
 .disc-value {
   font-size: 56rpx;
@@ -919,7 +943,7 @@ export default {
 }
 .disc-sub {
   font-size: 24rpx;
-  color: #B45309;
+  color: $status-warning-strong;
   margin-top: 8rpx;
 }
 .disc-sub-row {
@@ -1006,7 +1030,7 @@ export default {
 .trend-bar {
   flex: 1;
   margin: 0 6rpx;
-  background: linear-gradient(180deg, #FBBF24, $dim-wealth);
+  background: linear-gradient(180deg, $status-warning, $dim-wealth);
   border-radius: 6rpx 6rpx 0 0;
 }
 .child-trend-days {
@@ -1040,7 +1064,7 @@ export default {
   width: 88rpx;
   height: 88rpx;
   border-radius: 24rpx;
-  background: #FFF7ED;
+  background: $dim-wealth-tint;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -1055,7 +1079,7 @@ export default {
 }
 
 .logout-item {
-  color: #EF4444;
+  color: $status-danger;
 }
 
 /* 未登录 */
@@ -1067,13 +1091,13 @@ export default {
 .brand-strap {
   margin: 16rpx 30rpx 0;
   padding: 20rpx 28rpx;
-  background: linear-gradient(135deg, #FFFBEB, rgba(245,158,11,0.12));
+  background: linear-gradient(135deg, $dim-wealth-tint, rgba(245,158,11,0.12));
   border-radius: 16rpx;
   border-left: 8rpx solid $dim-wealth;
 }
 .brand-strap-text {
   font-size: 26rpx;
-  color: #92400E;
+  color: $status-warning-strong;
   font-weight: 500;
 }
 
@@ -1152,8 +1176,8 @@ export default {
   padding: 4rpx 12rpx;
   border-radius: 8rpx;
 }
-.insurance-item-status.status-active { background: #DCFCE7; color: #16A34A; }
-.insurance-item-status.status-expired { background: #FEF2F2; color: #DC2626; }
+.insurance-item-status.status-active { background: $status-success-soft; color: $status-success-text; }
+.insurance-item-status.status-expired { background: $status-danger-tint; color: $status-danger-deep; }
 .insurance-item-status.status-cancelled { background: $app-surface-subtle; color: $app-text-muted; }
 .insurance-item-row {
   display: flex;
@@ -1183,7 +1207,7 @@ export default {
   display: flex;
   align-items: center;
   padding: 12rpx 16rpx;
-  background: #FFFBEB;
+  background: $dim-wealth-tint;
   border-radius: 12rpx;
   margin-bottom: 8rpx;
 }
@@ -1204,16 +1228,16 @@ export default {
   font-weight: 500;
 }
 .insight-item-tag.tag-strong {
-  background: #D1FAE5;
-  color: #059669;
+  background: $status-success-soft;
+  color: $status-success-deep;
 }
 .insight-item-tag.tag-improve {
-  background: #FEF3C7;
-  color: #D97706;
+  background: $status-warning-soft;
+  color: $status-warning-deep;
 }
 .insight-tip {
   padding: 12rpx 16rpx;
-  background: #FFFBEB;
+  background: $dim-wealth-tint;
   border-radius: 12rpx;
 }
 .insight-tip-text {
@@ -1265,7 +1289,7 @@ export default {
   justify-content: center;
   margin-right: 10rpx;
   font-size: 20rpx;
-  background: linear-gradient(135deg, #FFF7ED, #FFF3E8);
+  background: linear-gradient(135deg, $dim-wealth-tint, $dim-body-tint);
 }
 .portrait-dim-name {
   font-size: 24rpx;
@@ -1280,7 +1304,7 @@ export default {
 }
 .portrait-dim-bar-track {
   height: 10rpx;
-  background: #FFF7ED;
+  background: $dim-wealth-tint;
   border-radius: 5rpx;
   overflow: hidden;
   margin-bottom: 4rpx;
@@ -1298,21 +1322,21 @@ export default {
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
-  background: linear-gradient(135deg, #FFF7ED, #FFF3E8);
+  background: linear-gradient(135deg, $dim-wealth-tint, $dim-body-tint);
   border-radius: 12rpx;
   border-left: 8rpx solid $dim-wealth;
   margin-top: 16rpx;
 }
 .portrait-tip-text {
   font-size: 22rpx;
-  color: #C2410C;
+  color: $brand-primary-deep;
   line-height: 1.5;
 }
 
 /* 月度引导语 */
 .monthly-guidance {
   margin: 0 30rpx 20rpx;
-  background: linear-gradient(135deg, #FFFBEB, rgba(245,158,11,0.1));
+  background: linear-gradient(135deg, $dim-wealth-tint, rgba(245,158,11,0.1));
   border-radius: 16rpx;
   padding: 20rpx 24rpx;
   display: flex;

+ 74 - 31
cfc-frontend/pages/wisdom-detail/index.vue

@@ -7,6 +7,7 @@
 
     <!-- 品牌头部 -->
     <PageBanner theme="wisdom" tagline="认知赋能 · 提升学习效率" quote="认知赋能 · 智慧人生" />
+    <DimensionPageSkeleton v-if="pageLoading" dim-code="wisdom" />
 
     <!-- 品牌定位语(登录前显示) -->
     <view class="brand-strap" v-if="!isLoggedIn">
@@ -242,13 +243,15 @@ import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
+import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
 import { getVisibleSections, getChildren, getFamilyEnergySandbox, getEnergySandbox, getAssessmentLatestResult, getDanReportByChild, getGutCognitionIndicators, getLatestIndicators, getRadarData } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
-  components: { TabTransition, RadarChart, FamilyEnergyBar, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
+  components: { TabTransition, RadarChart, FamilyEnergyBar, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
+    pageLoading: false,
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
@@ -458,6 +461,17 @@ export default {
       return result
     }
   },
+  created() {
+    // 首屏加载骨架:已登录用户的数据请求期间占位,稳态视觉零变化
+    // 未登录走静态内容,不进 loading,故此处不读 isLoggedIn
+    var token = uni.getStorageSync('token')
+    if (token) {
+      this.pageLoading = true
+      var self = this
+      setTimeout(function() { self.pageLoading = false }, 600)
+    }
+  },
+
   onShow() {
     this.showTabTransition = true
 
@@ -740,6 +754,25 @@ export default {
 </script>
 
 <style scoped lang="scss">
+
+// 页面级局部调色板:仅本页专有色阶,跨页共性一律走 uni.scss 全局令牌
+// 智·深阶(渐变尾)
+$c-wisdom-deep: #4F46E5;
+// 智·亮阶(渐变尾)
+$c-wisdom-light: #818CF8;
+// 智·极深文字
+$c-wisdom-ink: #3730A3;
+// 浅蓝底 A
+$c-tint-a: #F0F4FF;
+// 浅蓝底 B
+$c-tint-b: #F8FAFF;
+// 浅蓝底 C
+$c-tint-c: #EEF2FF;
+// 浅蓝底 D
+$c-tint-d: #E8F1FE;
+// 强文字(深色块内)
+$c-ink-strong: #1D1D1F;
+
 .wisdom-container {
   min-height: 100vh;
   background: $app-bg;
@@ -813,17 +846,27 @@ export default {
 .section-sub {
   flex: 1 1 100%;
   font-size: 22rpx;
-  color: #94A3B8;
+  color: $app-text-subtle;
   line-height: 1.4;
   margin-top: 6rpx;
   display: block;
 }
 
 .section-more {
+  position: relative;
   flex-shrink: 0;
   font-size: 24rpx;
   color: $dim-wisdom;
 }
+/* 触摸目标扩展至 88rpx(微信最小可点区域):文字链 ~34rpx -> 88rpx,视觉零变化 */
+.section-more::after {
+  content: '';
+  position: absolute;
+  left: -8rpx;
+  right: -16rpx;
+  top: -27rpx;
+  bottom: -27rpx;
+}
 
 /* 得分解读卡片 */
 .score-explain-card {
@@ -856,7 +899,7 @@ export default {
 
 /* 主按钮 */
 .primary-btn {
-  background: linear-gradient(135deg, $dim-wisdom, #4F46E5);
+  background: linear-gradient(135deg, $dim-wisdom, $c-wisdom-deep);
   color: $app-surface;
   font-size: 26rpx;
   font-weight: bold;
@@ -882,7 +925,7 @@ export default {
 .indicator-body { margin-bottom: 8rpx; }
 .indicator-value { font-size: 40rpx; font-weight: bold; color: $dim-wisdom; }
 .indicator-placeholder { font-size: 26rpx; color: $app-border; }
-.indicator-range { font-size: 20rpx; color: #bbb; }
+.indicator-range { font-size: 20rpx; color: $app-border; }
 .bottom-spacer { height: 120rpx; }
 
 /* DAN 报告卡片 */
@@ -909,12 +952,12 @@ export default {
   border-radius: 8rpx;
 }
 .dan-status.confirmed {
-  background: #D1FAE5;
-  color: #065F46;
+  background: $status-success-soft;
+  color: $status-success-strong;
 }
 .dan-status.pending {
-  background: #FEF3C7;
-  color: #92400E;
+  background: $status-warning-soft;
+  color: $status-warning-strong;
 }
 .dan-report-desc {
   font-size: 28rpx;
@@ -943,7 +986,7 @@ export default {
 .avg-badge {
   flex: 1 1 100%;
   font-size: 20rpx;
-  color: #94A3B8;
+  color: $app-text-subtle;
   margin-top: 6rpx;
   display: block;
 }
@@ -952,14 +995,14 @@ export default {
 .brand-strap {
   margin: 16rpx 30rpx 0;
   padding: 24rpx 28rpx;
-  background: linear-gradient(135deg, #F0F0FF, rgba(99,102,241,0.16));
+  background: linear-gradient(135deg, $dim-wisdom-tint, rgba(99,102,241,0.16));
   border-radius: 16rpx;
   border-left: 8rpx solid $dim-wisdom;
   box-shadow: 0 2rpx 12rpx rgba(99,102,241,0.12);
 }
 .brand-strap-text {
   font-size: 26rpx;
-  color: #3730A3;
+  color: $c-wisdom-ink;
   font-weight: bold;
   line-height: 1.5;
 }
@@ -968,7 +1011,7 @@ export default {
 .monthly-guidance {
   margin: 16rpx 30rpx 0;
   padding: 20rpx 28rpx;
-  background: linear-gradient(135deg, #F0F0FF, rgba(99,102,241,0.08));
+  background: linear-gradient(135deg, $dim-wisdom-tint, rgba(99,102,241,0.08));
   border-radius: 16rpx;
   border-left: 8rpx solid $dim-wisdom;
   display: flex;
@@ -978,7 +1021,7 @@ export default {
 .guidance-icon { font-size: 32rpx; }
 .guidance-text {
   font-size: 26rpx;
-  color: #3730A3;
+  color: $c-wisdom-ink;
   font-weight: 500;
 }
 
@@ -1018,7 +1061,7 @@ export default {
 .gut-indicator-bar-track {
   flex: 1;
   height: 16rpx;
-  background: #F0F0F0;
+  background: $app-surface-subtle;
   border-radius: 8rpx;
   overflow: hidden;
 }
@@ -1041,16 +1084,16 @@ export default {
   text-align: center;
 }
 .status-normal {
-  background: #D1FAE5;
-  color: #059669;
+  background: $status-success-soft;
+  color: $status-success-deep;
 }
 .status-abnormal {
-  background: #FEE2E2;
-  color: #DC2626;
+  background: $status-danger-soft;
+  color: $status-danger-deep;
 }
 .status-warning {
-  background: #FEF3C7;
-  color: #D97706;
+  background: $status-warning-soft;
+  color: $status-warning-deep;
 }
 .gut-empty {
   display: flex;
@@ -1068,7 +1111,7 @@ export default {
   margin-bottom: 24rpx;
 }
 .gut-upload-btn {
-  background: linear-gradient(135deg, $dim-wisdom, #818CF8);
+  background: linear-gradient(135deg, $dim-wisdom, $c-wisdom-light);
   color: $app-surface;
   font-size: 26rpx;
   font-weight: 600;
@@ -1094,7 +1137,7 @@ export default {
   display: flex;
   align-items: center;
   padding: 12rpx 16rpx;
-  background: #F8FAFF;
+  background: $c-tint-b;
   border-radius: 12rpx;
   margin-bottom: 8rpx;
 }
@@ -1115,16 +1158,16 @@ export default {
   font-weight: 500;
 }
 .insight-item-tag.tag-strong {
-  background: #D1FAE5;
-  color: #059669;
+  background: $status-success-soft;
+  color: $status-success-deep;
 }
 .insight-item-tag.tag-improve {
-  background: #FEF3C7;
-  color: #D97706;
+  background: $status-warning-soft;
+  color: $status-warning-deep;
 }
 .insight-tip {
   padding: 12rpx 16rpx;
-  background: #EEF2FF;
+  background: $c-tint-c;
   border-radius: 12rpx;
 }
 .insight-tip-text {
@@ -1143,7 +1186,7 @@ export default {
   margin-bottom: 20rpx;
 }
 .insight-btn {
-  background: linear-gradient(135deg, $dim-wisdom, #818CF8);
+  background: linear-gradient(135deg, $dim-wisdom, $c-wisdom-light);
   color: $app-surface;
   font-size: 26rpx;
   font-weight: 600;
@@ -1212,7 +1255,7 @@ export default {
 }
 .portrait-dim-bar-track {
   height: 10rpx;
-  background: #F0F4FF;
+  background: $c-tint-a;
   border-radius: 5rpx;
   overflow: hidden;
   margin-bottom: 4rpx;
@@ -1230,14 +1273,14 @@ export default {
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
-  background: linear-gradient(135deg, #F0F4FF, #E8F1FE);
+  background: linear-gradient(135deg, $c-tint-a, $c-tint-d);
   border-radius: 12rpx;
   border-left: 8rpx solid $dim-wisdom;
   margin-top: 16rpx;
 }
 .portrait-tip-text {
   font-size: 22rpx;
-  color: #1D1D1F;
+  color: $c-ink-strong;
   line-height: 1.5;
 }
 </style>

+ 61 - 13
cfc-frontend/uni.scss

@@ -22,18 +22,18 @@
 // 1. 品牌色彩 (Brand Colors)
 // ============================================================
 
-/* 主色调 - 柔和天蓝 (Logo蓝色) */
+// 主色调 - 柔和天蓝 (Logo蓝色)
 $uni-primary: #5B9BD5;          // Logo中蓝 - 主品牌色
 $uni-primary-light: #8FC5E8;    // 浅蓝
 $uni-primary-dark: #3A7CC4;     // 深蓝
 $uni-primary-pale: #D6EAF8;     // 极浅蓝 (Logo背景色)
 
-/* 辅助暖色 - 珊瑚粉 (家庭温暖) */
+// 辅助暖色 - 珊瑚粉 (家庭温暖)
 $uni-warm: #F08A7A;             // 心形暖色 - 保留"关爱"感
 $uni-warm-light: #F4B0A5;
 $uni-warm-dark: #E06A58;
 
-/* 语义色 (Semantic) */
+// 语义色 (Semantic)
 $uni-success: #4CAF50;
 $uni-success-light: #81C784;
 $uni-warning: #FFB74D;
@@ -42,7 +42,7 @@ $uni-error: #EF5350;
 $uni-error-light: #E57373;
 $uni-info: #5B9BD5;             // 复用了品牌蓝
 
-/* 中性色 (Neutral) — 冷调中性,匹配蓝色系 */
+// 中性色 (Neutral) — 冷调中性,匹配蓝色系
 $uni-bg-color: #F5F9FC;        // 极浅蓝灰背景
 $uni-bg-color-grey: #EDF2F7;   // 浅灰蓝
 $uni-bg-color-dark: #E2E8F0;   // 灰蓝
@@ -59,7 +59,7 @@ $uni-text-color-inverse: #FFFFFF;
 // 2. 字体系统 (Typography)
 // ============================================================
 
-/* 字体栈 - 小程序不支持自定义字体 */
+// 字体栈 - 小程序不支持自定义字体
 $uni-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
   'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
   'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
@@ -67,7 +67,7 @@ $uni-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
 $uni-font-family-display: $uni-font-family;
 $uni-font-family-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
 
-/* 字号 */
+// 字号
 $uni-font-size-xs: 20rpx;
 $uni-font-size-sm: 24rpx;
 $uni-font-size-base: 28rpx;
@@ -76,7 +76,7 @@ $uni-font-size-xl: 36rpx;
 $uni-font-size-2xl: 44rpx;
 $uni-font-size-3xl: 52rpx;
 
-/* 字重 */
+// 字重
 $uni-font-weight-normal: 400;
 $uni-font-weight-medium: 500;
 $uni-font-weight-bold: 600;
@@ -348,12 +348,12 @@ $uni-transition-slow: 0.3s ease;
 // 本段补齐「真实在用」的语义令牌;页面改用这些名字,避免逐个硬编码。
 // 注意:mp-weixin 对 CSS var(--x) 运行时解析不稳定,故统一用 SCSS 变量(编译期内联,零运行时风险)。
 
-/* 品牌主色 —— 与 pages.json tabBar.selectedColor 一致 */
+// 品牌主色 —— 与 pages.json tabBar.selectedColor 一致
 $brand-primary: #F97316;
 $brand-primary-dark: #EA580C;
 $brand-primary-soft: rgba(249, 115, 22, 0.12);
 
-/* 五维身份色:页面 PageBanner / 分段左条 / CTA / 价值卡浅底统一取用 */
+// 五维身份色:页面 PageBanner / 分段左条 / CTA / 价值卡浅底统一取用
 $dim-body:   #FF8C42;
 $dim-mind:   #FF6B9D;
 $dim-wisdom: #6366F1;
@@ -366,7 +366,7 @@ $dim-wisdom-soft: rgba(99, 102, 241, 0.15);
 $dim-action-soft: rgba(16, 185, 129, 0.15);
 $dim-wealth-soft: rgba(245, 158, 11, 0.15);
 
-/* 语义角色(与应用真实观感一致) */
+// 语义角色(与应用真实观感一致)
 $app-bg:             #F5F7FA;
 $app-surface:        #FFFFFF;
 $app-surface-subtle: #F7F8FA;
@@ -376,17 +376,17 @@ $app-text-muted:     #8A94A3;
 $app-border-subtle:  #E7EBF0;
 $app-border:         #CBD5E1;
 
-/* 状态:按压/禁用用透明度与语言表达,不新增多套颜色 */
+// 状态:按压/禁用用透明度与语言表达,不新增多套颜色
 $press-opacity:    0.85;
 $disabled-opacity: 0.45;
 
-/* 动效令牌:只声明稳定属性,禁止 transition: all */
+// 动效令牌:只声明稳定属性,禁止 transition: all
 $ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
 $dur-press:      0.12s;
 $dur-state:      0.2s;
 $dur-structural: 0.26s;
 
-/* 可复用 mixin */
+// 可复用 mixin
 @mixin dim-card($radius: $uni-radius-md) {
   background: $app-surface;
   border-radius: $radius;
@@ -402,3 +402,51 @@ $dur-structural: 0.26s;
   min-height: $size;
   min-width: $size;
 }
+
+
+// ============================================================
+// 13. 状态色阶 / 品牌亮阶 / 维度浅底
+// ============================================================
+// 状态色只用于传达状态,禁止当装饰使用;浅底为不透明 tint,用于卡片与分区背景。
+
+// 状态色阶
+$status-success:        #10B981;
+$status-success-soft:   #D1FAE5;
+$status-success-strong: #065F46;
+$status-warning:        #FBBF24;
+$status-warning-soft:   #FEF3C7;
+$status-warning-strong: #92400E;
+$status-danger:         #EF4444;
+$status-danger-soft:    #FEE2E2;
+$status-danger-strong:  #B91C1C;
+
+// 品牌橙亮阶(渐变/高亮用)
+$brand-primary-light:   #FB923C;
+
+// 维度浅底(不透明 tint)
+$dim-body-tint:   #FFF8F0;
+$dim-mind-tint:   #FFF0F5;
+$dim-wisdom-tint: #F0F0FF;
+$dim-action-tint: #F0FFF4;
+$dim-wealth-tint: #FFFBEB;
+
+// 边框与文字的次级阶
+$app-border-strong: #D1D5DB;
+$app-text-subtle:   #94A3B8;
+
+
+// ============================================================
+// 14. 状态深阶 / 品牌深阶(收尾:跨页共性)
+// ============================================================
+// -deep 为色板 600 档,用于状态文字与强调;-tint 为极浅底。
+// 仅收录在 2 个及以上页面中复用的色值,单页专有色阶由页面级局部变量承载。
+
+// 状态深阶(文字 / 强调)
+$status-danger-deep:    #DC2626;  // 危险态文字:较差 / 已过期
+$status-danger-tint:    #FEF2F2;  // 危险态极浅底
+$status-warning-deep:   #D97706;  // 警示态文字
+$status-success-deep:   #059669;  // 成功态文字
+$status-success-text:   #16A34A;  // 成功态文字(green-600)
+
+// 品牌 / 身 深阶文字
+$brand-primary-deep:    #C2410C;

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

@@ -1955,6 +1955,7 @@ export const getArticleCategories = () => request('/api/articles/categories', 'P
 export const getArticleList = (data) => request('/api/articles/list', 'POST', data)
 export const getArticleDetail = (data) => request('/api/articles/detail', 'POST', data)
 export const getArticleAudio = (data) => request('/api/articles/audio', 'POST', data)
+export const getArticleRelatedProducts = (data) => request('/api/articles/related-products', 'POST', data)
 export const getFeaturedArticles = (data) => request('/api/articles/featured', 'POST', data)
 export const recordArticleRead = (data) => request('/api/articles/record-read', 'POST', data)
 export const reportReadingTime = (data) => request('/api/articles/report-reading-time', 'POST', data)

+ 312 - 7
cfc-langgraph/app/api/adapter.py

@@ -1,6 +1,6 @@
 from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel, ConfigDict
-from typing import Optional
+from pydantic import BaseModel, ConfigDict, AliasChoices, Field
+from typing import Optional, List, Dict, Any
 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
@@ -12,7 +12,7 @@ from app.config import settings
 from app.prompt_service import get_prompt
 from langchain_openai import ChatOpenAI
 from langchain_core.messages import SystemMessage, HumanMessage
-from app.models.health_plan import HealthPlanResponse, PlanTask
+from app.models.health_plan import HealthPlanResponse, PlanTask, MultiMemberPlanResponse, PlanPart, HealthPlanSection
 import uuid
 import time
 import re
@@ -547,20 +547,23 @@ async def health_coach_generate(req: HealthCoachGenerateRequest):
 # ===== 健康方案生成(结构化 JSON)=====
 
 class HealthPlanRequest(BaseModel):
-    member_ids: Optional[str] = None
+    # 兼容 camelCase(Java 侧历史写法)与 snake_case(本服务规范)两种入参键名
+    model_config = ConfigDict(populate_by_name=True)
+    member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
     dimensions: Optional[str] = None
     goal: str = ""
-    family_id: Optional[int] = None
+    family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
 
 
 class HealthPlanRegenerateRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
     section: str  # nutrition | diet | exercise
     feedback: str = ""
     existing_section_content: str = ""
-    member_ids: Optional[str] = None
+    member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
     dimensions: Optional[str] = None
     goal: str = ""
-    family_id: Optional[int] = None
+    family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
 
 
 TEXT_PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
@@ -667,6 +670,7 @@ PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根
 3. 建议要具体可执行,避免空泛
 4. 营养补充部分要具体到产品类型和用量
 5. 严重健康问题建议咨询医生
+6. 当问卷中某项关键信息缺失或不确定(如抗生素用药史、排便性状、过敏表现、饮食结构不明确)时,应在方案末尾明确提示用户:「为了获得更精准的饮食建议,建议您进行肠道菌群检测(GI Navigator 或其他权威检测),以获取确切的菌群组成数据」。
 
 ## 画像数据使用指南
 如果提供了用户的画像数据(五维评分、身体指标、心理指标等),请结合这些真实数据给出更有针对性的建议。特别关注异常指标(如睡眠不足、压力偏高、运动频率低等),在方案中明确说明这些指标的现状和改善方向。
@@ -700,6 +704,49 @@ REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。
 - 建议要具体可执行"""
 
 
+def _format_diet_preferences(dp: dict) -> str:
+    if not dp:
+        return ""
+    parts = []
+    def _list(v):
+        if not v:
+            return ""
+        if isinstance(v, list):
+            return ", ".join(str(x) for x in v)
+        try:
+            parsed = json.loads(v)
+            if isinstance(parsed, list):
+                return ", ".join(str(x) for x in parsed)
+        except Exception:
+            pass
+        return str(v)
+    if dp.get("allergies"):
+        parts.append(f"过敏原:{_list(dp['allergies'])}")
+    if dp.get("absoluteAvoid"):
+        parts.append(f"忌口:{_list(dp['absoluteAvoid'])}")
+    if dp.get("mustEat"):
+        parts.append(f"必吃:{_list(dp['mustEat'])}")
+    if dp.get("religiousDiet") and dp["religiousDiet"] not in ("none", None, ""):
+        parts.append(f"宗教饮食:{dp['religiousDiet']}")
+    if dp.get("spiceLevel"):
+        parts.append(f"辣度:{dp['spiceLevel']}")
+    if dp.get("noPreference"):
+        parts.append("无特殊饮食偏好")
+    if dp.get("dietStructure"):
+        try:
+            ds = json.loads(dp["dietStructure"])
+            if isinstance(ds, dict):
+                if ds.get("vegetableFruit"):
+                    parts.append(f"每日蔬果量:{ds['vegetableFruit']}")
+                if ds.get("dairy"):
+                    parts.append(f"乳制品:{ds['dairy']}")
+                if ds.get("sugaryDrinks"):
+                    parts.append(f"含糖饮料:{ds['sugaryDrinks']}")
+        except Exception:
+            parts.append(f"饮食结构:{dp['dietStructure']}")
+    return "; ".join(parts)
+
+
 def _format_health_status(hs: dict) -> str:
     if not hs:
         return ""
@@ -735,6 +782,48 @@ def _format_health_status(hs: dict) -> str:
         out.append(f"用药:{meds}")
     if hs.get("notes"):
         out.append(f"备注:{hs['notes']}")
+    # 生活方式与肠道健康(心情/睡眠/排便/抗生素/过敏表现)
+    if hs.get("lifestyle"):
+        try:
+            ls = json.loads(hs["lifestyle"])
+            parts = []
+            if isinstance(ls, dict):
+                if ls.get("mood"):
+                    parts.append(f"心情状态:{ls['mood']}")
+                if ls.get("sleepDuration"):
+                    parts.append(f"睡眠时长:{ls['sleepDuration']}")
+                if ls.get("bedtime"):
+                    parts.append(f"入睡时间:{ls['bedtime']}")
+                if ls.get("bowelFrequency"):
+                    parts.append(f"排便频率:{ls['bowelFrequency']}")
+                if ls.get("bowelForm"):
+                    parts.append(f"粪便性状:{ls['bowelForm']}")
+                if ls.get("antibioticHistory"):
+                    parts.append(f"近2年抗生素用药史:{ls['antibioticHistory']}")
+                if ls.get("allergySymptoms") and ls["allergySymptoms"]:
+                    sym = ", ".join(ls["allergySymptoms"]) if isinstance(ls["allergySymptoms"], list) else str(ls["allergySymptoms"])
+                    parts.append(f"过敏表现:{sym}")
+                # 按性别/年龄动态采集的专属项
+                if ls.get("menstrualCycle"):
+                    parts.append(f"月经周期:{ls['menstrualCycle']}")
+                if ls.get("specialPeriod"):
+                    parts.append(f"特殊时期:{ls['specialPeriod']}")
+                if ls.get("growthDevelopment"):
+                    parts.append(f"生长发育:{ls['growthDevelopment']}")
+                if ls.get("pickyEating"):
+                    parts.append(f"挑食情况:{ls['pickyEating']}")
+                if ls.get("osteoporosisRisk"):
+                    parts.append(f"骨质疏松风险:{ls['osteoporosisRisk']}")
+                if ls.get("fallRisk"):
+                    parts.append(f"跌倒风险:{ls['fallRisk']}")
+                if ls.get("screenTime"):
+                    parts.append(f"屏幕时间:{ls['screenTime']}")
+                if ls.get("teenMood"):
+                    parts.append(f"情绪状态:{ls['teenMood']}")
+            if parts:
+                out.append("生活方式:" + "; ".join(parts))
+        except Exception:
+            out.append(f"生活方式:{hs['lifestyle']}")
     return "; ".join(out)
 
 
@@ -778,6 +867,10 @@ async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_i
             member["health_status"] = await java.get_member_health_status(int(member["id"]))
         except Exception:
             member["health_status"] = {}
+        try:
+            member["diet_preferences"] = await java.get_member_diet_preferences(int(member["id"]))
+        except Exception:
+            member["diet_preferences"] = {}
 
     # 3. 知识库
     kb_results = []
@@ -893,6 +986,201 @@ def _classify_action(title: str) -> Optional[dict]:
     return None
 
 
+MULTI_MEMBER_SYNTHESIS_PROMPT = """你是一个家庭健康方案规划师。以下是一个家庭中 N 位成员的独立健康方案(每位成员已由 LLM 单独生成)。请分析这些方案,找出共性部分与个性部分,输出结构化 JSON。
+
+## 输出格式(必须输出合法 JSON,不要有其他内容)
+
+{
+  "common": {
+    "overview": "所有成员共性的总体概述(100 字以内,说明方案目标和核心策略)",
+    "sections": [
+      {
+        "key": "nutrition|diet|exercise",
+        "title": "营养补充建议",
+        "content": "Markdown 格式的详细内容",
+        "items": [],
+        "tasks": [
+          {
+            "action_type": "buy|read|exercise|checkin|diet|activity",
+            "title": "具体可执行动作(如:购买维生素D3补充剂)",
+            "dimension": "body|mind|wisdom|action|wealth",
+            "frequency": "once|daily",
+            "notes": "补充说明(可空)"
+          }
+        ]
+      }
+    ],
+    "abnormal_indicators": []
+  },
+  "individual": {
+    "<memberId>": {
+      "overview": "该成员独有的总体概述(如该成员无独有内容,写\"无\")",
+      "sections": [
+        {
+          "key": "nutrition|diet|exercise",
+          "title": "营养补充建议(个性化)",
+          "content": "Markdown 格式的详细内容",
+          "items": [],
+          "tasks": [
+            {
+              "action_type": "buy|read|exercise|checkin|diet|activity",
+              "title": "具体可执行动作",
+              "dimension": "body|mind|wisdom|action|wealth",
+              "frequency": "once|daily",
+              "notes": "补充说明(可空)"
+            }
+          ]
+        }
+      ],
+      "abnormal_indicators": [
+        {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
+      ]
+    }
+  }
+}
+
+## 规则
+1. **common**:出现在至少两个成员方案中的相似/重复内容。包括:共同适用的饮食大方向、共同的生活习惯、所有人都需要执行的锻炼/打卡等。**common 的 sections 应保留完整 tasks,因为这些任务会分配给每个成员各一份。**
+2. **individual.<memberId>**:只适用于该成员独有的内容,包括:针对该成员专属指标异常的建议、该成员特有的疾病史/过敏史、年龄相关的个性化建议等。该成员的个性 section 不能包含已放入 common 的内容(避免重复)。
+3. 如果某成员没有独有内容,其 individual 的 sections 为空数组 `[]`,overview 写"无",abnormal_indicators 为 `[]`。
+4. **abnormal_indicators**:多成员共有的异常指标放入 common;只属于某个成员的异常指标放入 individual 该成员的 abnormal_indicators。
+5. knowledge_sources 不输出。
+
+## 任务约定
+- `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
+- `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
+- `frequency`:`once`=一次性任务;`daily`=每日重复任务。
+- `title` 必须是**具体可执行的动作**,不要写纯原理/机制描述。
+- 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。"""
+
+
+def _build_individual_plan_prompt(member: dict, member_indicators: list, kb_results: list, goal: str, dimensions: str) -> str:
+    """为单个成员构建独立方案生成 prompt(只含该成员自身数据)。"""
+    parts = [PLAN_SYSTEM_PROMPT]
+    parts.append(f"\n## 用户目标\n{goal}")
+    if dimensions:
+        parts.append(f"\n## 重点关注维度\n{dimensions}")
+    parts.append("\n## 成员信息")
+    profile = member.get("profile", {})
+    dims = profile.get("dimension_scores", {})
+    body = profile.get("body_metrics", {})
+    mind = profile.get("mind_metrics", {})
+    parts.append(f"- {member['name']} (id: {member['id']}, 年龄: {member['age']})")
+    hs_text = _format_health_status(member.get("health_status"))
+    if hs_text:
+        parts.append(f"  健康自检: {hs_text}")
+    dp_text = _format_diet_preferences(member.get("diet_preferences"))
+    if dp_text:
+        parts.append(f"  饮食偏好: {dp_text}")
+    if dims:
+        parts.append(f"  五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
+    if body.get('sleep_dur_avg'):
+        parts.append(f"  平均睡眠: {body['sleep_dur_avg']}小时/天")
+    if mind.get('stress_avg'):
+        parts.append(f"  平均压力: {mind['stress_avg']}/10")
+    if body.get('exercise_count_week'):
+        parts.append(f"  周运动: {body['exercise_count_week']}次")
+    if member_indicators:
+        parts.append("\n## 该成员健康指标")
+        for ind in member_indicators[:15]:
+            status = ind.get("status", "")
+            if status in ("abnormal", "high", "low", "偏高", "偏低"):
+                parts.append(f"- 【异常】{ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
+            else:
+                parts.append(f"- {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
+    if kb_results:
+        parts.append("\n## 知识库参考")
+        for r in kb_results[:6]:
+            title = r.get("metadata", {}).get("title", "")
+            content = r.get("content", "")[:200]
+            parts.append(f"---\n{title}\n{content}")
+    return "\n".join(parts)
+
+
+async def _generate_individual_plan(llm, member: dict, member_indicators: list, kb_results: list, goal: str, dimensions: str) -> dict:
+    """为单个成员调用 LLM 生成独立方案;返回 HealthPlanResponse.model_dump() 结构,失败返回 {}。"""
+    prompt = _build_individual_plan_prompt(member, member_indicators, kb_results, goal, dimensions)
+    try:
+        response = await llm.ainvoke([SystemMessage(content=prompt)])
+        result = _parse_plan_response(response.content)
+        if result["success"]:
+            return result["data"]
+        logger.warning("成员 %s 方案生成解析失败: %s", member.get("name"), result.get("error"))
+        return {}
+    except Exception as e:
+        logger.error("成员 %s 方案生成失败: %s", member.get("name"), e)
+        return {}
+
+
+async def _synthesize_multi_member_plan(llm, members_info: list, individual_plans: dict, goal: str, dimensions: str) -> dict:
+    """将 N 份单人方案交给 LLM 做交集合成,返回 MultiMemberPlanResponse.model_dump() 结构。
+
+    LLM 失败或输出不合法时,退化为纯程序拼接:common 各部分置空,individual 直接透传各成员方案。
+    """
+    blocks = []
+    for m in members_info:
+        mid = str(m["id"])
+        plan = individual_plans.get(mid, {})
+        block_lines = [f"### 成员 {m['name']} (memberId: {mid})"]
+        if not plan:
+            block_lines.append("(无数据,individual 对应位置输出空 sections)")
+        else:
+            ov = plan.get("overview", "")
+            if ov:
+                block_lines.append(f"概述: {ov}")
+            for s in plan.get("sections", []):
+                skey = s.get("key", "")
+                stitle = s.get("title", "")
+                scontent = s.get("content", "")
+                sitems = s.get("items") or []
+                stasks = s.get("tasks") or []
+                block_lines.append(f"[{skey}] {stitle}")
+                block_lines.append(f"  content:\n{scontent}")
+                if sitems:
+                    block_lines.append(f"  items: {json.dumps(sitems, ensure_ascii=False)[:400]}")
+                if stasks:
+                    block_lines.append(f"  tasks: {json.dumps(stasks, ensure_ascii=False)[:400]}")
+            ai = plan.get("abnormal_indicators") or []
+            if ai:
+                block_lines.append(f"异常指标: {json.dumps(ai, ensure_ascii=False)[:300]}")
+        blocks.append("\n".join(block_lines))
+
+    prompt = MULTI_MEMBER_SYNTHESIS_PROMPT + "\n\n## 各成员独立方案(请按 memberId 归并,memberId 必须与下列标题中的 id 完全一致)\n\n"
+    prompt += "\n\n---\n\n".join(blocks)
+    try:
+        response = await llm.ainvoke([SystemMessage(content=prompt)])
+        answer = response.content
+        start = answer.find("{")
+        end = answer.rfind("}") + 1
+        if start >= 0 and end > start:
+            parsed = json.loads(answer[start:end])
+            # 用 Pydantic 校验并规整结构
+            mmpr = MultiMemberPlanResponse.model_validate(parsed)
+            return mmpr.model_dump()
+        logger.warning("多人方案交集合成 LLM 输出无 JSON,退化为程序拼接")
+    except Exception as e:
+        logger.error("多人方案交集合成失败: %s,退化为程序拼接", e)
+
+    # 程序化兜底:common 置空,individual 直接透传各成员方案
+    individual = {}
+    for m in members_info:
+        mid = str(m["id"])
+        plan = individual_plans.get(mid, {})
+        if plan:
+            sections = []
+            for s in plan.get("sections", []):
+                try:
+                    sections.append(HealthPlanSection.model_validate(s))
+                except Exception:
+                    continue
+            individual[mid] = PlanPart(
+                overview=plan.get("overview", ""),
+                sections=sections,
+                abnormal_indicators=plan.get("abnormal_indicators", []),
+            )
+    return MultiMemberPlanResponse(common=PlanPart(overview=""), individual=individual).model_dump()
+
+
 @router.post("/health/plan/generate", response_model=dict)
 async def health_plan_generate(req: HealthPlanRequest):
     """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
@@ -916,6 +1204,17 @@ async def health_plan_generate(req: HealthPlanRequest):
         except Exception:
             member["profile"] = {}
 
+    # === 多人方案:先每人单独生成,再 LLM 交集合成(共性/个性) ===
+    if len(members_info) > 1:
+        individual_plans = {}
+        for member in members_info:
+            member_indicators = [ind for ind in all_indicators if ind.get("_member_name") == member["name"]]
+            plan = await _generate_individual_plan(llm, member, member_indicators, kb_results, goal, req.dimensions or "")
+            individual_plans[str(member["id"])] = plan
+        result = await _synthesize_multi_member_plan(llm, members_info, individual_plans, goal, req.dimensions or "")
+        return {"success": True, "data": result, "parse_error": None}
+
+    # === 单人生成:保持原有 flat 结构(向后兼容) ===
     # 构建 prompt
     parts = [await get_prompt("health_plan") or PLAN_SYSTEM_PROMPT]
     parts.append(f"\n## 用户目标\n{goal}")
@@ -931,6 +1230,9 @@ async def health_plan_generate(req: HealthPlanRequest):
         hs_text = _format_health_status(m.get("health_status"))
         if hs_text:
             parts.append(f"  健康自检: {hs_text}")
+        dp_text = _format_diet_preferences(m.get("diet_preferences"))
+        if dp_text:
+            parts.append(f"  饮食偏好: {dp_text}")
         if dims:
             parts.append(f"  五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
         if body.get('sleep_dur_avg'):
@@ -991,6 +1293,9 @@ async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
         hs_text = _format_health_status(m.get("health_status"))
         if hs_text:
             ctx_parts.append(f"  健康自检: {hs_text}")
+        dp_text = _format_diet_preferences(m.get("diet_preferences"))
+        if dp_text:
+            ctx_parts.append(f"  饮食偏好: {dp_text}")
     for ind in all_indicators[:10]:
         if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
             ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")

+ 53 - 0
cfc-langgraph/app/graphs/health_plan_graph.py

@@ -87,6 +87,7 @@ class PlanState(TypedDict):
     members_info: dict
     indicators: list
     abnormal_indicators: list
+    indicator_changes: list
     kb_results: list
     overview: str
     nutrition_section: str
@@ -127,6 +128,7 @@ async def collect_data(state: PlanState) -> dict:
     # 1b. 获取健康指标
     all_indicators = []
     abnormal_list = []
+    indicator_changes = []  # 成员指标"当前值 vs 上次"变化 (来自 member_indicator_current)
     for member in members_info:
         reports = await java.get_member_reports(int(member["id"]))
         if not reports:
@@ -139,6 +141,22 @@ async def collect_data(state: PlanState) -> dict:
             ind["_member_name"] = member["name"]
             all_indicators.append(ind)
 
+        # 拉取指标变化 (delta/trend), 即使无报告也可能有历史指标
+        cur_list = await java.get_member_indicator_current(int(member["id"]))
+        for cur in cur_list:
+            delta = cur.get("delta")
+            trend = cur.get("trend", "")
+            if delta is not None and trend in ("up", "down"):
+                indicator_changes.append({
+                    "member": member["name"],
+                    "member_id": member["id"],
+                    "indicator": cur.get("name", ""),
+                    "current": cur.get("value", ""),
+                    "previous": cur.get("previousValue", ""),
+                    "delta": delta,
+                    "trend": trend,
+                })
+
     # 1c. 识别异常指标
     known_indicators = {}
     for ind in all_indicators:
@@ -167,6 +185,31 @@ async def collect_data(state: PlanState) -> dict:
         if status in ("abnormal", "high", "low", "偏高", "偏低"):
             abnormal_list.append(entry)
 
+    # 1c-2. 把"指标变化(delta 较大)"也视为值得关注的变化点, 并入 abnormal_list
+    _change_seen = set()
+    for ch in indicator_changes:
+        key = (ch["member"], ch["indicator"])
+        if key in _change_seen:
+            continue
+        _change_seen.add(key)
+        try:
+            d = float(ch["delta"])
+        except (TypeError, ValueError):
+            continue
+        if abs(d) >= 10:  # 显著变化阈值 (按指标量级可调)
+            arrow = "上升" if d > 0 else "下降"
+            abnormal_list.append({
+                "member": ch["member"],
+                "indicator": ch["indicator"],
+                "value": str(ch.get("current", "")),
+                "unit": "",
+                "ref_range": "上次: %s" % ch.get("previous", ""),
+                "description": "指标较上次%s %.1f (当前 %.1f, 上次 %s)" % (
+                    arrow, abs(d), float(ch["current"]) if ch.get("current") is not None else -1, ch.get("previous", "")),
+                "suggestion": "该指标近期%s明显, 方案中应体现针对性调整" % arrow,
+                "_is_change": True,
+            })
+
     # 1d. 知识库检索
     kb_results = []
     queries = set()
@@ -183,6 +226,7 @@ async def collect_data(state: PlanState) -> dict:
         "members_info": members_info,
         "indicators": all_indicators,
         "abnormal_indicators": abnormal_list,
+        "indicator_changes": indicator_changes,
         "kb_results": kb_results,
     }
 
@@ -210,6 +254,15 @@ async def build_prompt(state: PlanState) -> str:
             if ind.get("description"):
                 parts.append(f"  说明: {ind['description']}")
 
+    if state.get("indicator_changes"):
+        parts.append("\n## 指标较上次报告的变化(方案必须考虑这些趋势)")
+        for ch in state["indicator_changes"][:12]:
+            arrow = "上升" if ch.get("trend") == "up" else "下降"
+            parts.append(
+                f"- {ch[member]} - {ch[indicator]}: {ch.get(current,)} "
+                f"(上次: {ch.get(previous,)}, {arrow} {abs(ch.get(delta, 0))})"
+            )
+
     if state["kb_results"]:
         parts.append("\n## 知识库参考")
         for r in state["kb_results"][:6]:

+ 56 - 1
cfc-langgraph/app/models/health_plan.py

@@ -1,5 +1,5 @@
 from pydantic import BaseModel
-from typing import Literal, Optional, List
+from typing import Literal, Optional, List, Dict, Any
 
 
 class HealthPlanGenerateRequest(BaseModel):
@@ -26,7 +26,62 @@ class HealthPlanSection(BaseModel):
 
 
 class HealthPlanResponse(BaseModel):
+    """单成员方案 / 兼容旧结构(扁平 sections)。
+
+    多成员(common+individual)请使用 MultiMemberPlanResponse。
+    本类保留以向后兼容单人方案路径(len(member_ids) <= 1)。
+    """
+    overview: str = ""
+    sections: List[HealthPlanSection] = []
+    abnormal_indicators: List[dict] = []
+    knowledge_sources: List[dict] = []
+
+
+class PlanPart(BaseModel):
+    """方案的一个逻辑部分:可承载共性(common)或个性(individual)。"""
     overview: str = ""
     sections: List[HealthPlanSection] = []
     abnormal_indicators: List[dict] = []
     knowledge_sources: List[dict] = []
+
+
+class MultiMemberPlanResponse(BaseModel):
+    """多人方案:共性 + 各成员个性。
+
+    - common: 适用于全体成员的共性方案部分
+    - individual: 按 member_id 分组的个性方案部分
+    """
+    common: PlanPart = PlanPart()
+    individual: Dict[str, PlanPart] = {}
+
+    def build_rendered_content(self, member_names: Dict[str, str]) -> str:
+        """把 common+individual 组合渲染为 Markdown 文本(供 planContent 使用)。
+
+        member_names: {memberId: 成员名},用于在标题里标注个性部分归属。
+        """
+        out: List[str] = []
+        out.append("# 健康方案\n\n")
+        common = self.common
+        out.append("## 共性方案\n")
+        if common.overview:
+            out.append("### 方案概述\n\n" + common.overview + "\n\n")
+        for sec in common.sections:
+            out.append(f"## {sec.title}\n\n{sec.content}\n\n")
+        if common.abnormal_indicators:
+            out.append("## 共性异常指标\n")
+            for ind in common.abnormal_indicators:
+                out.append(f"- {ind.get('member','?')} - {ind.get('indicator','')}: {ind.get('value','')} {ind.get('unit','')}")
+        if self.individual:
+            out.append("\n---\n\n# 个性方案\n")
+            for mid, part in self.individual.items():
+                name = member_names.get(mid, f"成员{mid}")
+                out.append(f"\n## {name} 个性方案\n")
+                if part.overview:
+                    out.append("### 概述\n\n" + part.overview + "\n")
+                for sec in part.sections:
+                    out.append(f"### {sec.title}\n\n{sec.content}\n")
+                if part.abnormal_indicators:
+                    out.append("### 异常指标\n")
+                    for ind in part.abnormal_indicators:
+                        out.append(f"- {ind.get('member','?')} - {ind.get('indicator','')}: {ind.get('value','')} {ind.get('unit','')}")
+        return "".join(out)

+ 18 - 0
cfc-langgraph/app/tools/java_client.py

@@ -208,3 +208,21 @@ class JavaClient:
             return data["data"]
         return {}
 
+    async def get_member_diet_preferences(self, member_id: int) -> dict:
+        """获取家庭成员饮食偏好(diet_preferences,含饮食结构现状)"""
+        client = await self._get_client()
+        resp = await client.post("/api/diet/preferences/current-member", json={"memberId": member_id})
+        data = resp.json()
+        if data.get("code") == 200 and data.get("data"):
+            return data["data"]
+        return {}
+
+    async def get_member_indicator_current(self, member_id: int) -> list[dict]:
+        """获取成员指标当前值+相对上次变化(member_indicator_current,含 delta/trend)"""
+        client = await self._get_client()
+        resp = await client.post("/api/health/indicators/current", json={"memberId": member_id})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+

+ 47 - 4
cfc-langgraph/tests/test_health_plan.py

@@ -1,5 +1,5 @@
-from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse
-from app.api.adapter import _parse_plan_response, _fallback_extract_tasks
+from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse, MultiMemberPlanResponse, PlanPart
+from app.api.adapter import _parse_plan_response, _fallback_extract_tasks, _synthesize_multi_member_plan
 
 
 def test_plan_task_valid():
@@ -17,14 +17,14 @@ def test_plan_task_invalid_action_type():
     import pytest
     from pydantic import ValidationError
     with pytest.raises(ValidationError):
-        PlanTask(action_type="cook", title="做饭", dimension="body")
+        PlanTask.model_validate({"action_type": "cook", "title": "做饭", "dimension": "body"})
 
 
 def test_plan_task_invalid_dimension():
     import pytest
     from pydantic import ValidationError
     with pytest.raises(ValidationError):
-        PlanTask(action_type="diet", title="少油少盐", dimension="earth")
+        PlanTask.model_validate({"action_type": "diet", "title": "少油少盐", "dimension": "earth"})
 
 
 def test_plan_response_with_tasks():
@@ -81,3 +81,46 @@ def test_regenerate_tasks_validation_filter():
             continue
     assert len(valid) == 1
     assert valid[0]["action_type"] == "diet"
+
+
+def test_multi_member_plan_response_structure():
+    """多人方案结构校验:common + individual 按 memberId 分组"""
+    resp = MultiMemberPlanResponse()
+    assert resp.common.overview == ""
+    assert resp.individual == {}
+
+
+def test_multi_member_plan_response_with_data():
+    resp = MultiMemberPlanResponse(
+        common=PlanPart(overview="全家共性方案", sections=[
+            HealthPlanSection(
+                key="diet", title="饮食建议", content="## 共同饮食",
+                tasks=[PlanTask(action_type="diet", title="每日三餐均衡", dimension="body", frequency="daily")]
+            )
+        ]),
+        individual={
+            "123": PlanPart(overview="成员A个性方案", sections=[
+                HealthPlanSection(
+                    key="nutrition", title="营养补充", content="## 维生素D3",
+                    tasks=[PlanTask(action_type="buy", title="购买维生素D3", dimension="wealth", frequency="once")]
+                )
+            ])
+        }
+    )
+    data = resp.model_dump()
+    assert data["common"]["sections"][0]["tasks"][0]["action_type"] == "diet"
+    assert data["individual"]["123"]["sections"][0]["tasks"][0]["action_type"] == "buy"
+    assert "123" in data["individual"]
+
+
+def test_multi_member_plan_response_fallback_structure():
+    """LLM 合成失败时程序化兜底:common 置空,individual 透传各成员方案"""
+    resp = MultiMemberPlanResponse(common=PlanPart(overview=""), individual={
+        "456": PlanPart(overview="成员B个性", sections=[
+            HealthPlanSection(key="exercise", title="运动计划", content="## 跑步",
+                             tasks=[PlanTask(action_type="exercise", title="每周跑步3次", dimension="body", frequency="daily")])
+        ])
+    })
+    data = resp.model_dump()
+    assert data["common"]["overview"] == ""
+    assert data["individual"]["456"]["sections"][0]["tasks"][0]["title"] == "每周跑步3次"

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-e81e05beecd7341506d73b9277875b6cbe498297
+b15da55182bac8f8e9199271cf5d360510746740

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1604",
+  "version": "1.0.1632",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1604",
+      "version": "1.0.1632",
       "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.1605",
+  "version": "1.0.1633",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,435 @@
 
 ---
 
+## v1.0.1633 (2026-10-15)
+
+### Bug 修复
+- 文章阅读完成答题改为一题一页
+- 分享海报移除模板/背景色选择行并上移画布
+
+### 其他
+- - 新增步骤条「第 X / N 题」与圆点进度(未答/已选/当前三态)
+- - 导航按钮:首题下一步,中间题上/下一题,末题提交答案
+- - quizAnswers 按题下标存储,返回上一题答案保留,提交逻辑不变
+- - 题数由后端动态返回,改为 5 题也自适应;同步移除写死
+-   「三个问题」的文案
+- 
+-   标签,管理端编辑商品时无法回显已有标签
+- - 管理端商品编辑页新增「商品标签」输入(逗号分隔),保存时随
+-   ProductCreateRequest.product 提交,adminCreate/adminUpdate
+-   直接落库
+- - 标签与文章标签精确匹配后方会出现在小程序文章详情页
+-   「相关商品」推荐中,留空则不参与匹配
+- 
+- 获取文章标签与商品标签匹配的推荐商品(复用 DimensionProducts 组件,
+- 点击跳转商品详情页)。请求放在 loadDetail 内只走一次,避免
+- onLoad/onShow 双发;游客未登录同样可见(该路径已在 JwtInterceptor
+- 白名单)。
+- 
+
+### 新功能
+- 后台商品编辑支持标签字段,支撑文章-商品匹配推荐
+- 文章详情页接入关联商品推荐
+
+
+## v1.0.1632 (2026-10-15)
+
+### 新功能
+- 身体指标卡片展示较上次变化 delta/trend
+
+### 其他
+- - 配色沿用 overview 卡 trend-up/down/stable 约定
+- - 数据来自 /api/health/indicators 新增的 trendDesc/delta/trend 字段
+- 
+
+
+## v1.0.1631 (2026-10-15)
+
+### 新功能
+- getLatestIndicatorsByDomain 返回 delta/trend/trendDesc
+
+### 其他
+- - 附加 previousValue/delta/trend/trendDesc(较上次±x, stripTrailingZeros 去尾零)
+- - MemberIndicatorCurrentService 加 getByMemberAndDefinition
+- 
+
+
+## v1.0.1630 (2026-10-15)
+
+### 新功能
+- B方案-成员指标当前值+相对上次变化(delta/trend)
+
+### 其他
+- - 迁移378: member_indicator_current 表(唯一键 member_id+definition_id)
+- - HealthReportService.refreshMemberIndicatorCurrent(报告确认后按最新vs次新同名指标算 diff)
+- - rebuildForMember + /api/health/indicators/rebuild(admin 回填存量)
+- - /api/health/indicators/current 读取端点
+- - getLatestIndicatorsByDomain 读取口径修复: source_id(报告ID)→subject_id(成员ID)
+-   (写入 source_id=reportId 保留每报告快照, 读端按 subject_id=memberId 查最新)
+- - confirmDraft + collect 接入 refreshMemberIndicatorCurrent
+- LangGraph:
+- - JavaClient.get_member_indicator_current
+- - collect_data 拉取指标变化(delta>=10 显著变化并入 abnormal_list)
+- - build_prompt 增加指标较上次报告的变化段落, 方案生成考虑趋势
+- 数据: member21 回填 50 条 diff(报告42→58)
+- 
+
+
+## v1.0.1629 (2026-10-15)
+
+### Bug 修复
+- 修正肠道菌群卡片 category 英文枚举(实际全库为中文)
+
+### 其他
+- - 排除 taxonomy(门/纲/目/科级别分类构成,非菌种卡片应展示)
+- - 旧代码过滤 (core/probiotic/harmful/other) 与 DB 中文枚举永不匹配,
+-   导致 /api/health/gut-flora 永远空(报告有 135 行却读不到)
+- 
+
+
+## v1.0.1628 (2026-10-15)
+
+### Bug 修复
+- 修复健康自检问卷→方案生成链路 5 处缺陷
+
+### 其他
+-   LangGraph HealthPlanRequest/RegenerateRequest 加 camelCase 别名容错(AliasChoices)
+- - B 方案「确认保存」不可达:three sections 仅 nutrition 可确认,改为逐板块推进,
+-   全部确认后退出编辑显示「确认保存」
+- - C 多成员方案前端未适配:用 common(+individual 追加)填充三大板块,
+-   并修正模板中未定义的 secTitles/secIcons(应为 sectionTitles/sectionIcons)
+- - D 等级重复:9.9 元/月健康会员统一复用既有 BASIC「体验会员」,
+-   迁移 366 改为清理误建的 LITE 等级并迁移存量记录(三段独立 try)
+- - E 自适应问卷答案未进 Prompt:_format_health_status 输出月经周期/特殊时期/
+-   生长发育/挑食/骨质疏松/跌倒/屏幕时间/情绪等性别年龄专属项
+- 验证:mvn -q clean compile EXIT=0;node --check 通过;py_compile 通过;
+- audit-duplicate-api-calls 0 error/104 warning
+- 
+
+
+## v1.0.1627 (2026-10-15)
+
+### 重构
+- UserContentWeightService 接口化(遗留改动补提交)
+
+### Bug 修复
+- A方案-报告统一挂 member_id(subject_id 归一化)
+
+### 其他
+- - bindReportToMember 写库前归一化(集中守卫点);reassignReport 加 familyId==null 守卫
+- - matchUserByName 三处 return user.getId() 改为 return resolveSubjectToMemberId
+- - HealthReportController + ReportCollectService buildReportFromPayload 写前归一化
+- - FamilyMemberService.migrateMemberAssets 按 subject_id=memberId 迁移报告(成员迁移保 id)
+- - 数据修复: 9 条 health_reports.subject_id 错绑已迁移(mysqldump 备份保留)
+- 
+
+
+## v1.0.1626 (2026-10-15)
+
+### 重构
+- 商城一级分类宫格改为横向滚动 chips
+
+### 其他
+- 
+
+
+## v1.0.1625 (2026-10-15)
+
+### 新功能
+- 新增企业员工健康关怀解决方案落地页 intro.html
+
+
+## v1.0.1624 (2026-10-15)
+
+### Bug 修复
+- 打卡类任务点击后跳转打卡页而非直接完成
+
+### 其他
+- 的 actionType 分支此前只挂在 startRequired 两阶段路径上,普通任务
+- 点击「去打卡」会直接调 completeTask 接口,绕过打卡页、不累计进度。
+- 现于 handleTaskAction 直接完成前增加 diet/sleep/exercise 跳转分支,
+- 打卡提交后由后端回写(一次性任务 completeTask / 每日任务 updateProgress)。
+- 
+
+
+## v1.0.1623 (2026-10-15)
+
+### 重构
+- 移除数据库初始化中修改菜单内容的迁移脚本
+
+### 其他
+- - 删除迁移251/372 migrateHealthMenuMount(健康饮食组菜单重组)
+- - 删除迁移267 migratePlanCenterMenu(方案中心菜单插入)
+- - 连带删除专属辅助方法 insertPlanCenterChild/updateMenuPermByPath/
+-   deleteHealthMenuByPath/insertHealthMenu/moveMenu
+- - 保留 queryMenuId/seedSysMenus(244 种子数据与建表仍执行)与
+-   grantMenuToRole(迁移318 系统提示词菜单仍使用)
+- 菜单内容(sys_menu 行)不再由初始化脚本在每次启动时改写,交由人工/管理端维护
+- 
+- - 新增 components/DimensionPageSkeleton.vue:五维页首屏加载骨架,已登录用户数据请求期间占位,避免「空白 -> 内容」硬跳变,稳态视觉零变化
+- - body/wisdom/mind/wealth/action 五维页接入 pageLoading 骨架(created 判断 token)
+- - 触摸目标扩展至 88rpx(微信最小可点区域):help-icon、help-dialog-close、section-more 用 ::after 外扩,不改视觉
+- - 新增 docs/dimension-pages-stage4-audit.md 阶段四审计报告
+- 后端:
+- - 新增 ContentByWeightController + UserContentWeightServiceImpl:用户/家庭内容偏好权重(商品/活动/文章)查询接口
+- 文档:
+- - 补充 docs/参考资料 与 知识库 下的参考资料文档
+- (构建产物 dist_bak_*/dist_prev_* 及临时脚本未纳入提交)
+- 
+
+### 新功能
+- 五维页阶段四 — 首屏骨架 + 88rpx 触摸目标 + 内容偏好权重接口
+
+
+## v1.0.1622 (2026-10-15)
+
+### Bug 修复
+- 修复异步采集并发重跑导致同一草稿重复入库
+
+### 其他
+- collecting 状态且 confirmed_report_id 为空,被 ReportCollectRetryTask 的 10
+- 分钟卡死兜底误判为卡死并重新提交采集;两次采集的读-写幂等守卫存在竞态,
+- 均通过检查后各自调用 insertReport,产生重复报告。
+- 修复:
+- - insertReport 前守卫改为 CAS 原子抢占(collecting->inserting,仅
+-   confirmed_report_id 为空时成功),后到者失败跳过,消除并发读-写竞态
+- - collect() 开头早退守卫补检查 confirmed_report_id
+- - listPendingRetry 卡死兜底阈值 10 -> 30 分钟,避免正常长解析被误判
+- 
+
+
+## v1.0.1621 (2026-10-15)
+
+### Bug 修复
+- 内容分享海报尺寸调整(800→633)
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- 首页推荐商品支持向下翻页
+- DimensionProducts组件支持分页加载+商城样式
+- 推荐商品接口支持分页(page/size)
+
+
+## v1.0.1620 (2026-10-15)
+
+### Bug 修复
+- 统一知识库启动时自愈回填缺失模块
+
+### 其他
+- 因 importFromClasspath(false) 的 DB 非空跳过逻辑永不补齐,导致食物搜索/营养素/疾病关联/
+- 版本号在生产为空。load() 增加 backfillMissingModules:DB 非空但缺 MODULE_KEYS 时从
+- classpath 种子逐键补齐(幂等,不动已存在模块)。
+- 
+
+
+## v1.0.1619 (2026-10-15)
+
+### 新功能
+- 统一知识库管理端维护接口 + 页面
+
+### 其他
+- - 新增 AdminUnifiedKnowledgeController (/api/admin/knowledge-base/unified/*, 权限 admin/operator/finance/knowledge_admin/butler)
+- - cfc-web 新增统一知识库管理页(模块列表+JSON编辑/格式化/保存/从种子重置, 路由/菜单挂知识库分组)
+- - 写库后 load() 热重载内存索引, 无需重启
+- 
+
+
+## v1.0.1618 (2026-10-15)
+
+### 样式
+- 清理 Phase3 残留渐变与装饰性 emoji(企业级 UI 规范)
+
+### 其他
+- - 8 个文件移除装饰性 emoji(标题/卡片前缀),LangGraphAdmin 改为 el-icon,ReviewCenter 移除图标位
+- - TeacherOrders 重命名 gradient-* 类名、TeacherTeam 重命名 gradients 变量(均已为实色)
+- - 功能性箭头与用户可配置 icon 数据字段(ArticleCategory/gates/ProblemDomainManage)保留
+- 
+
+
+## v1.0.1617 (2026-10-14)
+
+### 重构
+- StatCard 铺开(PurchaseManagement 4 处,严格安全条件)
+
+### 其他
+-   额外内容(如 stat-trend)、value 表达式不得含过滤器
+- - 原因:首次宽松迁移把 Dashboard.vue 卡片的 @click.native 路由跳转与
+-   stat-trend 提示文字一并丢弃(功能回退),已整体回退后按严格条件重做
+- - 结果:仅 PurchaseManagement 4 处满足安全条件;其余 12 个文件因结构
+-   含 stat-info / stat-trend / 事件绑定 / 过滤器而跳过,需逐页手工处理
+- 
+
+
+## v1.0.1616 (2026-10-14)
+
+### 重构
+- 复用组件铺开第一批(StatusTag 43 处 + PageHeader 41 处)
+
+### 其他
+- - 迁移单行纯文本状态标签 43 处(17 个文件)
+- - 保留原有 v-if/v-else 分支与 type 映射,零逻辑变更
+- - 未动含插值的 118 处:其中混有非状态用途(如数值展示 totalPoints),
+-   自动替换会误伤语义,留待逐页判断
+- PageHeader:
+- - 迁移 admin-page-header 41 处(40 个文件),保留 slot="header" 等属性与内部操作区
+- - 用 div 配对计数定位闭合标签,兼容含嵌套 div.admin-page-actions 的结构
+- - 7 处无 admin-page-title 的非标准结构保守跳过
+- - 修复:迁移脚本误改了 PageHeader.vue 自身注释中的示例代码,已从已提交版本恢复
+- 验证: npm run build EXIT=0(65034ms,428 个文件)
+- 
+
+
+## v1.0.1615 (2026-10-14)
+
+### Bug 修复
+- resolve 14 corrupted files - restore from ca76d8a62, remove cff388913 injection, fix dimension-subdims location, clean reminder-config key expression
+
+
+## v1.0.1614 (2026-10-14)
+
+### 新功能
+- Phase 2 引入企业级复用组件层(StatCard/StatusTag/PageHeader/SectionTitle)
+- 创建任务表单 actionTypes 新增饮食/睡眠/运动打卡选项
+
+### 其他
+-   * StatCard   KPI 统计卡,兼容旧数据 {color,icon,value,label},旧 color 名自动映射(purple->primary)
+-   * StatusTag  状态标签,支持 options 映射式与 text+type 简单式两种用法
+-   * PageHeader 页头,标题+副标题+右侧操作区 slot
+-   * SectionTitle 区块小标题
+- - 组件 class 统一 adm- 前缀:Vue2 scoped 样式会命中子组件根元素,
+-   而 8 个页面各自定义了 .stat-card,不加前缀会被父页样式污染
+- - 样板迁移 admin/MembershipCenter.vue:改用 PageHeader + StatCard,数据零改动
+- - 后续铺开优先级:el-tag 129 页 > admin-page-header 47 页 > stat-card 19 页
+- 
+
+### 文档
+- 打卡任务链路实现计划归档更新 + cfc-web 版本号递增
+
+
+## v1.0.1613 (2026-10-14)
+
+### Bug 修复
+- 肠道菌群报告缺失增强字段(肠道预测年龄/肠型)不阻断入库
+- 迁移375 修复 article_quiz_records 缺列导致答题入库报错
+
+### 其他
+- 
+- questions/answers 为 VARCHAR(255) 与实体 TEXT 不符。补齐4列并扩为 TEXT,与 schema.sql 对齐。
+- 
+
+
+## v1.0.1612 (2026-10-14)
+
+### 重构
+- Phase 1 企业级 UI 统一 + 修复非表格页表单被裁切无滚动条
+
+### 其他
+- - 全局清扫旧品牌紫 #6366F1/#8B5CF6/#818CF8/#A78BFA -> 品牌蓝 #1677FF/#4096FF
+- - Dashboard/Login 移除装饰性渐变与 emoji,改为扁平 token 语义色
+- - Login 品牌面板 ::before 微弱径向光晕改为纯平占位层
+- - MembershipCenter .stat-purple 卡片由紫改品牌蓝
+- - Layout 侧栏菜单激活色 #6366F1 -> #1677FF
+- 修复:
+- - admin-tokens: 卡片弹性撑满链仅在含 .adm-table-wrap(表格页)时生效;
+-   非表格页(表单/编辑/详情)恢复自然高度,由外层 .el-main 统一整体滚动。
+-   修复商品编辑(ProductEdit)等长表单页内容被容器裁切且无滚动条、
+-   下半部分无法编辑的问题(该类页根为 .admin-page 且不含表格)。
+- 
+
+
+## v1.0.1610 (2026-10-14)
+
+### Bug 修复
+- 超级管理员(admin)放开代传报告权限(isProxyUploader 加 admin 白名单)
+
+
+## v1.0.1609 (2026-10-14)
+
+### 文档
+- 补充设计文档完成逻辑分支矩阵(按taskId/任务类型/补打三维度穷举8分支)
+- 营养产品标签方案(2026-10-12-nutrition-product-tagging.md)
+
+### Bug 修复
+- 消除 AI 建议与 structuredAnalysis 解析 WARN 日志
+- 家庭成员可编辑判断放宽(family-members.vue)
+- 修复活动管理页空白——错误使用 getActivityList 而非 getGuideActivityList
+
+### 其他
+-    加 @JsonIgnoreProperties(ignoreUnknown=true) 兜底未知字段,避免 AI 输出扩展
+-    导致 Jackson 反序列化整体失败(FiveDimensionSelfCheckService.parseAdvices)。
+- 2. EnergyService/EmiReportService.parseStructuredAnalysis:structured_analysis
+-    实际存储的是 DAN 解析 items 平铺数组 [{code,name,value,category},...]
+-    (DanReportUploadService.confirmUpload 写入),并非嵌套对象 {extra:{...}}。
+-    原 JSON.parseObject(Map.class) 遇 [ 开头抛 fastjson illegal input WARN。
+-    现对 [ 开头(平铺数组)静默返回 null,调用方据此降级(自驱力/自我概念回 0,
+-    与"初期回 0 后续由自检问卷填充"语义一致);嵌套对象形态仍按原逻辑解析。
+- 
+
+### 新功能
+- 文章详情页关联商品推荐(article-detail.vue + api.js)
+- 文章关联商品标签匹配推荐(ArticleController/Product/ProductRecommendationService + schema products.tags)
+- 营养产品功效标签与年龄/剂量UL校验(实体/迁移/服务/接口 + schema 营养部分 + DatabaseInitializer)
+
+
+## v1.0.1608 (2026-10-14)
+
+### Bug 修复
+- 解析完成后清理 OSS 下载中转缓存(.jdcloud-cache 用后即删)
+- 采集链路三重加固——防止非菌群报告/类型不匹配报告静默入库
+
+### 其他
+- - 仅删除 .jdcloud-cache 缓存文件(getFilePath 下载产物),本地磁盘直存原文件不动
+- - 解析成功/失败/异常所有路径均清理;重试时自动重新下载
+- 
+- 采集校验 matched 1/23 pass=false 仍照常入库,前端显示"内容未采集完整"。
+- 加固(ReportCollectService.collect 健康管道):
+- ① 采集指标校验 pass=false → 阻止自动入库,标记失败由管理端人工处理/重传
+- ② gut_flora 家族类型解析结果必须含菌群实质数据(gutFlora/indicators 非空),
+-    只解析出 overview 的报告判定为内容不符
+- ③ 指纹识别类型与用户所选报告类型不一致(非 DAN)→ 阻止入库并明确提示
+- runCollectValidation 三个重载改为返回 boolean(跳过校验返回 true)。
+- DAN 管道分流不受影响(指纹命中 dan_* 仍走 collectDanReport)。
+- 
+
+
+## v1.0.1607 (2026-10-13)
+
+### 重构
+- 全项目时间显示统一走全局 formatTime/formatDate(移除 52 文件本地格式化方法,内联 substring/toLocaleString 改全局,formatDateTime 并入 formatTime)
+
+
+## v1.0.1606 (2026-10-13)
+
+### 文档
+- API_REFERENCE.md 三个打卡接口补 taskId 参数说明
+- 打卡接入任务完成链路状态更新为全栈完成
+
+### 新功能
+- 三个Controller补isDailyProgress二次分流、前端三个打卡页+tasks.vue透传taskId
+- 每日自动生成饮食/睡眠/运动打卡进度任务
+- 报告管理模块重组(合并健康报告/代传独立/详情合并/删除报告审核)
+
+### 其他
+- - cron 主流程接入,每日为所有能登录用户幂等生成 3 条 is_daily_progress 任务
+- - 新增实现计划文档 docs/superpowers/plans/2026-10-13-health-checkin-task-linkage-plan.md
+- 
+- - JS 方法 loadDanReports/goDanUpload/viewDanReport 与 CSS 一直在,仅模板缺失导致采集内容不显示
+- - 与 wisdom-detail L167-184 同构
+- 
+
+### Bug 修复
+- 恢复心智维度页 DAN 报告模板块(72f6e0693 误删,参照 wisdom-detail)
+
+
 ## v1.0.1605 (2026-10-13)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1605
+> 当前版本: v1.0.1633
 
 ## 历史版本
 
@@ -8,6 +8,435 @@
 
 ---
 
+## v1.0.1633 (2026-10-15)
+
+### Bug 修复
+- 文章阅读完成答题改为一题一页
+- 分享海报移除模板/背景色选择行并上移画布
+
+### 其他
+- - 新增步骤条「第 X / N 题」与圆点进度(未答/已选/当前三态)
+- - 导航按钮:首题下一步,中间题上/下一题,末题提交答案
+- - quizAnswers 按题下标存储,返回上一题答案保留,提交逻辑不变
+- - 题数由后端动态返回,改为 5 题也自适应;同步移除写死
+-   「三个问题」的文案
+- 
+-   标签,管理端编辑商品时无法回显已有标签
+- - 管理端商品编辑页新增「商品标签」输入(逗号分隔),保存时随
+-   ProductCreateRequest.product 提交,adminCreate/adminUpdate
+-   直接落库
+- - 标签与文章标签精确匹配后方会出现在小程序文章详情页
+-   「相关商品」推荐中,留空则不参与匹配
+- 
+- 获取文章标签与商品标签匹配的推荐商品(复用 DimensionProducts 组件,
+- 点击跳转商品详情页)。请求放在 loadDetail 内只走一次,避免
+- onLoad/onShow 双发;游客未登录同样可见(该路径已在 JwtInterceptor
+- 白名单)。
+- 
+
+### 新功能
+- 后台商品编辑支持标签字段,支撑文章-商品匹配推荐
+- 文章详情页接入关联商品推荐
+
+
+## v1.0.1632 (2026-10-15)
+
+### 新功能
+- 身体指标卡片展示较上次变化 delta/trend
+
+### 其他
+- - 配色沿用 overview 卡 trend-up/down/stable 约定
+- - 数据来自 /api/health/indicators 新增的 trendDesc/delta/trend 字段
+- 
+
+
+## v1.0.1631 (2026-10-15)
+
+### 新功能
+- getLatestIndicatorsByDomain 返回 delta/trend/trendDesc
+
+### 其他
+- - 附加 previousValue/delta/trend/trendDesc(较上次±x, stripTrailingZeros 去尾零)
+- - MemberIndicatorCurrentService 加 getByMemberAndDefinition
+- 
+
+
+## v1.0.1630 (2026-10-15)
+
+### 新功能
+- B方案-成员指标当前值+相对上次变化(delta/trend)
+
+### 其他
+- - 迁移378: member_indicator_current 表(唯一键 member_id+definition_id)
+- - HealthReportService.refreshMemberIndicatorCurrent(报告确认后按最新vs次新同名指标算 diff)
+- - rebuildForMember + /api/health/indicators/rebuild(admin 回填存量)
+- - /api/health/indicators/current 读取端点
+- - getLatestIndicatorsByDomain 读取口径修复: source_id(报告ID)→subject_id(成员ID)
+-   (写入 source_id=reportId 保留每报告快照, 读端按 subject_id=memberId 查最新)
+- - confirmDraft + collect 接入 refreshMemberIndicatorCurrent
+- LangGraph:
+- - JavaClient.get_member_indicator_current
+- - collect_data 拉取指标变化(delta>=10 显著变化并入 abnormal_list)
+- - build_prompt 增加指标较上次报告的变化段落, 方案生成考虑趋势
+- 数据: member21 回填 50 条 diff(报告42→58)
+- 
+
+
+## v1.0.1629 (2026-10-15)
+
+### Bug 修复
+- 修正肠道菌群卡片 category 英文枚举(实际全库为中文)
+
+### 其他
+- - 排除 taxonomy(门/纲/目/科级别分类构成,非菌种卡片应展示)
+- - 旧代码过滤 (core/probiotic/harmful/other) 与 DB 中文枚举永不匹配,
+-   导致 /api/health/gut-flora 永远空(报告有 135 行却读不到)
+- 
+
+
+## v1.0.1628 (2026-10-15)
+
+### Bug 修复
+- 修复健康自检问卷→方案生成链路 5 处缺陷
+
+### 其他
+-   LangGraph HealthPlanRequest/RegenerateRequest 加 camelCase 别名容错(AliasChoices)
+- - B 方案「确认保存」不可达:three sections 仅 nutrition 可确认,改为逐板块推进,
+-   全部确认后退出编辑显示「确认保存」
+- - C 多成员方案前端未适配:用 common(+individual 追加)填充三大板块,
+-   并修正模板中未定义的 secTitles/secIcons(应为 sectionTitles/sectionIcons)
+- - D 等级重复:9.9 元/月健康会员统一复用既有 BASIC「体验会员」,
+-   迁移 366 改为清理误建的 LITE 等级并迁移存量记录(三段独立 try)
+- - E 自适应问卷答案未进 Prompt:_format_health_status 输出月经周期/特殊时期/
+-   生长发育/挑食/骨质疏松/跌倒/屏幕时间/情绪等性别年龄专属项
+- 验证:mvn -q clean compile EXIT=0;node --check 通过;py_compile 通过;
+- audit-duplicate-api-calls 0 error/104 warning
+- 
+
+
+## v1.0.1627 (2026-10-15)
+
+### 重构
+- UserContentWeightService 接口化(遗留改动补提交)
+
+### Bug 修复
+- A方案-报告统一挂 member_id(subject_id 归一化)
+
+### 其他
+- - bindReportToMember 写库前归一化(集中守卫点);reassignReport 加 familyId==null 守卫
+- - matchUserByName 三处 return user.getId() 改为 return resolveSubjectToMemberId
+- - HealthReportController + ReportCollectService buildReportFromPayload 写前归一化
+- - FamilyMemberService.migrateMemberAssets 按 subject_id=memberId 迁移报告(成员迁移保 id)
+- - 数据修复: 9 条 health_reports.subject_id 错绑已迁移(mysqldump 备份保留)
+- 
+
+
+## v1.0.1626 (2026-10-15)
+
+### 重构
+- 商城一级分类宫格改为横向滚动 chips
+
+### 其他
+- 
+
+
+## v1.0.1625 (2026-10-15)
+
+### 新功能
+- 新增企业员工健康关怀解决方案落地页 intro.html
+
+
+## v1.0.1624 (2026-10-15)
+
+### Bug 修复
+- 打卡类任务点击后跳转打卡页而非直接完成
+
+### 其他
+- 的 actionType 分支此前只挂在 startRequired 两阶段路径上,普通任务
+- 点击「去打卡」会直接调 completeTask 接口,绕过打卡页、不累计进度。
+- 现于 handleTaskAction 直接完成前增加 diet/sleep/exercise 跳转分支,
+- 打卡提交后由后端回写(一次性任务 completeTask / 每日任务 updateProgress)。
+- 
+
+
+## v1.0.1623 (2026-10-15)
+
+### 重构
+- 移除数据库初始化中修改菜单内容的迁移脚本
+
+### 其他
+- - 删除迁移251/372 migrateHealthMenuMount(健康饮食组菜单重组)
+- - 删除迁移267 migratePlanCenterMenu(方案中心菜单插入)
+- - 连带删除专属辅助方法 insertPlanCenterChild/updateMenuPermByPath/
+-   deleteHealthMenuByPath/insertHealthMenu/moveMenu
+- - 保留 queryMenuId/seedSysMenus(244 种子数据与建表仍执行)与
+-   grantMenuToRole(迁移318 系统提示词菜单仍使用)
+- 菜单内容(sys_menu 行)不再由初始化脚本在每次启动时改写,交由人工/管理端维护
+- 
+- - 新增 components/DimensionPageSkeleton.vue:五维页首屏加载骨架,已登录用户数据请求期间占位,避免「空白 -> 内容」硬跳变,稳态视觉零变化
+- - body/wisdom/mind/wealth/action 五维页接入 pageLoading 骨架(created 判断 token)
+- - 触摸目标扩展至 88rpx(微信最小可点区域):help-icon、help-dialog-close、section-more 用 ::after 外扩,不改视觉
+- - 新增 docs/dimension-pages-stage4-audit.md 阶段四审计报告
+- 后端:
+- - 新增 ContentByWeightController + UserContentWeightServiceImpl:用户/家庭内容偏好权重(商品/活动/文章)查询接口
+- 文档:
+- - 补充 docs/参考资料 与 知识库 下的参考资料文档
+- (构建产物 dist_bak_*/dist_prev_* 及临时脚本未纳入提交)
+- 
+
+### 新功能
+- 五维页阶段四 — 首屏骨架 + 88rpx 触摸目标 + 内容偏好权重接口
+
+
+## v1.0.1622 (2026-10-15)
+
+### Bug 修复
+- 修复异步采集并发重跑导致同一草稿重复入库
+
+### 其他
+- collecting 状态且 confirmed_report_id 为空,被 ReportCollectRetryTask 的 10
+- 分钟卡死兜底误判为卡死并重新提交采集;两次采集的读-写幂等守卫存在竞态,
+- 均通过检查后各自调用 insertReport,产生重复报告。
+- 修复:
+- - insertReport 前守卫改为 CAS 原子抢占(collecting->inserting,仅
+-   confirmed_report_id 为空时成功),后到者失败跳过,消除并发读-写竞态
+- - collect() 开头早退守卫补检查 confirmed_report_id
+- - listPendingRetry 卡死兜底阈值 10 -> 30 分钟,避免正常长解析被误判
+- 
+
+
+## v1.0.1621 (2026-10-15)
+
+### Bug 修复
+- 内容分享海报尺寸调整(800→633)
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- 首页推荐商品支持向下翻页
+- DimensionProducts组件支持分页加载+商城样式
+- 推荐商品接口支持分页(page/size)
+
+
+## v1.0.1620 (2026-10-15)
+
+### Bug 修复
+- 统一知识库启动时自愈回填缺失模块
+
+### 其他
+- 因 importFromClasspath(false) 的 DB 非空跳过逻辑永不补齐,导致食物搜索/营养素/疾病关联/
+- 版本号在生产为空。load() 增加 backfillMissingModules:DB 非空但缺 MODULE_KEYS 时从
+- classpath 种子逐键补齐(幂等,不动已存在模块)。
+- 
+
+
+## v1.0.1619 (2026-10-15)
+
+### 新功能
+- 统一知识库管理端维护接口 + 页面
+
+### 其他
+- - 新增 AdminUnifiedKnowledgeController (/api/admin/knowledge-base/unified/*, 权限 admin/operator/finance/knowledge_admin/butler)
+- - cfc-web 新增统一知识库管理页(模块列表+JSON编辑/格式化/保存/从种子重置, 路由/菜单挂知识库分组)
+- - 写库后 load() 热重载内存索引, 无需重启
+- 
+
+
+## v1.0.1618 (2026-10-15)
+
+### 样式
+- 清理 Phase3 残留渐变与装饰性 emoji(企业级 UI 规范)
+
+### 其他
+- - 8 个文件移除装饰性 emoji(标题/卡片前缀),LangGraphAdmin 改为 el-icon,ReviewCenter 移除图标位
+- - TeacherOrders 重命名 gradient-* 类名、TeacherTeam 重命名 gradients 变量(均已为实色)
+- - 功能性箭头与用户可配置 icon 数据字段(ArticleCategory/gates/ProblemDomainManage)保留
+- 
+
+
+## v1.0.1617 (2026-10-14)
+
+### 重构
+- StatCard 铺开(PurchaseManagement 4 处,严格安全条件)
+
+### 其他
+-   额外内容(如 stat-trend)、value 表达式不得含过滤器
+- - 原因:首次宽松迁移把 Dashboard.vue 卡片的 @click.native 路由跳转与
+-   stat-trend 提示文字一并丢弃(功能回退),已整体回退后按严格条件重做
+- - 结果:仅 PurchaseManagement 4 处满足安全条件;其余 12 个文件因结构
+-   含 stat-info / stat-trend / 事件绑定 / 过滤器而跳过,需逐页手工处理
+- 
+
+
+## v1.0.1616 (2026-10-14)
+
+### 重构
+- 复用组件铺开第一批(StatusTag 43 处 + PageHeader 41 处)
+
+### 其他
+- - 迁移单行纯文本状态标签 43 处(17 个文件)
+- - 保留原有 v-if/v-else 分支与 type 映射,零逻辑变更
+- - 未动含插值的 118 处:其中混有非状态用途(如数值展示 totalPoints),
+-   自动替换会误伤语义,留待逐页判断
+- PageHeader:
+- - 迁移 admin-page-header 41 处(40 个文件),保留 slot="header" 等属性与内部操作区
+- - 用 div 配对计数定位闭合标签,兼容含嵌套 div.admin-page-actions 的结构
+- - 7 处无 admin-page-title 的非标准结构保守跳过
+- - 修复:迁移脚本误改了 PageHeader.vue 自身注释中的示例代码,已从已提交版本恢复
+- 验证: npm run build EXIT=0(65034ms,428 个文件)
+- 
+
+
+## v1.0.1615 (2026-10-14)
+
+### Bug 修复
+- resolve 14 corrupted files - restore from ca76d8a62, remove cff388913 injection, fix dimension-subdims location, clean reminder-config key expression
+
+
+## v1.0.1614 (2026-10-14)
+
+### 新功能
+- Phase 2 引入企业级复用组件层(StatCard/StatusTag/PageHeader/SectionTitle)
+- 创建任务表单 actionTypes 新增饮食/睡眠/运动打卡选项
+
+### 其他
+-   * StatCard   KPI 统计卡,兼容旧数据 {color,icon,value,label},旧 color 名自动映射(purple->primary)
+-   * StatusTag  状态标签,支持 options 映射式与 text+type 简单式两种用法
+-   * PageHeader 页头,标题+副标题+右侧操作区 slot
+-   * SectionTitle 区块小标题
+- - 组件 class 统一 adm- 前缀:Vue2 scoped 样式会命中子组件根元素,
+-   而 8 个页面各自定义了 .stat-card,不加前缀会被父页样式污染
+- - 样板迁移 admin/MembershipCenter.vue:改用 PageHeader + StatCard,数据零改动
+- - 后续铺开优先级:el-tag 129 页 > admin-page-header 47 页 > stat-card 19 页
+- 
+
+### 文档
+- 打卡任务链路实现计划归档更新 + cfc-web 版本号递增
+
+
+## v1.0.1613 (2026-10-14)
+
+### Bug 修复
+- 肠道菌群报告缺失增强字段(肠道预测年龄/肠型)不阻断入库
+- 迁移375 修复 article_quiz_records 缺列导致答题入库报错
+
+### 其他
+- 
+- questions/answers 为 VARCHAR(255) 与实体 TEXT 不符。补齐4列并扩为 TEXT,与 schema.sql 对齐。
+- 
+
+
+## v1.0.1612 (2026-10-14)
+
+### 重构
+- Phase 1 企业级 UI 统一 + 修复非表格页表单被裁切无滚动条
+
+### 其他
+- - 全局清扫旧品牌紫 #6366F1/#8B5CF6/#818CF8/#A78BFA -> 品牌蓝 #1677FF/#4096FF
+- - Dashboard/Login 移除装饰性渐变与 emoji,改为扁平 token 语义色
+- - Login 品牌面板 ::before 微弱径向光晕改为纯平占位层
+- - MembershipCenter .stat-purple 卡片由紫改品牌蓝
+- - Layout 侧栏菜单激活色 #6366F1 -> #1677FF
+- 修复:
+- - admin-tokens: 卡片弹性撑满链仅在含 .adm-table-wrap(表格页)时生效;
+-   非表格页(表单/编辑/详情)恢复自然高度,由外层 .el-main 统一整体滚动。
+-   修复商品编辑(ProductEdit)等长表单页内容被容器裁切且无滚动条、
+-   下半部分无法编辑的问题(该类页根为 .admin-page 且不含表格)。
+- 
+
+
+## v1.0.1610 (2026-10-14)
+
+### Bug 修复
+- 超级管理员(admin)放开代传报告权限(isProxyUploader 加 admin 白名单)
+
+
+## v1.0.1609 (2026-10-14)
+
+### 文档
+- 补充设计文档完成逻辑分支矩阵(按taskId/任务类型/补打三维度穷举8分支)
+- 营养产品标签方案(2026-10-12-nutrition-product-tagging.md)
+
+### Bug 修复
+- 消除 AI 建议与 structuredAnalysis 解析 WARN 日志
+- 家庭成员可编辑判断放宽(family-members.vue)
+- 修复活动管理页空白——错误使用 getActivityList 而非 getGuideActivityList
+
+### 其他
+-    加 @JsonIgnoreProperties(ignoreUnknown=true) 兜底未知字段,避免 AI 输出扩展
+-    导致 Jackson 反序列化整体失败(FiveDimensionSelfCheckService.parseAdvices)。
+- 2. EnergyService/EmiReportService.parseStructuredAnalysis:structured_analysis
+-    实际存储的是 DAN 解析 items 平铺数组 [{code,name,value,category},...]
+-    (DanReportUploadService.confirmUpload 写入),并非嵌套对象 {extra:{...}}。
+-    原 JSON.parseObject(Map.class) 遇 [ 开头抛 fastjson illegal input WARN。
+-    现对 [ 开头(平铺数组)静默返回 null,调用方据此降级(自驱力/自我概念回 0,
+-    与"初期回 0 后续由自检问卷填充"语义一致);嵌套对象形态仍按原逻辑解析。
+- 
+
+### 新功能
+- 文章详情页关联商品推荐(article-detail.vue + api.js)
+- 文章关联商品标签匹配推荐(ArticleController/Product/ProductRecommendationService + schema products.tags)
+- 营养产品功效标签与年龄/剂量UL校验(实体/迁移/服务/接口 + schema 营养部分 + DatabaseInitializer)
+
+
+## v1.0.1608 (2026-10-14)
+
+### Bug 修复
+- 解析完成后清理 OSS 下载中转缓存(.jdcloud-cache 用后即删)
+- 采集链路三重加固——防止非菌群报告/类型不匹配报告静默入库
+
+### 其他
+- - 仅删除 .jdcloud-cache 缓存文件(getFilePath 下载产物),本地磁盘直存原文件不动
+- - 解析成功/失败/异常所有路径均清理;重试时自动重新下载
+- 
+- 采集校验 matched 1/23 pass=false 仍照常入库,前端显示"内容未采集完整"。
+- 加固(ReportCollectService.collect 健康管道):
+- ① 采集指标校验 pass=false → 阻止自动入库,标记失败由管理端人工处理/重传
+- ② gut_flora 家族类型解析结果必须含菌群实质数据(gutFlora/indicators 非空),
+-    只解析出 overview 的报告判定为内容不符
+- ③ 指纹识别类型与用户所选报告类型不一致(非 DAN)→ 阻止入库并明确提示
+- runCollectValidation 三个重载改为返回 boolean(跳过校验返回 true)。
+- DAN 管道分流不受影响(指纹命中 dan_* 仍走 collectDanReport)。
+- 
+
+
+## v1.0.1607 (2026-10-13)
+
+### 重构
+- 全项目时间显示统一走全局 formatTime/formatDate(移除 52 文件本地格式化方法,内联 substring/toLocaleString 改全局,formatDateTime 并入 formatTime)
+
+
+## v1.0.1606 (2026-10-13)
+
+### 文档
+- API_REFERENCE.md 三个打卡接口补 taskId 参数说明
+- 打卡接入任务完成链路状态更新为全栈完成
+
+### 新功能
+- 三个Controller补isDailyProgress二次分流、前端三个打卡页+tasks.vue透传taskId
+- 每日自动生成饮食/睡眠/运动打卡进度任务
+- 报告管理模块重组(合并健康报告/代传独立/详情合并/删除报告审核)
+
+### 其他
+- - cron 主流程接入,每日为所有能登录用户幂等生成 3 条 is_daily_progress 任务
+- - 新增实现计划文档 docs/superpowers/plans/2026-10-13-health-checkin-task-linkage-plan.md
+- 
+- - JS 方法 loadDanReports/goDanUpload/viewDanReport 与 CSS 一直在,仅模板缺失导致采集内容不显示
+- - 与 wisdom-detail L167-184 同构
+- 
+
+### Bug 修复
+- 恢复心智维度页 DAN 报告模板块(72f6e0693 误删,参照 wisdom-detail)
+
+
 ## v1.0.1605 (2026-10-13)
 
 ### 新功能

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio