Explorar el Código

Merge origin/cfclub: 解决 DatabaseInitializer 迁移编号冲突(efficacy_tags→372,products.tags→373)

E2E Test Bot hace 1 semana
padre
commit
bd6c83564f
Se han modificado 100 ficheros con 5657 adiciones y 904 borrados
  1. 13 0
      .githooks/pre-commit
  2. 205 38
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java
  4. 48 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java
  5. 57 5
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java
  6. 50 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java
  7. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java
  8. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  9. 27 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductController.java
  10. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java
  11. 46 7
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietPreferencesController.java
  12. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java
  13. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java
  14. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java
  15. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DiseaseIntakeLimit.java
  16. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DrugInteraction.java
  17. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthExerciseRecord.java
  18. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthMealRecord.java
  19. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthSleepRecord.java
  20. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  21. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java
  22. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DiseaseIntakeLimitMapper.java
  23. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DrugInteractionMapper.java
  24. 4 5
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  25. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java
  26. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java
  27. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  28. 320 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthFilterService.java
  29. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java
  30. 26 8
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
  31. 80 9
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  32. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  33. 14 1
      cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java
  34. 12 2
      cfc-backend/src/main/java/com/etotem/cfc/service/WechatWaitMsgService.java
  35. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java
  36. 28 2
      cfc-backend/src/main/java/com/etotem/cfc/service/notification/WebhookNotificationChannel.java
  37. 91 0
      cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java
  38. 46 15
      cfc-backend/src/main/resources/schema.sql
  39. 117 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminProductControllerBatchShelveTest.java
  40. 201 0
      cfc-frontend/components/DimensionVisitorCard.vue
  41. 5 1
      cfc-frontend/components/RecommendedFeed.vue
  42. 461 0
      cfc-frontend/components/login-sheet.vue
  43. 121 0
      cfc-frontend/docs/dimension-pages-login-state-plan.md
  44. 5 0
      cfc-frontend/main.js
  45. 114 0
      cfc-frontend/mixins/login-sheet.js
  46. 18 0
      cfc-frontend/pages.json
  47. 2 12
      cfc-frontend/pages/action-detail/components/DimensionSubDims.vue
  48. 39 37
      cfc-frontend/pages/action-detail/index.vue
  49. 6 3
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  50. 6 3
      cfc-frontend/pages/activity/index.vue
  51. 5 1
      cfc-frontend/pages/ai/chat.vue
  52. 5 1
      cfc-frontend/pages/article-center/article-detail.vue
  53. 5 1
      cfc-frontend/pages/article-center/index.vue
  54. 5 1
      cfc-frontend/pages/article-center/my-posts.vue
  55. 1 1
      cfc-frontend/pages/assessment/execution.vue
  56. 5 1
      cfc-frontend/pages/body-detail/dimension-detail.vue
  57. 67 65
      cfc-frontend/pages/body-detail/index.vue
  58. 7 3
      cfc-frontend/pages/child/tasks.vue
  59. 6 9
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  60. 5 1
      cfc-frontend/pages/discover/index.vue
  61. 1 1
      cfc-frontend/pages/growth-main/index.vue
  62. 4 1
      cfc-frontend/pages/growth/diet-checkin.vue
  63. 5 2
      cfc-frontend/pages/growth/exercise-checkin.vue
  64. 5 2
      cfc-frontend/pages/growth/sleep-checkin.vue
  65. 7 3
      cfc-frontend/pages/growth/upload.vue
  66. 7 3
      cfc-frontend/pages/guide/activities/detail.vue
  67. 7 3
      cfc-frontend/pages/guide/activities/index.vue
  68. 7 3
      cfc-frontend/pages/guide/packages/share.vue
  69. 1 9
      cfc-frontend/pages/health-main/index.vue
  70. 329 0
      cfc-frontend/pages/health/components/BasicVitalsStep.vue
  71. 264 0
      cfc-frontend/pages/health/components/ConfirmStep.vue
  72. 451 0
      cfc-frontend/pages/health/components/DietHabitsStep.vue
  73. 338 0
      cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue
  74. 520 0
      cfc-frontend/pages/health/components/MedicalHistoryStep.vue
  75. 1 1
      cfc-frontend/pages/health/gut-flora-detail.vue
  76. 151 461
      cfc-frontend/pages/health/health-plan-summary.vue
  77. 1 4
      cfc-frontend/pages/health/my-feedback.vue
  78. 244 0
      cfc-frontend/pages/health/plan-list.vue
  79. 1 1
      cfc-frontend/pages/health/report-confirm.vue
  80. 4 4
      cfc-frontend/pages/home-pages/parent-index.vue
  81. 24 14
      cfc-frontend/pages/index-home/index.vue
  82. 11 11
      cfc-frontend/pages/invite/join-form.vue
  83. 4 2
      cfc-frontend/pages/invite/join.vue
  84. 7 3
      cfc-frontend/pages/meal/recipe-detail.vue
  85. 7 3
      cfc-frontend/pages/mind-detail/articles.vue
  86. 102 79
      cfc-frontend/pages/mind-detail/index.vue
  87. 1 1
      cfc-frontend/pages/parent/child-detail.vue
  88. 7 3
      cfc-frontend/pages/promotion/material.vue
  89. 205 0
      cfc-frontend/pages/reminder-config/index.vue
  90. 13 4
      cfc-frontend/pages/shop/cart/cart.vue
  91. 7 3
      cfc-frontend/pages/shop/checkout/checkout.vue
  92. 13 4
      cfc-frontend/pages/shop/index/index.vue
  93. 7 3
      cfc-frontend/pages/shop/order-detail/order-detail.vue
  94. 7 3
      cfc-frontend/pages/shop/order-list/order-list.vue
  95. 1 1
      cfc-frontend/pages/shop/search/search.vue
  96. 14 5
      cfc-frontend/pages/tasks/tasks.vue
  97. 29 17
      cfc-frontend/pages/teacher/families.vue
  98. 166 0
      cfc-frontend/pages/teacher/plan-create.vue
  99. 236 0
      cfc-frontend/pages/teacher/plan-list.vue
  100. 2 2
      cfc-frontend/pages/teacher/service-tasks.vue

+ 13 - 0
.githooks/pre-commit

@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# 知识库提交前校验:暂存区含 docs/参考资料/知识库 变更时自动运行校验脚本。
+# 阻断不合规提交(返回非 0 时 git commit 会失败),可用 git commit --no-verify 跳过。
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+KB_DIR="docs/参考资料/知识库"
+VALIDATOR="$ROOT/docs/scripts/validate_kb.py"
+
+if git diff --cached --name-only -- "$KB_DIR" | grep -q .; then
+  echo "[pre-commit] 检测到知识库变更,运行校验: $VALIDATOR"
+  python3 "$VALIDATOR" "$ROOT/$KB_DIR"
+fi

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

@@ -3009,6 +3009,21 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createFatGoalTable();
         addProductVendorVisibleColumn();
 
+        // 迁移367: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记)
+        addProductHealthConstraintColumns();
+
+        // 迁移368: 创建 drug_interactions 表(药物-食物禁忌对照表)
+        createDrugInteractionsTable();
+
+        // 迁移369: 创建 disease_intake_limits 表(疾病-饮食摄入阈值)
+        createDiseaseIntakeLimitsTable();
+
+        // 迁移370: 初始化 drug_interactions 种子数据
+        seedDrugInteractionsData();
+
+        // 迁移371: 初始化 disease_intake_limits 种子数据
+        seedDiseaseIntakeLimitsData();
+
         // 指标统一层迁移(356~362)
         migrateIndicatorDefinitionsColumns();
         migrateIndicatorValuesColumns();
@@ -3024,10 +3039,22 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移364: health_report_drafts 添加解析进度/重试字段(known-type 解析失败退避重试,不再静默回退 opencode)
         addDraftParseProgressColumns();
 
-        // 迁移365: nutrition_product 添加 efficacy_tags 列(成分功效标签)
+        // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库)
+        ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'");
+
+        // 迁移366: 新增 LITE 轻享会员等级(9.9元/月,解锁健康自检问卷与个性化方案)
+        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 轻享会员等级");
+        } catch (Exception e) {
+            log.warn("迁移366: 插入 LITE 会员等级失败或已存在: {}", e.getMessage());
+        }
+
+        // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)
         addNutritionProductEfficacyTags();
 
-        // 迁移366: products 添加 tags 列(文章-商品标签关联推荐)
+        // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
         addProductTagsColumn();
     }
 
@@ -9284,7 +9311,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
             log.warn("迁移250失败: {}", e.getMessage());
         }
 
-        // 迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材(《角色权限与菜单归类整合方案》第二部分,幂等)
+        // 迁移251/372: 健康饮食组报告管理模块重组(挂载报告管理/代传报告/应季食材,删除健康报告/报告审核菜单)
         migrateHealthMenuMount();
 
         // 迁移252: 创建用户画像表
@@ -9690,9 +9717,10 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
     }
 
     /**
-     * 迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材(存量库 sys_menu 幂等补齐)
-     * 对应页面 ReportManagement.vue / HealthReportAudit.vue / SeasonalFoods.vue 已开发完成但此前未挂载;
-     * 同时将健康饮食组排序对齐《浠艾福后台-角色权限与菜单归类整合方案》最终菜单树。
+     * 迁移251/372: 健康饮食组 报告管理模块重组(存量库 sys_menu 幂等补齐)
+     * - 迁移251:挂载 报告管理/应季食材 并排序对齐《浠艾福后台-角色权限与菜单归类整合方案》最终菜单树;
+     * - 迁移372:报告管理模块重组——删除已下线 健康报告(/health-reports) 与 报告审核(/report-audit) 菜单,
+     *   新增 代传报告(/report-proxy-upload) 菜单(对应前端 ProxyReportUpload.vue / 路由 /report-proxy-upload)。
      */
     private void migrateHealthMenuMount() {
         try {
@@ -9701,39 +9729,60 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                 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);
+            // 0. 迁移372: 删除已下线的 健康报告/报告审核 菜单(含角色授权,幂等)
+            deleteHealthMenuByPath(healthGroup, "/health-reports");
+            deleteHealthMenuByPath(healthGroup, "/report-audit");
+            // 1. 存量菜单排序对齐(报告管理0..五维能量10),先更新防止插入冲突
+            jdbcTemplate.update("UPDATE sys_menu SET sort=0 WHERE parent_id=" + healthGroup + " AND path='/report-management'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=2 WHERE parent_id=" + healthGroup + " AND path='/health-indicators'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=3 WHERE parent_id=" + healthGroup + " AND path='/health-checkins'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=4 WHERE parent_id=" + healthGroup + " AND path='/emotion-alert'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=5 WHERE parent_id=" + healthGroup + " AND path='/nutrition-mappings'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=6 WHERE parent_id=" + healthGroup + " AND path='/foods'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=7 WHERE parent_id=" + healthGroup + " AND path='/recipes'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=8 WHERE parent_id=" + healthGroup + " AND path='/seasonal-foods'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=9 WHERE parent_id=" + healthGroup + " AND path='/nutrition-products'");
+            jdbcTemplate.update("UPDATE sys_menu SET sort=10 WHERE parent_id=" + healthGroup + " AND path='/energy-sandbox'");
+            // 2. 按 path 幂等插入页面菜单
+            Long reportManagementId = insertHealthMenu(healthGroup, "报告管理", "/report-management", "el-icon-document", "health:reports", 0);
+            Long proxyUploadId = insertHealthMenu(healthGroup, "代传报告", "/report-proxy-upload", "el-icon-upload", "health:reports", 1);
+            Long seasonalFoodsId = insertHealthMenu(healthGroup, "应季食材", "/seasonal-foods", "el-icon-apple", "diet:foods", 8);
             // 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};
+                Long[] menuIds = {reportManagementId, proxyUploadId, seasonalFoodsId};
                 for (String role : roles) {
                     for (Long menuId : menuIds) {
                         grantMenuToRole(role, menuId);
                     }
                 }
-                log.info("迁移251: 已为 admin/butler/nutritionist 补授权新健康菜单");
+                log.info("迁移251/372: 已为 admin/butler/nutritionist 补授权健康菜单");
             }
-            log.info("迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材完成");
+            log.info("迁移251/372: 健康饮食组菜单重组完成(报告管理/代传报告/应季食材)");
         } catch (Exception e) {
             log.warn("迁移251失败: {}", e.getMessage());
         }
     }
 
+    /**
+     * 按 path 删除健康饮食叶子菜单并清理角色授权(幂等)
+     */
+    private void deleteHealthMenuByPath(Long parentId, String path) {
+        try {
+            Long menuId = queryMenuId("SELECT id FROM sys_menu WHERE parent_id=" + parentId + " AND path='" + path + "'");
+            if (menuId == null) {
+                return;
+            }
+            jdbcTemplate.update("DELETE FROM sys_role_menu WHERE menu_id=" + menuId);
+            jdbcTemplate.update("DELETE FROM sys_menu WHERE id=" + menuId);
+            log.info("迁移372: 已删除下线健康菜单「{}」", path);
+        } catch (Exception e) {
+            log.warn("迁移372: 删除菜单「{}」失败: {}", path, e.getMessage());
+        }
+    }
+
     /**
      * 按 path 幂等插入健康饮食叶子菜单,返回菜单 id(已存在返回原 id)
      */
@@ -9851,18 +9900,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"},
@@ -12385,6 +12433,125 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可bypass)'");
     }
 
+    // 迁移367: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记)
+    private void addProductHealthConstraintColumns() {
+        ensureColumn("products", "contraindicated_diseases", "VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如\"慢性肾病,肝病\")'");
+        ensureColumn("products", "allergen_tags", "VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如\"花生,小麦\")'");
+        ensureColumn("products", "drug_interaction_flags", "TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)'");
+    }
+
+    // 迁移368: 创建 drug_interactions 表(药物-食物禁忌对照表)
+    private void createDrugInteractionsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS drug_interactions (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)', " +
+                    "drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)', " +
+                    "interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)', " +
+                    "risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔', " +
+                    "action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔', " +
+                    "interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)', " +
+                    "note VARCHAR(255) COMMENT '风险说明', " +
+                    "UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst), " +
+                    "INDEX idx_interacting (interacting_subst)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表'");
+            log.info("迁移368: 已创建drug_interactions表");
+        } catch (Exception e) {
+            log.warn("迁移368: 创建drug_interactions表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移369: 创建 disease_intake_limits 表(疾病-饮食摄入阈值)
+    private void createDiseaseIntakeLimitsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS disease_intake_limits (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)', " +
+                    "nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)', " +
+                    "unit VARCHAR(20) NOT NULL COMMENT '单位', " +
+                    "threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限', " +
+                    "threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限', " +
+                    "block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)', " +
+                    "stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)', " +
+                    "INDEX idx_disease_nutrient (disease_type, nutrient)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表'");
+            log.info("迁移369: 已创建disease_intake_limits表");
+        } catch (Exception e) {
+            log.warn("迁移369: 创建disease_intake_limits表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移370: 初始化 drug_interactions 种子数据(高危组合,幂等)
+    private void seedDrugInteractionsData() {
+        String[][] seed = {
+                {"CCB", "硝苯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"},
+                {"CCB", "氨氯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"},
+                {"他汀类", "辛伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"},
+                {"他汀类", "阿托伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"},
+                {"抗凝药", "华法林", "维生素K", "2", "warn", "0", "维K拮抗药效,摄入量保持稳定,调整饮食结构前先与医生沟通"},
+                {"抗凝药", "华法林", "大蒜", "2", "warn", "0", "大蒜可能增强抗凝作用,增加出血风险"},
+                {"抗生素", "四环素", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
+                {"抗生素", "喹诺酮类", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
+                {"抗生素", "喹诺酮类", "钙剂", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
+                {"铁剂", "硫酸亚铁", "茶", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"},
+                {"铁剂", "硫酸亚铁", "咖啡", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"},
+                {"铁剂", "硫酸亚铁", "钙剂", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"},
+                {"铁剂", "硫酸亚铁", "牛奶", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"},
+                {"甲状腺药", "左甲状腺素", "豆浆", "3", "space", "240", "影响吸收,需间隔4小时"},
+                {"甲状腺药", "左甲状腺素", "高纤维食物", "3", "space", "240", "影响吸收,需间隔4小时"},
+                {"甲状腺药", "左甲状腺素", "钙剂", "3", "space", "240", "影响吸收,需间隔4小时"},
+                {"保钾利尿剂", "螺内酯", "高钾食物", "2", "warn", "0", "高钾血症风险,避免香蕉、橙汁、低钠盐大量集中摄入"},
+                {"MAOI", "苯乙肼", "发酵食品", "2", "warn", "0", "酪胺反应可致血压骤升,避免奶酪/豆豉/腌制品/陈年食品"},
+                {"头孢类", "头孢类", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"},
+                {"甲硝唑", "甲硝唑", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"},
+                {"降糖药", "磺脲类", "酒精", "1", "block", "0", "低血糖风险,避免空腹饮酒"}
+        };
+        for (String[] row : seed) {
+            try {
+                Integer cnt = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM drug_interactions WHERE drug_class=? AND drug_specifics=? AND interacting_subst=?",
+                        Integer.class, row[0], row[1], row[2]);
+                if (cnt != null && cnt > 0) continue;
+                jdbcTemplate.update(
+                        "INSERT INTO drug_interactions (drug_class, drug_specifics, interacting_subst, risk_level, action_type, interval_minutes, note) VALUES (?,?,?,?,?,?,?)",
+                        row[0], row[1], row[2], Integer.valueOf(row[3]), row[4], Integer.valueOf(row[5]), row[6]);
+            } catch (Exception e) {
+                log.warn("迁移370: 插入drug_interactions种子失败: {}", e.getMessage());
+            }
+        }
+        log.info("迁移370: drug_interactions种子数据初始化完成");
+    }
+
+    // 迁移371: 初始化 disease_intake_limits 种子数据(幂等)
+    private void seedDiseaseIntakeLimitsData() {
+        String[][] seed = {
+                {"慢性肾病", "protein", "g/kg/day", "0.8", "0.6", "杨桃", "1"},
+                {"慢性肾病", "potassium", "mmol/L/day", "5.0", "3.5", "杨桃,香蕉,橙子,土豆,菠菜,菌菇", "1"},
+                {"慢性肾病", "phosphorus", "mg/day", "800", "600", "杨桃,奶制品,坚果,加工食品", "1"},
+                {"糖尿病", "carb", "g/meal", "75", "30", "", "0"},
+                {"糖尿病", "fructose", "g/day", "50", "0", "含糖饮料,果汁,蜂蜜,甜点", "0"},
+                {"高血压", "sodium", "g/day", "5", "2", "腌菜,酱菜,酱油,加工肉制品,挂面,咸味零食", "0"},
+                {"痛风", "purine", "mg/day", "300", "150", "动物内脏,部分海鲜,浓肉汤", "0"},
+                {"痛风", "fructose", "g/day", "50", "0", "含糖饮料,蜂蜜", "0"},
+                {"肝病", "protein", "g/kg/day", "1.0", "0.5", "", "1"},
+                {"甲状腺疾病", "iodine", "ug/day", "300", "150", "海带,紫菜", "0"}
+        };
+        for (String[] row : seed) {
+            try {
+                Integer cnt = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM disease_intake_limits WHERE disease_type=? AND nutrient=?",
+                        Integer.class, row[0], row[1]);
+                if (cnt != null && cnt > 0) continue;
+                jdbcTemplate.update(
+                        "INSERT INTO disease_intake_limits (disease_type, nutrient, unit, threshold_max, threshold_min, block_foods, stage_dependent) VALUES (?,?,?,?,?,?,?)",
+                        row[0], row[1], row[2], row[3], row[4], row[5], Integer.valueOf(row[6]));
+            } catch (Exception e) {
+                log.warn("迁移371: 插入disease_intake_limits种子失败: {}", e.getMessage());
+            }
+        }
+        log.info("迁移371: disease_intake_limits种子数据初始化完成");
+    }
+
     // 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
     private void createProcurementTaskTable() {
         try {
@@ -12436,7 +12603,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    // 迁移365: nutrition_product 添加 efficacy_tags 列(成分功效标签)
+    // 迁移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\"])'");
@@ -12444,9 +12611,9 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    // 迁移366: products 添加 tags 列(文章-商品标签关联推荐)
+    // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
     private void addProductTagsColumn() {
         ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'");
-        log.info("迁移366: products.tags 已确认存在");
+        log.info("迁移373: products.tags 已确认存在");
     }
 }

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

@@ -144,8 +144,8 @@ public class EnergyController {
         Map<String, Object> m = new HashMap<>();
         m.put("id", null);
         m.put("gateType", "SELF_CHECK");
-        m.put("name", "五维基础能量检测");
-        m.put("description", "检查家庭的能量维度值,解锁能量沙盘");
+        m.put("name", "健康自检问卷");
+        m.put("description", "完成健康自检问卷,解锁能量沙盘");
         m.put("icon", "📊");
         return m;
     }

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java

@@ -6,15 +6,21 @@ import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.HealthExerciseRecord;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.HealthExerciseRecordMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.WechatService;
+import com.etotem.cfc.service.TaskService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 import com.etotem.cfc.util.ParamUtils;
+import lombok.extern.slf4j.Slf4j;
 
 import javax.annotation.Resource;
 import java.util.Calendar;
@@ -23,6 +29,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+@Slf4j
 @Tag(name = "运动打卡")
 @RestController
 @RequestMapping("/api/health/exercise")
@@ -31,6 +38,15 @@ public class HealthExerciseController {
     @Resource
     private HealthExerciseRecordMapper healthExerciseRecordMapper;
 
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private UserMapper userMapper;
 
@@ -72,6 +88,38 @@ public class HealthExerciseController {
         if (!hasContent) return Result.error("请填写运动时长或添加图片/语音");
         record.setCreatedAt(new Date());
         healthExerciseRecordMapper.insert(record);
+        
+        // ===== 任务回写挂钩 =====
+        try {
+            if (record.getTaskId() != null) {
+                Task linkedTask = taskMapper.selectById(record.getTaskId());
+                if (linkedTask != null) {
+                    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);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("运动打卡任务回写失败: {}", e.getMessage());
+        }
                 // 触发画像重算
         try {
             Long triggerMemberId = record.getMemberId();

+ 57 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -4,11 +4,17 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.HealthMealRecord;
 import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.HealthMealRecordMapper;
 import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import org.springframework.web.bind.annotation.*;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.PointsService;
+import com.etotem.cfc.service.TaskService;
+import com.etotem.cfc.service.ProfileComputeService;
+import com.etotem.cfc.util.ParamUtils;
+import lombok.extern.slf4j.Slf4j;
 
 import javax.annotation.Resource;
 import java.sql.Date;
@@ -16,9 +22,11 @@ import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.stream.Collectors;
-import com.etotem.cfc.service.PointsService;
-import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
 
+@Slf4j
 @Tag(name = "饮食打卡")
 @RestController
 @RequestMapping("/api/health/meal")
@@ -33,6 +41,15 @@ public class HealthMealController {
     @Resource
     private PointsService pointsService;
 
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private com.etotem.cfc.service.ProfileComputeService profileComputeService;
 
@@ -230,6 +247,41 @@ public class HealthMealController {
         record.setCreatedAt(new java.util.Date());
         healthMealRecordMapper.insert(record);
 
+        // ===== 任务回写挂钩 =====
+        // 非补打打卡才回写任务(补打不参与任务完成逻辑)
+        if (!isBackfill) {
+            try {
+                if (record.getTaskId() != null) {
+                    Task linkedTask = taskMapper.selectById(record.getTaskId());
+                    if (linkedTask != null) {
+                        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);
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("饮食打卡任务回写失败: {}", e.getMessage());
+            }
+        }
+
         // 只有非补打才发积分
         if (!isBackfill) {
             try {

+ 50 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java

@@ -3,7 +3,11 @@ package com.etotem.cfc.controller;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.HealthSleepRecord;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.HealthSleepRecordMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -14,7 +18,10 @@ import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
+import com.etotem.cfc.service.TaskService;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Tag(name = "睡眠打卡")
 @RestController
 @RequestMapping("/api/health/sleep")
@@ -23,6 +30,15 @@ public class HealthSleepController {
     @Resource
     private HealthSleepRecordMapper healthSleepRecordMapper;
 
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private com.etotem.cfc.service.ProfileComputeService profileComputeService;
 
@@ -50,7 +66,40 @@ public class HealthSleepController {
         if (record.getWakeTime() == null) return Result.error("请选择醒来时间");
         record.setCreatedAt(new Date());
         healthSleepRecordMapper.insert(record);
-                // 触发画像重算
+
+        // ===== 任务回写挂钩 =====
+        try {
+            if (record.getTaskId() != null) {
+                Task linkedTask = taskMapper.selectById(record.getTaskId());
+                if (linkedTask != null) {
+                    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);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("睡眠打卡任务回写失败: {}", e.getMessage());
+        }
+
+        // 触发画像重算
         try {
             Long triggerMemberId = record.getMemberId();
             if (triggerMemberId != null) {

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -34,6 +34,16 @@ public class HealthStatusController {
                 : healthStatusService.getByUserId(userId));
     }
 
+    @Operation(summary = "内部:按成员查询健康现状(供 LangGraph 方案生成,无用户归属校验)", hidden = true)
+    @PostMapping("/internal/get")
+    public Result<HealthStatus> internalGet(@RequestBody(required = false) Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        if (memberId == null) {
+            return Result.error("缺少参数: memberId");
+        }
+        return Result.success(healthStatusService.getByMemberIdOnly(memberId));
+    }
+
     @Operation(summary = "保存/更新当前账号健康现状(memberId 为空存账号级,非空存成员级)")
     @PostMapping("/save")
     public Result<HealthStatus> save(

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -174,8 +174,9 @@ public class AdminArticleController {
         if (articleId != null) {
             Article existing = articleService.getById(articleId);
             if (existing != null && !"draft".equals(existing.getStatus())
-                    && !"published".equals(existing.getStatus())) {
-                return Result.error("仅草稿或已上架状态可编辑");
+                    && !"published".equals(existing.getStatus())
+                    && !"withdrawn".equals(existing.getStatus())) {
+                return Result.error("仅草稿、已上架或已撤回状态可编辑");
             }
         }
         Article article = new Article();

+ 27 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductController.java

@@ -136,12 +136,33 @@ if (params.get("distributionSystemId") != null) {
     @Operation(summary = "批量商品上下架")
     @PostMapping("/batch-shelve")
     public Result<String> batchShelve(@RequestBody Map<String, Object> params) {
-        List<Long> productIds = (List<Long>) params.get("productIds");
-        Boolean shelve = params.get("shelve") != null
-                ? (Boolean) params.get("shelve")
-                : null;
-
-        if (productIds == null || productIds.isEmpty()) {
+        // Jackson 把 Map<String,Object> 里的 JSON 数字数组反序列化为 List<Integer>,
+        // 直接 (List<Long>) 强转是擦除强转(编译通过),到服务层 for (Long id : list)
+        // 才会在运行期对元素做 Integer -> Long 转型并抛 ClassCastException。
+        // 这里逐元素走 ParamUtils.getLong(Number / 数字字符串均安全解析)。
+        List<Long> productIds = new java.util.ArrayList<>();
+        Object rawIds = params.get("productIds");
+        if (rawIds instanceof List) {
+            for (Object item : (List<?>) rawIds) {
+                Long id = ParamUtils.getLong(item);
+                if (id != null) {
+                    productIds.add(id);
+                }
+            }
+        }
+        // 同理:shelve 若被前端意外送成字符串 "true"/"false" 会 ClassCastException,
+        // 用 Number/String 归一;布尔值直接读。
+        Object shelveObj = params.get("shelve");
+        Boolean shelve;
+        if (shelveObj instanceof Boolean) {
+            shelve = (Boolean) shelveObj;
+        } else if (shelveObj instanceof String && !((String) shelveObj).isEmpty()) {
+            shelve = Boolean.parseBoolean(((String) shelveObj).trim());
+        } else {
+            shelve = null;
+        }
+
+        if (productIds.isEmpty()) {
             return Result.error("productIds不能为空");
         }
         if (shelve == null) {

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java

@@ -109,7 +109,7 @@ public class ArticleCommentController {
         Long articleId = ParamUtils.getLong(params.get("articleId"));
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
         Article article = articleMapper.selectById(articleId);
-        if (article == null) return Result.error("文章不存在");
+        if (article == null || !"published".equals(article.getStatus())) return Result.error("文章不存在");
 
         String content = article.getContent();
         if (content != null && content.length() > 2000) content = content.substring(0, 2000);

+ 46 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietPreferencesController.java

@@ -3,9 +3,11 @@ package com.etotem.cfc.controller.diet;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.DietPreferencesDTO;
 import com.etotem.cfc.service.DietPreferencesService;
+import com.etotem.cfc.service.FamilyMemberService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.Map;
 
 @RestController
 @RequestMapping("/api/diet/preferences")
@@ -14,22 +16,59 @@ public class DietPreferencesController {
     @Resource
     private DietPreferencesService dietPreferencesService;
 
+    @Resource
+    private FamilyMemberService familyMemberService;
+
     @PostMapping("/current-member")
     public Result<DietPreferencesDTO> getCurrentMemberPreferences(
-            @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
-        if (familyMemberId == null) {
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long currentMemberId,
+            @RequestBody(required = false) Map<String, Object> params) {
+        // 显式 memberId(须同家庭)优先,未传时回退登录账号本人
+        Long memberId = resolveMemberId(params != null ? params.get("memberId") : null, familyId, currentMemberId);
+        if (memberId == null) {
             return Result.success(null);
         }
-        DietPreferencesDTO dto = dietPreferencesService.getCurrentMemberPreferences(familyMemberId);
-        return Result.success(dto);
+        return Result.success(dietPreferencesService.getCurrentMemberPreferences(memberId));
     }
 
     @PostMapping("/save")
     public Result<Void> savePreferences(
-            @RequestAttribute("familyMemberId") Long familyMemberId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long currentMemberId,
             @RequestBody DietPreferencesDTO dto) {
-        dto.setFamilyMemberId(familyMemberId);
-        dietPreferencesService.savePreferences(familyMemberId, dto);
+        // 显式 familyMemberId(须同家庭)优先,未传时回退登录账号本人
+        Long memberId = resolveMemberId(dto.getFamilyMemberId(), familyId, currentMemberId);
+        if (memberId == null) {
+            return Result.error("未指定成员");
+        }
+        dto.setFamilyMemberId(memberId);
+        dietPreferencesService.savePreferences(memberId, dto);
         return Result.success(null);
     }
+
+    /**
+     * 解析目标成员:显式 memberId(须与登录用户同家庭)优先,否则回退登录成员。
+     */
+    private Long resolveMemberId(Object requestedRaw, Long familyId, Long currentMemberId) {
+        Long requested = toLong(requestedRaw);
+        if (requested != null && familyMemberService.isMemberInFamily(familyId, requested)) {
+            return requested;
+        }
+        return currentMemberId;
+    }
+
+    private Long toLong(Object raw) {
+        if (raw == null) {
+            return null;
+        }
+        if (raw instanceof Number) {
+            return ((Number) raw).longValue();
+        }
+        try {
+            return Long.valueOf(String.valueOf(raw).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java

@@ -63,7 +63,7 @@ public class CreateTaskDTO {
     // TASK-SYS-248: 后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑)
     private String successorTaskIds;
 
-    // TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order
+    // TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order/diet/sleep/exercise
     private String actionType;
 
     // TASK-SYS-248: 联动参数JSON

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

@@ -18,4 +18,5 @@ public class DietPreferencesDTO {
     private String goalSource;
     private Integer goalConfirmed;
     private String filledAt;
+    private Boolean noPreference;
 }

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

@@ -24,6 +24,7 @@ public class DietPreferences implements Serializable {
     private String healthGoals;
     private String goalSource;
     private Integer goalConfirmed;
+    private Boolean noPreference;
     private Date filledAt;
     private Date updatedAt;
 }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DiseaseIntakeLimit.java

@@ -0,0 +1,23 @@
+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;
+
+@Data
+@TableName("disease_intake_limits")
+public class DiseaseIntakeLimit implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String diseaseType;
+    private String nutrient;
+    private String unit;
+    private BigDecimal thresholdMax;
+    private BigDecimal thresholdMin;
+    private String blockFoods;
+    private Integer stageDependent;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DrugInteraction.java

@@ -0,0 +1,22 @@
+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;
+
+@Data
+@TableName("drug_interactions")
+public class DrugInteraction implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String drugClass;
+    private String drugSpecifics;
+    private String interactingSubst;
+    private Integer riskLevel;
+    private String actionType;
+    private Integer intervalMinutes;
+    private String note;
+}

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -31,4 +32,8 @@ public class HealthExerciseRecord implements Serializable {
     /** 语音转写文字 */
     private String voiceText;
     private Date createdAt;
+
+    /** 关联任务ID(一次性任务打卡后自动完成;null=走每日任务累计路径) */
+    @TableField(exist = false)
+    private Long taskId;
 }

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -52,4 +53,8 @@ public class HealthMealRecord {
     private Integer isBackfill;
 
     private Date createdAt;
+
+    /** 关联任务ID(一次性任务打卡后自动完成;null=走每日任务累计路径) */
+    @TableField(exist = false)
+    private Long taskId;
 }

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.fasterxml.jackson.annotation.JsonFormat;
@@ -37,6 +38,10 @@ public class HealthSleepRecord implements Serializable {
     private String remark;
     private Date createdAt;
 
+    /** 关联任务ID(一次性任务打卡后自动完成;null=走每日任务累计路径) */
+    @TableField(exist = false)
+    private Long taskId;
+
     /**
      * 前端时间选择器传 "HH:mm"(如 21:00),而 JDK 8+ 的 java.sql.Time.valueOf 只接受
      * "HH:mm:ss" 三段格式;Jackson 2.13 对 java.sql.Time 的 @JsonFormat 反序列化不生效

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

@@ -44,6 +44,15 @@ public class Product implements Serializable {
     private String domain;
     /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
     private String dimensionWeights;
+
+    /** 禁忌基础疾病,逗号分隔(如"慢性肾病,肝病"),NULL=无禁忌 */
+    private String contraindicatedDiseases;
+
+    /** 过敏原标签,逗号分隔(如"花生,小麦"),NULL=无过敏原 */
+    private String allergenTags;
+
+    /** 是否命中药物-食物相互作用(1=是,需对照drug_interactions表) */
+    private Integer drugInteractionFlags;
     private String externalSource;
     private String externalId;
     private String externalData;

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

@@ -127,7 +127,7 @@ private Date acceptDeadline;
     /** TASK-SYS-248: 后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑) */
     private String successorTaskIds;
 
-    /** TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order */
+    /** TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order/diet/sleep/exercise */
     private String actionType;
 
     /** TASK-SYS-248: 联动参数JSON: {"gameCode":"schulte","productId":123,"minSeconds":30,"completeMilestone":"paid","surveyId":99} */

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DiseaseIntakeLimitMapper.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DiseaseIntakeLimit;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface DiseaseIntakeLimitMapper extends BaseMapper<DiseaseIntakeLimit> {
+
+    @Select("SELECT * FROM disease_intake_limits WHERE disease_type = #{diseaseType} AND nutrient = #{nutrient}")
+    DiseaseIntakeLimit findByDiseaseAndNutrient(String diseaseType, String nutrient);
+
+    @Select("SELECT * FROM disease_intake_limits WHERE disease_type = #{diseaseType}")
+    List<DiseaseIntakeLimit> findByDiseaseType(String diseaseType);
+
+    @Select("SELECT * FROM disease_intake_limits WHERE block_foods LIKE CONCAT('%', #{foodName}, '%')")
+    List<DiseaseIntakeLimit> findByBlockFood(String foodName);
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DrugInteractionMapper.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DrugInteraction;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface DrugInteractionMapper extends BaseMapper<DrugInteraction> {
+
+    @Select("SELECT * FROM drug_interactions WHERE drug_class = #{drugClass} AND drug_specifics = #{drugSpecifics}")
+    List<DrugInteraction> findByDrugClassAndSpecifics(String drugClass, String drugSpecifics);
+
+    @Select("SELECT * FROM drug_interactions WHERE interacting_subst = #{interactingSubst}")
+    List<DrugInteraction> findByInteractingSubst(String interactingSubst);
+
+    @Select("SELECT * FROM drug_interactions WHERE drug_specifics = #{drugSpecifics}")
+    List<DrugInteraction> findByDrugSpecifics(String drugSpecifics);
+
+    @Select("SELECT * FROM drug_interactions WHERE interacting_subst = #{subst} AND risk_level = 1")
+    List<DrugInteraction> findHardBlocksByInteractingSubst(String subst);
+}

+ 4 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -988,9 +988,8 @@ public class ArticleService {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .ge(Article::getUpdatedAt, since)
                 .orderByDesc(Article::getUpdatedAt);
-        if (status != null && !status.trim().isEmpty()) {
-            wrapper.eq(Article::getStatus, status.trim());
-        }
+        String s = (status != null && !status.trim().isEmpty()) ? status.trim() : "published";
+        wrapper.eq(Article::getStatus, s);
         return articleMapper.selectList(wrapper);
     }
 
@@ -1146,8 +1145,8 @@ public class ArticleService {
         if (article == null) {
             throw new RuntimeException("文章不存在");
         }
-        if (!"rejected".equals(article.getStatus())) {
-            throw new RuntimeException("仅已驳回的文章可重新编辑");
+        if (!"rejected".equals(article.getStatus()) && !"withdrawn".equals(article.getStatus())) {
+            throw new RuntimeException("仅已驳回或已撤回的文章可重新编辑");
         }
         article.setStatus("draft");
         article.setAuditStatus(null);

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

@@ -55,6 +55,9 @@ public class BeijingNutritionService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private HealthFilterService healthFilterService;
+
     // ============================================================
     // 核心推荐引擎
     // ============================================================
@@ -694,12 +697,21 @@ public class BeijingNutritionService {
         bacteriaStatusMap.values().forEach(mergedBacteriaStatus::putAll);
         Map<String, BacteriaFoodMapping.FoodAdjustment> adjustments = bacteriaFoodMapping.computeAdjustments(mergedBacteriaStatus);
 
+        // 加载家庭成员疾病画像(用于疾病硬拦截)
+        HealthFilterService.HealthProfile mergedHealthProfile = new HealthFilterService.HealthProfile();
+        mergedHealthProfile.profileMissing = participantIds.isEmpty();
+        for (Long memberId : participantIds) {
+            HealthFilterService.HealthProfile memberProfile = healthFilterService.loadProfile(memberId, null, null);
+            mergedHealthProfile.diseases.addAll(memberProfile.diseases);
+        }
+
         List<Food> allFoods = foodMapper.selectList(null);
         List<ScoredIngredient> scored = new ArrayList<>();
 
         for (Food food : allFoods) {
             String foodName = food.getName();
             if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) continue;
+            if (healthFilterService.isFoodHardBlocked(foodName, mergedHealthProfile)) continue;
 
             BacteriaFoodMapping.FoodAdjustment adj = adjustments.get(foodName);
             double bacteriaScore = scoreBacteria(adj != null ? adj.getDirection() : null, food.getScore());

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

@@ -56,6 +56,7 @@ public class DietPreferencesService {
         entity.setHealthGoals(toJSON(dto.getHealthGoals()));
         entity.setGoalSource(dto.getGoalSource());
         entity.setGoalConfirmed(dto.getGoalConfirmed() != null ? dto.getGoalConfirmed() : 0);
+        entity.setNoPreference(dto.getNoPreference() != null ? dto.getNoPreference() : false);
         
         if (entity.getId() == null) {
             dietPreferencesMapper.insert(entity);
@@ -87,6 +88,7 @@ public class DietPreferencesService {
         dto.setHealthGoals(fromJSON(entity.getHealthGoals()));
         dto.setGoalSource(entity.getGoalSource());
         dto.setGoalConfirmed(entity.getGoalConfirmed());
+        dto.setNoPreference(entity.getNoPreference());
         dto.setFilledAt(entity.getFilledAt() != null ? entity.getFilledAt().toString() : null);
         return dto;
     }

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

@@ -76,6 +76,17 @@ public class FamilyMemberService {
     @Resource
     private MembershipService membershipService;
 
+    /**
+     * 校验成员是否属于指定家庭(用于避免跨家庭读写成员数据)
+     */
+    public boolean isMemberInFamily(Long familyId, Long memberId) {
+        if (familyId == null || memberId == null) {
+            return false;
+        }
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        return member != null && familyId.equals(member.getFamilyId());
+    }
+
     /**
      * 添加家庭成员
      */

+ 320 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthFilterService.java

@@ -0,0 +1,320 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DietPreferences;
+import com.etotem.cfc.entity.DiseaseIntakeLimit;
+import com.etotem.cfc.entity.DrugInteraction;
+import com.etotem.cfc.entity.HealthStatus;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
+import com.etotem.cfc.mapper.DiseaseIntakeLimitMapper;
+import com.etotem.cfc.mapper.DrugInteractionMapper;
+import com.etotem.cfc.mapper.HealthStatusMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 推荐引擎三级规则过滤器(硬拦截/软警告/正常推荐)
+ * 数据源:
+ * - 疾病史/用药:health_status.disease_history / medications (JSON [{name,note}])
+ * - 过敏原:diet_preferences.allergies (JSON [string])
+ * - 商品禁忌:products.contraindicated_diseases / allergen_tags
+ * - 药物-食物禁忌:drug_interactions
+ * - 疾病-饮食阈值:disease_intake_limits
+ */
+@Slf4j
+@Service
+public class HealthFilterService {
+
+    @Resource
+    private HealthStatusMapper healthStatusMapper;
+
+    @Resource
+    private DietPreferencesMapper dietPreferencesMapper;
+
+    @Resource
+    private DrugInteractionMapper drugInteractionMapper;
+
+    @Resource
+    private DiseaseIntakeLimitMapper diseaseIntakeLimitMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /** 年龄硬性禁忌:年龄(岁,含下限) → 禁止食材关键词 */
+    private static final Map<String, String> AGE_BLOCK_RULES = new HashMap<>();
+    static {
+        AGE_BLOCK_RULES.put("1", "蜂蜜");
+        AGE_BLOCK_RULES.put("3", "整粒坚果");
+    }
+
+    /**
+     * 用户健康画像(从档案实时加载)
+     */
+    public static class HealthProfile {
+        public Long userId;
+        public Long memberId;
+        public Set<String> diseases = new HashSet<>();
+        public Set<String> allergies = new HashSet<>();
+        public Set<String> medications = new HashSet<>();
+        public Integer age;
+        public boolean profileMissing;
+        public boolean sensitivePregnant;
+        public boolean sensitivePostSurgery;
+    }
+
+    /**
+     * 过滤结果:通过的商品 + 需人工复核的软警告
+     */
+    public static class FilterResult {
+        public List<Product> allowed = new ArrayList<>();
+        public List<String> warnings = new ArrayList<>();
+        public List<String> blockedReasons = new ArrayList<>();
+    }
+
+    /**
+     * 加载用户健康画像
+     */
+    public HealthProfile loadProfile(Long memberId, Long userId, Integer age) {
+        HealthProfile p = new HealthProfile();
+        p.memberId = memberId;
+        p.userId = userId;
+        p.age = age;
+
+        HealthStatus status = null;
+        if (memberId != null) {
+            List<HealthStatus> list = healthStatusMapper.selectList(
+                    new LambdaQueryWrapper<HealthStatus>()
+                            .eq(HealthStatus::getMemberId, memberId)
+                            .last("LIMIT 1"));
+            if (!list.isEmpty()) status = list.get(0);
+        }
+        if (status == null && userId != null) {
+            List<HealthStatus> list = healthStatusMapper.selectList(
+                    new LambdaQueryWrapper<HealthStatus>()
+                            .eq(HealthStatus::getUserId, userId)
+                            .last("LIMIT 1"));
+            if (!list.isEmpty()) status = list.get(0);
+        }
+
+        if (status == null) {
+            p.profileMissing = true;
+        } else {
+            p.diseases.addAll(parseNameList(status.getDiseaseHistory()));
+            p.medications.addAll(parseNameList(status.getMedications()));
+            for (String d : p.diseases) {
+                if (d.contains("妊娠") || d.contains("怀孕") || d.contains("哺乳")) {
+                    p.sensitivePregnant = true;
+                }
+                if (d.contains("术后") || d.contains("手术")) {
+                    p.sensitivePostSurgery = true;
+                }
+            }
+        }
+
+        if (memberId != null) {
+            DietPreferences prefs = dietPreferencesMapper.selectOne(
+                    new LambdaQueryWrapper<DietPreferences>()
+                            .eq(DietPreferences::getFamilyMemberId, memberId)
+                            .last("LIMIT 1"));
+            if (prefs != null && prefs.getAllergies() != null) {
+                p.allergies.addAll(parseStringList(prefs.getAllergies()));
+            }
+        }
+        return p;
+    }
+
+    /**
+     * 核心过滤:硬拦截 + 软警告分级
+     */
+    public FilterResult filterProducts(HealthProfile profile, List<Product> products) {
+        FilterResult result = new FilterResult();
+        if (products == null) return result;
+
+        if (profile.profileMissing) {
+            result.warnings.add("健康档案缺失,按更严格规则执行,建议完善档案或咨询医生后使用");
+        }
+        if (profile.sensitivePregnant) {
+            result.warnings.add("孕哺期用户,方案需人工复核/建议咨询医生");
+        }
+        if (profile.sensitivePostSurgery) {
+            result.warnings.add("术后恢复期用户,方案需人工复核/建议咨询医生");
+        }
+
+        for (Product product : products) {
+            List<String> reasons = checkProductBlocks(profile, product);
+            if (reasons.isEmpty()) {
+                result.allowed.add(product);
+            } else {
+                result.blockedReasons.add(product.getName() + ":" + String.join(";", reasons));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 商品级硬拦截检查:返回拦截原因列表,空=通过
+     */
+    public List<String> checkProductBlocks(HealthProfile profile, Product product) {
+        List<String> reasons = new ArrayList<>();
+
+        String contraindicated = product.getContraindicatedDiseases();
+        if (contraindicated != null && !contraindicated.isEmpty()) {
+            for (String disease : profile.diseases) {
+                if (disease == null || disease.isEmpty()) continue;
+                if (containsToken(contraindicated, disease)) {
+                    reasons.add("禁忌疾病" + disease + "命中,硬拦截");
+                }
+            }
+        }
+
+        String allergens = product.getAllergenTags();
+        if (allergens != null && !allergens.isEmpty()) {
+            for (String allergy : profile.allergies) {
+                if (allergy == null || allergy.isEmpty()) continue;
+                if (containsToken(allergens, allergy)) {
+                    reasons.add("过敏原" + allergy + "命中,硬拦截");
+                }
+            }
+        }
+
+        if (profile.age != null) {
+            if (profile.age < 1 && containsToken(product.getName(), "蜂蜜")) {
+                reasons.add("1岁内禁食蜂蜜,硬拦截");
+            }
+            if (profile.age < 3 && containsToken(product.getName(), "坚果")) {
+                reasons.add("3岁内禁食整粒坚果防呛咳,硬拦截");
+            }
+        }
+
+        List<String> drugWarnings = checkDrugInteractions(profile, product);
+        reasons.addAll(drugWarnings);
+
+        return reasons;
+    }
+
+    /**
+     * 药物-食物相互作用检查:命中risk_level=1硬拦截,2/3返回软警告/间隔提示
+     */
+    public List<String> checkDrugInteractions(HealthProfile profile, Product product) {
+        List<String> warnings = new ArrayList<>();
+        if (profile.medications.isEmpty()) return warnings;
+
+        String productName = product.getName() == null ? "" : product.getName();
+        String allergenTags = product.getAllergenTags() == null ? "" : product.getAllergenTags();
+
+        List<DrugInteraction> interactions = drugInteractionMapper.selectList(null);
+        for (DrugInteraction it : interactions) {
+            String subst = it.getInteractingSubst();
+            if (subst == null || subst.isEmpty()) continue;
+            boolean hitFood = containsToken(productName, subst) || containsToken(allergenTags, subst);
+            if (!hitFood) continue;
+
+            boolean medHit = profile.medications.stream()
+                    .anyMatch(m -> m != null && (containsToken(m, it.getDrugSpecifics()) || containsToken(m, it.getDrugClass())));
+            if (!medHit) continue;
+
+            if (it.getRiskLevel() != null && it.getRiskLevel() == 1) {
+                warnings.add("药物-食物禁忌" + subst + "命中(" + it.getDrugSpecifics() + "),硬拦截");
+            } else if (it.getRiskLevel() != null && it.getRiskLevel() == 3) {
+                int minutes = it.getIntervalMinutes() != null ? it.getIntervalMinutes() : 60;
+                warnings.add("药物" + it.getDrugSpecifics() + "与" + subst + "需间隔" + minutes + "分钟服用");
+            } else {
+                warnings.add("药物" + it.getDrugSpecifics() + "与" + subst + "存在相互作用,建议咨询医生");
+            }
+        }
+        return warnings;
+    }
+
+    /**
+     * 食材/商品是否被疾病硬拦截(表#7 block_foods)
+     */
+    public boolean isFoodHardBlocked(String foodName, HealthProfile profile) {
+        if (foodName == null || foodName.isEmpty() || profile.diseases.isEmpty()) return false;
+        List<DiseaseIntakeLimit> limits = diseaseIntakeLimitMapper.selectList(null);
+        for (DiseaseIntakeLimit limit : limits) {
+            if (!profile.diseases.contains(limit.getDiseaseType())) continue;
+            if (limit.getBlockFoods() != null && containsToken(limit.getBlockFoods(), foodName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 营养素累计校验(表#7):返回超限原因,空=通过
+     */
+    public List<String> validateNutrients(Map<String, Double> nutrientTotals, HealthProfile profile) {
+        List<String> violations = new ArrayList<>();
+        if (nutrientTotals == null || nutrientTotals.isEmpty() || profile.diseases.isEmpty()) {
+            return violations;
+        }
+        List<DiseaseIntakeLimit> limits = diseaseIntakeLimitMapper.selectList(null);
+        for (DiseaseIntakeLimit limit : limits) {
+            if (!profile.diseases.contains(limit.getDiseaseType())) continue;
+            Double total = nutrientTotals.get(limit.getNutrient());
+            if (total == null) continue;
+            if (limit.getThresholdMax() != null && total.doubleValue() > limit.getThresholdMax().doubleValue()) {
+                violations.add(limit.getDiseaseType() + ":" + limit.getNutrient() + "每日摄入" + total
+                        + limit.getUnit() + "超上限" + limit.getThresholdMax() + limit.getUnit() + ",需替换或标记");
+            }
+            if (limit.getThresholdMin() != null && total.doubleValue() < limit.getThresholdMin().doubleValue()) {
+                violations.add(limit.getDiseaseType() + ":" + limit.getNutrient() + "每日摄入不足" + limit.getThresholdMin() + limit.getUnit());
+            }
+        }
+        return violations;
+    }
+
+    private List<String> parseNameList(String json) {
+        List<String> names = new ArrayList<>();
+        if (json == null || json.isEmpty()) return names;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node.isArray()) {
+                for (JsonNode item : node) {
+                    JsonNode name = item.get("name");
+                    if (name != null && !name.asText().isEmpty()) names.add(name.asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析JSON字段失败: {}", e.getMessage());
+        }
+        return names;
+    }
+
+    private List<String> parseStringList(String json) {
+        List<String> list = new ArrayList<>();
+        if (json == null || json.isEmpty()) return list;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node.isArray()) {
+                for (JsonNode item : node) {
+                    if (item.isTextual() && !item.asText().isEmpty()) list.add(item.asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析JSON数组失败: {}", e.getMessage());
+        }
+        return list;
+    }
+
+    private boolean containsToken(String haystack, String needle) {
+        if (haystack == null || needle == null || needle.isEmpty()) return false;
+        String[] tokens = haystack.split("[,,、\\s]+");
+        for (String t : tokens) {
+            if (t != null && !t.isEmpty() && t.equals(needle.trim())) return true;
+        }
+        return false;
+    }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java

@@ -13,6 +13,9 @@ public interface HealthStatusService {
     /** 查询指定家庭成员的健康现状(memberId 为空时退化为账号级) */
     HealthStatus getByMemberId(Long userId, Long memberId);
 
+    /** 内部:仅按 memberId 查询健康现状(供 LangGraph 方案生成,不做用户归属校验) */
+    HealthStatus getByMemberIdOnly(Long memberId);
+
     /** 按成员保存/更新健康现状(memberId 为空时退化为账号级) */
     HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status);
 }

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

@@ -43,6 +43,9 @@ public class ProductRecommendationService {
     @Resource
     private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
 
+    @Resource
+    private HealthFilterService healthFilterService;
+
     @Resource
     private ReferralTreeMapper referralTreeMapper;
 
@@ -73,6 +76,9 @@ public class ProductRecommendationService {
             String dimensionCode, Long familyId, Long memberId,
             List<Long> excludeProductIds, int limit, Long userId) {
 
+        // 0. 加载成员健康画像(硬拦截基础)
+        HealthFilterService.HealthProfile healthProfile = healthFilterService.loadProfile(memberId, null, null);
+
         // 1. 获取已购商品ID(90天内)
         Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
         if (excludeProductIds != null) {
@@ -109,6 +115,9 @@ public class ProductRecommendationService {
                                 .orderByAsc(Product::getSortOrder)
                 );
 
+                HealthFilterService.FilterResult mappedFilter = healthFilterService.filterProducts(healthProfile, mapped);
+                mapped = mappedFilter.allowed;
+
                 // 构建 productId → mapping 得分
                 Map<Long, Integer> mappingScores = mappings.stream()
                         .collect(Collectors.toMap(
@@ -165,6 +174,8 @@ public class ProductRecommendationService {
             }
             SortUtil.applySort(domainQw);
             List<Product> domainMatched = productMapper.selectList(domainQw);
+             HealthFilterService.FilterResult domainFilter = healthFilterService.filterProducts(healthProfile, domainMatched);
+             domainMatched = domainFilter.allowed;
             for (Product p : domainMatched) {
                 Map<String, Object> item = new LinkedHashMap<>();
                 item.put("id", p.getId());
@@ -186,7 +197,7 @@ public class ProductRecommendationService {
 
         // 4. 关联关系推荐(动态:画像缺口、共购、复购)
         List<Map<String, Object>> assocResults = queryAssociationRecommendations(
-                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size());
+                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size(), healthProfile);
 
         // 合并并去重(关联推荐优先级高于维度映射,但保留维度的reason/matchScore)
         if (assocResults != null && !assocResults.isEmpty()) {
@@ -207,7 +218,8 @@ public class ProductRecommendationService {
      */
     private List<Map<String, Object>> queryAssociationRecommendations(
             String dimensionCode, Long familyId, Long memberId,
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0) return Collections.emptyList();
 
@@ -217,7 +229,7 @@ public class ProductRecommendationService {
         // 4.1 画像缺口关联:维度得分低时推荐对应商品
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
             List<Map<String, Object>> profileAssocs = queryProfileGapAssociations(
-                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3);
+                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3, healthProfile);
             results.addAll(profileAssocs);
             if (results.size() >= reqCount) return results.subList(0, reqCount);
         }
@@ -225,7 +237,7 @@ public class ProductRecommendationService {
         // 4.2 共购关联:买了A的用户也买了B(从订单数据挖掘)
         if (purchasedIds != null && !purchasedIds.isEmpty()) {
             List<Map<String, Object>> coPurchaseAssocs = queryCoPurchaseAssociations(
-                    purchasedIds, excludeProductIds, reqCount - results.size());
+                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile);
             results.addAll(coPurchaseAssocs);
             if (results.size() >= reqCount) return results.subList(0, reqCount);
         }
@@ -233,7 +245,7 @@ public class ProductRecommendationService {
         // 4.3 复购关联:买了A后X天再买A的关联商品
         if (purchasedIds != null && !purchasedIds.isEmpty()) {
             List<Map<String, Object>> repurchaseAssocs = queryRepurchaseAssociations(
-                    purchasedIds, excludeProductIds, reqCount - results.size());
+                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile);
             results.addAll(repurchaseAssocs);
         }
 
@@ -245,7 +257,8 @@ public class ProductRecommendationService {
      */
     private List<Map<String, Object>> queryProfileGapAssociations(
             String dimensionCode, Map<String, Integer> memberScores,
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0) return Collections.emptyList();
 
@@ -278,6 +291,7 @@ public class ProductRecommendationService {
 
             Product product = productMapper.selectById(assoc.getTargetProductId());
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -316,7 +330,8 @@ public class ProductRecommendationService {
      * 共购关联:从订单数据挖掘“买了A的用户也买了B”
      */
     private List<Map<String, Object>> queryCoPurchaseAssociations(
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
 
@@ -344,6 +359,7 @@ public class ProductRecommendationService {
 
             Product product = productMapper.selectById(targetId);
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -371,7 +387,8 @@ public class ProductRecommendationService {
      * 复购关联:买了A后X天再买相关商品的关联
      */
     private List<Map<String, Object>> queryRepurchaseAssociations(
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
 
@@ -394,6 +411,7 @@ public class ProductRecommendationService {
 
             Product product = productMapper.selectById(targetId);
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());

+ 80 - 9
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;
@@ -152,9 +153,10 @@ public class ReportCollectService {
         if (STATUS_COMPLETED.equals(draft.getParseStatus())) {
             return;
         }
+        String pdfPath = null;
         try {
             healthReportDraftService.markParseStarted(draftId);
-            String pdfPath = resolvePdfPath(draft);
+            pdfPath = resolvePdfPath(draft);
             if (pdfPath == null) {
                 healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
                 return;
@@ -185,6 +187,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,11 +235,29 @@ 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));
@@ -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 输入。
@@ -1045,14 +1096,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 +1111,7 @@ public class ReportCollectService {
                 addCollectName(names, item.getCode());
             }
         }
-        runCollectValidation(draftId, typeId, names);
+        return runCollectValidation(draftId, typeId, names);
     }
 
     /**
@@ -1068,13 +1119,13 @@ 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;
         }
         List<String> missing = new ArrayList<>();
         int matched = 0;
@@ -1102,6 +1153,26 @@ public class ReportCollectService {
         } catch (Exception e) {
             log.warn("记录采集校验结果失败 draftId={}, typeId={}", draftId, typeId, e);
         }
+        return missing.isEmpty();
+    }
+
+    /** 是否为肠道菌群家族类型(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 */

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

@@ -1221,10 +1221,10 @@ if (todayPenaltyTotal < PENALTY_MAX_DAILY) {
 int penalty = Math.min(task.getPoints(), PENALTY_MAX_DAILY - todayPenaltyTotal);
 return -penalty;
 } else {
-return 0; // 达到每日扣分上限
+return points; // 达到每日扣分上限:不扣分,任务完成仍发放基础分
 }
         } else {
-            return 0; // 扣分未开启
+            return points; // 扣分未开启:任务完成发放基础分
         }
     }
 

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

@@ -45,6 +45,9 @@ public class UnlockGateService {
     @Resource
     private HealthReportMapper healthReportMapper;
 
+    @Resource
+    private HealthStatusMapper healthStatusMapper;
+
     @Resource
     private ProductOrderMapper productOrderMapper;
 
@@ -165,7 +168,17 @@ public class UnlockGateService {
                 new LambdaQueryWrapper<FiveDimensionScore>()
                         .eq(FiveDimensionScore::getFamilyId, familyId)
                         .gt(FiveDimensionScore::getScore, 0));
-        return scoreCount >= 1;
+        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;
     }
 
     // ========== 当前锁定关卡 ==========

+ 12 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/WechatWaitMsgService.java

@@ -49,16 +49,26 @@ public class WechatWaitMsgService {
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     /**
-     * 推送一条待办消息。成功返回 true;任何异常均捕获并记录 WARN,返回 false(由调用方决定是否重试)。
+     * 推送一条待办消息(使用全局 msgLabel)。成功返回 true;任何异常均捕获并记录 WARN,返回 false。
      */
     public boolean sendWaitMsg(String content) {
+        return sendWaitMsg(content, null);
+    }
+
+    /**
+     * 推送一条待办消息,可指定 msgLabel(如按接收人手机号定向推送)。
+     * <p>
+     * label 为空时回退全局 {@link #resolveMsgLabel()}(sys_config → yml 默认值)。
+     * 成功返回 true;任何异常均捕获并记录 WARN,返回 false(由调用方决定是否重试)。
+     */
+    public boolean sendWaitMsg(String content, String label) {
         try {
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);
             headers.set("Authorization", waitMsgToken);
 
             Map<String, String> body = new HashMap<>();
-            body.put("msgLabel", resolveMsgLabel());
+            body.put("msgLabel", (label != null && !label.trim().isEmpty()) ? label.trim() : resolveMsgLabel());
             body.put("content", content);
 
             String json = objectMapper.writeValueAsString(body);

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java

@@ -39,6 +39,15 @@ public class HealthStatusServiceImpl implements HealthStatusService {
                         .eq(HealthStatus::getMemberId, memberId));
     }
 
+    @Override
+    public HealthStatus getByMemberIdOnly(Long memberId) {
+        if (memberId == null) return null;
+        return healthStatusMapper.selectOne(
+                new LambdaQueryWrapper<HealthStatus>()
+                        .eq(HealthStatus::getMemberId, memberId)
+                        .last("LIMIT 1"));
+    }
+
     @Override
     public HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status) {
         if (userId == null) throw new IllegalArgumentException("userId不能为空");

+ 28 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/notification/WebhookNotificationChannel.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.service.notification;
 
 import com.etotem.cfc.dto.NotificationMessage;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.WechatWaitMsgService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -13,6 +15,9 @@ import javax.annotation.Resource;
  * <p>
  * 复用 WechatWaitMsgService 向微信待办接口推送;URL/token 见
  * etotem.webhook.wait-msg.*(缺省内联默认值),msgLabel 经 sys_config 可配。
+ * <p>
+ * 定向推送:若接收用户配置了手机号,则以手机号作为 msgLabel(按人定向),
+ * 否则回退全局 msgLabel(sys_config → yml 默认值)。
  */
 @Component
 public class WebhookNotificationChannel implements NotificationChannel {
@@ -22,6 +27,9 @@ public class WebhookNotificationChannel implements NotificationChannel {
     @Resource
     private WechatWaitMsgService wechatWaitMsgService;
 
+    @Resource
+    private UserMapper userMapper;
+
     @Override
     public String getChannelType() {
         return "wechat";
@@ -34,9 +42,27 @@ public class WebhookNotificationChannel implements NotificationChannel {
 
     @Override
     public void send(NotificationMessage message) {
-        boolean ok = wechatWaitMsgService.sendWaitMsg(message.getContent());
+        // 优先用接收人手机号作为 msgLabel(按用户定向推送);无手机号则用全局默认
+        String label = resolveUserPhone(message.getUserId());
+        boolean ok = wechatWaitMsgService.sendWaitMsg(message.getContent(), label);
         if (!ok) {
-            log.warn("微信待办推送失败: userId={}, title={}", message.getUserId(), message.getTitle());
+            log.warn("微信待办推送失败: userId={}, title={}, label={}", message.getUserId(), message.getTitle(), label);
+        }
+    }
+
+    /** 查询接收用户手机号作为定向 msgLabel;失败或无手机号返回 null(走全局默认) */
+    private String resolveUserPhone(Long userId) {
+        if (userId == null) {
+            return null;
+        }
+        try {
+            User u = userMapper.selectById(userId);
+            if (u != null && u.getPhone() != null && !u.getPhone().trim().isEmpty()) {
+                return u.getPhone().trim();
+            }
+        } catch (Exception e) {
+            log.warn("查询用户手机号失败 userId={}, error={}", userId, e.getMessage());
         }
+        return null;
     }
 }

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

@@ -2,12 +2,15 @@ package com.etotem.cfc.task;
 import javax.annotation.Resource;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.List;
@@ -22,6 +25,7 @@ import com.etotem.cfc.config.DatabaseInitializer;
  * - weekly: 每周生成(按模板 deadline 的星期几)
  * - monthly: 每月生成(按模板 deadline 的日)
  * 兼容旧版 repeat_type (none/daily/weekly):repeat_type=daily 等价 frequency=daily,weekly 等价 weekly
+ * 额外:每日为能登录用户生成 3 条内置健康打卡任务([DIET]/[SLEEP]/[EXERCISE],is_daily_progress=1)
  */
 @Slf4j
 @Component
@@ -30,6 +34,16 @@ public class RepeatTaskGenerator {
     @Resource
     private TaskMapper taskMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /** 内置健康打卡任务模板定义(title 前缀约定与 TaskService.updateProgress 的 taskKey 匹配一致) */
+    private static final List<String[]> CHECKIN_TEMPLATES = Arrays.asList(
+            new String[]{"[DIET]饮食打卡", "diet"},
+            new String[]{"[SLEEP]睡眠打卡", "sleep"},
+            new String[]{"[EXERCISE]运动打卡", "exercise"}
+    );
+
     /**
      * 每小时第5分执行,按频率分派生成重复任务
      */
@@ -134,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());
+        }
+    }
+
     /**
      * 隔天判定:模板创建时间到今天的间隔天数为偶数 → 今天生成
      */
@@ -177,6 +252,12 @@ public class RepeatTaskGenerator {
 * @param useNow 是否使用当前时间作为截止时间(hourly 任务使用)
 */
 private void createTaskFromTemplate(Task template, Date targetDate, boolean useNow) {
+// 周期起点:模板 earliest_start 为方案周期开始日,未到起点不生成实例(用于分阶段任务,
+// 如"第二阶段/第三阶段"任务在各自起始日之前不应出现)。
+if (!useNow && template.getEarliestStart() != null && targetDate != null
+        && targetDate.before(startOfDay(template.getEarliestStart()))) {
+    return;
+}
 // D3 周期边界:模板 deadline 的日期部分即方案周期终点,超过后不再生成实例。
 // 实例 deadline = targetDate + 模板时分,故以 targetDate 与模板 deadline 比较。
 if (!useNow && template.getDeadline() != null && targetDate != null
@@ -248,4 +329,14 @@ if (newTask.getPrerequisiteTaskId() != null) {
 taskMapper.insert(newTask);
 log.info("已生成重复任务: {} (截止: {})", newTask.getTitle(), newTask.getDeadline());
 }
+
+private Date startOfDay(Date date) {
+    Calendar cal = Calendar.getInstance();
+    cal.setTime(date);
+    cal.set(Calendar.HOUR_OF_DAY, 0);
+    cal.set(Calendar.MINUTE, 0);
+    cal.set(Calendar.SECOND, 0);
+    cal.set(Calendar.MILLISECOND, 0);
+    return cal.getTime();
+}
 }

+ 46 - 15
cfc-backend/src/main/resources/schema.sql

@@ -1190,6 +1190,9 @@ CREATE TABLE IF NOT EXISTS products (
     visible_roles JSON DEFAULT NULL COMMENT '可见角色: ["parent","child","teacher","admin"],NULL=全部可见',
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
     min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
+    contraindicated_diseases VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如"慢性肾病,肝病")',
+    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 '创建时间',
@@ -4733,6 +4736,7 @@ CREATE TABLE IF NOT EXISTS diet_preferences (
     health_goals JSON COMMENT '健康目标(减脂/增肌/控糖/肠道调理/护肝)',
     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=用户明确选择无特殊偏好)',
     filled_at TIMESTAMP NULL COMMENT '首次填写时间',
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_member (family_member_id),
@@ -6314,26 +6318,53 @@ CREATE TABLE IF NOT EXISTS indicator_correlations (
     INDEX idx_def_b (definition_id_b)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)';
 
--- Ǩ��363: �����ɹ����������Ʒ����Ƽ���ɹ������������ڹ�����
+-- 迁移368: 药物-食物禁忌对照表
+CREATE TABLE IF NOT EXISTS drug_interactions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)',
+    drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)',
+    interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)',
+    risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔',
+    action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔',
+    interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)',
+    note VARCHAR(255) COMMENT '风险说明',
+    UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst),
+    INDEX idx_interacting (interacting_subst)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表';
+
+-- 迁移369: 疾病-饮食摄入校验阈值表
+CREATE TABLE IF NOT EXISTS disease_intake_limits (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)',
+    nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)',
+    unit VARCHAR(20) NOT NULL COMMENT '单位',
+    threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限',
+    threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限',
+    block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)',
+    stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)',
+    INDEX idx_disease_nutrient (disease_type, nutrient)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表';
+
+-- 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
 CREATE TABLE IF NOT EXISTS procurement_tasks (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    task_id VARCHAR(64) NOT NULL COMMENT '����Ψһ��ʶ',
-    user_id BIGINT NOT NULL COMMENT '�����û�ID',
-    family_member_id BIGINT NOT NULL COMMENT '������ͥ��ԱID',
-    combo_tier VARCHAR(20) NOT NULL COMMENT 'ѡ����ײ͵ȼ�: high|medium|low',
-    original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT 'ԭʼ�ײ͵ȼ��������Զ���׷�٣�',
-    selected_product_sku_ids TEXT COMMENT '����ѡ�еIJ�ƷSKU�б���JSON���飩',
-    custom_addition_sku_ids TEXT COMMENT '�û��������IJ�ƷSKU�б���JSON���飩',
-    custom_removal_sku_ids TEXT COMMENT '�û��Ƴ��IJ�ƷSKU�б���JSON���飩',
-    status VARCHAR(32) NOT NULL COMMENT '����״̬: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED',
-    create_time DATETIME NOT NULL COMMENT '����ʱ��',
-    complete_time DATETIME DEFAULT NULL COMMENT '���ʱ��',
-    message_reminder_sent TINYINT DEFAULT 0 COMMENT '�Ƿ��ѷ�����Ϣ����',
-    execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '������ִ������ID',
+    task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
+    user_id BIGINT NOT NULL COMMENT '所属用户ID',
+    family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID',
+    combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low',
+    original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)',
+    selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)',
+    custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)',
+    custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)',
+    status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED',
+    create_time DATETIME NOT NULL COMMENT '创建时间',
+    complete_time DATETIME DEFAULT NULL COMMENT '完成时间',
+    message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒',
+    execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID',
     update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_task_id (task_id),
     INDEX idx_user_id (user_id),
     INDEX idx_family_member_id (family_member_id),
     INDEX idx_status (status),
     INDEX idx_create_time (create_time)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='�ɹ����������Ʒ����Ƽ���ɹ������������ڹ�����';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)';

+ 117 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminProductControllerBatchShelveTest.java

@@ -0,0 +1,117 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ProductService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 批量商品上下架(/api/admin/product/batch-shelve)类型安全回归测试。
+ *
+ * 故障:批量下架时抛 ClassCastException: Integer cannot be cast to Long。
+ * 根因:@RequestBody Map<String, Object> 使 Jackson 把 JSON 数字数组反序列化为
+ *      List<Integer>;控制器旧写法 (List<Long>) params.get("productIds") 为擦除强转,
+ *      编译通过,到服务层 for (Long id : list) 才在运行期对元素做 Integer->Long 转换而失败。
+ *
+ * 约定:productIds 必须是 Number 或数字字符串均可;解析后的 List<Long> 交给服务层。
+ * 用 ObjectMapper 反序列化真实 JSON 复现线上报文(与控制器收到的对象一致)。
+ */
+public class AdminProductControllerBatchShelveTest {
+
+    private static final ObjectMapper OM = new ObjectMapper();
+
+    private AdminProductController controller;
+    private ProductService productService;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        controller = new AdminProductController();
+        productService = mock(ProductService.class);
+        ReflectionTestUtils.setField(controller, "productService", productService);
+    }
+
+    @Test
+    public void batchShelve_jsonIntegerIds_convertedToLong() throws Exception {
+        // 复现线上报文:Jackson 对 JSON 数字产出 Integer
+        @SuppressWarnings("unchecked")
+        Map<String, Object> params = OM.readValue(
+                "{\"productIds\":[1,2,3],\"shelve\":false}", Map.class);
+        @SuppressWarnings("unchecked")
+        List<?> raw = (List<?>) params.get("productIds");
+        assertEquals(Integer.class, raw.get(0).getClass(),
+                "前置条件:Jackson 将 JSON 数字反序列化为 Integer(即本缺陷的诱因)");
+
+        when(productService.batchAdminShelve(any(), eq(false)))
+                .thenReturn(Result.success("批量下架成功"));
+
+        Result<String> result = controller.batchShelve(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals("批量下架成功", result.getData());
+
+        ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
+        verify(productService, times(1)).batchAdminShelve(captor.capture(), eq(false));
+        for (Object id : captor.getValue()) {
+            assertEquals(Long.class, id.getClass(),
+                    "传给服务层的元素必须是 Long,实际为 " + id.getClass());
+        }
+        assertEquals(3, captor.getValue().size());
+    }
+
+    @Test
+    public void batchShelve_largeValueAndStringNumberAccepted() throws Exception {
+        // 大数值(超出 Integer 范围,Jackson 产出 Long)与字符串数字都应安全解析
+        @SuppressWarnings("unchecked")
+        Map<String, Object> params = OM.readValue(
+                "{\"productIds\":[3000000000,\"42\"],\"shelve\":true}", Map.class);
+
+        when(productService.batchAdminShelve(any(), eq(true)))
+                .thenReturn(Result.success("批量上架成功"));
+
+        Result<String> result = controller.batchShelve(params);
+
+        assertEquals(200, result.getCode());
+        verify(productService, times(1))
+                .batchAdminShelve(eq(java.util.Arrays.asList(3000000000L, 42L)), eq(true));
+    }
+
+    @Test
+    public void batchShelve_missingOrEmptyIds_returnsCleanError() throws Exception {
+        // Result.error(msg) 约定:body code=500(HTTP 200)。要点是干净的业务错误,
+        // 而不是抛 ClassCastException / 崩溃。
+        @SuppressWarnings("unchecked")
+        Map<String, Object> absent = OM.readValue("{\"shelve\":false}", Map.class);
+        Result<String> r1 = controller.batchShelve(absent);
+        assertEquals(500, r1.getCode());
+        assertEquals("productIds不能为空", r1.getMessage());
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> empty = OM.readValue("{\"productIds\":[],\"shelve\":false}", Map.class);
+        Result<String> r2 = controller.batchShelve(empty);
+        assertEquals(500, r2.getCode());
+        assertEquals("productIds不能为空", r2.getMessage());
+
+        verify(productService, never()).batchAdminShelve(any(), any());
+    }
+
+    @Test
+    public void batchShelve_missingShelve_returnsCleanError() throws Exception {
+        @SuppressWarnings("unchecked")
+        Map<String, Object> params = OM.readValue("{\"productIds\":[1]}", Map.class);
+        Result<String> result = controller.batchShelve(params);
+        assertEquals(500, result.getCode());
+        assertEquals("shelve不能为空", result.getMessage());
+        verify(productService, never()).batchAdminShelve(any(), any());
+    }
+}

+ 201 - 0
cfc-frontend/components/DimensionVisitorCard.vue

@@ -0,0 +1,201 @@
+<template>
+  <view class="dvc">
+    <!-- 价值预览卡:维度浅底 + 维度色 CTA -->
+    <view class="dvc-card" :style="{ background: softColor }">
+      <view class="dvc-head">
+        <text class="dvc-title">{{ cardTitle }}</text>
+        <text class="dvc-sub">登录后即可解锁你的专属内容</text>
+      </view>
+
+      <view class="dvc-benefits">
+        <view class="dvc-benefit" v-for="(b, i) in benefits" :key="i">
+          <text class="dvc-dot" :style="{ background: mainColor }"></text>
+          <text class="dvc-benefit-text">{{ b }}</text>
+        </view>
+      </view>
+
+      <view class="dvc-actions">
+        <button class="dvc-btn-primary" :style="{ background: mainColor }" @click="onLogin">{{ ctaText }}</button>
+        <button
+          v-if="secondaryText"
+          class="dvc-btn-secondary"
+          :style="{ color: mainColor, borderColor: mainColor }"
+          @click="onSecondary">{{ secondaryText }}</button>
+      </view>
+    </view>
+
+    <!-- 维度专属预览占位(登录后展示真实数据) -->
+    <view class="dvc-preview" v-if="previewText">
+      <text class="dvc-preview-icon">{{ previewIcon }}</text>
+      <text class="dvc-preview-text">{{ previewText }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * 维度游客价值卡 —— 五维主页面登录前统一复用
+ *
+ * 维度色严格对齐 uni.scss 五维令牌(v4.0):
+ *   身 body   #FF8C42   心 mind   #FF6B9D
+ *   智 wisdom #6366F1   行 action #10B981   富 wealth #F59E0B
+ * 此处用显式 hex(与令牌值一致)而非 var(--x),确保 mp-weixin 运行时稳定渲染。
+ *
+ * 登录行为复用全局 mixin 的 openLoginSheet()(main.js 已全局注册)。
+ */
+var DIM_TOKENS = {
+  body:   { main: '#FF8C42', soft: 'rgba(255, 140, 66, 0.15)',  icon: '🌿' },
+  mind:   { main: '#FF6B9D', soft: 'rgba(255, 107, 157, 0.15)', icon: '🔥' },
+  wisdom: { main: '#6366F1', soft: 'rgba(99, 102, 241, 0.15)',  icon: '⚡' },
+  action: { main: '#10B981', soft: 'rgba(16, 185, 129, 0.15)',  icon: '🤝' },
+  wealth: { main: '#F59E0B', soft: 'rgba(245, 158, 11, 0.15)',  icon: '💧' }
+}
+
+export default {
+  props: {
+    /** 维度编码:body | mind | wisdom | action | wealth */
+    dimCode: { type: String, default: 'body' },
+    /** 卡片标题 */
+    cardTitle: { type: String, default: '登录后查看专属内容' },
+    /** 价值收益列表(2–3 条) */
+    benefits: {
+      type: Array,
+      default: function() { return [] }
+    },
+    /** 主登录按钮文案 */
+    ctaText: { type: String, default: '立即登录' },
+    /** 次级动作文案(可选) */
+    secondaryText: { type: String, default: '' },
+    /** 次级动作跳转地址(可选);留空则 emit('secondary') */
+    secondaryUrl: { type: String, default: '' },
+    /** 维度专属预览占位文案(可选);留空则不渲染预览块 */
+    previewText: { type: String, default: '' }
+  },
+  computed: {
+    token: function() {
+      return DIM_TOKENS[this.dimCode] || DIM_TOKENS.body
+    },
+    mainColor: function() { return this.token.main },
+    softColor: function() { return this.token.soft },
+    previewIcon: function() { return this.token.icon }
+  },
+  methods: {
+    onLogin: function() {
+      // 全局 mixin 提供 openLoginSheet;极端情况下回退整页登录
+      if (typeof this.openLoginSheet === 'function') {
+        this.openLoginSheet()
+      } else {
+        uni.navigateTo({ url: '/pages/login/login' })
+      }
+    },
+    onSecondary: function() {
+      if (this.secondaryUrl) {
+        uni.navigateTo({ url: this.secondaryUrl })
+      } else {
+        this.$emit('secondary')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dvc {
+  margin: 12rpx 30rpx 0;
+}
+
+.dvc-card {
+  border-radius: 24rpx;
+  padding: 32rpx 28rpx;
+}
+
+.dvc-head {
+  margin-bottom: 22rpx;
+}
+.dvc-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+  display: block;
+  line-height: 1.4;
+}
+.dvc-sub {
+  font-size: 22rpx;
+  color: #999999;
+  margin-top: 6rpx;
+  display: block;
+}
+
+.dvc-benefits {
+  margin-bottom: 26rpx;
+}
+.dvc-benefit {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.dvc-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+}
+.dvc-benefit-text {
+  font-size: 26rpx;
+  color: #333333;
+  line-height: 1.5;
+}
+
+.dvc-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+}
+.dvc-btn-primary {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  color: #ffffff;
+  font-size: 28rpx;
+  font-weight: 600;
+  border: none;
+  padding: 0;
+}
+.dvc-btn-primary:active { opacity: 0.85; }
+.dvc-btn-secondary {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  background: #ffffff;
+  font-size: 28rpx;
+  font-weight: 500;
+  border: 2rpx solid;
+  padding: 0;
+}
+.dvc-btn-secondary:active { opacity: 0.7; }
+
+.dvc-preview {
+  margin-top: 24rpx;
+  background: #ffffff;
+  border: 1rpx dashed #e5e7eb;
+  border-radius: 20rpx;
+  padding: 40rpx 28rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.dvc-preview-icon {
+  font-size: 44rpx;
+  margin-bottom: 14rpx;
+}
+.dvc-preview-text {
+  font-size: 24rpx;
+  color: #999999;
+  text-align: center;
+  line-height: 1.6;
+}
+</style>

+ 5 - 1
cfc-frontend/components/RecommendedFeed.vue

@@ -128,7 +128,11 @@ export default {
       return map[status] || ''
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + 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)
     }
   }
 }

+ 461 - 0
cfc-frontend/components/login-sheet.vue

@@ -0,0 +1,461 @@
+<template>
+  <view class="login-sheet-overlay" :class="{ 'login-sheet-overlay--visible': visible }" @click="handleOverlayClick">
+    <view class="login-sheet-panel" :class="{ 'login-sheet-panel--show': panelShow }" @click.stop>
+      <view class="login-sheet-header">
+        <view class="login-sheet-title">欢迎回来</view>
+      </view>
+      <scroll-view scroll-y class="login-sheet-scroll" :show-scrollbar="false">
+        <view class="login-sheet-content">
+          <!-- 品牌区 -->
+          <view class="brand-section">
+            <image class="logo" src="/static/logo.png" mode="aspectFit" @error="handleImageError"></image>
+            <text class="app-name">浠艾福</text>
+            <text class="identity">您的专属家庭主动健康管理师</text>
+            <text class="slogan">家里每个人的状态,你真的看见了?</text>
+          </view>
+
+          <!-- 理念卡 -->
+          <view class="concept-card">
+            <view class="concept-desc">
+              不等问题发生,先一步<text class="concept-highlight">识别风险 · 主动干预 · 持续评估</text>,把全家健康管理融入每一天。
+            </view>
+            <view class="loop-row">
+              <view class="loop-pill loop-pill-indigo"><text>🔍</text><text class="loop-pill-text">提前识别</text></view>
+              <text class="loop-arrow">→</text>
+              <view class="loop-pill loop-pill-green"><text>🛡️</text><text class="loop-pill-text">风险管控</text></view>
+              <text class="loop-arrow">→</text>
+              <view class="loop-pill loop-pill-amber"><text>⚡</text><text class="loop-pill-text">主动干预</text></view>
+              <text class="loop-arrow">→</text>
+              <view class="loop-pill loop-pill-red"><text>📊</text><text class="loop-pill-text">效果评估</text></view>
+            </view>
+          </view>
+
+          <!-- 五维标签 -->
+          <view class="dims-row">
+            <view class="dim-tag dim-tag-body"><text>🌏</text><text>身·健康</text></view>
+            <view class="dim-tag dim-tag-wisdom"><text>⚡</text><text>智·赋能</text></view>
+            <view class="dim-tag dim-tag-wealth"><text>💧</text><text>富·创富</text></view>
+            <view class="dim-tag dim-tag-action"><text>🌿</text><text>行·和谐</text></view>
+            <view class="dim-tag dim-tag-mind"><text>🔥</text><text>心·大爱</text></view>
+          </view>
+
+          <!-- 登录表单 -->
+          <view class="login-form">
+            <view class="phone-section">
+              <button
+                class="phone-btn"
+                open-type="getPhoneNumber"
+                @getphonenumber="getPhoneNumber"
+                @tap="onPhoneBtnTap"
+                :loading="loading"
+                :disabled="!agreed || loading || tapLock"
+              >
+                <text class="phone-btn-icon">📱</text>
+                <text>手机号快捷登录</text>
+              </button>
+            </view>
+
+            <!-- 暂不登录 -->
+            <view class="skip-section">
+              <text class="skip-btn" @click="handleSkipLogin">暂不登录,先逛逛</text>
+            </view>
+
+            <view class="agreement">
+              <checkbox-group @change="onAgreementChange">
+                <label>
+                  <checkbox value="agree" :checked="agreed" />
+                  <text class="agreement-text">我已阅读并同意</text>
+                </label>
+              </checkbox-group>
+              <text class="link" @click="showAgreement">《用户协议》</text>
+              <text class="agreement-text">和</text>
+              <text class="link" @click="showPrivacy">《隐私政策》</text>
+            </view>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { wechatPhoneLogin } from '../utils/api.js'
+
+export default {
+  name: 'login-sheet',
+  data() {
+    return {
+      visible: false,
+      panelShow: false,
+      loading: false,
+      // tapLock:在「点击」瞬间上锁,用于关闭授权弹窗弹出前的连点窗口(见 onPhoneBtnTap)
+      tapLock: false,
+      agreed: false,
+      nickname: '',
+      avatar: '',
+      redirectUrl: '',
+      _tapLockTimer: null
+    }
+  },
+  beforeDestroy() {
+    this.clearTimers()
+  },
+  methods: {
+    clearTimers() {
+      if (this._tapLockTimer) {
+        clearTimeout(this._tapLockTimer)
+        this._tapLockTimer = null
+      }
+    },
+    // 由宿主页面通过 mixin openLoginSheet() 调用,或 pages/login/login.vue 兜底壳调用
+    open(options) {
+      options = options || {}
+      // 处理邀请码
+      if (options.inviteCode) {
+        uni.setStorageSync('inviteCode', options.inviteCode)
+        uni.setStorageSync('inviteType', options.inviteType || 'family')
+      }
+      // 处理推荐码(refCode)
+      if (options.refCode) {
+        uni.setStorageSync('refCode', options.refCode)
+      }
+      // 存储登录后跳转地址
+      if (options.redirect) {
+        this.redirectUrl = decodeURIComponent(options.redirect)
+      }
+      this.visible = true
+      this.$nextTick(() => {
+        setTimeout(() => {
+          this.panelShow = true
+        }, 10)
+      })
+    },
+    close() {
+      this.panelShow = false
+      setTimeout(() => {
+        this.visible = false
+      }, 250)
+    },
+    handleOverlayClick() {
+      this.close()
+    },
+    handleImageError() {
+      console.log('Logo 加载失败,使用默认显示')
+    },
+    onAgreementChange(e) {
+      this.agreed = e.detail.value.length > 0
+    },
+    showAgreement() {
+      uni.navigateTo({
+        url: '/pages/policy/agreement'
+      })
+    },
+    showPrivacy() {
+      uni.navigateTo({
+        url: '/pages/policy/privacy'
+      })
+    },
+    handleSkipLogin() {
+      // 不登录直接关闭浮层,停留当前页
+      this.close()
+    },
+    // 点击瞬间上锁,关闭「授权弹窗弹出前」的连点窗口
+    onPhoneBtnTap() {
+      if (this.loading || this.tapLock) return
+      this.tapLock = true
+      if (this._tapLockTimer) clearTimeout(this._tapLockTimer)
+      this._tapLockTimer = setTimeout(() => {
+        this.tapLock = false
+        this._tapLockTimer = null
+      }, 3000)
+    },
+    getPhoneNumber(e) {
+      // 授权流程已结束,释放 tap 锁与兜底定时器,按钮恢复可点
+      if (this._tapLockTimer) {
+        clearTimeout(this._tapLockTimer)
+        this._tapLockTimer = null
+      }
+      this.tapLock = false
+
+      if (!this.agreed) {
+        uni.showToast({ title: '请先同意用户协议', icon: 'none' })
+        return
+      }
+      if (!e.detail || !e.detail.code) {
+        uni.showToast({ title: '授权失败,请重试', icon: 'none' })
+        return
+      }
+      var self = this
+      self.loading = true
+      // 点击授权时实时获取 login code(微信 code 一次性有效,不能复用旧 code)
+      uni.login({
+        provider: 'weixin',
+        success: function(loginRes) {
+          if (!loginRes.code) {
+            uni.showToast({ title: '网络异常,请重试', icon: 'none' })
+            self.loading = false
+            return
+          }
+          wechatPhoneLogin({ code: loginRes.code, phoneCode: e.detail.code }).then(function(res) {
+            self.completeLogin(res.data)
+          }).catch(function(err) {
+            uni.showToast({ title: err.message || '登录失败', icon: 'none' })
+            self.loading = false
+          })
+        },
+        fail: function() {
+          uni.showToast({ title: '网络异常,请重试', icon: 'none' })
+          self.loading = false
+        }
+      })
+    },
+    completeLogin(data) {
+      if (!data) return
+      // 写入登录态
+      if (data.token) uni.setStorageSync('token', data.token)
+      if (data.userId) uni.setStorageSync('userId', data.userId)
+      if (data.openid) uni.setStorageSync('openid', data.openid)
+      if (data.nickname) {
+        this.nickname = data.nickname
+        uni.setStorageSync('nickname', data.nickname)
+      }
+      if (data.avatar) {
+        this.avatar = data.avatar
+        uni.setStorageSync('avatar', data.avatar)
+      }
+      // 通知宿主页面(页面通过 @success="onSheetLoginSuccess" 刷新数据)
+      this.$emit('success', data)
+      // 关闭浮层,停留当前页
+      this.close()
+    }
+  }
+}
+</script>
+
+<style scoped>
+/* ===== 浮层遮罩 ===== */
+.login-sheet-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 9999;
+  opacity: 0;
+  visibility: hidden;
+  transition: opacity 0.25s ease;
+}
+.login-sheet-overlay--visible {
+  opacity: 1;
+  visibility: visible;
+}
+
+/* ===== 底部面板 ===== */
+.login-sheet-panel {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 85vh;
+  max-height: 85vh;
+  background: linear-gradient(180deg, #fffaf3 0%, #fff3e4 100%);
+  border-radius: 32rpx 32rpx 0 0;
+  overflow: hidden;
+  transform: translateY(100%);
+  transition: transform 0.3s ease;
+  display: flex;
+  flex-direction: column;
+}
+.login-sheet-panel--show {
+  transform: translateY(0);
+}
+
+.login-sheet-header {
+  padding: 24rpx 32rpx 8rpx;
+  display: flex;
+  justify-content: center;
+}
+.login-sheet-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+/* ===== 滚动区域 ===== */
+.login-sheet-scroll {
+  flex: 1;
+  min-height: 0;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.login-sheet-content {
+  padding: 24rpx 48rpx 60rpx;
+  box-sizing: border-box;
+}
+
+/* ===== 品牌区 ===== */
+.brand-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 24rpx 0 8rpx;
+}
+.logo {
+  width: 128rpx;
+  height: 128rpx;
+  border-radius: 32rpx;
+}
+.app-name {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #333;
+  margin-top: 16rpx;
+}
+.identity {
+  font-size: 26rpx;
+  color: #f97316;
+  margin-top: 8rpx;
+}
+.slogan {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 12rpx;
+  text-align: center;
+}
+
+/* ===== 理念卡 ===== */
+.concept-card {
+  background: rgba(249, 115, 22, 0.08);
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-top: 28rpx;
+}
+.concept-desc {
+  font-size: 26rpx;
+  color: #555;
+  line-height: 1.6;
+}
+.concept-highlight {
+  color: #f97316;
+  font-weight: 600;
+}
+.loop-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 16rpx;
+  flex-wrap: nowrap;  /* 禁止换行,确保四步/五维在一行 */
+  margin-bottom: 16rpx;
+}
+.loop-pill {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  border-radius: 999rpx;
+  padding: 6rpx 12rpx;
+  font-size: 22rpx;
+  flex-shrink: 0;  /* 防止被压缩导致文本换行 */
+  white-space: nowrap;
+}
+.loop-pill-indigo { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
+.loop-pill-green { background: rgba(16, 185, 129, 0.12); color: #10b981; }
+.loop-pill-amber { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
+.loop-pill-red { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
+.loop-pill-text {
+  margin-left: 4rpx;
+  white-space: nowrap;  /* 禁止四字步骤文本换行 */
+}
+.loop-arrow {
+  font-size: 24rpx;
+  color: #bbb;
+  padding: 0 2rpx;
+  flex-shrink: 0;  /* 箭头不被压缩 */
+}
+
+/* ===== 五维标签 ===== */
+.dims-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+  flex-wrap: nowrap;  /* 禁止换行,保持五维维度在一行 */
+  margin-top: 24rpx;
+  padding: 0 32rpx;  /* 左右留白,避免贴边 */
+  box-sizing: border-box;
+}
+.dim-tag {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  border-radius: 999rpx;
+  padding: 8rpx 14rpx;
+  font-size: 22rpx;
+  color: #fff;
+  margin: 0 8rpx;  /* 使用 margin 替代 gap,微信小程序不支持 gap */
+  white-space: nowrap;
+}
+.dim-tag-body { background: #10b981; }
+.dim-tag-wisdom { background: #6366f1; }
+.dim-tag-wealth { background: #f59e0b; }
+.dim-tag-action { background: #14b8a6; }
+.dim-tag-mind { background: #ef4444; }
+
+/* ===== 登录表单 ===== */
+.login-form {
+  margin-top: 32rpx;
+}
+.phone-btn {
+  width: 100%;
+  height: 96rpx;
+  border-radius: 48rpx;
+  background: linear-gradient(135deg, #f97316, #fb923c);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 600;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  border: none;
+}
+.phone-btn::after {
+  border: none;
+}
+.phone-btn[disabled] {
+  background: #e0d8cc !important;
+  color: #aaa !important;
+}
+.phone-btn-icon {
+  margin-right: 8rpx;
+}
+
+/* ===== 暂不登录 ===== */
+.skip-section {
+  display: flex;
+  justify-content: center;
+  margin-top: 24rpx;
+}
+.skip-btn {
+  font-size: 26rpx;
+  color: #999;
+  text-decoration: underline;
+}
+
+/* ===== 协议 ===== */
+.agreement {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  flex-wrap: wrap;
+  margin-top: 24rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+.link {
+  color: #f97316;
+}
+</style>

+ 121 - 0
cfc-frontend/docs/dimension-pages-login-state-plan.md

@@ -0,0 +1,121 @@
+# 五维主页面 · 登录前 / 登录后 显示规划
+
+> **范围**:`pages/body-detail`(身)、`pages/wisdom-detail`(智)、`pages/mind-detail`(心)、`pages/wealth`(富)、`pages/action-detail`(行)
+> **目的**:固化五个维度详情页在「登录前(游客态)」与「登录后」各自应显示的内容与顺序,作为开发与验收的唯一依据
+> **版本**:V1.0 · 2026-10-12
+> **已确认决策**(廖总拍板):① 心页 PageBanner 维持「心智合一」双色;② 身 / 智保留 `preview-text`(统一五页骨架文案);③ `body-detail` 游客卡 DOM 位置上移至 monthly-guidance 之后。
+
+---
+
+## 一、统一设计原则
+
+1. **价值前置**:登录前先让用户"看见登录后能得到什么"——能自然预览的直接展示,无自然预览的用虚线占位,**不硬塞假数据**。
+2. **维度身份**:PageBanner / 品牌色 / CTA 严格走维度色令牌;**唯一例外是心维持双色**(品牌意图,见 §四)。
+3. **转化漏斗**:每个登录前页面必须有 **1 个维度色主登录 CTA + 1 个次级动作**(先逛 / 先看介绍),统一由 `DimensionVisitorCard` 承载。
+4. **一致中允许差异**:骨架统一、各维度预览形态允许不同(由业务内容决定,见 §四豁免说明)。
+
+---
+
+## 二、登录前(游客态)统一骨架
+
+自上而下五层:
+
+| 序 | 层 | 显示条件 | 说明 |
+|---|---|---|---|
+| ① | `PageBanner` | 始终 | 维度头部;心页用 `mind-wisdom` 双色,其余单维度色 |
+| ② | `brand-strap` | `v-if="!isLoggedIn"` | 维度定位语一句话 |
+| ③ | 自然预览 | 按维度(见下) | 能预览真实感的直接展示 |
+| ④ | `monthly-guidance` | `v-if="!isLoggedIn"` | 每月引导语(🌿/⚡/🔥/💧/🌿) |
+| ⑤ | `DimensionVisitorCard` | `v-if="!isLoggedIn"` | 价值卡 + 维度色主 CTA + 次级动作 + 虚线占位 |
+
+---
+
+## 三、各维度:登录前 / 登录后显示清单
+
+### 身 `body-detail`
+
+**登录前**:PageBanner(橙 `#FF8C42`) → brand-strap「科学管理全家健康」→ **健康概览卡(空态槽位:身高体重 / 血压 / 心率 = 暂无数据)** → monthly-guidance → `DimensionVisitorCard`
+- card-title:身体维度 · 全家健康管理
+- benefits:身高体重血压心率趋势 / 肠道菌群与疾病风险预警 / 家庭成员健康画像对比
+- CTA:立即登录查看健康概览 / 次级:查看健康报告 → `/pages/health/report-list`
+- preview-text:登录后展示你的健康概览 · 趋势曲线
+
+**登录后**:身维度画像 → 健康概览(真实值 + 趋势)→ 健康趋势分析 `RadarChart`(个人数据)→ 身体指标卡 → 肠道菌群(无报告时显示上传引导)→ 疾病风险 → 家庭成员选择条 → 五维能量条 → `DimensionIntroCard body` → 功能入口网格
+
+### 智 `wisdom-detail`
+
+**登录前**:PageBanner(靛 `#6366F1`) → brand-strap「提升全家认知能力与学习效率」→ **学习能力评估 `RadarChart`(同龄平均值;"查看详情"仅登录后可点)** → monthly-guidance → `DimensionVisitorCard`
+- card-title:智维度 · 认知赋能 / CTA:登录做认知测评 / 次级:了解认知维度 → `dimension-intro`
+- preview-text:登录后展示你的认知能力雷达图
+
+**登录后**:家庭成员选择条 → 五维能量条 → 认知指标卡 → 学习解读 → 认知强项与提升 → `DimensionIntroCard wisdom`
+
+### 心 `mind-detail`
+
+**登录前**:PageBanner(**`mind-wisdom` 双色**) → brand-strap「关注全家心理健康与情绪成长」→ monthly-guidance → `DimensionVisitorCard`
+- card-title:心维度 · 心理成长 / CTA:登录查看心理画像 / 次级:了解心理维度 → `dimension-intro`
+- preview-text:登录后展示你的心理画像与情绪关联
+- 注:**登录前无数据预览内容**(天盘 / 成员条 / 心理报告均登录后才出现),仅虚线占位
+
+**登录后**:家庭天盘 `FamilyTianpanCard` → 家庭成员选择条 → 五维能量条 → 心理指标卡 → EMI 心理测评报告 → 心维度画像 → `DimensionIntroCard mind`
+
+### 富 `wealth`
+
+**登录前**:PageBanner(琥珀 `#F59E0B`) → brand-strap「分享价值,收获财富」→ monthly-guidance → `DimensionVisitorCard` → **商城精选横滑(真实商品)**
+- card-title:富维度 · 利他创富 / CTA:登录解锁财富能量 / 次级:逛逛商城 → `/pages/shop/index/index`
+- **无 preview-text**(豁免:已有商城精选真实预览,避免重复占位)
+
+**登录后**:家庭成员选择条 → 五维能量条 → 身克富杠杆警告 → 财富能量环 → 财富成长(水位)→ 子维度水滴 → 子维度评分 → 财富强项与提升 → 富维度画像 → `DimensionIntroCard wealth`
+
+### 行 `action-detail`
+
+**登录前**:PageBanner(绿 `#10B981`) → brand-strap「改善全家关系与沟通能力」→ monthly-guidance → `DimensionVisitorCard`
+- card-title:行维度 · 关系滋养 / CTA:登录绘制关系网络 / 次级:了解关系维度 → `dimension-intro`
+- preview-text:登录后绘制你的家庭关系网络图
+- 注:**登录前无数据预览内容**(关系图 / 章鱼图 / 成员条均登录后才出现),仅虚线占位
+
+**登录后**:家庭成员选择条 → 五维能量条 → 关系解读 → 行维度画像 → 关系六维 → 家庭关系图 `FamilyRelationGraph` → 章鱼图 `OctopusDiagram` → 珍珠图 / 能力图 → `DimensionIntroCard action`
+
+---
+
+## 四、维度差异与豁免说明
+
+| 项 | 决策 | 理由 |
+|---|---|---|
+| 心 PageBanner `mind-wisdom` 双色 | **保留**(已确认) | 「心智合一」品牌意图,非 bug;与单维度色规则是刻意例外 |
+| 富 不加 `preview-text` | 豁免 | 已有「商城精选」真实预览,虚线占位会重复 |
+| 身 / 智 保留 `preview-text` | **保留**(已确认) | 虽有自然预览,仍保留虚线文案以统一五页骨架表述 |
+| 身 健康概览卡「始终显示」 | 豁免 | 登录前展示空槽位 = 天然的结构预览 |
+| 智 认知雷达「始终显示」 | 豁免 | 登录前显示同龄平均值 = 天然预览,且"查看详情"入口登录后才出现 |
+| 心 / 行 无自然预览 | 豁免 | 业务上无无需登录即可展示的内容,故仅虚线占位 |
+
+---
+
+## 五、组件职责边界(重要 · 历史事故防线)
+
+| 组件 | 归属 | props | 禁止 |
+|---|---|---|---|
+| `DimensionVisitorCard` | **登录前游客态** | `dimCode / cardTitle / benefits / ctaText / secondaryText / secondaryUrl / previewText` | 禁止用于 `isLoggedIn` 分支 |
+| `DimensionIntroCard` | **登录后** | `dimCode / learnMoreText / learnMoreUrl` | 禁止承载登录 CTA(它无 CTA 能力) |
+
+> **历史事故**:曾有提交(`490e69e90`,"修复 DimensionVisitorCard 幽灵引用")因组件文件缺失,把五页机械替换为 `DimensionIntroCard`,但未适配 props,导致登录前登录 CTA / benefits 全部失效。二者**不可互换**,修改时按上表核对。
+
+---
+
+## 六、验收清单
+
+- [ ] 五页登录前骨架一致(①~⑤,仅维度内容差异)
+- [ ] 每页登录前均有**维度色主登录 CTA**,点击可唤起登录浮层(`openLoginSheet`)
+- [ ] 每页登录前均有次级动作且不要求登录(健康报告 / 维度介绍 / 商城)
+- [ ] 登录后各页显示真实数据模块,不再出现游客卡
+- [ ] 心页 PageBanner 双色、富页无虚线占位、身 / 智保留虚线占位
+- [ ] `body-detail` 游客卡位于 monthly-guidance 之后
+- [ ] `npm run build:mp-weixin` 编译通过
+
+---
+
+## 七、待办
+
+1. ⏳ 编译验证:`npm run build:mp-weixin`(阻塞项 `pages/vendor/team-system/team-system.vue` 可选链已按 AGENTS.md 规范改为 `&&`,待跑通确认)
+2. ⏳ 真机核对:登录前五页骨架与 CTA 可点;登录后数据与占位的切换
+3. ⏳ 所有改动尚未提交 git

+ 5 - 0
cfc-frontend/main.js

@@ -3,12 +3,17 @@ import Vue from 'vue'
 import store from './store'
 import FamilyEmptyState from './components/FamilyEmptyState/FamilyEmptyState.vue'
 import CustomTabBar from './custom-tab-bar/index.vue'
+import LoginSheet from './components/login-sheet.vue'
+import loginSheetMixin from './mixins/login-sheet.js'
 import { formatPriceWithSymbol, formatPrice, formatRate, formatScore } from '@/utils/format'
 
 Vue.config.productionTip = false
 
 Vue.component('FamilyEmptyState', FamilyEmptyState)
 Vue.component('custom-tab-bar', CustomTabBar)
+// 全局登录浮层:注册组件 + 注入 mixin(页面任意位置 this.openLoginSheet() 即从底部浮出)
+Vue.component('login-sheet', LoginSheet)
+Vue.mixin(loginSheetMixin)
 
 Vue.prototype.$store = store
 Vue.prototype.formatPriceWithSymbol = formatPriceWithSymbol

+ 114 - 0
cfc-frontend/mixins/login-sheet.js

@@ -0,0 +1,114 @@
+/**
+ * 全局登录浮层 mixin(注册于 main.js:Vue.mixin(loginSheetMixin))
+ * 页面内任意位置调用 this.openLoginSheet(options) 即从底部浮出登录浮层
+ *   - options:{ inviteCode, inviteType, refCode, redirect, onSuccess }
+ *     onSuccess 为登录成功回调(JS 直接传函数,不经序列化),在 storage 写入、浮层关闭、
+ *     pendingFamilyInviteCode 跳转判断之前执行,适合「登录后续接购买/报名」类流程
+ *   - 组件内(LoginGuideCard)调用时,本 mixin 会沿 $parent 链向上找到宿主页面浮层;
+ *     宿主页面未挂浮层时回退到整页登录(历史行为,向后兼容)
+ *
+ * 已登录守卫:
+ *   - 若 uni.getStorageSync('token') 存在,不弹浮层,优先调用页面 onLoginSuccess(),
+ *     否则 loadData(),都无则静默返回(防止已登录用户误触发登录浮层)
+ */
+function findLoginSheet(vm) {
+  var cur = vm
+  var depth = 0
+  while (cur && depth < 20) {
+    if (cur.$refs && cur.$refs.loginSheet) {
+      return cur.$refs.loginSheet
+    }
+    cur = cur.$parent
+    depth++
+  }
+  return null
+}
+
+export default {
+  methods: {
+    openLoginSheet: function(options) {
+      options = options || {}
+      // 已登录守卫
+      var token = uni.getStorageSync('token')
+      if (token) {
+        if (typeof this.onLoginSuccess === 'function') {
+          try {
+            this.onLoginSuccess()
+          } catch (e) {
+            console.warn('onLoginSuccess 调用失败', e)
+          }
+          return
+        }
+        if (typeof this.loadData === 'function') {
+          try {
+            this.loadData()
+          } catch (e) {
+            console.warn('loadData 调用失败', e)
+          }
+          return
+        }
+        return
+      }
+
+      var sheet = findLoginSheet(this)
+      if (sheet && typeof sheet.open === 'function') {
+        sheet.open(options)
+      } else {
+        // 宿主页面未挂浮层时回退到整页登录(历史行为,向后兼容)
+        var url = '/pages/login/login'
+        var qs = []
+        if (options.inviteCode) {
+          qs.push('inviteCode=' + encodeURIComponent(options.inviteCode))
+          qs.push('inviteType=' + encodeURIComponent(options.inviteType || 'family'))
+        }
+        if (options.refCode) {
+          qs.push('refCode=' + encodeURIComponent(options.refCode))
+        }
+        if (options.redirect) {
+          qs.push('redirect=' + encodeURIComponent(options.redirect))
+        }
+        if (qs.length) url += '?' + qs.join('&')
+        uni.navigateTo({ url: url })
+      }
+    },
+
+    closeLoginSheet: function() {
+      var sheet = findLoginSheet(this)
+      if (sheet && typeof sheet.close === 'function') {
+        sheet.close()
+      }
+    },
+
+    onSheetLoginSuccess: function(data) {
+      if (typeof this.onLoginSuccess === 'function') {
+        try {
+          this.onLoginSuccess(data)
+        } catch (e) {
+          console.warn('onSheetLoginSuccess 调用失败', e)
+        }
+      } else if (typeof this.loadData === 'function') {
+        try {
+          this.loadData()
+        } catch (e) {
+          console.warn('loadData 调用失败', e)
+        }
+      }
+      // 登录成功后,尝试刷新当前页面,重置登录状态
+      try {
+        var pages = getCurrentPages()
+        if (pages.length) {
+          var curPage = pages[pages.length - 1]
+          if (curPage && typeof curPage.onLoad === 'function' && typeof curPage.onShow === 'function') {
+            var options = curPage.options || {}
+            curPage.onLoad(options)
+            curPage.onShow()
+          } else if (curPage && typeof curPage.onShow === 'function') {
+            curPage.onShow()
+          }
+        }
+      } catch (e) {
+        console.warn('刷新当前页失败', e)
+      }
+    }
+  }
+}

+ 18 - 0
cfc-frontend/pages.json

@@ -113,6 +113,18 @@
             "navigationBarTitleText": "方案详情"
           }
         },
+        {
+          "path": "plan-list",
+          "style": {
+            "navigationBarTitleText": "我的方案"
+          }
+        },
+        {
+          "path": "plan-create",
+          "style": {
+            "navigationBarTitleText": "新建方案"
+          }
+        },
         {
           "path": "team",
           "style": {
@@ -1090,6 +1102,12 @@
             "navigationBarTitleText": "方案制定"
           }
         },
+        {
+          "path": "plan-list",
+          "style": {
+            "navigationBarTitleText": "我的方案"
+          }
+        },
         {
           "path": "teacher-service",
           "style": {

+ 2 - 12
cfc-frontend/pages/action-detail/components/DimensionSubDims.vue

@@ -19,11 +19,11 @@
           <view class="dim-item-score-wrap">
             <text
               class="dim-item-score"
-              :class="getScoreClass(d.score)"
+              :class="d.score >= 80 ? 'score-good' : (d.score >= 60 ? 'score-ok' : 'score-low')"
             >{{ d.score }}</text>
             <text
               class="dim-item-level"
-              :class="'level-' + getScoreLevel(d.score)"
+              :class="'level-' + (d.score >= 80 ? 'good' : (d.score >= 60 ? 'ok' : 'low'))"
             >{{ getScoreLevelText(d.score) }}</text>
           </view>
         </view>
@@ -48,21 +48,11 @@ export default {
     dims: { type: Array, default: () => [] }
   },
   methods: {
-    getScoreLevel(score) {
-      if (score >= 80) return 'good'
-      if (score >= 60) return 'ok'
-      return 'low'
-    },
     getScoreLevelText(score) {
       if (score >= 80) return '优秀'
       if (score >= 60) return '良好'
       if (score > 0) return '待提升'
       return '-'
-    },
-    getScoreClass(score) {
-      if (score >= 80) return 'score-good'
-      if (score >= 60) return 'score-ok'
-      return 'score-low'
     }
   }
 }

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

@@ -30,7 +30,7 @@
     </view>
 
     <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
-<DimensionIntroCard
+<DimensionVisitorCard
   v-if="!isLoggedIn"
   dimCode="action"
   card-title="行维度 · 关系滋养"
@@ -41,7 +41,8 @@
   ]"
   cta-text="登录绘制关系网络"
   secondary-text="了解关系维度"
-  secondary-url="/pages/action-detail/dimension-intro" />
+  secondary-url="/pages/action-detail/dimension-intro"
+  preview-text="登录后绘制你的家庭关系网络图" />
 
     <!-- 登录后:五维能量条 -->
     <FamilyEnergyBar
@@ -266,13 +267,14 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
+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 { 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, FloatingAvatar, RadarChart, DimensionSubDims },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, DimensionVisitorCard, FloatingAvatar, RadarChart, DimensionSubDims },
   data() {
     return {
       isLoggedIn: false,
@@ -840,10 +842,10 @@ export default {
 }
 </script>
 
-<style scoped>
+<style scoped lang="scss">
 .container {
   min-height: 100vh;
-  background: linear-gradient(180deg, #F0FFF4 0%, #F5F7FA 40%);
+  background: linear-gradient(180deg, #F0FFF4 0%, $app-bg 40%);
   padding-bottom: 120rpx;
 }
 
@@ -851,7 +853,7 @@ export default {
 .brand-strap {
   margin: 24rpx 30rpx 0;
   padding: 28rpx 32rpx;
-  background: linear-gradient(135deg, #10B981 0%, #34D399 50%, #6EE7B7 100%);
+  background: linear-gradient(135deg, $dim-action 0%, #34D399 50%, #6EE7B7 100%);
   border-radius: 24rpx;
   box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.25);
   position: relative;
@@ -879,7 +881,7 @@ export default {
 }
 .brand-strap-text {
   font-size: 30rpx;
-  color: #FFFFFF;
+  color: $app-surface;
   font-weight: 600;
   text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
   position: relative;
@@ -890,7 +892,7 @@ export default {
 .monthly-guidance {
   margin: 20rpx 30rpx 0;
   padding: 24rpx 28rpx;
-  background: linear-gradient(135deg, #FFFFFF 0%, #F0FFF4 100%);
+  background: linear-gradient(135deg, $app-surface 0%, #F0FFF4 100%);
   border-radius: 20rpx;
   border: 1rpx solid rgba(16, 185, 129, 0.15);
   box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.08);
@@ -918,7 +920,7 @@ export default {
   right: 24rpx;
   height: 3rpx;
   border-radius: 3rpx 3rpx 0 0;
-  background: linear-gradient(90deg, #10B981, #34D399);
+  background: linear-gradient(90deg, $dim-action, #34D399);
   opacity: 0.7;
 }
 .section-header {
@@ -933,7 +935,7 @@ export default {
   flex-direction: row;
   align-items: center;
   gap: 8rpx;
-  border-left: 8rpx solid #10B981;
+  border-left: 8rpx solid $dim-action;
   padding-left: 16rpx;
 }
 .help-icon {
@@ -947,12 +949,12 @@ export default {
   align-items: center;
   justify-content: center;
   flex-shrink: 0;
-  transition: all 0.2s;
-  background: #FFFFFF;
+  transition: opacity 0.2s $ease-standard, transform 0.2s $ease-standard;
+  background: $app-surface;
 }
 .help-icon:active {
-  border-color: #10B981;
-  color: #10B981;
+  border-color: $dim-action;
+  color: $dim-action;
   background: #F0FFF4;
   transform: scale(1.1);
 }
@@ -976,7 +978,7 @@ export default {
   font-size: 24rpx;
   font-weight: 500;
   white-space: nowrap;
-  transition: all 0.2s;
+  transition: opacity 0.2s $ease-standard, transform 0.2s $ease-standard;
   box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
 }
 .section-link:active {
@@ -985,40 +987,40 @@ export default {
 }
 .section-link-octopus {
   background: linear-gradient(135deg, #F97316, #FB923C);
-  color: #fff;
+  color: $app-surface;
   box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.3);
 }
 .section-link-pearl {
   background: linear-gradient(135deg, #0EA5E9, #38BDF8);
-  color: #fff;
+  color: $app-surface;
   box-shadow: 0 4rpx 16rpx rgba(14,165,233,0.3);
 }
 .section-link-ability {
-  background: linear-gradient(135deg, #10B981, #34D399);
-  color: #fff;
+  background: linear-gradient(135deg, $dim-action, #34D399);
+  color: $app-surface;
   box-shadow: 0 4rpx 16rpx rgba(16,185,129,0.3);
 }
 
 /* 关系解读卡片 — 状态色 */
 .score-explain-card {
-  background: linear-gradient(135deg, #FFFFFF 0%, #F0FFF4 100%);
+  background: linear-gradient(135deg, $app-surface 0%, #F0FFF4 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, #FFFFFF 0%, #D1FAE5 100%);
+  background: linear-gradient(135deg, $app-surface 0%, #D1FAE5 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, #FFFFFF 0%, #FEF3C7 100%);
+  background: linear-gradient(135deg, $app-surface 0%, #FEF3C7 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, #FFFFFF 0%, #FEE2E2 100%);
+  background: linear-gradient(135deg, $app-surface 0%, #FEE2E2 100%);
   border-color: rgba(239, 68, 68, 0.25);
   box-shadow: 0 4rpx 20rpx rgba(239, 68, 68, 0.12);
 }
@@ -1040,12 +1042,12 @@ export default {
   transition: transform 0.2s;
 }
 .explain-icon-wrap:active { transform: scale(0.95); }
-.explain-icon-good { background: linear-gradient(135deg, #10B981, #34D399); box-shadow: 0 4rpx 12rpx rgba(16,185,129,0.3); }
+.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 {
   font-size: 28rpx;
-  color: #FFFFFF;
+  color: $app-surface;
   font-weight: 700;
 }
 .explain-info {
@@ -1064,7 +1066,7 @@ export default {
   line-height: 1;
   letter-spacing: -2rpx;
 }
-.score-explain-good .explain-score { color: #10B981; }
+.score-explain-good .explain-score { color: $dim-action; }
 .score-explain-warn .explain-score { color: #D97706; }
 .score-explain-bad .explain-score { color: #DC2626; }
 .explain-score-unit {
@@ -1094,7 +1096,7 @@ export default {
   z-index: 999;
 }
 .help-dialog {
-  background: #FFFFFF;
+  background: $app-surface;
   border-radius: 24rpx;
   width: 85%;
   max-width: 520rpx;
@@ -1108,7 +1110,7 @@ export default {
   align-items: center;
   padding: 28rpx 32rpx 20rpx;
   border-bottom: 1rpx solid #F1F5F9;
-  background: linear-gradient(135deg, #F0FFF4, #FFFFFF);
+  background: linear-gradient(135deg, #F0FFF4, $app-surface);
 }
 .help-dialog-title {
   font-size: 32rpx;
@@ -1145,7 +1147,7 @@ export default {
   display: flex;
   flex-direction: row;
   justify-content: space-around;
-  background: linear-gradient(135deg, #FFFFFF 0%, #F0FFF4 100%);
+  background: linear-gradient(135deg, $app-surface 0%, #F0FFF4 100%);
   border-radius: 24rpx;
   padding: 32rpx 16rpx;
   box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.1);
@@ -1168,7 +1170,7 @@ export default {
   justify-content: center;
   margin-bottom: 12rpx;
   box-shadow: 0 4rpx 12rpx rgba(16, 185, 129, 0.15);
-  transition: all 0.2s;
+  transition: opacity 0.2s $ease-standard, transform 0.2s $ease-standard;
 }
 .func-item:active .func-icon-wrap {
   transform: scale(0.95);
@@ -1190,7 +1192,7 @@ export default {
 /* 维度画像样式 */
 .dimension-portrait-section {
   margin: 24rpx 30rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 24rpx;
   padding: 32rpx 24rpx;
   box-shadow: 0 4rpx 20rpx rgba(16, 185, 129, 0.12);
@@ -1198,7 +1200,7 @@ export default {
 }
 .portrait-tier {
   font-size: 26rpx;
-  color: #10B981;
+  color: $dim-action;
   font-weight: bold;
   margin-left: 16rpx;
 }
@@ -1207,7 +1209,7 @@ export default {
 }
 .portrait-desc {
   font-size: 26rpx;
-  color: #666;
+  color: $app-text-secondary;
   line-height: 1.6;
   display: block;
 }
@@ -1235,13 +1237,13 @@ export default {
 }
 .portrait-dim-name {
   font-size: 24rpx;
-  color: #333;
+  color: $app-text;
   flex: 1;
 }
 .portrait-dim-score {
   font-size: 26rpx;
   font-weight: bold;
-  color: #10B981;
+  color: $dim-action;
   margin-right: 12rpx;
 }
 .portrait-dim-bar-track {
@@ -1259,14 +1261,14 @@ export default {
 }
 .portrait-dim-desc {
   font-size: 20rpx;
-  color: #666;
+  color: $app-text-secondary;
   display: block;
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #F0FFF4, #E6FFF2);
   border-radius: 12rpx;
-  border-left: 8rpx solid #10B981;
+  border-left: 8rpx solid $dim-action;
   margin-top: 16rpx;
 }
 .portrait-tip-text {

+ 6 - 3
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -358,9 +358,12 @@ export default {
   methods: {
     // ===== 工具方法 =====
     getImageUrl: function(path) {
-      if (!path) return ''
-      if (path.indexOf('http://') === 0 || path.indexOf('https://') === 0) return path
-      return config.API_BASE_URL + path
+ cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
+      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)
     },
     checkMemberStatus: function() {
       var that = this

+ 6 - 3
cfc-frontend/pages/activity/index.vue

@@ -376,9 +376,12 @@ export default {
   },
   methods: {
     getImageUrl: function(path) {
-      if (!path) return ''
-      if (path.indexOf('http://') === 0 || path.indexOf('https://') === 0) return path
-      return config.API_BASE_URL + path
+ cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
+      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)
     },
     onTabChange: function(key) {
       this.currentTab = key

+ 5 - 1
cfc-frontend/pages/ai/chat.vue

@@ -498,7 +498,11 @@ export default {
       return (d.getMonth() + 1) + '/' + d.getDate()
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + 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)
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/article-center/article-detail.vue

@@ -205,7 +205,11 @@ export default {
     getImageUrl: function(path) {
       if (!path) return ''
       if (path.indexOf('http://') === 0 || path.indexOf('https://') === 0) return path
-      return config.API_BASE_URL + 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)
     },
     resolveContentUrls: function(html) {
       if (!html) return html

+ 5 - 1
cfc-frontend/pages/article-center/index.vue

@@ -317,7 +317,11 @@ onLoad(options) {
       uni.navigateBack()
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + 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)
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/article-center/my-posts.vue

@@ -120,7 +120,11 @@ export default {
       return 'status-draft'
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + 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/assessment/execution.vue

@@ -15,7 +15,7 @@
     <view v-else-if="execution" class="execution-detail">
       <!-- 状态卡片 -->
       <view class="status-card">
-        <view class="status-icon" :class="getStatusClass(execution)">
+        <view class="status-icon" :class="execution.userConfirmed === 1 ? 'completed' : (execution.assessorStatus === 'completed' && (!execution.plannerId || execution.plannerStatus === 'completed') ? 'ready' : 'pending')">
           <view class="icon-circle"></view>
           <view v-if="getStatusClass(execution) === 'completed'" class="icon-check"></view>
           <view v-else-if="getStatusClass(execution) === 'ready'" class="icon-alert"></view>

+ 5 - 1
cfc-frontend/pages/body-detail/dimension-detail.vue

@@ -329,7 +329,11 @@ export default {
       }
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + 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)
     }
   }
 }

+ 67 - 65
cfc-frontend/pages/body-detail/index.vue

@@ -102,6 +102,21 @@
       <text class="guidance-text">{{ monthlyGuidance }}</text>
     </view>
 
+    <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
+    <DimensionVisitorCard
+      v-if="!isLoggedIn"
+      dimCode="body"
+      card-title="身体维度 · 全家健康管理"
+      :benefits="[
+        '身高体重 / 血压 / 心率趋势一目了然',
+        '肠道菌群健康与疾病风险预警',
+        '家庭成员健康画像对比'
+      ]"
+      cta-text="立即登录查看健康概览"
+      secondary-text="查看健康报告"
+      secondary-url="/pages/health/report-list"
+      preview-text="登录后展示你的健康概览 · 趋势曲线" />
+
     <!-- 已登录用户区域 -->
     <view v-if="isLoggedIn">
       <!-- 身体指标卡片 -->
@@ -212,20 +227,6 @@
       </view>
     </view>
 
-    <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
-<DimensionIntroCard
-  v-if="!isLoggedIn"
-  dimCode="body"
-  card-title="身体维度 · 全家健康管理"
-  :benefits="[
-    '身高体重 / 血压 / 心率趋势一目了然',
-    '肠道菌群健康与疾病风险预警',
-    '家庭成员健康画像对比'
-  ]"
-  cta-text="立即登录查看健康概览"
-  secondary-text="查看健康报告"
-  secondary-url="/pages/health/report-list" />
-
     <view class="bottom-spacer"></view>
     <FloatingAvatar />
   </view>
@@ -238,6 +239,7 @@ import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 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 {
@@ -255,7 +257,7 @@ import {
 } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
   data() {
     return {
       isLoggedIn: false,
@@ -622,17 +624,17 @@ export default {
 }
 </script>
 
-<style scoped>
+<style scoped lang="scss">
 .body-container {
   min-height: 100vh;
-  background-color: #F5F7FA;
+  background-color: $app-bg;
   display: flex;
   flex-direction: column;
 }
 
 /* ─── 健康概览卡片 ─── */
 .health-overview-card {
-  background-color: #FFFFFF;
+  background-color: $app-surface;
   border-radius: 24rpx;
   margin: 20rpx 20rpx 12rpx;
   box-shadow: 0 4rpx 16rpx rgba(255, 140, 66, 0.12);
@@ -647,14 +649,14 @@ export default {
 .overview-title {
   font-size: 32rpx;
   font-weight: 700;
-  color: #FF8C42;
+  color: $dim-body;
   margin-bottom: 4rpx;
   display: block;
 }
 
 .overview-sub {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
 }
 
 .overview-grid {
@@ -681,7 +683,7 @@ export default {
   width: 52rpx;
   height: 52rpx;
   border-radius: 50%;
-  background: linear-gradient(135deg, #FF8C42, #FFB366);
+  background: linear-gradient(135deg, $dim-body, #FFB366);
   display: flex;
   align-items: center;
   justify-content: center;
@@ -699,7 +701,7 @@ export default {
 
 .overview-name {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   display: block;
   margin-bottom: 4rpx;
 }
@@ -707,12 +709,12 @@ export default {
 .overview-value {
   font-size: 40rpx;
   font-weight: 700;
-  color: #FF8C42;
+  color: $dim-body;
 }
 
 .overview-placeholder {
   font-size: 22rpx;
-  color: #ccc;
+  color: $app-border;
 }
 
 .overview-trend {
@@ -729,11 +731,11 @@ export default {
 
 .trend-up { color: #10B981; }
 .trend-down { color: #FF6B6B; }
-.trend-stable { color: #999; }
+.trend-stable { color: $app-text-muted; }
 
 .trend-desc {
   font-size: 18rpx;
-  color: #888;
+  color: $app-text-muted;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -751,14 +753,14 @@ export default {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: linear-gradient(135deg, #FF8C42, #FF6B1A);
+  background: linear-gradient(135deg, $dim-body, #FF6B1A);
   border-radius: 48rpx;
   padding: 20rpx 0;
   box-shadow: 0 4rpx 16rpx rgba(255, 107, 26, 0.35);
 }
 
 .overview-nav-btn-text {
-  color: #FFFFFF;
+  color: $app-surface;
   font-size: 26rpx;
   font-weight: 600;
 }
@@ -768,14 +770,14 @@ export default {
   display: flex;
   align-items: center;
   justify-content: center;
-  background-color: #FFFFFF;
+  background-color: $app-surface;
   border-radius: 48rpx;
   padding: 20rpx 0;
-  border: 2rpx solid #FF8C42;
+  border: 2rpx solid $dim-body;
 }
 
 .overview-nav-btn-secondary-text {
-  color: #FF8C42;
+  color: $dim-body;
   font-size: 26rpx;
   font-weight: 600;
 }
@@ -785,7 +787,7 @@ export default {
 .indicator-section,
 .gut-section,
 .risk-section {
-  background-color: #FFFFFF;
+  background-color: $app-surface;
   border-radius: 24rpx;
   margin: 0 20rpx 16rpx;
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
@@ -796,19 +798,19 @@ export default {
   display: flex;
   align-items: center;
   padding: 24rpx 24rpx 16rpx;
-  border-bottom: 1rpx solid #F5F5F5;
-  border-left: 8rpx solid #FF8C42;
+  border-bottom: 1rpx solid $app-surface-subtle;
+  border-left: 8rpx solid $dim-body;
 }
 
 .section-title {
   font-size: 30rpx;
   font-weight: 600;
-  color: #333;
+  color: $app-text;
 }
 
 .section-more {
   font-size: 24rpx;
-  color: #FF8C42;
+  color: $dim-body;
   font-weight: 500;
   margin-left: auto;
   padding-left: 16rpx;
@@ -817,7 +819,7 @@ export default {
 .avg-badge {
   display: block;
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   padding: 0 24rpx 16rpx;
 }
 
@@ -843,7 +845,7 @@ export default {
 }
 
 .indicator-card.no-data {
-  background: #F8F8F8;
+  background: $app-surface-subtle;
   border-color: #eee;
 }
 
@@ -856,12 +858,12 @@ export default {
 
 .indicator-name {
   font-size: 24rpx;
-  color: #666;
+  color: $app-text-secondary;
 }
 
 .indicator-unit {
   font-size: 20rpx;
-  color: #999;
+  color: $app-text-muted;
 }
 
 .indicator-body {
@@ -871,23 +873,23 @@ export default {
 .indicator-value {
   font-size: 40rpx;
   font-weight: 700;
-  color: #FF8C42;
+  color: $dim-body;
 }
 
 .indicator-placeholder {
   font-size: 24rpx;
-  color: #ccc;
+  color: $app-border;
 }
 
 .indicator-footer {
   font-size: 20rpx;
-  color: #999;
+  color: $app-text-muted;
   text-align: right;
 }
 
 /* ─── 肠道菌群 ─── */
 .gut-section {
-  background-color: #FFFFFF;
+  background-color: $app-surface;
   border-radius: 24rpx;
   margin: 0 20rpx 16rpx;
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
@@ -903,7 +905,7 @@ export default {
 
 .gut-summary-text {
   font-size: 26rpx;
-  color: #FF8C42;
+  color: $dim-body;
   font-weight: 500;
 }
 
@@ -917,7 +919,7 @@ export default {
 
 .gut-group-title {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   margin-bottom: 12rpx;
   display: block;
 }
@@ -932,13 +934,13 @@ export default {
   display: flex;
   align-items: center;
   padding: 10rpx 16rpx;
-  background-color: #F8F8F8;
+  background-color: $app-surface-subtle;
   border-radius: 16rpx;
   font-size: 22rpx;
 }
 
 .gut-item-name {
-  color: #333;
+  color: $app-text;
   flex: 1;
   max-width: 120rpx;
   overflow: hidden;
@@ -953,7 +955,7 @@ export default {
 
 .gut-item-value {
   font-size: 20rpx;
-  color: #333;
+  color: $app-text;
   font-weight: 500;
 }
 
@@ -969,19 +971,19 @@ export default {
 .gut-empty-hint {
   padding: 40rpx 24rpx;
   text-align: center;
-  color: #999;
+  color: $app-text-muted;
   font-size: 24rpx;
 }
 
 .gut-upload-link {
-  color: #FF8C42;
+  color: $dim-body;
   margin-left: 8rpx;
   font-weight: 500;
 }
 
 /* ─── 疾病风险 ─── */
 .risk-section {
-  background-color: #FFFFFF;
+  background-color: $app-surface;
   border-radius: 24rpx;
   margin: 0 20rpx 16rpx;
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
@@ -997,7 +999,7 @@ export default {
   justify-content: space-between;
   align-items: center;
   padding: 16rpx 0;
-  border-bottom: 1rpx solid #f5f5f5;
+  border-bottom: 1rpx solid $app-surface-subtle;
 }
 
 .risk-item:last-child {
@@ -1006,7 +1008,7 @@ export default {
 
 .risk-name {
   font-size: 26rpx;
-  color: #333;
+  color: $app-text;
 }
 
 .risk-right {
@@ -1026,13 +1028,13 @@ export default {
 }
 
 .risk-severe .risk-level { color: #FF6B6B; }
-.risk-attention .risk-level { color: #FF8C42; }
-.risk-normal .risk-level { color: #999; }
+.risk-attention .risk-level { color: $dim-body; }
+.risk-normal .risk-level { color: $app-text-muted; }
 
 /* ─── 月引导语 ─── */
 .dimension-portrait-section {
   margin: 20rpx 30rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 24rpx;
   padding: 28rpx 24rpx;
   box-shadow: 0 4rpx 20rpx rgba(255, 140, 66, 0.12);
@@ -1040,7 +1042,7 @@ export default {
 }
 .portrait-tier {
   font-size: 26rpx;
-  color: #FF8C42;
+  color: $dim-body;
   font-weight: bold;
   margin-left: 16rpx;
 }
@@ -1049,7 +1051,7 @@ export default {
 }
 .portrait-desc {
   font-size: 26rpx;
-  color: #666;
+  color: $app-text-secondary;
   line-height: 1.6;
   display: block;
 }
@@ -1076,13 +1078,13 @@ export default {
 }
 .portrait-dim-name {
   font-size: 26rpx;
-  color: #333;
+  color: $app-text;
   flex: 1;
 }
 .portrait-dim-score {
   font-size: 28rpx;
   font-weight: bold;
-  color: #FF8C42;
+  color: $dim-body;
   margin-right: 16rpx;
 }
 .portrait-dim-bar-track {
@@ -1099,14 +1101,14 @@ export default {
 }
 .portrait-dim-desc {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   display: block;
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #FFF7ED, #FFF0E4);
   border-radius: 12rpx;
-  border-left: 8rpx solid #FF8C42;
+  border-left: 8rpx solid $dim-body;
 }
 .portrait-tip-text {
   font-size: 24rpx;
@@ -1130,7 +1132,7 @@ export default {
 
 .guidance-text {
   font-size: 24rpx;
-  color: #FF8C42;
+  color: $dim-body;
 }
 
 /* ─── 品牌定位语(登录前) ─── */
@@ -1142,7 +1144,7 @@ export default {
 }
 .brand-strap-text {
   font-size: 26rpx;
-  color: #FF8C42;
+  color: $dim-body;
   font-weight: 600;
 }
 

+ 7 - 3
cfc-frontend/pages/child/tasks.vue

@@ -56,7 +56,7 @@
               <view class="task-title">{{ task.title }}</view>
               <view class="task-meta">
                 <BaseBadge variant="warning" size="xs">
-                  <text class="points-text">+{{ task.points }} ⭐</text>
+                  <text class="points-text">+{{ task.points }} CF</text>
                 </BaseBadge>
                 <text
                   class="task-time"
@@ -250,8 +250,12 @@ export default {
       this.completingTaskId = taskId
       try {
         res = await completeTaskApi(taskId, this.currentChildId, '')
-        points = res.data.pointsEarned
-        uni.showToast({ title: '获得 ' + points + ' 积分', icon: 'success' })
+        if (res.data && res.data.needReview) {
+          uni.showToast({ title: '已提交,待审核', icon: 'none' })
+        } else {
+          points = res.data.pointsEarned
+          uni.showToast({ title: '获得 ' + points + ' CF', icon: 'success' })
+        }
         setTimeout(() => {
           this.completingTaskId = null
           this.loadData()

+ 6 - 9
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="getSpecOptionClass(group.groupId, option)"
+              :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')"
               @click="selectSpec(group.groupId, option)"
             >
               <text class="spec-option-text">{{ option.name }}</text>
@@ -348,9 +348,12 @@ export default {
       })
     },
     getImageUrl: function(path) {
-      if (!path) return '/static/default-product.png'
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return '/static/default-product.png'
       if (path.startsWith('http') || path.startsWith('/static/')) return path
-      return config.API_BASE_URL + 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)
     },
     onSwiperImageError: function(idx) {
       // 单张图片加载失败时,替换为占位图
@@ -521,12 +524,6 @@ 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' })
     },

+ 5 - 1
cfc-frontend/pages/discover/index.vue

@@ -229,7 +229,11 @@ export default {
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + 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)
     },
     goKnowledgeCenter() {
       uni.navigateTo({ url: '/pages/article-center/index' })

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

@@ -217,7 +217,7 @@
               <view class="task-preview-left">
                 <text class="task-preview-name">{{ task.title }}</text>
                 <view class="task-preview-meta">
-                  <text class="task-preview-points">+{{ task.points }}分</text>
+                  <text class="task-preview-points">+{{ task.points }}CF</text>
                   <text class="task-preview-deadline" v-if="task.deadline">⏱ {{ formatTaskTime(task.deadline) }}</text>
                 </view>
               </view>

+ 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)

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

@@ -273,9 +273,13 @@ export default {
     formatPrice: function(cents) {
       if (cents === null || cents === undefined) return '¥0'
       return '¥' + (cents / 100).toFixed(2)
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

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

@@ -318,9 +318,13 @@ 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) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

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

@@ -207,9 +207,13 @@ 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) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

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

@@ -154,9 +154,13 @@ export default {
     },
     closePoster() {
       this.$refs.posterPopup.close()
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

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

@@ -543,15 +543,7 @@ export default {
       uni.navigateTo({ url: url })
     },
     goPlanSummary: function() {
-      var url = '/pages/health/health-plan-summary'
-      if (this.lastPlanId) {
-        url += '?planId=' + this.lastPlanId
-      }
-      var mid = this.selectedMemberId
-      if (mid) {
-        url += (url.indexOf('?') >= 0 ? '&' : '?') + 'memberId=' + mid
-      }
-      uni.navigateTo({ url: url })
+      uni.navigateTo({ url: '/pages/health/plan-list' })
     },
     loadHealthData: function() {
       var self = this

+ 329 - 0
cfc-frontend/pages/health/components/BasicVitalsStep.vue

@@ -0,0 +1,329 @@
+<template>
+  <view class="vitals-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">身高 (cm)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': heightQuick === 'normal' }"
+            @click="setHeightQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': heightQuick === 'high' }"
+            @click="setHeightQuick('high')"
+          ><text>偏高</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': heightQuick === 'low' }"
+            @click="setHeightQuick('low')"
+          ><text>偏低</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.heightCm" placeholder="如 170"
+             @input="onInput" />
+      <text class="unit-hint">单位:厘米</text>
+    </view>
+
+    <!-- 体重 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">体重 (kg)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': weightQuick === 'normal' }"
+            @click="setWeightQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': weightQuick === 'high' }"
+            @click="setWeightQuick('high')"
+          ><text>偏重</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': weightQuick === 'low' }"
+            @click="setWeightQuick('low')"
+          ><text>偏轻</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.weightKg" placeholder="如 60"
+             @input="onInput" />
+      <text class="unit-hint">单位:公斤</text>
+    </view>
+
+    <!-- 血压 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">血压</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': bpQuick === 'normal' }"
+            @click="setBpQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': bpQuick === 'high' }"
+            @click="setBpQuick('high')"
+          ><text>偏高</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': bpQuick === 'low' }"
+            @click="setBpQuick('low')"
+          ><text>偏低</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="text" v-model="inner.bloodPressure" placeholder="如 120/80"
+             @input="onInput" />
+      <text class="unit-hint">格式:收缩压/舒张压 (mmHg)</text>
+    </view>
+
+    <!-- 血糖 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">空腹血糖 (mmol/L)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': glucoseQuick === 'normal' }"
+            @click="setGlucoseQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': glucoseQuick === 'high' }"
+            @click="setGlucoseQuick('high')"
+          ><text>偏高</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': glucoseQuick === 'low' }"
+            @click="setGlucoseQuick('low')"
+          ><text>偏低</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.bloodGlucose" placeholder="如 5.6"
+             @input="onInput" />
+    </view>
+
+    <!-- 血脂 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">总胆固醇 (mmol/L)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': lipidQuick === 'normal' }"
+            @click="setLipidQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': lipidQuick === 'high' }"
+            @click="setLipidQuick('high')"
+          ><text>偏高</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.lipidCholesterol" placeholder="如 4.5"
+             @input="onInput" />
+    </view>
+
+    <!-- 其他补充 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">其他补充(选填)</text>
+      </view>
+      <textarea class="notes-area" v-model="inner.notes" placeholder="其他想补充的健康信息"
+                :maxlength="500" @input="onInput" />
+    </view>
+  </view>
+</template>
+
+<script>
+// 快捷标签 → 参考数值(近似参考值,点击后填入输入框,用户可继续手改)
+var QUICK_VALUES = {
+  height: { normal: '170', high: '185', low: '155' },
+  weight: { normal: '60', high: '80', low: '45' },
+  bloodPressure: { normal: '120/80', high: '140/90', low: '90/60' },
+  bloodGlucose: { normal: '5.0', high: '7.0', low: '3.5' },
+  lipid: { normal: '4.5', high: '6.2' }
+}
+
+function matchQuick(map, val) {
+  if (val === '' || val === null || val === undefined) return ''
+  var v = String(val)
+  for (var k in map) {
+    if (Object.prototype.hasOwnProperty.call(map, k) && map[k] === v) return k
+  }
+  return ''
+}
+
+export default {
+  name: 'BasicVitalsStep',
+  props: {
+    value: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data() {
+    return {
+      inner: {
+        heightCm: '',
+        weightKg: '',
+        bloodPressure: '',
+        bloodGlucose: '',
+        lipidCholesterol: '',
+        notes: ''
+      }
+    }
+  },
+  computed: {
+    heightQuick: function() { return matchQuick(QUICK_VALUES.height, this.inner.heightCm) },
+    weightQuick: function() { return matchQuick(QUICK_VALUES.weight, this.inner.weightKg) },
+    bpQuick: function() { return matchQuick(QUICK_VALUES.bloodPressure, this.inner.bloodPressure) },
+    glucoseQuick: function() { return matchQuick(QUICK_VALUES.bloodGlucose, this.inner.bloodGlucose) },
+    lipidQuick: function() { return matchQuick(QUICK_VALUES.lipid, this.inner.lipidCholesterol) }
+  },
+  watch: {
+    value: {
+      deep: true,
+      handler: function(val) {
+        this.syncFromValue(val)
+      }
+    }
+  },
+  created: function() {
+    this.syncFromValue(this.value)
+  },
+  methods: {
+    syncFromValue: function(val) {
+      if (!val) return
+      this.inner.heightCm = val.heightCm != null ? String(val.heightCm) : ''
+      this.inner.weightKg = val.weightKg != null ? String(val.weightKg) : ''
+      this.inner.bloodPressure = val.bloodPressure != null ? String(val.bloodPressure) : ''
+      this.inner.bloodGlucose = val.bloodGlucose != null ? String(val.bloodGlucose) : ''
+      this.inner.lipidCholesterol = val.lipidCholesterol != null ? String(val.lipidCholesterol) : ''
+      this.inner.notes = val.notes != null ? String(val.notes) : ''
+    },
+    onInput: function() {
+      var payload = {
+        heightCm: this.inner.heightCm,
+        weightKg: this.inner.weightKg,
+        bloodPressure: this.inner.bloodPressure,
+        bloodGlucose: this.inner.bloodGlucose,
+        lipidCholesterol: this.inner.lipidCholesterol,
+        notes: this.inner.notes
+      }
+      this.$emit('input', payload)
+    },
+    // 快捷选择:点击标签把对应参考数值填入输入框(用户可继续手改)
+    setHeightQuick: function(k) {
+      this.inner.heightCm = QUICK_VALUES.height[k] || ''
+      this.onInput()
+    },
+    setWeightQuick: function(k) {
+      this.inner.weightKg = QUICK_VALUES.weight[k] || ''
+      this.onInput()
+    },
+    setBpQuick: function(k) {
+      this.inner.bloodPressure = QUICK_VALUES.bloodPressure[k] || ''
+      this.onInput()
+    },
+    setGlucoseQuick: function(k) {
+      this.inner.bloodGlucose = QUICK_VALUES.bloodGlucose[k] || ''
+      this.onInput()
+    },
+    setLipidQuick: function(k) {
+      this.inner.lipidCholesterol = QUICK_VALUES.lipid[k] || ''
+      this.onInput()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.vitals-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;
+}
+.quick-chips {
+  display: flex;
+  flex-wrap: wrap;
+}
+.quick-chip {
+  padding: 8rpx 20rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 22rpx;
+  color: #64748B;
+  margin-left: 10rpx;
+  margin-bottom: 6rpx;
+}
+.quick-chip-on {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.num-input {
+  height: 80rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  background: #F8FAFC;
+}
+.unit-hint {
+  display: block;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 10rpx;
+}
+.notes-area {
+  width: 100%;
+  min-height: 160rpx;
+  background: #F8FAFC;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 16rpx;
+  font-size: 26rpx;
+  color: #333;
+}
+</style>

+ 264 - 0
cfc-frontend/pages/health/components/ConfirmStep.vue

@@ -0,0 +1,264 @@
+<template>
+  <view class="confirm-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>
+      </view>
+      <view class="preview-row" v-if="healthStatus.heightCm">
+        <text class="preview-label">身高</text>
+        <text class="preview-value">{{ healthStatus.heightCm }} cm</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.weightKg">
+        <text class="preview-label">体重</text>
+        <text class="preview-value">{{ healthStatus.weightKg }} kg</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.bloodPressure">
+        <text class="preview-label">血压</text>
+        <text class="preview-value">{{ healthStatus.bloodPressure }}</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.bloodGlucose">
+        <text class="preview-label">空腹血糖</text>
+        <text class="preview-value">{{ healthStatus.bloodGlucose }} mmol/L</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.lipidCholesterol">
+        <text class="preview-label">总胆固醇</text>
+        <text class="preview-value">{{ healthStatus.lipidCholesterol }} mmol/L</text>
+      </view>
+    </view>
+
+    <!-- 疾病史 -->
+    <view class="form-card" v-if="healthStatus.selectedDiseases && healthStatus.selectedDiseases.length > 0">
+      <view class="card-head">
+        <text class="card-title">疾病史</text>
+      </view>
+      <view class="tag-wrap">
+        <view v-for="(d, i) in healthStatus.selectedDiseases" :key="getKey(d.name, i)" class="tag-item">
+          <text>{{ d.name }}</text>
+          <text v-if="d.diagnosedAt" class="tag-date">{{ d.diagnosedAt }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 用药 -->
+    <view class="form-card" v-if="hasMeds">
+      <view class="card-head">
+        <text class="card-title">在服药物/治疗</text>
+      </view>
+      <view v-for="(med, i) in medRows" :key="getKey(med.name, i)" class="med-line">
+        <text class="med-name">{{ med.name }}</text>
+        <text v-if="med.durationText" class="med-duration">{{ med.durationText }}</text>
+      </view>
+    </view>
+
+    <!-- 饮食习惯 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">饮食习惯</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.allergies && dietPreferences.allergies.length > 0">
+        <text class="preview-label">过敏原</text>
+        <text class="preview-value">{{ dietPreferences.allergies.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.absoluteAvoid && dietPreferences.absoluteAvoid.length > 0">
+        <text class="preview-label">绝对忌口</text>
+        <text class="preview-value">{{ dietPreferences.absoluteAvoid.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.mustEat && dietPreferences.mustEat.length > 0">
+        <text class="preview-label">必吃</text>
+        <text class="preview-value">{{ dietPreferences.mustEat.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.flavorPref && dietPreferences.flavorPref.length > 0">
+        <text class="preview-label">口味</text>
+        <text class="preview-value">{{ dietPreferences.flavorPref.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.cuisinePref && dietPreferences.cuisinePref.length > 0">
+        <text class="preview-label">菜系</text>
+        <text class="preview-value">{{ dietPreferences.cuisinePref.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.healthGoals && dietPreferences.healthGoals.length > 0">
+        <text class="preview-label">健康目标</text>
+        <text class="preview-value">{{ dietPreferences.healthGoals.join('、') }}</text>
+      </view>
+      <text class="empty-hint" v-if="!hasDiet && noPreference">无特殊偏好</text>
+      <text class="empty-hint" v-else-if="!hasDiet">未填写饮食习惯</text>
+    </view>
+
+    <!-- 保存并生成 -->
+    <view class="btn-pair">
+      <button class="btn-prev" @click="goBack">上一步</button>
+      <button class="btn-generate" :disabled="submitting" @click="onSubmit">
+        {{ submitting ? '保存中...' : '保存并生成方案' }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'ConfirmStep',
+  props: {
+    healthStatus: {
+      type: Object,
+      default: function() { return {} }
+    },
+    dietPreferences: {
+      type: Object,
+      default: function() { return {} }
+    },
+    submitting: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    medRows: function() {
+      var rows = this.healthStatus.medRows || []
+      return rows.filter(function(m) { return m && m.name })
+    },
+    hasMeds: function() {
+      return this.medRows.length > 0
+    },
+    hasDiet: function() {
+      var d = this.dietPreferences
+      return !!(d &&
+        ((d.allergies && d.allergies.length > 0) ||
+        (d.absoluteAvoid && d.absoluteAvoid.length > 0) ||
+        (d.mustEat && d.mustEat.length > 0) ||
+        (d.flavorPref && d.flavorPref.length > 0) ||
+        (d.healthGoals && d.healthGoals.length > 0)))
+    },
+    noPreference: function() {
+      return !!(this.dietPreferences && this.dietPreferences.noPreference)
+    }
+  },
+  methods: {
+    getKey: function(name, i) {
+      return 'k' + (name || '') + i
+    },
+    goBack: function() {
+      this.$emit('back')
+    },
+    onSubmit: function() {
+      this.$emit('submit', {
+        healthStatus: this.healthStatus,
+        dietPreferences: this.dietPreferences
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.confirm-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;
+}
+.preview-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12rpx 0;
+  border-bottom: 2rpx solid #F8FAFC;
+}
+.preview-label {
+  font-size: 26rpx;
+  color: #64748B;
+}
+.preview-value {
+  font-size: 26rpx;
+  color: #334155;
+  font-weight: 500;
+}
+.tag-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.tag-item {
+  padding: 10rpx 20rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 24rpx;
+  color: #334155;
+  margin: 0 14rpx 14rpx 0;
+}
+.tag-date {
+  color: #94A3B8;
+  margin-left: 8rpx;
+}
+.med-line {
+  display: flex;
+  justify-content: space-between;
+  padding: 10rpx 0;
+}
+.med-name {
+  font-size: 26rpx;
+  color: #334155;
+}
+.med-duration {
+  font-size: 24rpx;
+  color: #94A3B8;
+}
+.empty-hint {
+  display: block;
+  font-size: 24rpx;
+  color: #94A3B8;
+  padding: 10rpx 0;
+}
+.btn-pair {
+  display: flex;
+  padding: 20rpx 0;
+}
+.btn-prev,
+.btn-generate {
+  flex: 1;
+  margin: 0 10rpx;
+  border-radius: 999rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.btn-prev {
+  background: #F1F5F9;
+  color: #334155;
+}
+.btn-generate {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+</style>

+ 451 - 0
cfc-frontend/pages/health/components/DietHabitsStep.vue

@@ -0,0 +1,451 @@
+<template>
+  <view class="diet-step">
+    <view class="step-title-row">
+      <text class="step-title">饮食习惯</text>
+      <text class="step-sub">忌口与偏好,选得越细,方案越贴合</text>
+    </view>
+
+    <!-- 无特殊偏好:一键清空本节并直接进入确认步 -->
+    <view class="pref-none-row">
+      <button
+        class="pref-none-btn"
+        :class="{ 'pref-none-btn-active': inner.noPreference }"
+        @click="setNoPreference"
+      >{{ 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="tag-wrap">
+        <view
+          v-for="(item, i) in presetAllergies"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.allergies.indexOf(item) >= 0 }"
+          @click="toggleChip('allergies', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+      <view class="custom-row">
+        <input class="custom-input" type="text" v-model="customInput" placeholder="输入其他过敏原"
+               confirm-type="done" @confirm="addCustom('allergies')" />
+        <text class="add-link" @click="addCustom('allergies')">添加</text>
+      </view>
+    </view>
+
+    <!-- 绝对忌口 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">绝对忌口</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetAvoid"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.absoluteAvoid.indexOf(item) >= 0 }"
+          @click="toggleChip('absoluteAvoid', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+      <view class="custom-row">
+        <input class="custom-input" type="text" v-model="customInput" placeholder="输入其他忌口"
+               confirm-type="done" @confirm="addCustom('absoluteAvoid')" />
+        <text class="add-link" @click="addCustom('absoluteAvoid')">添加</text>
+      </view>
+    </view>
+
+    <!-- 必吃 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">必吃食物</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetMustEat"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.mustEat.indexOf(item) >= 0 }"
+          @click="toggleChip('mustEat', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 宗教饮食 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">宗教饮食</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(opt, i) in religiousOptions"
+          :key="opt.value"
+          class="tag-item"
+          :class="{ 'tag-active': inner.religiousDiet === opt.value }"
+          @click="setReligious(opt.value)"
+        >
+          <text>{{ opt.label }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 辣度 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">辣度偏好</text>
+      </view>
+      <view class="slider-row">
+        <text class="slider-label">不辣</text>
+        <slider class="slider" min="0" max="5" :value="inner.spiceLevel || 0"
+                @change="onSpiceChange" show-value />
+        <text class="slider-label">特辣</text>
+      </view>
+      <text class="spice-label">当前:{{ spiceLabels[inner.spiceLevel] || '不辣' }}</text>
+    </view>
+
+    <!-- 口味 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">口味偏好</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetFlavor"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.flavorPref.indexOf(item) >= 0 }"
+          @click="toggleChip('flavorPref', item)"
+        >
+          <text>{{ item }}</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="tag-wrap">
+        <view
+          v-for="(item, i) in presetCuisine"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.cuisinePref.indexOf(item) >= 0 }"
+          @click="toggleChip('cuisinePref', item)"
+        >
+          <text>{{ item }}</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="tag-wrap">
+        <view
+          v-for="(item, i) in presetCooking"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.cookingMethods.indexOf(item) >= 0 }"
+          @click="toggleChip('cookingMethods', item)"
+        >
+          <text>{{ item }}</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="tag-wrap">
+        <view
+          v-for="(item, i) in presetGoals"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.healthGoals.indexOf(item) >= 0 }"
+          @click="toggleChip('healthGoals', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+// 预设选项(与 pages/diet/preferences.vue 保持一致,后端 diet_preferences 表字段对齐)
+var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
+var PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
+var PRESET_FLAVOR = ['酸', '甜', '咸', '鲜', '苦', '清淡', '浓郁']
+var PRESET_CUISINE = ['川', '粤', '鲁', '淮扬', '日料', '西餐', '东南亚', '韩餐']
+var PRESET_COOKING = ['蒸', '煮', '炒', '烤', '炖', '凉拌', '煎', '炸']
+var PRESET_MUSTEAT = ['蔬菜', '水果', '粗粮', '鱼类', '鸡蛋', '豆制品', '菌菇']
+var PRESET_GOALS = ['减脂', '增肌', '控糖', '肠道调理', '增强免疫']
+
+export default {
+  name: 'DietHabitsStep',
+  props: {
+    value: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data() {
+    return {
+      inner: {
+        allergies: [],
+        absoluteAvoid: [],
+        mustEat: [],
+        religiousDiet: 'none',
+        spiceLevel: 0,
+        flavorPref: [],
+        cuisinePref: [],
+        cookingMethods: [],
+        healthGoals: [],
+        noPreference: false
+      },
+      customInput: '',
+      spiceLabels: ['不辣', '微辣', '中辣', '偏辣', '很辣', '特辣']
+    }
+  },
+  computed: {
+    presetAllergies: function() { return PRESET_ALLERGIES },
+    presetAvoid: function() { return PRESET_AVOID },
+    presetFlavor: function() { return PRESET_FLAVOR },
+    presetCuisine: function() { return PRESET_CUISINE },
+    presetCooking: function() { return PRESET_COOKING },
+    presetMustEat: function() { return PRESET_MUSTEAT },
+    presetGoals: function() { return PRESET_GOALS },
+    religiousOptions: function() {
+      return [
+        { value: 'none', label: '无限制' },
+        { value: 'halal', label: '清真' },
+        { value: 'vegetarian', label: '素食' },
+        { value: 'vegan', label: '纯素' }
+      ]
+    }
+  },
+  watch: {
+    value: {
+      deep: true,
+      handler: function(val) {
+        this.syncFromValue(val)
+      }
+    }
+  },
+  created: function() {
+    this.syncFromValue(this.value)
+  },
+  methods: {
+    syncFromValue: function(val) {
+      if (!val) return
+      var self = this
+      var fields = ['allergies', 'absoluteAvoid', 'mustEat', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals']
+      fields.forEach(function(f) {
+        self.inner[f] = Array.isArray(val[f]) ? val[f].slice() : []
+      })
+      self.inner.religiousDiet = val.religiousDiet || 'none'
+      self.inner.spiceLevel = val.spiceLevel != null ? val.spiceLevel : 0
+      self.inner.noPreference = !!val.noPreference
+    },
+    emitInput: function() {
+      this.$emit('input', {
+        allergies: this.inner.allergies.slice(),
+        absoluteAvoid: this.inner.absoluteAvoid.slice(),
+        mustEat: this.inner.mustEat.slice(),
+        religiousDiet: this.inner.religiousDiet,
+        spiceLevel: this.inner.spiceLevel,
+        flavorPref: this.inner.flavorPref.slice(),
+        cuisinePref: this.inner.cuisinePref.slice(),
+        cookingMethods: this.inner.cookingMethods.slice(),
+        healthGoals: this.inner.healthGoals.slice(),
+        noPreference: this.inner.noPreference
+      })
+    },
+    toggleChip: function(field, value) {
+      var list = this.inner[field]
+      if (!list) return
+      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.inner.noPreference = false
+      this.emitInput()
+    },
+    addCustom: function(field) {
+      var value = (this.customInput || '').trim()
+      if (!value) return
+      var list = this.inner[field] || []
+      if (list.indexOf(value) < 0) {
+        this.inner[field] = list.concat([value])
+        this.inner.noPreference = false
+        this.emitInput()
+      }
+      this.customInput = ''
+    },
+    setReligious: function(value) {
+      this.inner.religiousDiet = value
+      this.inner.noPreference = false
+      this.emitInput()
+    },
+    onSpiceChange: function(e) {
+      this.inner.spiceLevel = e.detail.value
+      this.inner.noPreference = false
+      this.emitInput()
+    },
+    // 「无特殊偏好」:清空本节并标记,随后通知父级直接进入确认步
+    setNoPreference: function() {
+      this.inner.allergies = []
+      this.inner.absoluteAvoid = []
+      this.inner.mustEat = []
+      this.inner.religiousDiet = 'none'
+      this.inner.spiceLevel = 0
+      this.inner.flavorPref = []
+      this.inner.cuisinePref = []
+      this.inner.cookingMethods = []
+      this.inner.healthGoals = []
+      this.inner.noPreference = true
+      this.emitInput()
+      this.$emit('skip-no-preference')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.diet-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;
+}
+.pref-none-row {
+  margin-bottom: 20rpx;
+}
+.pref-none-btn {
+  width: 100%;
+  padding: 20rpx 0;
+  border-radius: 16rpx;
+  background: #FFF7ED;
+  color: #F97316;
+  font-size: 28rpx;
+  font-weight: 600;
+  border: 2rpx solid #FED7AA;
+  line-height: 1.4;
+}
+.pref-none-btn-active {
+  background: #F97316;
+  color: #fff;
+  border-color: #F97316;
+}
+.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;
+}
+.tag-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.tag-item {
+  padding: 12rpx 24rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 24rpx;
+  color: #334155;
+  margin: 0 14rpx 14rpx 0;
+}
+.tag-active {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.custom-row {
+  display: flex;
+  align-items: center;
+  margin-top: 10rpx;
+}
+.custom-input {
+  flex: 1;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 20rpx;
+  font-size: 26rpx;
+  background: #F8FAFC;
+}
+.add-link {
+  color: #F97316;
+  font-size: 26rpx;
+  font-weight: 600;
+  padding: 14rpx 10rpx;
+  margin-left: 10rpx;
+}
+.slider-row {
+  display: flex;
+  align-items: center;
+}
+.slider-label {
+  font-size: 24rpx;
+  color: #94A3B8;
+  width: 72rpx;
+}
+.slider {
+  flex: 1;
+}
+.spice-label {
+  display: block;
+  font-size: 24rpx;
+  color: #F97316;
+  text-align: center;
+  margin-top: 8rpx;
+}
+</style>

+ 338 - 0
cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue

@@ -0,0 +1,338 @@
+<template>
+  <view class="wizard-wrap">
+    <!-- 内部步骤指示器(4 步:基础体征/疾病史/饮食习惯/确认) -->
+    <view class="wizard-steps">
+      <view
+        v-for="(s, i) in wizardSteps"
+        :key="i"
+        class="wizard-step"
+        :class="{ 'wizard-step-active': currentStep === i + 1, 'wizard-step-done': currentStep > i + 1 }"
+        @click="goToStep(i + 1)"
+      >
+        <text class="wizard-step-num">{{ i + 1 }}</text>
+        <text class="wizard-step-label">{{ s }}</text>
+      </view>
+    </view>
+
+    <!-- 步骤内容 -->
+    <view class="wizard-body">
+      <!-- Step 1: 基础体征 -->
+      <view v-if="currentStep === 1">
+        <basic-vitals-step
+          ref="stepRef"
+          :value="currentMemberForm"
+          @input="onVitalsInput"
+        />
+      </view>
+
+      <!-- Step 2: 疾病史与用药 -->
+      <view v-if="currentStep === 2">
+        <medical-history-step
+          ref="stepRef"
+          :value="currentMemberForm"
+          @input="onMedicalInput"
+        />
+      </view>
+
+      <!-- Step 3: 饮食习惯 -->
+      <view v-if="currentStep === 3">
+        <diet-habits-step
+          ref="stepRef"
+          :value="currentMemberDiet"
+          @input="onDietInput"
+          @skip-no-preference="onDietSkipNoPreference"
+        />
+      </view>
+
+      <!-- Step 4: 确认 -->
+      <view v-if="currentStep === 4">
+        <confirm-step
+          ref="stepRef"
+          :health-status="currentMemberForm"
+          :diet-preferences="currentMemberDiet"
+          :submitting="submitting"
+          @submit="handleSubmit"
+          @back="prev"
+        />
+      </view>
+    </view>
+
+    <!-- 底部导航:上一步 / 下一步(最后一步为保存并生成) -->
+    <view class="wizard-footer" v-if="currentStep < 4">
+      <button class="btn-prev" :disabled="currentStep === 1" @click="prev">
+        <text>上一步</text>
+      </button>
+      <button class="btn-next" :disabled="validating" @click="next">
+        <text>{{ validating ? '校验中...' : '下一步' }}</text>
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+import BasicVitalsStep from './BasicVitalsStep.vue'
+import MedicalHistoryStep from './MedicalHistoryStep.vue'
+import DietHabitsStep from './DietHabitsStep.vue'
+import ConfirmStep from './ConfirmStep.vue'
+
+export default {
+  name: 'HealthQuestionnaireWizard',
+  components: {
+    BasicVitalsStep,
+    MedicalHistoryStep,
+    DietHabitsStep,
+    ConfirmStep
+  },
+  props: {
+    // 成员表单快照:memberId → { form, lipidCholesterol, selectedDiseases, medRows }
+    memberFormMap: {
+      type: Object,
+      default: function() { return {} }
+    },
+    // 成员饮食习惯快照:memberId → dietPreferences 对象
+    dietMap: {
+      type: Object,
+      default: function() { return {} }
+    },
+    // 当前填写成员
+    activeFormMemberId: {
+      type: String,
+      default: ''
+    },
+    // 提交中(由父组件 loading 驱动,失败后按钮可恢复)
+    submitting: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      currentStep: 1,
+      validating: false
+    }
+  },
+  computed: {
+    wizardSteps: function() {
+      return ['基础体征', '疾病史', '饮食习惯', '确认']
+    },
+    // 当前成员的健康表单(来自父组件快照)
+    currentMemberForm: function() {
+      var map = this.memberFormMap || {}
+      var key = this.activeFormMemberId || '__account'
+      var snapshot = map[key] || {}
+      return {
+        heightCm: snapshot.form && snapshot.form.heightCm || '',
+        weightKg: snapshot.form && snapshot.form.weightKg || '',
+        bloodPressure: snapshot.form && snapshot.form.bloodPressure || '',
+        bloodGlucose: snapshot.form && snapshot.form.bloodGlucose || '',
+        notes: snapshot.form && snapshot.form.notes || '',
+        lipidCholesterol: snapshot.lipidCholesterol || '',
+        selectedDiseases: snapshot.selectedDiseases || [],
+        medRows: snapshot.medRows || [],
+        noMedication: !!snapshot.noMedication
+      }
+    },
+    // 当前成员的饮食习惯
+    currentMemberDiet: function() {
+      var map = this.dietMap || {}
+      var key = this.activeFormMemberId || '__account'
+      return map[key] || {}
+    }
+  },
+  watch: {
+    // 切换填写成员时回到第 1 步(各成员数据快照已保留,不影响已填内容)
+    activeFormMemberId: function() {
+      this.currentStep = 1
+    }
+  },
+  methods: {
+    // —— 步骤导航 ——
+    goToStep: function(n) {
+      if (n < 1 || n > 4) return
+      if (n <= this.currentStep) {
+        // 回退到已完成步骤,无需校验
+        this.currentStep = n
+        return
+      }
+      // 向前跳需逐级校验中间每一步(保证"每步都校验")
+      for (var s = this.currentStep; s < n; s++) {
+        if (!this.validateStep(s)) return
+      }
+      this.currentStep = n
+    },
+    prev: function() {
+      if (this.currentStep > 1) {
+        this.currentStep -= 1
+      }
+    },
+    next: function() {
+      var pass = this.validateStep(this.currentStep)
+      if (!pass) return
+      if (this.currentStep < 4) {
+        this.currentStep += 1
+      }
+    },
+    // —— 每步校验(用户在确认需求时明确要求"每步都校验") ——
+    validateStep: function(step) {
+      var self = this
+      var validatorMap = {
+        1: this.validateVitals,
+        2: this.validateMedical,
+        3: this.validateDiet
+      }
+      var fn = validatorMap[step]
+      if (fn) {
+        var msg = fn.call(this)
+        if (msg) {
+          uni.showToast({ title: msg, icon: 'none' })
+          return false
+        }
+      }
+      return true
+    },
+    // Step1: 体征数值合法性
+    validateVitals: function() {
+      var form = this.currentMemberForm
+      var height = String(form.heightCm || '').trim()
+      var weight = String(form.weightKg || '').trim()
+      var bp = String(form.bloodPressure || '').trim()
+      var glucose = String(form.bloodGlucose || '').trim()
+      if (height && !/^\d{2,3}(\.\d+)?$/.test(height)) {
+        return '身高格式不正确'
+      }
+      if (weight && !/^\d{1,3}(\.\d+)?$/.test(weight)) {
+        return '体重格式不正确'
+      }
+      if (bp && !/^\d{2,3}\/\d{2,3}$/.test(bp)) {
+        return '血压格式应为 120/80'
+      }
+      if (glucose && !/^\d{1,2}(\.\d+)?$/.test(glucose)) {
+        return '血糖格式不正确'
+      }
+      return ''
+    },
+    // Step2: 疾病史至少填写一项,或已标记"无长期用药"
+    validateMedical: function() {
+      var snapshot = this.currentMemberForm
+      if (snapshot.noMedication) return ''
+      var diseases = snapshot.selectedDiseases || []
+      var meds = snapshot.medRows || []
+      var hasMed = meds.some(function(m) { return m && m.name && String(m.name).trim() })
+      if (diseases.length === 0 && !hasMed) {
+        return '请至少填写一项疾病史或药物,如无请勾选"无长期用药"'
+      }
+      return ''
+    },
+    // Step3: 饮食习惯至少填写一项(忌口/偏好/必吃/菜系/烹饪/辣度/宗教饮食/目标任一)
+    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 (!hasAny && !(diet.spiceLevel && Number(diet.spiceLevel) > 0) && !(diet.religiousDiet && diet.religiousDiet !== 'none')) {
+        return '请至少选择一项饮食习惯偏好'
+      }
+      return ''
+    },
+    // —— 子组件数据同步回父组件 ——
+    onVitalsInput: function(payload) {
+      this.$emit('update-vitals', payload)
+    },
+    onMedicalInput: function(payload) {
+      this.$emit('update-medical', payload)
+    },
+    onDietInput: function(payload) {
+      this.$emit('update-diet', payload)
+    },
+    // 第3步「无特殊偏好」:跳过校验直接进入确认步
+    onDietSkipNoPreference: function() {
+      this.currentStep = 4
+    },
+    // —— 确认步骤提交:聚合所有成员数据后交给父组件统一保存+生成 ——
+    handleSubmit: function() {
+      var self = this
+      this.$emit('submit', {
+        memberFormMap: self.memberFormMap,
+        dietMap: self.dietMap,
+        activeFormMemberId: self.activeFormMemberId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.wizard-wrap {
+  display: flex;
+  flex-direction: column;
+  min-height: 600rpx;
+}
+.wizard-steps {
+  display: flex;
+  justify-content: space-between;
+  padding: 20rpx 10rpx 10rpx;
+  background: #fff;
+}
+.wizard-step {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex: 1;
+}
+.wizard-step-num {
+  width: 44rpx;
+  height: 44rpx;
+  border-radius: 22rpx;
+  background: #e0e0e0;
+  color: #999;
+  font-size: 24rpx;
+  font-weight: bold;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.wizard-step-active .wizard-step-num {
+  background: #F97316;
+  color: #fff;
+}
+.wizard-step-done .wizard-step-num {
+  background: #4CAF50;
+  color: #fff;
+}
+.wizard-step-label {
+  font-size: 20rpx;
+  color: #999;
+  white-space: nowrap;
+  margin-top: 6rpx;
+}
+.wizard-step-active .wizard-step-label,
+.wizard-step-done .wizard-step-label {
+  color: #333;
+  font-weight: 500;
+}
+.wizard-body {
+  flex: 1;
+  padding: 10rpx 20rpx;
+}
+.wizard-footer {
+  display: flex;
+  padding: 20rpx 20rpx 30rpx;
+}
+.btn-prev,
+.btn-next {
+  flex: 1;
+  margin: 0 10rpx;
+  border-radius: 999rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.btn-prev {
+  background: #F1F5F9;
+  color: #334155;
+}
+.btn-next {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+</style>

+ 520 - 0
cfc-frontend/pages/health/components/MedicalHistoryStep.vue

@@ -0,0 +1,520 @@
+<template>
+  <view class="medical-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-count">已选 {{ inner.selectedDiseases.length }} 项</text>
+      </view>
+
+      <view v-for="(group, gi) in diseaseGroups" :key="group.key" class="disease-group">
+        <view class="group-head" @click="toggleGroup(group.key)">
+          <text class="group-title">{{ group.title }}</text>
+          <text class="group-arrow">{{ expandedGroups.indexOf(group.key) >= 0 ? '收起' : '展开' }}</text>
+        </view>
+        <view v-if="expandedGroups.indexOf(group.key) >= 0" class="tag-wrap">
+          <view
+            v-for="(item, ii) in group.items"
+            :key="item.name"
+            class="tag-item"
+            :class="{ 'tag-active': selectedNames.indexOf(item.name) >= 0 }"
+            @click="toggleDisease(item.name)"
+          >
+            <text>{{ item.name }}</text>
+            <text v-if="item.risk" class="risk-badge">★</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 自定义疾病 -->
+      <view class="custom-row">
+        <input class="custom-input" type="text" v-model="diseaseInput" placeholder="输入其他疾病" />
+        <text class="add-link" @click="addCustomDisease">添加</text>
+      </view>
+
+      <!-- 已选疾病 + 确诊时间 -->
+      <view v-if="inner.selectedDiseases.length > 0" class="selected-area">
+        <view class="selected-title">已选疾病</view>
+        <view v-for="(item, si) in inner.selectedDiseases" :key="getSelKey(si)" class="sel-row">
+          <text class="sel-name">{{ item.name }}</text>
+          <picker mode="date" fields="month" :value="item.diagnosedAt || currentMonth" :end="monthEnd" @change="onDiagChange(si, $event)">
+            <view class="sel-diag">{{ item.diagnosedAt ? item.diagnosedAt : '设确诊时间' }}</view>
+          </picker>
+          <text class="sel-del" @click="removeSelected(si)">×</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 在服药物/治疗 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">在服药物/治疗</text>
+        <text class="card-count">{{ inner.medRows.length }} 条</text>
+      </view>
+      <view v-for="(med, idx) in inner.medRows" :key="getMedKey(idx)" class="med-row">
+        <view class="med-line">
+          <input class="med-name" type="text" v-model="med.name" placeholder="药物品牌,如 立普妥"
+                 @input="emitInput" />
+          <text class="med-del" @click="removeMed(idx)">删除</text>
+        </view>
+        <view class="med-line">
+          <picker mode="date" fields="month" :value="med.startDate || currentMonth" :end="monthEnd" @change="onMedStartChange(idx, $event)">
+            <view class="med-picker">{{ med.startDate ? med.startDate : '选择开始日期' }}</view>
+          </picker>
+          <text class="med-forget" @click="onForgetClick(idx)">记不清</text>
+        </view>
+        <view class="med-line" v-if="med.durationText">
+          <text class="med-duration">已服时长:{{ med.durationText }}</text>
+        </view>
+        <view class="med-line">
+          <input class="med-time" type="text" v-model="med.time" placeholder="服药时间,如 早饭后一次"
+                 @input="emitInput" />
+        </view>
+      </view>
+      <text class="add-link" @click="addMed">+ 添加药物/治疗</text>
+    </view>
+
+    <!-- 快捷:无长期用药 -->
+    <view class="form-card">
+      <view
+        class="quick-chip"
+        :class="{ 'quick-chip-on': noMedication }"
+        @click="toggleNoMedication"
+      >
+        <text>{{ noMedication ? '✓ 已选' : '无长期用药' }}</text>
+      </view>
+      <text class="unit-hint" v-if="noMedication">已标记无长期用药,可跳过药物填写</text>
+    </view>
+  </view>
+</template>
+
+<script>
+// 预置疾病清单(与 health-plan-summary.vue 保持一致的 8 组)
+var DISEASE_GROUPS = [
+  { key: 'cardio', title: '心血管代谢', items: [
+    { name: '高血压', risk: false },
+    { name: '糖尿病', risk: false },
+    { name: '血脂异常', risk: false },
+    { name: '冠心病', risk: false },
+    { name: '脑卒中', risk: false },
+    { name: '心力衰竭', risk: false },
+    { name: '心房颤动', risk: false },
+    { name: '慢阻肺', risk: false },
+    { name: '心脑血管疾病', risk: true },
+    { name: 'II型糖尿病', risk: true },
+    { name: '肥胖', risk: true },
+    { name: '超重', risk: false },
+    { name: '高尿酸血症', risk: false },
+    { name: '骨质疏松', risk: false }
+  ]},
+  { key: 'digest', title: '消化系统', items: [
+    { name: '幽门螺杆菌感染', risk: false },
+    { name: '脂肪肝', risk: false },
+    { name: '慢性胃炎', risk: false },
+    { name: '胃食管反流', risk: false },
+    { name: '肠易激综合征', risk: true },
+    { name: '炎症性肠炎', risk: true },
+    { name: '慢性便秘', risk: true },
+    { name: '感染性腹泻', risk: true },
+    { name: '胆结石', risk: false },
+    { name: '炎症性肠病', risk: false },
+    { name: '肝病', risk: true },
+    { name: '胃病', risk: true },
+    { name: '胆病', risk: true },
+    { name: '肾病', risk: true },
+    { name: '腹胀', risk: true }
+  ]},
+  { key: 'immune', title: '过敏免疫', items: [
+    { name: '过敏性鼻炎', risk: true },
+    { name: '哮喘', risk: false },
+    { name: '湿疹', risk: false },
+    { name: '食物过敏', risk: false },
+    { name: '自体免疫病', risk: true }
+  ]},
+  { key: 'neuro', title: '精神神经', items: [
+    { name: '抑郁症', risk: true },
+    { name: '失眠', risk: true },
+    { name: '焦虑障碍', risk: false },
+    { name: '偏头痛', risk: false },
+    { name: '自闭症', risk: true }
+  ]},
+  { key: 'resp', title: '呼吸系统', items: [
+    { name: '肺部感染或疾病', risk: true },
+    { name: '肺部疾病', risk: true }
+  ]},
+  { key: 'tumor', title: '肿瘤', items: [
+    { name: '结直肠癌', risk: true },
+    { name: '肺癌', risk: false },
+    { name: '甲状腺癌', risk: false },
+    { name: '肝癌', risk: false },
+    { name: '胃癌', risk: false },
+    { name: '乳腺癌', risk: false },
+    { name: '食管癌', risk: false }
+  ]},
+  { key: 'child', title: '儿童青少年', items: [
+    { name: '近视', risk: false },
+    { name: '龋齿', risk: false },
+    { name: '注意缺陷多动障碍', risk: false },
+    { name: '甲状腺疾病', risk: true }
+  ]},
+  { key: 'other', title: '其他', items: [
+    { name: '缺铁性贫血', risk: false },
+    { name: '贫血', risk: false }
+  ]}
+]
+
+export default {
+  name: 'MedicalHistoryStep',
+  props: {
+    value: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data() {
+    return {
+      diseaseGroups: DISEASE_GROUPS,
+      expandedGroups: [],
+      diseaseInput: '',
+      currentMonth: '',
+      monthEnd: ''
+    }
+  },
+  computed: {
+    inner: function() {
+      return this.value || {}
+    },
+    noMedication: function() {
+      return !!this.inner.noMedication
+    },
+    selectedNames: function() {
+      var list = this.inner.selectedDiseases || []
+      return list.map(function(d) { return d.name })
+    }
+  },
+  created: function() {
+    var now = new Date()
+    var y = now.getFullYear()
+    var m = now.getMonth() + 1
+    var mStr = m < 10 ? '0' + m : '' + m
+    this.currentMonth = y + '-' + mStr
+    var lastDay = new Date(y, m, 0).getDate()
+    this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
+    // 默认展开前 3 组
+    this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
+    // 已有选中项时展开对应组
+    var self = this
+    ;(this.inner.selectedDiseases || []).forEach(function(d) {
+      var gk = self.findGroupKey(d.name)
+      if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
+    })
+  },
+  methods: {
+    emitInput: function() {
+      this.$emit('input', this.inner)
+    },
+    findGroupKey: function(name) {
+      for (var i = 0; i < DISEASE_GROUPS.length; i++) {
+        var items = DISEASE_GROUPS[i].items
+        for (var j = 0; j < items.length; j++) {
+          if (items[j].name === name) return DISEASE_GROUPS[i].key
+        }
+      }
+      return ''
+    },
+    toggleGroup: function(key) {
+      var idx = this.expandedGroups.indexOf(key)
+      if (idx >= 0) this.expandedGroups.splice(idx, 1)
+      else this.expandedGroups.push(key)
+    },
+    toggleDisease: function(name) {
+      var list = this.inner.selectedDiseases || []
+      var idx = -1
+      for (var i = 0; i < list.length; i++) {
+        if (list[i].name === name) { idx = i; break }
+      }
+      var next
+      if (idx >= 0) {
+        next = list.slice(0, idx).concat(list.slice(idx + 1))
+      } else {
+        next = list.concat([{ name: name, diagnosedAt: '', custom: false }])
+      }
+      this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+    },
+    addCustomDisease: function() {
+      var name = (this.diseaseInput || '').trim()
+      if (!name) return
+      var list = this.inner.selectedDiseases || []
+      var exists = list.some(function(d) { return d.name === name })
+      if (!exists) {
+        var next = list.concat([{ name: name, diagnosedAt: '', custom: true }])
+        this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+      }
+      this.diseaseInput = ''
+    },
+    getSelKey: function(i) { return 's' + i },
+    getMedKey: function(i) { return 'm' + i },
+    removeSelected: function(idx) {
+      var list = this.inner.selectedDiseases || []
+      var next = list.slice(0, idx).concat(list.slice(idx + 1))
+      this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+    },
+    onDiagChange: function(idx, e) {
+      var list = this.inner.selectedDiseases || []
+      var item = Object.assign({}, list[idx], { diagnosedAt: e.detail.value })
+      var next = list.slice()
+      next[idx] = item
+      this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+    },
+    addMed: function() {
+      var rows = (this.inner.medRows && this.inner.medRows.length > 0 ? this.inner.medRows.slice() : [])
+      rows.push({ name: '', startDate: '', durationText: '', time: '' })
+      this.$emit('input', Object.assign({}, this.inner, { medRows: rows }))
+    },
+    removeMed: function(idx) {
+      var rows = (this.inner.medRows || []).slice()
+      rows.splice(idx, 1)
+      this.$emit('input', Object.assign({}, this.inner, { medRows: rows }))
+    },
+    onMedStartChange: function(idx, e) {
+      var start = e.detail.value
+      var rows = (this.inner.medRows || []).slice()
+      rows[idx] = Object.assign({}, rows[idx], { startDate: start, durationText: this.calcDuration(start) })
+      this.$emit('input', Object.assign({}, this.inner, { medRows: rows }))
+    },
+    onForgetClick: function(idx) {
+      var self = this
+      uni.showModal({
+        title: '记不清开始日期',
+        editable: true,
+        placeholderText: '如:约 3 个月',
+        success: function(res) {
+          if (res.confirm && res.content) {
+            var rows = (self.inner.medRows || []).slice()
+            rows[idx] = Object.assign({}, rows[idx], { durationText: res.content.trim() })
+            self.$emit('input', Object.assign({}, self.inner, { medRows: rows }))
+          }
+        }
+      })
+    },
+    calcDuration: function(startDate) {
+      if (!startDate) return ''
+      var parts = startDate.split('-')
+      if (parts.length !== 2) return ''
+      var sy = parseInt(parts[0], 10)
+      var sm = parseInt(parts[1], 10)
+      var now = new Date()
+      var months = (now.getFullYear() - sy) * 12 + (now.getMonth() + 1 - sm)
+      if (months < 1) months = 1
+      if (months < 12) return '已服 ' + months + ' 个月'
+      return '已服 ' + Math.floor(months / 12) + ' 年'
+    },
+    toggleNoMedication: function() {
+      this.$emit('input', Object.assign({}, this.inner, { noMedication: !this.noMedication }))
+    }
+  }
+}
+</script>
+
+<style scoped>
+.medical-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-count {
+  font-size: 22rpx;
+  color: #F97316;
+  background: #FFF3E0;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+.group-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 0 8rpx;
+}
+.group-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #475569;
+}
+.group-arrow {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.tag-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.tag-item {
+  padding: 12rpx 24rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 24rpx;
+  color: #334155;
+  margin: 0 14rpx 14rpx 0;
+  position: relative;
+}
+.tag-active {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.risk-badge {
+  color: #EF4444;
+  margin-left: 6rpx;
+}
+.custom-row {
+  display: flex;
+  align-items: center;
+  margin-top: 10rpx;
+}
+.custom-input {
+  flex: 1;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 20rpx;
+  font-size: 26rpx;
+  background: #F8FAFC;
+}
+.add-link {
+  color: #F97316;
+  font-size: 26rpx;
+  font-weight: 600;
+  padding: 14rpx 10rpx;
+  margin-left: 10rpx;
+}
+.selected-area {
+  margin-top: 16rpx;
+  border-top: 2rpx solid #F1F5F9;
+  padding-top: 16rpx;
+}
+.selected-title {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-bottom: 10rpx;
+}
+.sel-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.sel-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+}
+.sel-diag {
+  font-size: 22rpx;
+  color: #F97316;
+  background: #FFF3E0;
+  padding: 8rpx 20rpx;
+  border-radius: 999rpx;
+}
+.sel-del {
+  color: #EF4444;
+  font-size: 32rpx;
+  padding: 0 10rpx;
+}
+.med-row {
+  border: 2rpx solid #F1F5F9;
+  border-radius: 12rpx;
+  padding: 16rpx;
+  margin-bottom: 16rpx;
+}
+.med-line {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+.med-line:last-child {
+  margin-bottom: 0;
+}
+.med-name,
+.med-time {
+  flex: 1;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 10rpx;
+  padding: 12rpx 16rpx;
+  font-size: 26rpx;
+  background: #F8FAFC;
+}
+.med-del {
+  color: #EF4444;
+  font-size: 24rpx;
+  padding: 0 10rpx;
+}
+.med-picker {
+  font-size: 24rpx;
+  color: #F97316;
+  background: #FFF3E0;
+  padding: 10rpx 20rpx;
+  border-radius: 999rpx;
+}
+.med-forget {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-left: 16rpx;
+}
+.med-duration {
+  font-size: 22rpx;
+  color: #64748B;
+}
+.quick-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 14rpx 28rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 26rpx;
+  color: #64748B;
+}
+.quick-chip-on {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.unit-hint {
+  display: block;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 10rpx;
+}
+</style>

+ 1 - 1
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -975,7 +975,7 @@ applyDraftPayload: function(payload) {
               cancelText: '看报告',
               success: function(modalRes) {
                 if (modalRes.confirm) {
-                  uni.redirectTo({ url: '/pages/health/health-plan-summary?planId=' + planId })
+                  uni.redirectTo({ url: '/pages/health/plan-list' })
                 } else {
                   goDetail()
                 }

+ 151 - 461
cfc-frontend/pages/health/health-plan-summary.vue

@@ -51,106 +51,22 @@
         <text class="form-member-save">每个成员独立保存,切换后自动保留</text>
       </view>
 
-      <!-- 基础体征 -->
-      <view class="form-section">
-        <view class="form-title">基础体征</view>
-        <view class="form-row">
-          <text class="form-label">身高 (cm)</text>
-          <input class="form-input" type="digit" v-model="form.heightCm" placeholder="如 170" />
-        </view>
-        <view class="form-row">
-          <text class="form-label">体重 (kg)</text>
-          <input class="form-input" type="digit" v-model="form.weightKg" placeholder="如 60" />
-        </view>
-        <view class="form-row">
-          <text class="form-label">血压</text>
-          <input class="form-input" type="text" v-model="form.bloodPressure" placeholder="如 120/80" />
-        </view>
-        <view class="form-row">
-          <text class="form-label">空腹血糖 (mmol/L)</text>
-          <input class="form-input" type="digit" v-model="form.bloodGlucose" placeholder="如 5.6" />
-        </view>
-      </view>
-
-      <!-- 血脂 -->
-      <view class="form-section">
-        <view class="form-title">血脂</view>
-        <view class="form-row">
-          <text class="form-label">总胆固醇 (mmol/L)</text>
-          <input class="form-input" type="digit" v-model="lipidCholesterol" placeholder="如 4.5" />
-        </view>
-      </view>
-
-      <!-- 疾病史 -->
-      <view class="form-section">
-        <view class="form-title">疾病史</view>
-        <view v-for="(group, gi) in diseaseGroups" :key="group.key" class="disease-group">
-          <view class="group-head" @click="toggleGroup(group.key)">
-            <text class="group-title">{{ group.title }}</text>
-            <text class="group-arrow">{{ expandedGroups.indexOf(group.key) >= 0 ? '收起' : '展开' }}</text>
-          </view>
-          <view v-if="expandedGroups.indexOf(group.key) >= 0" class="tag-wrap">
-            <view
-              v-for="(item, ii) in group.items"
-              :key="item.name"
-              class="tag-item"
-              :class="{ 'tag-active': selectedNames.indexOf(item.name) >= 0 }"
-              @click="toggleDisease(item.name)">
-              <text>{{ item.name }}</text>
-              <text v-if="item.risk" class="risk-badge" @click.stop="showRiskTip(item.name)">★</text>
-            </view>
-          </view>
-        </view>
-        <view class="custom-row">
-          <input class="custom-input" type="text" v-model="diseaseInput" placeholder="输入其他疾病" />
-          <text class="add-link" @click="addCustomDisease">添加</text>
-        </view>
-        <view v-if="selectedDiseases.length > 0" class="selected-area">
-          <view class="selected-title">已选疾病({{ selectedDiseases.length }})</view>
-          <view v-for="(item, si) in selectedDiseases" :key="getSelKey(si)" class="sel-row">
-            <text class="sel-name">{{ item.name }}</text>
-            <picker mode="date" fields="month" :value="item.diagnosedAt || currentMonth" :end="monthEnd" @change="onDiagChange(si, $event)">
-              <view class="sel-diag">{{ item.diagnosedAt ? item.diagnosedAt : '设确诊时间' }}</view>
-            </picker>
-            <text class="sel-del" @click="removeSelected(si)">×</text>
-          </view>
-        </view>
-      </view>
-
-      <!-- 在服药物/治疗 -->
-      <view class="form-section">
-        <view class="form-title">在服药物/治疗</view>
-        <view v-for="(med, idx) in medRows" :key="idx" class="med-row">
-          <view class="med-line">
-            <input class="med-name" type="text" v-model="med.name" placeholder="药物品牌,如 立普妥" />
-            <text class="med-del" @click="removeMed(idx)">删除</text>
-          </view>
-          <view class="med-line">
-            <picker mode="date" fields="month" :value="med.startDate || currentMonth" :end="monthEnd" @change="onMedStartChange(idx, $event)">
-              <view class="med-picker">{{ med.startDate ? med.startDate : '选择开始日期' }}</view>
-            </picker>
-            <text class="med-forget" @click="onForgetClick(idx)">记不清</text>
-          </view>
-          <view class="med-line" v-if="med.durationText">
-            <text class="med-duration">已服时长:{{ med.durationText }}</text>
-          </view>
-          <view class="med-line">
-            <input class="med-time" type="text" v-model="med.time" placeholder="服药时间,如 早饭后一次" />
-          </view>
-        </view>
-        <text class="add-link" @click="addMed">+ 添加药物/治疗</text>
-      </view>
-
-      <!-- 其他补充 -->
-      <view class="form-section">
-        <view class="form-title">其他补充</view>
-        <textarea class="notes-area" v-model="form.notes" placeholder="其他想补充的健康信息(选填)" :maxlength="500" />
-      </view>
+      <!-- 分步问卷向导(体征 / 疾病史 / 饮食习惯 / 确认) -->
+      <health-questionnaire-wizard
+        ref="wizard"
+        :member-form-map="memberFormMap"
+        :diet-map="memberDietMap"
+        :active-form-member-id="activeFormMemberId"
+        :submitting="loading"
+        @update-vitals="onWizardVitals"
+        @update-medical="onWizardMedical"
+        @update-diet="onWizardDiet"
+        @submit="onWizardSubmit"
+      />
 
       <!-- 底部按钮 -->
       <view class="btn-pair">
-        <button class="btn-skip" @click="skipToGenerate">跳过</button>
-        <button class="btn-generate" :disabled="loading" @click="saveAndGenerate">{{ loading ? '生成中...' : '保存并生成' }}</button>
+        <button class="btn-skip" @click="skipToGenerate">跳过问卷,直接生成</button>
       </view>
     </view>
 
@@ -266,86 +182,13 @@
 </template>
 
 <script>
-import { generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, approvePlan } from '../../utils/api'
-import { getHealthStatus, saveHealthStatus } from '../../utils/api'
+import { generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } from '../../utils/api'
+import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership } from '../../utils/api'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
-
-// v2: 预置疾病清单(菌群报告病症风险 ★ + 互联网统计常见病 两源合并去重,8 组 54 项)
-var DISEASE_GROUPS = [
-  { key: 'cardio', title: '心血管代谢', items: [
-    { name: '高血压', risk: false },
-    { name: '糖尿病', risk: false },
-    { name: '血脂异常', risk: false },
-    { name: '冠心病', risk: false },
-    { name: '脑卒中', risk: false },
-    { name: '心力衰竭', risk: false },
-    { name: '心房颤动', risk: false },
-    { name: '慢阻肺', risk: false },
-    { name: '心脑血管疾病', risk: true },
-    { name: 'II型糖尿病', risk: true },
-    { name: '肥胖', risk: true },
-    { name: '超重', risk: false },
-    { name: '高尿酸血症', risk: false },
-    { name: '骨质疏松', risk: false }
-  ]},
-  { key: 'digest', title: '消化系统', items: [
-    { name: '幽门螺杆菌感染', risk: false },
-    { name: '脂肪肝', risk: false },
-    { name: '慢性胃炎', risk: false },
-    { name: '胃食管反流', risk: false },
-    { name: '肠易激综合征', risk: true },
-    { name: '炎症性肠炎', risk: true },
-    { name: '慢性便秘', risk: true },
-    { name: '感染性腹泻', risk: true },
-    { name: '胆结石', risk: false },
-    { name: '炎症性肠病', risk: false },
-    { name: '肝病', risk: true },
-    { name: '胃病', risk: true },
-    { name: '胆病', risk: true },
-    { name: '肾病', risk: true },
-    { name: '腹胀', risk: true }
-  ]},
-  { key: 'immune', title: '过敏免疫', items: [
-    { name: '过敏性鼻炎', risk: true },
-    { name: '哮喘', risk: false },
-    { name: '湿疹', risk: false },
-    { name: '食物过敏', risk: false },
-    { name: '自体免疫病', risk: true }
-  ]},
-  { key: 'neuro', title: '精神神经', items: [
-    { name: '抑郁症', risk: true },
-    { name: '失眠', risk: true },
-    { name: '焦虑障碍', risk: false },
-    { name: '偏头痛', risk: false },
-    { name: '自闭症', risk: true }
-  ]},
-  { key: 'resp', title: '呼吸系统', items: [
-    { name: '肺部感染或疾病', risk: true },
-    { name: '肺部疾病', risk: true }
-  ]},
-  { key: 'tumor', title: '肿瘤', items: [
-    { name: '结直肠癌', risk: true },
-    { name: '肺癌', risk: false },
-    { name: '甲状腺癌', risk: false },
-    { name: '肝癌', risk: false },
-    { name: '胃癌', risk: false },
-    { name: '乳腺癌', risk: false },
-    { name: '食管癌', risk: false }
-  ]},
-  { key: 'child', title: '儿童青少年', items: [
-    { name: '近视', risk: false },
-    { name: '龋齿', risk: false },
-    { name: '注意缺陷多动障碍', risk: false },
-    { name: '甲状腺疾病', risk: true }
-  ]},
-  { key: 'other', title: '其他', items: [
-    { name: '缺铁性贫血', risk: false },
-    { name: '贫血', risk: false }
-  ]}
-]
+import HealthQuestionnaireWizard from '@/pages/health/components/HealthQuestionnaireWizard.vue'
 
 export default {
-  components: { MemberChipPicker },
+  components: { MemberChipPicker, HealthQuestionnaireWizard },
   data() {
     return {
       // 步骤控制
@@ -357,24 +200,10 @@ export default {
       // 目标
       userGoal: '',
       goalLen: 0,
-      // 健康问卷表单
-      form: {
-        heightCm: '',
-        weightKg: '',
-        bloodPressure: '',
-        bloodGlucose: '',
-        notes: ''
-      },
-      lipidCholesterol: '',
-      // 疾病清单很大,不放进首屏 data,避免微信 setData 超限导致成员列表无法渲染
-      expandedGroups: [],
-      selectedDiseases: [],
-      diseaseInput: '',
-      medRows: [{ name: '', startDate: '', durationText: '', time: '' }],
-      currentMonth: '',
-      monthEnd: '',
-      // 多成员分别填写:memberId → 各自的表单快照 { form, lipidCholesterol, selectedDiseases, medRows }
+      // 多成员分别填写:memberId → 各自的表单快照 { form, lipidCholesterol, selectedDiseases, medRows, noMedication }
       memberFormMap: {},
+      // 多成员饮食习惯:memberId → dietPreferences 对象
+      memberDietMap: {},
       activeFormMemberId: '',
       // 方案数据
       planData: {},
@@ -396,10 +225,6 @@ export default {
     }
   },
   computed: {
-    // 仅在问卷步骤暴露疾病清单,减小 step1 的 setData 体积
-    diseaseGroups: function() {
-      return this.step >= 2 ? DISEASE_GROUPS : []
-    },
     canGenerate: function() {
       return this.selectedMemberIds.length > 0 && this.goalLen > 0 && !this.loading
     },
@@ -430,9 +255,6 @@ export default {
       var idx = this.step - 3
       return this.sectionHints[idx] || ''
     },
-    selectedNames: function() {
-      return this.selectedDiseases.map(function(d) { return d.name })
-    },
 selectedMembers: function() {
       var self = this
       var out = []
@@ -476,15 +298,10 @@ selectedMembers: function() {
       this.viewMode = true
       this.loadPlanDetail()
     }
-    // 初始化健康问卷时间
-    var now = new Date()
-    var y = now.getFullYear()
-    var m = now.getMonth() + 1
-    var mStr = m < 10 ? '0' + m : '' + m
-    this.currentMonth = y + '-' + mStr
-    var lastDay = new Date(y, m, 0).getDate()
-    this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
     // 健康问卷数据在成员选择组件 loaded 后按成员异步加载
+    if (!this.viewMode) {
+      this.checkQuestionnaireGate()
+    }
     // 自动执行
     if (options.autoExecute === '1') {
       setTimeout(function() { self.doAutoExecute() }, 1500)
@@ -524,7 +341,7 @@ selectedMembers: function() {
       if (formId) {
         this.activeFormMemberId = String(formId)
         var self = this
-        setTimeout(function() { self.loadHealthForm(formId) }, 100)
+        setTimeout(function() { self.loadHealthForm(formId); self.loadDietForm(formId) }, 100)
       }
     },
     onAddMember: function() {
@@ -540,14 +357,11 @@ selectedMembers: function() {
 
     // ===== 步骤导航 =====
     goToStep2: function() {
-      if (this.expandedGroups.length === 0) {
-        this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
-      }
-      // 多成员调研:确保 activeFormMemberId 指向第一个选中成员,并加载其表单
+      // 多成员调研:确保 activeFormMemberId 指向第一个选中成员,并加载其表单与饮食习惯
       if (!this.activeFormMemberId && this.selectedMemberIds.length > 0) {
         this.activeFormMemberId = String(this.selectedMemberIds[0])
         var self = this
-        setTimeout(function() { self.loadHealthForm(self.activeFormMemberId) }, 100)
+        setTimeout(function() { self.loadHealthForm(self.activeFormMemberId); self.loadDietForm(self.activeFormMemberId) }, 100)
       }
       this.step = 2
     },
@@ -556,19 +370,17 @@ selectedMembers: function() {
       this.generatePlan()
     },
 
-    // ===== 健康问卷方法 (从 health-status-form.vue 移植,支持按成员加载) =====
+    // ===== 健康问卷方法:按成员从服务端加载到 memberFormMap 快照 =====
     loadHealthForm: function(memberId) {
       var self = this
       var mid = memberId || self.activeFormMemberId || ''
-      // 已有该成员的本地快照 → 直接恢复
-      if (mid && self.memberFormMap[mid]) {
-        self.applyFormSnapshot(self.memberFormMap[mid])
-        return
-      }
+      var key = String(mid || '__account')
+      // 本地已有该成员快照 → 无需重复请求
+      if (self.memberFormMap[key]) return
       getHealthStatus(mid || undefined).then(function(res) {
         if (res && res.code === 200 && res.data) {
           var d = res.data
-          var snapshot = {
+          self.$set(self.memberFormMap, key, {
             form: {
               heightCm: d.heightCm || '',
               weightKg: d.weightKg || '',
@@ -577,59 +389,63 @@ selectedMembers: function() {
               notes: d.notes || ''
             },
             lipidCholesterol: self.parseLipidCholesterol(d.bloodLipids),
-            selectedDiseases: [],
-            medRows: self.parseMeds(d.medications)
-          }
-          self.memberFormMap[mid || '__account'] = snapshot
-          self.loadDiseasesInto(snapshot.selectedDiseases, d.diseaseHistory)
-          if (mid) self.applyFormSnapshot(snapshot)
-          else self.applyFormSnapshot({
-            form: snapshot.form,
-            lipidCholesterol: snapshot.lipidCholesterol,
-            selectedDiseases: snapshot.selectedDiseases,
-            medRows: snapshot.medRows
+            selectedDiseases: self.parseDiseases(d.diseaseHistory),
+            medRows: self.parseMeds(d.medications),
+            noMedication: false
           })
         }
       }).catch(function() {})
     },
-    // 切换成员时先保存当前编辑内容,再加载目标成员快照
-    switchMember: function(memberId) {
+    // 按成员加载饮食习惯到 memberDietMap
+    loadDietForm: function(memberId) {
       var self = this
-      if (String(memberId) === String(self.activeFormMemberId)) return
-      // 保存当前成员到快照
-      self.memberFormMap[self.activeFormMemberId || '__account'] = {
-        form: JSON.parse(JSON.stringify(self.form)),
-        lipidCholesterol: self.lipidCholesterol,
-        selectedDiseases: JSON.parse(JSON.stringify(self.selectedDiseases)),
-        medRows: JSON.parse(JSON.stringify(self.medRows))
+      var mid = memberId || self.activeFormMemberId || ''
+      var key = String(mid || '__account')
+      if (self.memberDietMap[key]) return
+      getDietPreferences({ memberId: mid }).then(function(res) {
+        var d = (res && res.code === 200 && res.data) ? res.data : null
+        self.$set(self.memberDietMap, key, self.normalizeDiet(d))
+      }).catch(function() {
+        self.$set(self.memberDietMap, key, self.normalizeDiet(null))
+      })
+    },
+    normalizeDiet: function(d) {
+      return {
+        allergies: (d && d.allergies) || [],
+        absoluteAvoid: (d && d.absoluteAvoid) || [],
+        mustEat: (d && d.mustEat) || [],
+        religiousDiet: (d && d.religiousDiet) || 'none',
+        spiceLevel: (d && d.spiceLevel) || 0,
+        flavorPref: (d && d.flavorPref) || [],
+        cuisinePref: (d && d.cuisinePref) || [],
+        cookingMethods: (d && d.cookingMethods) || [],
+        healthGoals: (d && d.healthGoals) || [],
+        noPreference: !!(d && d.noPreference)
       }
-      self.activeFormMemberId = String(memberId)
-      // 目标成员已有快照 → 恢复;无则按该成员从服务端加载
-      if (self.memberFormMap[self.activeFormMemberId]) {
-        self.applyFormSnapshot(self.memberFormMap[self.activeFormMemberId])
-      } else {
-        self.resetFormFields()
-        self.loadHealthForm(self.activeFormMemberId)
+    },
+    hasDietContent: function(diet) {
+      if (!diet) return false
+      var arrs = [diet.allergies, diet.absoluteAvoid, diet.mustEat, diet.flavorPref, diet.cuisinePref, diet.cookingMethods, diet.healthGoals]
+      for (var i = 0; i < arrs.length; i++) {
+        if (arrs[i] && arrs[i].length > 0) return true
       }
+      if (diet.spiceLevel && Number(diet.spiceLevel) > 0) return true
+      if (diet.religiousDiet && diet.religiousDiet !== 'none') return true
+      return false
     },
-    applyFormSnapshot: function(snapshot) {
-      if (!snapshot) return
-      this.form = JSON.parse(JSON.stringify(snapshot.form || {}))
-      this.lipidCholesterol = snapshot.lipidCholesterol || ''
-      this.selectedDiseases = JSON.parse(JSON.stringify(snapshot.selectedDiseases || []))
-      this.medRows = snapshot.medRows && snapshot.medRows.length > 0 ? JSON.parse(JSON.stringify(snapshot.medRows)) : [{ name: '', startDate: '', durationText: '', time: '' }]
-      var self = this
-      this.selectedDiseases.forEach(function(item) {
-        var gk = self.findGroupKey(item.name)
-        if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
-      })
+    // 保存用饮食对象:noPreference 为前端标记,但随表单落库(后端 diet_preferences.no_preference 持久化)
+    dietForSave: function(diet) {
+      var out = {}
+      var keys = ['allergies', 'absoluteAvoid', 'mustEat', 'religiousDiet', 'spiceLevel', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'noPreference']
+      keys.forEach(function(k) { out[k] = diet[k] })
+      return out
     },
-    resetFormFields: function() {
-      this.form = { heightCm: '', weightKg: '', bloodPressure: '', bloodGlucose: '', notes: '' }
-      this.lipidCholesterol = ''
-      this.selectedDiseases = []
-      this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
-      this.diseaseInput = ''
+    // 切换成员:仅切换当前填写对象(数据已按成员写入快照,无需手动保存)
+    switchMember: function(memberId) {
+      if (String(memberId) === String(this.activeFormMemberId)) return
+      this.activeFormMemberId = String(memberId)
+      this.loadHealthForm(this.activeFormMemberId)
+      this.loadDietForm(this.activeFormMemberId)
     },
     parseLipidCholesterol: function(json) {
       try {
@@ -640,26 +456,17 @@ selectedMembers: function() {
       } catch (e) {}
       return ''
     },
-    loadDiseasesInto: function(targetList, json) {
-      var self = this
+    parseDiseases: function(json) {
+      var list = []
       try {
         var arr = JSON.parse(json || '[]')
         if (arr instanceof Array) {
           arr.forEach(function(item) {
-            if (item.name && self.selectedNames.indexOf(item.name) < 0) {
-              targetList.push({
-                name: item.name,
-                diagnosedAt: item.diagnosedAt || '',
-                custom: false
-              })
-              var gk = self.findGroupKey(item.name)
-              if (gk && self.expandedGroups.indexOf(gk) < 0) {
-                self.expandedGroups.push(gk)
-              }
-            }
+            if (item.name) list.push({ name: item.name, diagnosedAt: item.diagnosedAt || '', custom: false })
           })
         }
       } catch (e) {}
+      return list
     },
     parseMeds: function(json) {
       var list = []
@@ -676,103 +483,44 @@ selectedMembers: function() {
           })
         }
       } catch (e) {}
-      if (list.length === 0) list.push({ name: '', startDate: '', durationText: '', time: '' })
       return list
     },
-    toggleGroup: function(key) {
-      var idx = this.expandedGroups.indexOf(key)
-      if (idx >= 0) this.expandedGroups.splice(idx, 1)
-      else this.expandedGroups.push(key)
+    // ===== 向导事件:按当前成员写回快照 =====
+    onWizardVitals: function(flat) {
+      this.mergeWizardForm(flat)
     },
-    findGroupKey: function(name) {
-      for (var i = 0; i < DISEASE_GROUPS.length; i++) {
-        var items = DISEASE_GROUPS[i].items
-        for (var j = 0; j < items.length; j++) {
-          if (items[j].name === name) return DISEASE_GROUPS[i].key
-        }
-      }
-      return ''
+    onWizardMedical: function(flat) {
+      this.mergeWizardForm(flat)
     },
-    toggleDisease: function(name) {
-      var idx = -1
-      for (var i = 0; i < this.selectedDiseases.length; i++) {
-        if (this.selectedDiseases[i].name === name) { idx = i; break }
-      }
-      if (idx >= 0) {
-        this.selectedDiseases.splice(idx, 1)
-      } else {
-        this.selectedDiseases.push({ name: name, diagnosedAt: '', custom: false })
-        var gk = this.findGroupKey(name)
-        if (gk && this.expandedGroups.indexOf(gk) < 0) {
-          this.expandedGroups.push(gk)
-        }
-      }
+    onWizardDiet: function(diet) {
+      var key = String(this.activeFormMemberId || '__account')
+      this.$set(this.memberDietMap, key, diet)
     },
-    showRiskTip: function(name) {
-      uni.showModal({
-        title: '菌群报告关联',
-        content: '「' + name + '」与肠道菌群报告相关,勾选后出方案时可结合菌群报告风险值评估。',
-        showCancel: false
-      })
-    },
-    addCustomDisease: function() {
-      var name = (this.diseaseInput || '').trim()
-      if (!name) return
-      if (this.selectedNames.indexOf(name) < 0) {
-        this.selectedDiseases.push({ name: name, diagnosedAt: '', custom: true })
-      }
-      this.diseaseInput = ''
+    onWizardSubmit: function() {
+      this.saveAndGenerate()
     },
-    getSelKey: function(i) { return 's' + i },
-    removeSelected: function(idx) {
-      this.selectedDiseases.splice(idx, 1)
-    },
-    onDiagChange: function(idx, e) {
-      this.selectedDiseases[idx].diagnosedAt = e.detail.value
-    },
-    addMed: function() {
-      this.medRows.push({ name: '', startDate: '', durationText: '', time: '' })
-    },
-    onMedStartChange: function(idx, e) {
-      var start = e.detail.value
-      this.medRows[idx].startDate = start
-      this.medRows[idx].durationText = this.calcDuration(start)
-    },
-    onForgetClick: function(idx) {
-      var self = this
-      uni.showModal({
-        title: '记不清开始日期',
-        editable: true,
-        placeholderText: '如:约 3 个月',
-        success: function(res) {
-          if (res.confirm && res.content) {
-            self.medRows[idx].durationText = res.content.trim()
-          }
-        }
+    // 合并向导推送的扁平体征/疾病数据到当前成员快照
+    mergeWizardForm: function(flat) {
+      if (!flat) return
+      var key = String(this.activeFormMemberId || '__account')
+      var prev = this.memberFormMap[key] || {}
+      var pf = prev.form || {}
+      this.$set(this.memberFormMap, key, {
+        form: {
+          heightCm: flat.heightCm != null ? flat.heightCm : (pf.heightCm || ''),
+          weightKg: flat.weightKg != null ? flat.weightKg : (pf.weightKg || ''),
+          bloodPressure: flat.bloodPressure != null ? flat.bloodPressure : (pf.bloodPressure || ''),
+          bloodGlucose: flat.bloodGlucose != null ? flat.bloodGlucose : (pf.bloodGlucose || ''),
+          notes: flat.notes != null ? flat.notes : (pf.notes || '')
+        },
+        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)
       })
     },
-    calcDuration: function(startDate) {
-      if (!startDate) return ''
-      var parts = startDate.split('-')
-      if (parts.length !== 2) return ''
-      var sy = parseInt(parts[0], 10)
-      var sm = parseInt(parts[1], 10)
-      var now = new Date()
-      var months = (now.getFullYear() - sy) * 12 + (now.getMonth() + 1 - sm)
-      if (months < 1) months = 1
-      if (months < 12) return '已服 ' + months + ' 个月'
-      return '已服 ' + Math.floor(months / 12) + ' 年'
-    },
-    removeMed: function(idx) {
-      if (this.medRows.length > 1) this.medRows.splice(idx, 1)
-    },
     buildHealthPayload: function(snapshot) {
-      var s = snapshot || {
-        form: this.form,
-        lipidCholesterol: this.lipidCholesterol,
-        selectedDiseases: this.selectedDiseases,
-        medRows: this.medRows
-      }
+      var s = snapshot || {}
       var lipids = []
       if (s.lipidCholesterol) {
         lipids.push({ name: '总胆固醇', value: parseFloat(s.lipidCholesterol), unit: 'mmol/L', note: '' })
@@ -804,39 +552,21 @@ selectedMembers: function() {
       if (s.form.notes) payload.notes = s.form.notes
       return payload
     },
-    // 当前成员快照(无改动数据时返回 null)
+    // 当前成员快照(无数据时返回 null)
     snapshotForMember: function(memberId) {
-      var key = String(memberId)
-      // 优先用已保存的成员快照,其次当前活动表单
-      if (this.memberFormMap[key]) {
-        var s = this.memberFormMap[key]
-        var p = this.buildHealthPayload(s)
-        return this.hasPayload(p) ? p : null
-      }
-      // 活动成员直接用当前表单
-      if (key === String(this.activeFormMemberId)) {
-        var activePayload = this.buildHealthPayload()
-        return this.hasPayload(activePayload) ? activePayload : null
-      }
-      return null
+      var s = this.memberFormMap[String(memberId)]
+      if (!s) return null
+      var p = this.buildHealthPayload(s)
+      return this.hasPayload(p) ? p : null
     },
     hasPayload: function(payload) {
       if (!payload) return false
       return payload.heightCm || payload.weightKg || payload.bloodPressure || payload.bloodGlucose ||
              payload.bloodLipids || payload.diseaseHistory || payload.medications || payload.notes
     },
-    // 先保存当前活动成员的编辑内容到快照,再逐个成员保存健康档案,最后生成方案
+    // 逐个成员保存健康档案 + 饮食习惯,最后生成方案
     saveAndGenerate: function() {
       var self = this
-      // 保存当前活动成员到快照
-      if (self.activeFormMemberId) {
-        self.memberFormMap[self.activeFormMemberId] = {
-          form: JSON.parse(JSON.stringify(self.form)),
-          lipidCholesterol: self.lipidCholesterol,
-          selectedDiseases: JSON.parse(JSON.stringify(self.selectedDiseases)),
-          medRows: JSON.parse(JSON.stringify(self.medRows))
-        }
-      }
       // 血压格式校验(检查所有成员)
       var members = self.selectedMemberIds.slice()
       for (var i = 0; i < members.length; i++) {
@@ -850,18 +580,25 @@ selectedMembers: function() {
       var saveTasks = []
       members.forEach(function(mid) {
         var payload = self.snapshotForMember(mid)
-        if (!payload) return
-        payload.memberId = mid
-        saveTasks.push(saveHealthStatus(payload))
+        if (payload) {
+          payload.memberId = mid
+          saveTasks.push(saveHealthStatus(payload))
+        }
+        var diet = self.memberDietMap[String(mid)]
+        if (diet && (self.hasDietContent(diet) || diet.noPreference)) {
+          var dietPayload = self.dietForSave(diet)
+          dietPayload.familyMemberId = mid
+          saveTasks.push(saveDietPreferences(dietPayload))
+        }
       })
       if (saveTasks.length === 0) {
-        // 无健康数据,直接生成方案
+        // 无问卷数据,直接生成方案
         self.loading = false
         self.generatePlan()
         return
       }
       Promise.all(saveTasks).then(function() {
-        uni.showToast({ title: '健康信息已保存', icon: 'success' })
+        uni.showToast({ title: '信息已保存', icon: 'success' })
         self.loading = false
         self.generatePlan()
       }).catch(function() {
@@ -918,6 +655,27 @@ selectedMembers: function() {
         }
       })
     },
+    checkQuestionnaireGate: function() {
+      var self = this
+      getMyMembership().then(function(res) {
+        var m = (res && res.data) || {}
+        var level = m.memberLevel || m.levelCode || ''
+        if (level && level !== 'FREE') return
+        uni.showModal({
+          title: '开通 9.9 元/月健康会员',
+          content: '填写健康自检问卷需先开通月度健康会员(9.9 元/月),开通后可填写问卷并生成个性化健康方案。',
+          confirmText: '去开通',
+          cancelText: '暂不',
+          success: function(r) {
+            if (r.confirm) {
+              uni.navigateTo({ url: '/pages/membership/pay?levelCode=LITE&period=monthly' })
+            } else {
+              uni.navigateBack({ delta: 1 })
+            }
+          }
+        })
+      }).catch(function() {})
+    },
     // 付费墙引导:三选一(购买家庭会员 / 购买健康启航计划 / 取消)
     showPaywallSheet: function() {
       uni.showActionSheet({
@@ -1121,74 +879,6 @@ selectedMembers: function() {
       var stored = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId')
       return stored || null
     },
-    completePlanTask: function(item) {
-      var self = this
-      if (!item || !item.id || item.status === 'completed' || item.status === 'pending_review') {
-        return
-      }
-      var memberId = self.getPlanMemberId(item)
-      if (!memberId) {
-        uni.showToast({ title: '未找到执行成员', icon: 'none' })
-        return
-      }
-      uni.showModal({
-        title: '完成任务',
-        content: '确定完成「' + item.title + '」吗?',
-        success: function(res) {
-          if (!res.confirm) return
-          completeTask(item.id, memberId, '').then(function(r) {
-            if (r && r.code === 200) {
-              var needReview = r.data && r.data.needReview
-              if (needReview) {
-                uni.showToast({ title: '已提交,待审核', icon: 'none' })
-              } else {
-                var points = (r.data && r.data.pointsEarned) || item.points || 0
-                uni.showToast({ title: '获得 ' + points + ' 积分', icon: 'success' })
-              }
-              self.loadPlanTasks()
-            } else {
-              uni.showToast({ title: (r && r.message) || '完成失败', icon: 'none' })
-            }
-          }).catch(function() {
-            uni.showToast({ title: '完成失败', icon: 'none' })
-          })
-        }
-      })
-    },
-    goToTaskCenter: function() {
-      uni.navigateTo({ url: '/pages/tasks/tasks' })
-    },
-    reCreate: function() {
-      this.step = 1
-      this.viewMode = false
-      this.planId = null
-      this.planDetail = null
-      this.taskSubmitted = false
-      this.editing = false
-      this.sectionFeedback = ''
-      this.selectedMemberIds = []
-      this.userGoal = ''
-      this.planData = {}
-      this.sections = {
-        nutrition: { content: '', confirmed: false },
-        diet: { content: '', confirmed: false },
-        exercise: { content: '', confirmed: false }
-      }
-      // 重置健康问卷
-      this.form = { heightCm: '', weightKg: '', bloodPressure: '', bloodGlucose: '', notes: '' }
-      this.lipidCholesterol = ''
-      this.selectedDiseases = []
-      this.diseaseInput = ''
-      this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
-      this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
-      this.memberFormMap = {}
-      this.activeFormMemberId = ''
-      this.pickerMembers = []
-      this.goalLen = 0
-      if (this.$refs.memberPicker && this.$refs.memberPicker.reload) {
-        this.$refs.memberPicker.reload()
-      }
-    },
     goBack: function() {
       uni.navigateBack()
     }

+ 1 - 4
cfc-frontend/pages/health/my-feedback.vue

@@ -3,7 +3,7 @@
     <view class="mf-list" v-if="list.length > 0">
       <view class="mf-item" v-for="(item, idx) in list" :key="item.id">
         <view class="mf-item-head">
-          <text class="mf-badge" :class="badgeClass(item)">{{ typeText(item) }}</text>
+          <text class="mf-badge" :class="item.feedbackType === 'suggestion' ? 'mf-badge-suggestion' : 'mf-badge-question'">{{ typeText(item) }}</text>
           <text class="mf-time">{{ formatTime(item.createdAt) }}</text>
         </view>
         <text class="mf-content">{{ item.content }}</text>
@@ -75,9 +75,6 @@ export default {
     typeText: function(item) {
       return item.feedbackType === 'suggestion' ? '建议' : '疑问'
     },
-    badgeClass: function(item) {
-      return item.feedbackType === 'suggestion' ? 'mf-badge-suggestion' : 'mf-badge-question'
-    },
     isAnswered: function(item) {
       return item.aiStatus === 'answered' && !!item.aiAnswer
     },

+ 244 - 0
cfc-frontend/pages/health/plan-list.vue

@@ -0,0 +1,244 @@
+<template>
+  <view class="page">
+    <view class="header">
+      <text class="title">我的健康方案</text>
+      <text class="subtitle">查看和管理健康计划</text>
+    </view>
+
+    <!-- 新建方案按钮 -->
+    <view class="action-bar">
+      <button class="btn-create" @click="goCreatePlan">+ 新建方案</button>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty" v-else-if="plans.length === 0">
+      <text class="empty-icon">📋</text>
+      <text class="empty-title">暂无健康方案</text>
+      <text class="empty-desc">点击下方按钮创建第一个健康方案</text>
+    </view>
+
+    <!-- 方案列表 -->
+    <view class="plan-list" v-else>
+      <view
+        class="plan-card"
+        v-for="plan in plans"
+        :key="plan.id"
+        @click="goDetail(plan.id)"
+      >
+        <view class="card-top">
+          <text class="plan-title">{{ plan.goal || plan.memberName || '健康方案' }}</text>
+          <view class="plan-status" :class="'status-' + plan.status">
+            <text>{{ statusText(plan.status) }}</text>
+          </view>
+        </view>
+        <view class="card-meta">
+          <text class="meta-item" v-if="plan.memberName">成员:{{ plan.memberName }}</text>
+          <text class="meta-item">创建时间:{{ formatTime(plan.createdAt) }}</text>
+        </view>
+        <view class="plan-arrow">›</view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getHealthPlanList } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: false,
+      plans: []
+    }
+  },
+  onLoad() {
+    this.loadPlans()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false   // 首次进入:onLoad 已加载,跳过避免双发
+    } else {
+      this.loadPlans()            // 从子页面返回时刷新
+    }
+  },
+  methods: {
+    loadPlans() {
+      this.loading = true
+      var self = this
+      getHealthPlanList({}).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var list = res.data.list || res.data
+          self.plans = Array.isArray(list) ? list : []
+        } else {
+          self.plans = []
+        }
+      }).catch(function(err) {
+        self.loading = false
+        console.error('加载方案列表失败', err)
+        self.plans = []
+      })
+    },
+    goDetail(planId) {
+      uni.navigateTo({ url: '/pages/health/health-plan-summary?planId=' + planId })
+    },
+    goCreatePlan() {
+      uni.navigateTo({ url: '/pages/health/health-plan-summary' })
+    },
+    statusText(status) {
+      var map = {
+        'draft': '待确认',
+        'pending_review': '待审核',
+        'confirmed': '已确认',
+        'published': '已发布',
+        'rejected': '已驳回'
+      }
+      return map[status] || '未知'
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).substring(0, 19).replace('T', ' ')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #F5FAFE;
+  padding-bottom: 30rpx;
+}
+
+.header {
+  padding: 40rpx 30rpx 20rpx;
+}
+
+.title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #1F2D3D;
+  display: block;
+}
+
+.subtitle {
+  font-size: 26rpx;
+  color: #8A94A6;
+  margin-top: 8rpx;
+  display: block;
+}
+
+.action-bar {
+  padding: 0 30rpx 20rpx;
+}
+
+.btn-create {
+  width: 100%;
+  background: #4A9BD7;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  padding: 24rpx 0;
+  border-radius: 12rpx;
+  border: none;
+}
+
+.loading, .empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #8A94A6;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-top: 20rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+  margin-top: 10rpx;
+  text-align: center;
+}
+
+.plan-list {
+  padding: 0 30rpx;
+}
+
+.plan-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  position: relative;
+}
+
+.plan-card:active {
+  opacity: 0.8;
+}
+
+.card-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.plan-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+}
+
+.plan-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+
+.status-draft { background: #FFF3E0; color: #F97316; }
+.status-pending_review { background: #E3F2FD; color: #2196F3; }
+.status-confirmed { background: #E8F5E9; color: #4CAF50; }
+.status-published { background: #E8F5E9; color: #4CAF50; }
+.status-rejected { background: #FDE8E8; color: #C62828; }
+
+.card-meta {
+  display: flex;
+  flex-direction: column;
+  margin-top: 16rpx;
+  gap: 6rpx;
+}
+
+.meta-item {
+  font-size: 24rpx;
+  color: #bbb;
+}
+
+.plan-arrow {
+  position: absolute;
+  right: 24rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 36rpx;
+  color: #CCC;
+}
+</style>

+ 1 - 1
cfc-frontend/pages/health/report-confirm.vue

@@ -875,7 +875,7 @@ export default {
               cancelText: '看报告',
               success: function(modalRes) {
                 if (modalRes.confirm) {
-                  uni.redirectTo({ url: '/pages/health/health-plan-summary?planId=' + planId })
+                  uni.redirectTo({ url: '/pages/health/plan-list' })
                 } else {
                   goReport()
                 }

+ 4 - 4
cfc-frontend/pages/home-pages/parent-index.vue

@@ -116,12 +116,12 @@
             </view>
           </view>
           <view class="selfcheck-body">
-            <text class="selfcheck-title">五维家庭自检</text>
+            <text class="selfcheck-title">健康自检问卷</text>
             <text class="selfcheck-subtitle" v-if="selfCheckScore != null">
-              最近得分 {{ selfCheckScore }}/45 分
+              已完成健康自检 · 点击再次填写
             </text>
             <text class="selfcheck-subtitle" v-else>
-              15题 · 5分钟 · 生成五行相生寻源建议
+              4步问卷 · 完成自检解锁能量沙盘
             </text>
           </view>
           <text class="selfcheck-arrow">→</text>
@@ -1151,7 +1151,7 @@ export default {
       uni.navigateTo({ url: '/pages/share/index' })
     },
     goToSelfCheck: function() {
-      uni.navigateTo({ url: '/pages/family/self-check-entry' })
+      uni.navigateTo({ url: '/pages/health/health-plan-summary' })
     },
     loadSelfCheckStatus: function() {
       var self = this

+ 24 - 14
cfc-frontend/pages/index-home/index.vue

@@ -155,7 +155,7 @@
         >
           <view class="task-quick-info">
             <text class="task-quick-title line-clamp-1">{{ task.title }}</text>
-            <text class="task-quick-meta">+{{ task.points }}分</text>
+            <text class="task-quick-meta">+{{ task.points }}CF</text>
           </view>
           <button class="btn-quick-complete" @click="quickCompleteTask(task)">完成</button>
         </view>
@@ -603,12 +603,9 @@ membershipDays: 0,
     },
     selfTestSub: function() {
       if (this.sandboxData) {
-        if (this.selfCheckScore != null) {
-          return '最近自检得分 ' + this.selfCheckScore + '/45 分 · 点击查看'
-        }
-        return '最近综合得分 ' + (this.sandboxData.overallScore || 0) + ' 分 · 点击查看'
+        return '已完成健康自检 · 点击再次填写'
       }
-      return '15 题五维自检 · 约 20 秒'
+      return '4 步健康问卷 · 生成个性化方案 · 解锁能量沙盘'
     },
     // ===== Next Best Action 引导条(C 方案)=====
     // 优先级:沙盘未解锁 > 待填调研 > 待办任务 > 未读通知 > 默认引导
@@ -620,8 +617,8 @@ membershipDays: 0,
         return {
           type: 'self-check',
           icon: '⚡',
-          title: '先完成五维自检',
-          sub: '15 题,约 20 秒,解锁全家能量报告',
+          title: '先完成健康自检',
+          sub: '4 步问卷,解锁全家能量报告',
           btn: '去自检',
           color: 'orange'
         }
@@ -1497,7 +1494,11 @@ title: '添加家庭成员',
       var memberId = (task && task.familyMemberId) || self.currentChildId || uni.getStorageSync('currentMemberId')
       completeTaskApi(task.id, memberId, '').then(function(res) {
         if (res.code === 200) {
-          uni.showToast({ title: '获得 ' + (res.data && res.data.pointsEarned || 0) + ' 积分', icon: 'success' })
+          if (res.data && res.data.needReview) {
+            uni.showToast({ title: '已提交,待审核', icon: 'none' })
+          } else {
+            uni.showToast({ title: '获得 ' + (res.data && res.data.pointsEarned || 0) + ' CF', icon: 'success' })
+          }
           self.loadTodayTasks()
         } else {
           uni.showToast({ title: (res && res.message) || '完成失败', icon: 'none' })
@@ -1624,8 +1625,8 @@ title: '添加家庭成员',
           '&periodEnd=' + (firstPending.periodEnd || '')
         uni.navigateTo({ url: url })
       } else {
-        // 无待填调研 → 进入五维自检中间页
-        uni.navigateTo({ url: '/pages/family/self-check-entry' })
+        // 无待填调研 → 进入健康自检问卷(会员门槛由问卷页 onLoad 把关)
+        uni.navigateTo({ url: '/pages/health/health-plan-summary' })
       }
     },
     // ===== Next Best Action 引导条点击 =====
@@ -1968,9 +1969,18 @@ title: '添加家庭成员',
         self.referralCode = extractReferralCode(res)
       }).catch(function() {})
       self.referralCodeLoaded = true
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+    },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)
+
     },
     /** 转发给朋友/群(不指定 imageUrl,微信自动截取当前页面截图作缩略图;static/share-cover.png 不存在会导致卡片空白) */
     onShareAppMessage: function() {

+ 11 - 11
cfc-frontend/pages/invite/join-form.vue

@@ -177,8 +177,8 @@ export default {
 	margin-bottom: 30rpx;
 }
 .form-item {
-				margin-bottom: 40rpx;
-			}
+	margin-bottom: 40rpx;
+}
 .form-item:last-child {
 	margin-bottom: 0;
 }
@@ -192,15 +192,15 @@ export default {
 	color: #f44336;
 }
 .input, .picker-input {
-				padding: 20rpx;
-				background: #f5f5f5;
-				border-radius: 12rpx;
-				font-size: 28rpx;
-				box-sizing: border-box;
-				width: 100%;
-				min-height: 72rpx;
-				line-height: 1.5;
-			}
+	padding: 28rpx 24rpx;
+	background: #f5f5f5;
+	border-radius: 12rpx;
+	font-size: 30rpx;
+	box-sizing: border-box;
+	width: 100%;
+	min-height: 96rpx;
+	line-height: 1.4;
+}
 .avatar-upload {
 	width: 150rpx;
 	height: 150rpx;

+ 4 - 2
cfc-frontend/pages/invite/join.vue

@@ -235,9 +235,11 @@ export default {
             setTimeout(() => this.goHome(), 1000)
             return
           }
-          // 不在目标家庭 -> 去完善个人信息页提交申请
-          this.setRedirectInfo(false)
+          // 不在目标家庭 -> 自动跳转到完善个人信息页
           this.loading = false
+          this.redirecting = true
+          this.goJoinForm()
+          return
         } else {
           this.setRedirectInfo(false)
           this.loading = false

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

@@ -147,9 +147,13 @@ export default {
       } catch (e) {
         this.stepList = str.split(/[;;\n]/).filter(function(s) { return s.trim() })
       }
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

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

@@ -254,9 +254,13 @@ onShow: function() {
         .replace(/&quot;/gi, '"')
         .replace(/\s+/g, ' ')
         .trim()
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

+ 102 - 79
cfc-frontend/pages/mind-detail/index.vue

@@ -40,7 +40,7 @@
     </view>
 
     <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
-<DimensionIntroCard
+<DimensionVisitorCard
   v-if="!isLoggedIn"
   dimCode="mind"
   card-title="心维度 · 心理成长"
@@ -51,7 +51,8 @@
   ]"
   cta-text="登录查看心理画像"
   secondary-text="了解心理维度"
-  secondary-url="/pages/mind-detail/dimension-intro" />
+  secondary-url="/pages/mind-detail/dimension-intro"
+  preview-text="登录后展示你的心理画像与情绪关联" />
 
         <!-- 登录后:五维能量条 -->
         <FamilyEnergyBar
@@ -148,6 +149,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">
@@ -233,6 +255,7 @@
 import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
+import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import PsychCrisisBanner from '@/pages/mind-detail/components/PsychCrisisBanner.vue'
@@ -245,7 +268,7 @@ import config from '../../config.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
-components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, DimensionIntroCard },
+components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
   data() {
     return {
       isLoggedIn: false,
@@ -1185,10 +1208,10 @@ var descList = descMap[this.dominantElement] || descMap.wood
   }
 </script>
 
-<style scoped>
+<style scoped lang="scss">
 .container {
   min-height: 100vh;
-  background: #f5f7fa;
+  background: $app-bg;
   padding-bottom: 120rpx;
 }
 
@@ -1205,13 +1228,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .section-title-icon {
   font-size: 32rpx;
-  color: #fff;
+  color: $app-surface;
   margin-right: 12rpx;
 }
 .section-title-text {
   font-size: 32rpx;
   font-weight: bold;
-  color: #fff;
+  color: $app-surface;
 }
 
 /* ===== 心理报告上传卡片 ===== */
@@ -1286,7 +1309,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   display: flex;
   flex-direction: row;
   align-items: center;
-  background: #fff;
+  background: $app-surface;
   border-radius: 14rpx;
   padding: 20rpx 20rpx;
   margin-bottom: 8rpx;
@@ -1301,12 +1324,12 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .mind-report-item-name {
   font-size: 26rpx;
-  color: #333;
+  color: $app-text;
   flex: 1;
 }
 .mind-report-item-date {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   margin-right: 14rpx;
 }
 .mind-report-item-status {
@@ -1319,8 +1342,8 @@ var descList = descMap[this.dominantElement] || descMap.wood
   background: #F3E8FF;
 }
 .mind-report-item-status.pending {
-  color: #999;
-  background: #F5F5F5;
+  color: $app-text-muted;
+  background: $app-surface-subtle;
 }
 .mind-report-empty {
   display: flex;
@@ -1344,7 +1367,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   display: flex;
   flex-direction: row;
   margin: 20rpx 30rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   padding: 24rpx 16rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
@@ -1363,11 +1386,11 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .badge-value {
   font-size: 36rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
 }
 .badge-label {
   font-size: 20rpx;
-  color: #999;
+  color: $app-text-muted;
   margin-top: 4rpx;
 }
 
@@ -1376,7 +1399,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   margin: 20rpx 30rpx;
 }
 .emi-card {
-  background: #fff;
+  background: $app-surface;
   border-radius: 24rpx;
   padding: 30rpx;
   box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
@@ -1384,7 +1407,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .emi-card-title {
   font-size: 30rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
   display: block;
 }
 .personality-header {
@@ -1401,7 +1424,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .test-btn-text {
   font-size: 24rpx;
-  color: #fff;
+  color: $app-surface;
   font-weight: 500;
 }
 .emi-bar-item {
@@ -1412,7 +1435,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .emi-bar-label {
   font-size: 24rpx;
-  color: #666;
+  color: $app-text-secondary;
   width: 120rpx;
   flex-shrink: 0;
 }
@@ -1432,7 +1455,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .emi-bar-score {
   font-size: 26rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
   width: 60rpx;
   text-align: right;
   flex-shrink: 0;
@@ -1480,7 +1503,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 /* ===== EMI 心理报告卡片 ===== */
 .emi-card {
   margin: 30rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 24rpx;
   padding: 30rpx;
   box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
@@ -1491,7 +1514,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .emi-title {
   font-size: 30rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
   display: block;
 }
 .emi-overall-wrap {
@@ -1502,7 +1525,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   background: linear-gradient(135deg, #FF6B35, #FFD700);
   border-radius: 16rpx;
   margin: 20rpx 0;
-  color: #fff;
+  color: $app-surface;
 }
 .emi-overall-label {
   font-size: 26rpx;
@@ -1537,7 +1560,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .emi-dim-label {
   font-size: 26rpx;
-  color: #666;
+  color: $app-text-secondary;
 }
 .emi-dim-values {
   display: flex;
@@ -1599,7 +1622,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 /* ===== 占位卡 ===== */
 .placeholder-card {
   margin: 30rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 24rpx;
   padding: 60rpx 40rpx;
   display: flex;
@@ -1614,19 +1637,19 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .placeholder-title {
   font-size: 32rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
   margin-bottom: 16rpx;
 }
 .placeholder-desc {
   font-size: 26rpx;
-  color: #999;
+  color: $app-text-muted;
   text-align: center;
   line-height: 1.6;
   margin-bottom: 30rpx;
 }
 .placeholder-btn {
   background: linear-gradient(135deg, #FF6B35, #FFD700);
-  color: #fff;
+  color: $app-surface;
   font-size: 28rpx;
   font-weight: bold;
   padding: 16rpx 64rpx;
@@ -1639,7 +1662,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   margin: 20rpx 30rpx;
 }
 .culture-card {
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   padding: 24rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
@@ -1657,11 +1680,11 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .culture-card-title {
   font-size: 28rpx;
   font-weight: 600;
-  color: #333;
+  color: $app-text;
 }
 .culture-card-body {
   font-size: 24rpx;
-  color: #666;
+  color: $app-text-secondary;
   line-height: 1.6;
 }
 /* 八字四柱 */
@@ -1677,7 +1700,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .bazi-pillar-label {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   margin-bottom: 6rpx;
 }
 .bazi-pillar-value {
@@ -1718,7 +1741,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .wuxing-value {
   font-size: 22rpx;
-  color: #666;
+  color: $app-text-secondary;
   width: 56rpx;
   text-align: right;
 }
@@ -1769,7 +1792,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .energy-label {
   font-size: 28rpx;
   font-weight: 700;
-  background: linear-gradient(135deg, #F59E0B, #FF6B9D);
+  background: linear-gradient(135deg, #F59E0B, $dim-mind);
   -webkit-background-clip: text;
   -webkit-text-fill-color: transparent;
   background-clip: text;
@@ -1785,7 +1808,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 
 .energy-quote {
   font-size: 26rpx;
-  color: #E2E8F0;
+  color: $app-border-subtle;
   line-height: 1.8;
   display: block;
   margin-bottom: 14rpx;
@@ -1802,7 +1825,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   top: 4rpx;
   bottom: 4rpx;
   width: 4rpx;
-  background: linear-gradient(180deg, #F59E0B, #FF6B9D);
+  background: linear-gradient(180deg, #F59E0B, $dim-mind);
   border-radius: 2rpx;
 }
 
@@ -1847,7 +1870,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .culture-dual-item {
   flex: 1;
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   padding: 20rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
@@ -1861,7 +1884,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .culture-dual-label {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   margin-bottom: 6rpx;
 }
 .culture-dual-value {
@@ -1878,7 +1901,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 
 /* ===== 每日心理 ===== */
 .tip-card {
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
   display: flex;
@@ -1886,8 +1909,8 @@ var descList = descMap[this.dominantElement] || descMap.wood
   overflow: hidden;
 }
 .tip-left-bar {
-  width: 6rpx;
-  background: linear-gradient(180deg, #5B9BD5, #8FC5E8);
+  width: 8rpx;
+  background: $dim-mind;
   flex-shrink: 0;
 }
 .tip-body {
@@ -1897,13 +1920,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .tip-title {
   font-size: 28rpx;
   font-weight: bold;
-  color: #5B9BD5;
+  color: $dim-mind;
   display: block;
   margin-bottom: 12rpx;
 }
 .tip-content {
   font-size: 24rpx;
-  color: #666;
+  color: $app-text-secondary;
   line-height: 1.6;
   display: block;
   margin-bottom: 16rpx;
@@ -1928,7 +1951,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .section-title {
   font-size: 30rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
 }
 
 /* ===== 推荐阅读 ===== */
@@ -1937,7 +1960,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   flex-direction: column;
 }
 .article-card {
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   padding: 28rpx 24rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
@@ -1951,19 +1974,19 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .article-category-text {
   font-size: 20rpx;
-  color: #fff;
+  color: $app-surface;
   font-weight: 500;
 }
 .article-title {
   font-size: 28rpx;
   font-weight: bold;
-  color: #333;
+  color: $app-text;
   display: block;
   margin-bottom: 10rpx;
 }
 .article-summary {
   font-size: 24rpx;
-  color: #888;
+  color: $app-text-muted;
   line-height: 1.5;
   display: block;
   margin-bottom: 20rpx;
@@ -2002,7 +2025,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   display: flex;
   flex-direction: column;
   gap: 16rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   padding: 24rpx 16rpx;
   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
@@ -2033,7 +2056,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .func-label {
   font-size: 22rpx;
-  color: #666;
+  color: $app-text-secondary;
   font-weight: 500;
 }
 .func-item:active {
@@ -2043,21 +2066,21 @@ var descList = descMap[this.dominantElement] || descMap.wood
 /* ===== 底部 ===== */
 .indicator-cards-section { margin: 20rpx 30rpx; }
 .indicator-cards { display: flex; flex-direction: row; flex-wrap: wrap; gap: 16rpx; }
-.indicator-card { width: calc(50% - 8rpx); background: #fff; border-radius: 20rpx; padding: 24rpx 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.indicator-card { width: calc(50% - 8rpx); background: $app-surface; border-radius: 20rpx; padding: 24rpx 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
 .indicator-card.no-data { opacity: 0.5; }
 .indicator-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
-.indicator-name { font-size: 26rpx; color: #666; font-weight: 500; }
-.indicator-unit { font-size: 22rpx; color: #999; }
+.indicator-name { font-size: 26rpx; color: $app-text-secondary; font-weight: 500; }
+.indicator-unit { font-size: 22rpx; color: $app-text-muted; }
 .indicator-body { margin-bottom: 8rpx; }
-.indicator-value { font-size: 40rpx; font-weight: bold; color: #FF6B9D; }
-.indicator-placeholder { font-size: 26rpx; color: #ccc; }
+.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; }
 .bottom-spacer { height: 120rpx; }
 
 /* ===== 肠脑轴 ===== */
 .gut-insight-text {
   font-size: 24rpx;
-  color: #666;
+  color: $app-text-secondary;
   margin-bottom: 16rpx;
   padding: 12rpx 16rpx;
   background: #F0FFF4;
@@ -2080,13 +2103,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .gut-indicator-name {
   font-size: 22rpx;
-  color: #666;
+  color: $app-text-secondary;
   font-weight: 500;
   display: block;
 }
 .gut-indicator-impact {
   font-size: 18rpx;
-  color: #FF6B9D;
+  color: $dim-mind;
   display: block;
   margin-top: 2rpx;
 }
@@ -2104,7 +2127,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .gut-indicator-value {
   font-size: 22rpx;
-  color: #666;
+  color: $app-text-secondary;
   width: 60rpx;
   text-align: right;
 }
@@ -2130,7 +2153,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 
 /* ===== 关键信息(家庭+个人) ===== */
 .key-info-card {
-  background: #fff;
+  background: $app-surface;
   border-radius: 20rpx;
   padding: 24rpx;
   margin-top: 20rpx;
@@ -2148,7 +2171,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .key-info-title {
   font-size: 28rpx;
   font-weight: 600;
-  color: #333;
+  color: $app-text;
 }
 .key-info-body {
   display: flex;
@@ -2164,7 +2187,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .ki-subtitle {
   font-size: 22rpx;
-  color: #999;
+  color: $app-text-muted;
   margin-bottom: 12rpx;
   font-weight: 500;
 }
@@ -2179,7 +2202,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .ki-label {
   font-size: 22rpx;
-  color: #888;
+  color: $app-text-muted;
   margin-right: 8rpx;
   white-space: nowrap;
 }
@@ -2192,13 +2215,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .ki-valuel {
   font-size: 24rpx;
-  color: #333;
+  color: $app-text;
   font-weight: 500;
   white-space: nowrap;
 }
 .ki-valued {
   font-size: 24rpx;
-  color: #333;
+  color: $app-text;
   font-weight: 500;
 }
 .ki-swatch {
@@ -2212,7 +2235,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .ki-num {
   font-size: 30rpx;
   font-weight: 700;
-  color: #FF6B9D;
+  color: $dim-mind;
 }
 .ki-fortune {
   font-size: 24rpx;
@@ -2274,7 +2297,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 
 /* DAN 报告卡片 */
 .dan-card {
-  background: #fff;
+  background: $app-surface;
   border-radius: 16rpx;
   padding: 24rpx;
   margin-bottom: 16rpx;
@@ -2288,7 +2311,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .dan-report-date {
   font-size: 26rpx;
-  color: #666;
+  color: $app-text-secondary;
 }
 .dan-status {
   font-size: 22rpx;
@@ -2305,7 +2328,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .dan-report-desc {
   font-size: 28rpx;
-  color: #333;
+  color: $app-text;
   font-weight: 500;
   margin-bottom: 12rpx;
   display: block;
@@ -2326,7 +2349,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .section-title {
   font-size: 32rpx;
   font-weight: 700;
-  color: #333;
+  color: $app-text;
 }
 .section-more {
   font-size: 26rpx;
@@ -2340,7 +2363,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   padding: 20rpx 28rpx;
   background: linear-gradient(135deg, #FFF0F5, rgba(255,107,157,0.12));
   border-radius: 16rpx;
-  border-left: 8rpx solid #FF6B9D;
+  border-left: 8rpx solid $dim-mind;
 }
 .brand-strap-text {
   font-size: 26rpx;
@@ -2354,7 +2377,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   padding: 20rpx 28rpx;
   background: linear-gradient(135deg, #FFF0F5, rgba(255,107,157,0.08));
   border-radius: 16rpx;
-  border-left: 8rpx solid #FF6B9D;
+  border-left: 8rpx solid $dim-mind;
   display: flex;
   align-items: center;
   gap: 12rpx;
@@ -2379,7 +2402,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 /* 维度画像样式 */
 .dimension-portrait-section {
   margin: 24rpx 30rpx;
-  background: #fff;
+  background: $app-surface;
   border-radius: 24rpx;
   padding: 32rpx 24rpx;
   box-shadow: 0 4rpx 20rpx rgba(255, 107, 157, 0.12);
@@ -2387,7 +2410,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .portrait-tier {
   font-size: 26rpx;
-  color: #FF6B9D;
+  color: $dim-mind;
   font-weight: bold;
   margin-left: 16rpx;
 }
@@ -2396,7 +2419,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .portrait-desc {
   font-size: 26rpx;
-  color: #666;
+  color: $app-text-secondary;
   line-height: 1.6;
   display: block;
 }
@@ -2423,13 +2446,13 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .portrait-dim-name {
   font-size: 24rpx;
-  color: #333;
+  color: $app-text;
   flex: 1;
 }
 .portrait-dim-score {
   font-size: 26rpx;
   font-weight: bold;
-  color: #FF6B9D;
+  color: $dim-mind;
   margin-right: 12rpx;
 }
 .portrait-dim-bar-track {
@@ -2447,14 +2470,14 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .portrait-dim-desc {
   font-size: 20rpx;
-  color: #999;
+  color: $app-text-muted;
   display: block;
 }
 .portrait-tip {
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #FFF0F5, #FDF2F7);
   border-radius: 12rpx;
-  border-left: 8rpx solid #FF6B9D;
+  border-left: 8rpx solid $dim-mind;
   margin-top: 16rpx;
 }
 .portrait-tip-text {

+ 1 - 1
cfc-frontend/pages/parent/child-detail.vue

@@ -64,7 +64,7 @@
             <text class="task-status-icon">{{ task.status === 'completed' ? '✅' : '⏳' }}</text>
             <view class="task-info">
               <text class="task-title">{{ task.title }}</text>
-              <text class="task-points" v-if="task.points">+{{ task.points }}分</text>
+              <text class="task-points" v-if="task.points">+{{ task.points }}CF</text>
             </view>
           </view>
         </view>

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

@@ -220,9 +220,13 @@ export default {
           uni.showToast({ title: '下载失败', icon: 'none' })
         }
         })
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

+ 205 - 0
cfc-frontend/pages/reminder-config/index.vue

@@ -242,3 +242,208 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.reminder-config-page {
+  box-sizing: border-box;
+  min-height: 100vh;
+  background: #F5F9FC;
+  overflow: hidden;
+}
+.content {
+  padding: 30rpx;
+  box-sizing: border-box;
+  width: 100%;
+  overflow: hidden;
+}
+.cfg-card {
+  background-color: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 30rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 4rpx 16rpx rgba(91, 155, 213, 0.08);
+}
+.cfg-card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.cfg-card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.cfg-card-desc {
+  display: block;
+  margin-top: 12rpx;
+  font-size: 24rpx;
+  color: #94A3B8;
+  line-height: 1.6;
+}
+.cfg-switch {
+  padding: 10rpx 28rpx;
+  border-radius: 999rpx;
+  font-size: 26rpx;
+  background: #E2E8F0;
+  color: #64748B;
+}
+.cfg-switch-on {
+  background: linear-gradient(135deg, #5B9BD5, #8FC5E8);
+  color: #FFFFFF;
+  font-weight: 600;
+}
+.channel-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+  margin-top: 24rpx;
+}
+.channel-item {
+  box-sizing: border-box;
+  position: relative;
+  display: flex;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 20rpx;
+  background: #FFFFFF;
+  min-width: 200rpx;
+}
+.channel-selected {
+  border-color: #5B9BD5;
+  background: #5B9BD5;
+}
+.channel-disabled {
+  opacity: 0.55;
+}
+.channel-check {
+  position: absolute;
+  top: -8rpx;
+  right: -8rpx;
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 50%;
+  background-color: #FFFFFF;
+  color: #5B9BD5;
+  font-size: 20rpx;
+  font-weight: 900;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 2;
+  box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
+}
+.channel-info {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+.channel-name {
+  font-size: 26rpx;
+  color: #1E293B;
+  font-weight: 500;
+}
+.channel-selected .channel-name {
+  color: #FFFFFF;
+}
+.channel-reason {
+  font-size: 20rpx;
+  color: #94A3B8;
+}
+.channel-tip {
+  width: 100%;
+  margin-top: 16rpx;
+}
+.channel-tip-text {
+  font-size: 24rpx;
+  color: #5B9BD5;
+}
+.timing-list {
+  display: flex;
+  gap: 20rpx;
+  margin-top: 24rpx;
+}
+.timing-item {
+  flex: 1;
+  box-sizing: border-box;
+  padding: 24rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 20rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+.timing-active {
+  border-color: #5B9BD5;
+  background: #F0F7FF;
+}
+.timing-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.timing-active .timing-name {
+  color: #3A7CC4;
+}
+.timing-desc {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.offset-row {
+  display: flex;
+  align-items: center;
+  margin-top: 30rpx;
+  padding: 20rpx 24rpx;
+  background: #F8FAFC;
+  border-radius: 16rpx;
+}
+.offset-label {
+  font-size: 26rpx;
+  color: #1E293B;
+  margin-right: 20rpx;
+}
+.offset-input {
+  flex: 1;
+  font-size: 28rpx;
+  color: #1E293B;
+}
+.offset-unit {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-left: 12rpx;
+}
+.offset-hint {
+  display: block;
+  margin-top: 12rpx;
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.tip-card {
+  background: #D6EAF8;
+  border-radius: 16rpx;
+  padding: 24rpx 30rpx;
+  margin-bottom: 30rpx;
+}
+.tip-text {
+  font-size: 24rpx;
+  color: #3A7CC4;
+  line-height: 1.6;
+}
+.save-area {
+  padding: 40rpx 30rpx;
+}
+.save-btn {
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  color: #FFFFFF;
+  text-align: center;
+  padding: 28rpx 0;
+  border-radius: 999rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  box-shadow: 0 6rpx 20rpx rgba(91, 155, 213, 0.3);
+}
+.save-btn:active {
+  opacity: 0.9;
+  transform: scale(0.98);
+}
+</style>

+ 13 - 4
cfc-frontend/pages/shop/cart/cart.vue

@@ -43,7 +43,7 @@
                 </text>
               </view>
               <text class="item-name">{{ item.productName }}</text>
-              <text v-if="item.giftLevelName" class="gift-badge">🎁购此送 {{ item.giftLevelName }}会员</text>
+              <text v-if="item.giftLevelName" class="gift-badge">🎁</text>
               <text v-if="item.specDesc" class="item-spec">{{ item.specDesc }}</text>
               <text v-if="item.supplierName" class="item-supplier">供应商: {{ item.supplierName }}</text>
               <text class="item-delete" @click="onRemove(item.productId)">🗑</text>
@@ -422,9 +422,18 @@ export default {
     formatPriceWithSymbol(price) {
       if (price === null || price === undefined) return '0.00'
       return (Number(price) / 100).toFixed(2)
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+    },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)
+
     }
   }
 }

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

@@ -1115,9 +1115,13 @@ showOnlineSlotPicker: function() {
     selectServicePerson: function(person) {
       this.selectedServicePerson = person
       this.showServicePersonModal = false
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

+ 13 - 4
cfc-frontend/pages/shop/index/index.vue

@@ -126,7 +126,7 @@
             <view class="price-row">
               <text class="price">{{ formatPriceWithSymbol(item.price) }}</text>
               <text v-if="item.memberPrice" class="member-price">会员{{ formatPriceWithSymbol(item.memberPrice) }}</text>
-              <text v-if="item.giftLevelName" class="gift-badge">🎁购此送 {{ item.giftLevelName }}会员</text>
+              <text v-if="item.giftLevelName" class="gift-badge">🎁</text>
             </view>
             <text class="vendor-name">{{ item.brandName || '平台官方' }}</text>
           </view>
@@ -315,9 +315,18 @@ export default {
     formatPriceWithSymbol(price) {
       if (price === null || price === undefined) return ''
       return '¥' + (Number(price) / 100).toFixed(2)
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+    },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)
+
     }
   }
 }

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

@@ -290,9 +290,13 @@ export default {
     formatPriceWithSymbol(price) {
       if (!price && price !== 0) return '-'
       return '¥' + (price / 100).toFixed(2)
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     },
     // 履约状态标签
     fulfillStatusLabel: function(status) {

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

@@ -356,9 +356,13 @@ export default {
     },
     onImgError(e, item) {
       item.coverImage = ''
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+        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)
+        } cff388913 (fix(image): 云文件绝对 URL 不再重复叠加 API_BASE_URL 前缀)
     }
   }
 }

+ 1 - 1
cfc-frontend/pages/shop/search/search.vue

@@ -58,7 +58,7 @@
             <view class="price-row">
               <text class="price">{{ formatPriceWithSymbol(item.price) }}</text>
               <text v-if="item.memberPrice" class="member-price">会员{{ formatPriceWithSymbol(item.memberPrice) }}</text>
-              <text v-if="item.giftLevelName" class="gift-badge">🎁购此送 {{ item.giftLevelName }}会员</text>
+              <text v-if="item.giftLevelName" class="gift-badge">🎁</text>
             </view>
           </view>
         </view>

+ 14 - 5
cfc-frontend/pages/tasks/tasks.vue

@@ -32,7 +32,7 @@
             <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
           </view>
           <view class="task-meta">
-            <text class="points">+{{ task.points }}分</text>
+            <text class="points">+{{ task.points }}CF</text>
             <text class="creator">创建人:{{ task.creatorName || '家人' }}</text>
             <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
           </view>
@@ -63,7 +63,7 @@
             <text v-if="task.requireInput === 1" class="minigame-badge">✍️</text>
           </view>
           <view class="task-meta">
-            <text class="points">+{{ task.points }}分</text>
+            <text class="points">+{{ task.points }}CF</text>
             <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
           </view>
           <!-- 进度型任务:展示进度条 -->
@@ -96,7 +96,7 @@
             <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
           </view>
           <view class="task-meta">
-            <text class="points">+{{ task.points }}分</text>
+            <text class="points">+{{ task.points }}CF</text>
             <text class="assignee">{{ task.executorName || '未分配' }}</text>
             <text class="status-tag" :class="'status-' + task.status">{{ statusLabel(task.status) }}</text>
           </view>
@@ -402,7 +402,7 @@ export default {
         if (res.data && res.data.needReview) {
           uni.showToast({ title: '已提交,待审核', icon: 'none' })
         } else {
-          uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+          uni.showToast({ title: `获得 ${res.data.pointsEarned} CF`, icon: 'success' })
         }
         this.loadTodayTasks()
       } catch (e) {
@@ -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 '完成'
     },
     /** 统一任务动作入口:开始/联动跳转/填写/直接完成 */
@@ -477,6 +480,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() {
@@ -492,7 +501,7 @@ export default {
         if (res.data && res.data.needReview) {
           uni.showToast({ title: '已提交,待审核', icon: 'none' })
         } else {
-          uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+          uni.showToast({ title: `获得 ${res.data.pointsEarned} CF`, icon: 'success' })
         }
         this.closeInputModal()
         this.loadTodayTasks()

+ 29 - 17
cfc-frontend/pages/teacher/families.vue

@@ -5,6 +5,11 @@
       <text class="family-meta">查看待审核方案与任务</text>
     </view>
 
+    <view class="family-card entry" @click="goPlanList">
+      <text class="family-name">我的方案</text>
+      <text class="family-meta">查看和管理已制定的训练方案</text>
+    </view>
+
     <view class="filter-bar">
       <view class="filter-item" :class="{ active: filterType === 'all' }" @click="changeFilter('all')">全部</view>
       <view class="filter-item" :class="{ active: filterType === 'active' }" @click="changeFilter('active')">活跃</view>
@@ -12,23 +17,27 @@
     </view>
 
     <view class="family-list">
-      <view class="family-card" v-for="family in families" :key="family.id" @click="viewFamily(family)">
-        <view class="family-header">
-          <text class="family-name">{{ family.familyName || '家庭' + family.familyId }}</text>
-          <text class="family-status">{{ family.status === 'active' ? '服务中' : '已暂停' }}</text>
-        </view>
-        <view class="family-info">
-          <text class="info-item">👶 {{ family.childrenCount || 0 }}位成员</text>
-          <text class="info-item">📍 {{ family.address || '未设置地址' }}</text>
-        </view>
-        <view class="family-tasks">
-          <text class="task-info">今日任务: {{ family.todayTaskCount || 0 }}/{{ family.totalTaskCount || 0 }}</text>
-        </view>
-        <view class="family-actions">
-          <button class="btn-action" size="mini" @click.stop="viewDetail(family)">查看详情</button>
-          <!-- 聊天功能已隐藏(临时): <button class="btn-action primary" size="mini" @click.stop="sendMessage(family)">发消息</button> -->
-        </view>
-      </view>
+<view class="family-card" v-for="family in families" :key="family.id" @click="viewFamily(family)">
+         <view class="family-header">
+           <text class="family-name">{{ family.familyName || '家庭' + family.familyId }}</text>
+           <text class="family-status">{{ family.status === 'active' ? '服务中' : '已暂停' }}</text>
+         </view>
+         <view class="family-info">
+           <text class="info-item">👶 {{ family.childrenCount || 0 }}位成员</text>
+           <text class="info-item">📍 {{ family.address || '未设置地址' }}</text>
+         </view>
+         <view class="family-tasks">
+           <text class="task-info">今日任务: {{ family.todayTaskCount || 0 }}/{{ family.totalTaskCount || 0 }}</text>
+         </view>
+         <view class="family-actions">
+           <button class="btn-action" size="mini" @click.stop="viewDetail(family)">查看详情</button>
+            <view class="family-card plan-card-entry" v-if="family.id !== null" @click="viewPlanList">
+             <view class="family-header">
+               <text class="family-name">方案管理</text>
+             </view>
+           </view>
+         </view>
+       </view>
       <view class="empty-tip" v-if="families.length === 0">
         <text>暂无服务家庭</text>
       </view>
@@ -78,6 +87,9 @@ export default {
     },
     goWorkbench() {
       uni.navigateTo({ url: '/pages/teacher/review-workbench' })
+    },
+    goPlanList() {
+      uni.navigateTo({ url: '/pages/teacher/plan-list' })
     }
   }
 }

+ 166 - 0
cfc-frontend/pages/teacher/plan-create.vue

@@ -0,0 +1,166 @@
+<template>
+  <view class="page">
+    <view class="card">
+      <view class="form-group">
+        <text class="label">方案标题 <text class="required">*</text></text>
+        <input
+          class="input"
+          v-model="title"
+          placeholder="请输入方案标题"
+        />
+      </view>
+
+      <view class="form-group">
+        <text class="label">方案描述</text>
+        <textarea
+          class="textarea"
+          v-model="description"
+          placeholder="请输入方案描述(可选)"
+          :maxlength="-1"
+        />
+      </view>
+    </view>
+
+    <view class="actions">
+      <button class="btn-primary" :disabled="submitting" @click="submitPlan">
+        {{ submitting ? '提交中...' : '保存方案' }}
+      </button>
+      <button class="btn-cancel" @click="goBack">取消</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createTrainingPlan } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      title: '',
+      description: '',
+      submitting: false
+    }
+  },
+  methods: {
+    submitPlan() {
+      if (!this.title || !this.title.trim()) {
+        uni.showToast({ title: '请输入方案标题', icon: 'none' })
+        return
+      }
+
+      this.submitting = true
+
+      // 创建方案(memberId 留空,由后端关联当前用户)
+      createTrainingPlan(null, this.title.trim(), this.description.trim() || '')
+        .then(res => {
+          this.submitting = false
+          if (res && res.code === 200) {
+            uni.showToast({ title: '创建成功', icon: 'success' })
+            // 延迟跳转,让用户看到成功提示
+            setTimeout(() => {
+              uni.navigateBack()
+            }, 1000)
+          } else {
+            uni.showToast({ title: (res && res.message) || '创建失败', icon: 'none' })
+          }
+        })
+        .catch(err => {
+          this.submitting = false
+          console.error('创建方案失败', err)
+          uni.showToast({ title: '创建失败', icon: 'none' })
+        })
+    },
+    goBack() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  padding: 24rpx;
+  background: #F5FAFE;
+  min-height: 100vh;
+}
+
+.card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+}
+
+.form-group {
+  margin-bottom: 30rpx;
+}
+
+.form-group:last-child {
+  margin-bottom: 0;
+}
+
+.label {
+  font-size: 28rpx;
+  color: #1F2D3D;
+  display: block;
+  margin-bottom: 12rpx;
+  font-weight: 500;
+}
+
+.required {
+  color: #F44336;
+}
+
+.input {
+  width: 100%;
+  height: 80rpx;
+  background: #F7F9FC;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #1F2D3D;
+  box-sizing: border-box;
+}
+
+.textarea {
+  width: 100%;
+  min-height: 200rpx;
+  background: #F7F9FC;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  color: #1F2D3D;
+  box-sizing: border-box;
+}
+
+.actions {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.btn-primary {
+  width: 100%;
+  background: #4A9BD7;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  padding: 24rpx 0;
+  border-radius: 12rpx;
+  border: none;
+}
+
+.btn-primary:disabled {
+  background: #ccc;
+}
+
+.btn-cancel {
+  width: 100%;
+  background: #F2F4F7;
+  color: #5A6478;
+  font-size: 30rpx;
+  padding: 24rpx 0;
+  border-radius: 12rpx;
+  border: none;
+}
+</style>

+ 236 - 0
cfc-frontend/pages/teacher/plan-list.vue

@@ -0,0 +1,236 @@
+<template>
+  <view class="page">
+    <view class="header">
+      <text class="title">我的方案</text>
+      <text class="subtitle">查看和管理已制定的训练方案</text>
+    </view>
+
+    <view class="action-bar">
+      <button class="btn-primary" @click="goCreatePlan">+ 新建方案</button>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty" v-else-if="plans.length === 0">
+      <text class="empty-icon">📋</text>
+      <text class="empty-title">暂无方案</text>
+      <text class="empty-desc">点击上方按钮创建第一个训练方案</text>
+    </view>
+
+    <!-- 方案列表 -->
+    <view class="plan-list" v-else>
+      <view
+        class="plan-card"
+        v-for="plan in plans"
+        :key="plan.id"
+        @click="goDetail(plan.id)"
+      >
+        <view class="card-top">
+          <text class="plan-title">{{ plan.title || '未命名方案' }}</text>
+          <view class="plan-status" :class="'status-' + (plan.status || 0)">
+            <text>{{ statusText(plan.status) }}</text>
+          </view>
+        </view>
+        <text class="plan-desc" v-if="plan.description">{{ plan.description }}</text>
+        <view class="card-meta">
+          <text class="meta-item">成员:{{ plan.memberName || '--' }}</text>
+          <text class="meta-item">创建时间:{{ formatTime(plan.createdAt) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getTrainingPlanList } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: false,
+      plans: []
+    }
+  },
+  onLoad() {
+    this.loadPlans()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadPlans()
+    }
+  },
+  methods: {
+    loadPlans() {
+      this.loading = true
+      var self = this
+      getTrainingPlanList().then(function(res) {
+        self.loading = false
+        if (res && res.code === 200) {
+          self.plans = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+          self.plans = []
+        }
+      }).catch(function(err) {
+        self.loading = false
+        console.error('加载方案列表失败', err)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+        self.plans = []
+      })
+    },
+    goDetail(planId) {
+      uni.navigateTo({ url: '/pages/teacher/plan-detail?planId=' + planId })
+    },
+    goCreatePlan() {
+      uni.navigateTo({ url: '/pages/teacher/plan-create' })
+    },
+    statusText(status) {
+      var map = { 0: '草稿', 1: '进行中', 2: '已完成', 3: '已取消' }
+      return map[status] || '未知'
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      // 项目统一时间格式:取前19位并把 ISO 的 T 换成空格,避免 toLocaleString 的浏览器差异
+      return String(t).substring(0, 19).replace('T', ' ')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #F5FAFE;
+  padding-bottom: 30rpx;
+}
+
+.header {
+  padding: 40rpx 30rpx 20rpx;
+}
+
+.title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #1F2D3D;
+  display: block;
+}
+
+.subtitle {
+  font-size: 26rpx;
+  color: #8A94A6;
+  margin-top: 8rpx;
+  display: block;
+}
+
+.action-bar {
+  padding: 0 30rpx 20rpx;
+}
+
+.btn-primary {
+  width: 100%;
+  background: #4A9BD7;
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  padding: 24rpx 0;
+  border-radius: 12rpx;
+  border: none;
+}
+
+.loading, .empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #8A94A6;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-top: 20rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+  margin-top: 10rpx;
+  text-align: center;
+}
+
+.plan-list {
+  padding: 0 30rpx;
+}
+
+.plan-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+
+.plan-card:active {
+  opacity: 0.8;
+}
+
+.card-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.plan-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+}
+
+.plan-status {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+
+.status-0 { background: #FFF3E0; color: #F97316; }
+.status-1 { background: #E8F5E9; color: #4CAF50; }
+.status-2 { background: #E3F2FD; color: #2196F3; }
+.status-3 { background: #F5F5F5; color: #999; }
+
+.plan-desc {
+  font-size: 26rpx;
+  color: #666;
+  margin-top: 12rpx;
+  display: block;
+  line-height: 1.5;
+}
+
+.card-meta {
+  display: flex;
+  flex-direction: column;
+  margin-top: 16rpx;
+  gap: 6rpx;
+}
+
+.meta-item {
+  font-size: 24rpx;
+  color: #bbb;
+}
+</style>

+ 2 - 2
cfc-frontend/pages/teacher/service-tasks.vue

@@ -20,14 +20,14 @@
         @click="viewTaskDetail(task)">
         <view class="task-header">
           <view class="task-title-row">
-            <view class="task-icon" :class="getStatusClass(task)">
+            <view class="task-icon" :class="task.plannerStatus === 'completed' ? 'completed' : (task.assessorStatus === 'completed' ? 'ready' : 'pending')">
               <view v-if="getStatusClass(task) === 'completed'" class="icon-check"></view>
               <view v-else-if="getStatusClass(task) === 'ready'" class="icon-alert"></view>
               <view v-else class="icon-clock"></view>
             </view>
             <text class="task-title">服务执行 #{{ task.id }}</text>
           </view>
-          <text class="task-status" :class="getStatusClass(task)">
+          <text class="task-status" :class="task.plannerStatus === 'completed' ? 'completed' : (task.assessorStatus === 'completed' ? 'ready' : 'pending')">
             {{ getStatusText(task) }}
           </text>
         </view>

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