Explorar o código

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

liaoxg hai 1 mes
pai
achega
43815f2729
Modificáronse 100 ficheiros con 2099 adicións e 327 borrados
  1. 8 0
      .gitignore
  2. 9 2
      AGENTS.md
  3. 1 1
      cfc-backend/AGENTS.md
  4. 80 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  5. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  6. 43 6
      cfc-backend/src/main/java/com/etotem/cfc/config/SortInterceptor.java
  7. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java
  8. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  9. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  10. 3 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/MarketController.java
  11. 6 24
      cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java
  12. 13 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java
  13. 13 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java
  14. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java
  15. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java
  16. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/wish/WishController.java
  17. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java
  18. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java
  19. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/SwitchMemberVO.java
  20. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Child.java
  21. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
  22. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ChildMapper.java
  23. 4 4
      cfc-backend/src/main/java/com/etotem/cfc/service/AccountCleanupService.java
  24. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  25. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  26. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleService.java
  27. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
  28. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/service/CompatibilityService.java
  29. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  30. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java
  31. 60 3
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  32. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java
  33. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthCheckinService.java
  34. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  35. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/InviteCardService.java
  36. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java
  37. 6 83
      cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java
  38. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProblemCompletionService.java
  39. 166 6
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java
  40. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  41. 3 3
      cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java
  42. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TrainingPlanService.java
  43. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  44. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/WishService.java
  45. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java
  46. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/api/UserServiceInterface.java
  47. 5 5
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  48. 15 1
      cfc-backend/src/main/resources/schema.sql
  49. 158 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java
  50. 4 4
      cfc-frontend/components/FamilyMemberStrip.vue
  51. 1 1
      cfc-frontend/components/MemberChipPicker.vue
  52. 60 30
      cfc-frontend/components/ProfileHeader.vue
  53. 73 3
      cfc-frontend/pages/health/health-plan-summary.vue
  54. 2 2
      cfc-frontend/pages/health/teacher-service.vue
  55. 24 16
      cfc-frontend/pages/home-pages/parent-index.vue
  56. 38 11
      cfc-frontend/pages/purchase/index.vue
  57. 29 13
      cfc-frontend/store/index.js
  58. 26 0
      cfc-frontend/utils/api.js
  59. 1 1
      cfc-web/.last_build_commit
  60. 2 2
      cfc-web/package-lock.json
  61. 1 1
      cfc-web/package.json
  62. 142 0
      cfc-web/public/CHANGELOG-v1.0.md
  63. 143 1
      cfc-web/public/CHANGELOG.md
  64. 3 2
      cfc-web/src/api/guidePackage.js
  65. 4 2
      cfc-web/src/api/guideTask.js
  66. 4 2
      cfc-web/src/api/teacherMessage.js
  67. 21 1
      cfc-web/src/mixins/SortMixin.js
  68. 25 1
      cfc-web/src/views/Children.vue
  69. 20 0
      cfc-web/src/views/Families.vue
  70. 24 2
      cfc-web/src/views/GuideFamilyTask.vue
  71. 21 1
      cfc-web/src/views/GuidePackages.vue
  72. 22 3
      cfc-web/src/views/OperationLogs.vue
  73. 20 0
      cfc-web/src/views/Points.vue
  74. 20 0
      cfc-web/src/views/Rewards.vue
  75. 20 0
      cfc-web/src/views/TaskTemplates.vue
  76. 21 1
      cfc-web/src/views/TeacherConsult.vue
  77. 21 0
      cfc-web/src/views/Wishes.vue
  78. 31 3
      cfc-web/src/views/admin/Activities.vue
  79. 23 1
      cfc-web/src/views/admin/ActivityRegistrationReview.vue
  80. 28 2
      cfc-web/src/views/admin/ActivityReview.vue
  81. 28 3
      cfc-web/src/views/admin/ArticleCategory.vue
  82. 25 2
      cfc-web/src/views/admin/AssessmentAssign.vue
  83. 22 1
      cfc-web/src/views/admin/AssessmentOrders.vue
  84. 23 3
      cfc-web/src/views/admin/AssessmentProducts.vue
  85. 33 4
      cfc-web/src/views/admin/BadgeManage.vue
  86. 27 3
      cfc-web/src/views/admin/BaziConfigs.vue
  87. 27 3
      cfc-web/src/views/admin/BloodTypeConfigs.vue
  88. 29 2
      cfc-web/src/views/admin/ButlerAssignments.vue
  89. 40 6
      cfc-web/src/views/admin/CfValueManage.vue
  90. 23 2
      cfc-web/src/views/admin/CommentReview.vue
  91. 28 4
      cfc-web/src/views/admin/CouponGrantLog.vue
  92. 39 6
      cfc-web/src/views/admin/DanExecution.vue
  93. 40 4
      cfc-web/src/views/admin/DanSettlement.vue
  94. 32 4
      cfc-web/src/views/admin/EcomSupplierManage.vue
  95. 22 1
      cfc-web/src/views/admin/EmotionAlertManage.vue
  96. 21 3
      cfc-web/src/views/admin/EnergyBehaviorConfig.vue
  97. 21 2
      cfc-web/src/views/admin/EnergyRuleManagement.vue
  98. 29 3
      cfc-web/src/views/admin/FamilyEarnings.vue
  99. 24 2
      cfc-web/src/views/admin/FamilyEarningsWithdraw.vue
  100. 12 2
      cfc-web/src/views/admin/FileManage.vue

+ 8 - 0
.gitignore

@@ -77,3 +77,11 @@ venv/
 
 # 小红书日报推送本地配置(含飞书密钥,禁止提交)
 运营文案/xhs_daily_config.json
+
+# 小红书运营运行时数据(会话/状态/数据追踪/截图,不入库)
+运营文案/opencode_sessions.json
+运营文案/opencode_sessions.json.bak
+运营文案/xhs_comment_state.json
+运营文案/小红书发布/_过程脚本/replied_comments.json
+运营文案/小红书发布/_数据追踪/daily_reports.jsonl
+运营文案/小红书发布/_过程文件/截图/

+ 9 - 2
AGENTS.md

@@ -311,11 +311,18 @@ curl -X POST http://localhost:9082/api/migration/run
 
 ## 数据库
 
-- 地址:`192.168.16.251:3306/zxyj`(账号 `zxyj / zxyj@123`)
-- 只读源库:`bianwoyou.mysql.rds.aliyuncs.com:3305/sfms`(`SfmsDataSourceConfig` 配置)
+> ⚠️ **环境区分(重要)**:`192.168.16.251:3306` 是**测试库**,**不是生产库**。任何针对它的 DDL 迁移、调试、数据改动都只能用于测试验证,不能代表生产环境实际状态。生产数据库连接信息(主机/库名/账号)不在此处,需向运维确认后再填写到下方,禁止把测试库当成生产库来判断线上问题。
+
+- **测试库**:`192.168.16.251:3306/zxyj`(账号 `zxyj / zxyj@123`)—— 仅用于测试/开发验证
+- **只读源库**:`bianwoyou.mysql.rds.aliyuncs.com:3305/sfms`(`SfmsDataSourceConfig` 配置)
+- **生产库**:待补充(向运维确认后填入;所有线上判断必须基于生产库,而非上面的测试库)
 - 建表定义:`cfc-backend/src/main/resources/schema.sql`(完整快照)
 - 迁移入口:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
 
+> **一线原则**:判断"生产缺列 / Unknown column"等线上问题时,必须以**生产库**为准;测试库(192.168.16.251)的库表结构不代表生产,schema.sql 也只是同步镜像,最终以生产确认结果为准。
+
+> ⚠️ **`children` 表标注冲突**:schema.sql 将 `children` 标为"已废除/并入 family_members",但 `Child` 实体、`ChildMapper`、多个 Service(`AccountCleanupService`、`TaskStatsService`、`GrowthTaskService` 等)仍**活跃使用**,且生产库存在真实的 `children` 表。生产 `children` 表缺 `phone`/`id_card` 两列(`AccountCleanupService.exportUserData` 全字段 SELECT 会报 `Unknown column 'phone'`),用户已决定**不迁移,保持现状**。处理 `children` 相关需求时勿据此表已废弃而误删实体/迁移,也不要重复加 `phone`/`id_card` 迁移。
+
 ## 核心框架约定
 
 - **接口**: 统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`

+ 1 - 1
cfc-backend/AGENTS.md

@@ -221,7 +221,7 @@ cd cfc-langgraph && pip install -r requirements.txt && uvicorn src.app:app --por
 
 ## NOTES
 
-- 数据库:MySQL 8.0,地址 `192.168.16.251:3306/cfc`
+- 数据库:MySQL 8.0,**测试库**地址 `192.168.16.251:3306`(库名可能为 `zxyj`,参见根 AGENTS.md;该地址只是测试环境,**不是生产库**,生产连接信息需向运维确认)
 - 后端端口:9082,JWT 密钥在 `application.yml`
 - 街道数据:使用静态种子数据,街道匹配有回退机制
 - API冲突已修复:检查所有Controller确保无重复路径映射

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10345,5 +10345,85 @@ public class DatabaseInitializer implements CommandLineRunner {
         // 迁移294: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
         ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
         log.info("已添加 articles.sort_order 列(迁移294)");
+
+        // 迁移295: task_plan_instances 补齐实体已含但 DB 缺失的审核/确认/激活列(review/activate 写字段报 Unknown column)
+        ensureColumn("task_plan_instances", "source", "VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template'");
+        ensureColumn("task_plan_instances", "source_result_id", "BIGINT DEFAULT NULL COMMENT '关联测评结果ID(source=assessment时)'");
+        ensureColumn("task_plan_instances", "remark", "TEXT COMMENT '方案备注(含指导意见摘要)'");
+        ensureColumn("task_plan_instances", "reviewed_by", "BIGINT DEFAULT NULL COMMENT '审核人(规划师ID)'");
+        ensureColumn("task_plan_instances", "reviewed_at", "DATETIME DEFAULT NULL COMMENT '审核时间'");
+        ensureColumn("task_plan_instances", "review_comment", "TEXT COMMENT '审核意见'");
+        ensureColumn("task_plan_instances", "confirmed_by", "BIGINT DEFAULT NULL COMMENT '确认人(家长ID)'");
+        ensureColumn("task_plan_instances", "confirmed_at", "DATETIME DEFAULT NULL COMMENT '确认时间'");
+        ensureColumn("task_plan_instances", "activated_at", "DATETIME DEFAULT NULL COMMENT '激活时间'");
+        log.info("迁移295: 已补齐 task_plan_instances 审核/确认/激活列");
+
+        // 迁移296: 健康方案任务 source_type 'plan' -> 'health_plan'(消除与 task_plan_instances 的 ID 域冲突)
+        try {
+            // 冲突检测:source_id 同时存在于 health_plans 和 task_plan_instances 的任务(需人工确认,不阻断迁移)
+            Integer conflict = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM tasks t WHERE t.source_type = 'plan' " +
+                    "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = t.source_id) " +
+                    "AND EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = t.source_id)",
+                    Integer.class);
+            if (conflict != null && conflict > 0) {
+                log.warn("迁移296: 检测到{}条冲突任务(source_id同时存在于health_plans和task_plan_instances),需人工确认;无冲突部分仍继续迁移", conflict);
+            }
+            // 始终执行 UPDATE:仅迁移"存在于 health_plans 且不存在于 task_plan_instances"的任务(冲突部分不迁移,避免误改套餐任务)
+            int updated = jdbcTemplate.update(
+                    "UPDATE tasks SET source_type = 'health_plan' " +
+                    "WHERE source_type = 'plan' " +
+                    "AND EXISTS (SELECT 1 FROM health_plans h WHERE h.id = tasks.source_id) " +
+                    "AND NOT EXISTS (SELECT 1 FROM task_plan_instances i WHERE i.id = tasks.source_id)");
+            log.info("迁移296: 已修正{}条健康方案任务 source_type -> health_plan", updated);
+        } catch (Exception e) {
+            log.warn("迁移296执行失败(health_plans 表可能不存在): {}", e.getMessage());
+        }
+
+        // 迁移297: 认知训练方案任务补设 source_type='training_plan'(training_plan_tasks 关联溯源)
+        try {
+            Integer updated = jdbcTemplate.update(
+                    "UPDATE tasks t SET t.source_type = 'training_plan' " +
+                    "WHERE (t.source_type IS NULL OR t.source_type = '') " +
+                    "AND EXISTS (SELECT 1 FROM training_plan_tasks pt WHERE pt.task_id = t.id)");
+            log.info("迁移297: 已补设{}条认知训练方案任务 source_type -> training_plan", updated);
+        } catch (Exception e) {
+            log.warn("迁移297执行失败(training_plan_tasks 表可能不存在): {}", e.getMessage());
+        }
+
+        // 迁移298: 存量 role='child' 账号强制迁移为成员模型
+        // 1) 有 openid 的 child → role 改 parent(可登录成员,登录后进成员端界面,不能被切换)
+        // 2) 无 openid 的 child → 保持不可登录,确保有 family_members 记录(仅可被切换)
+        try {
+            Integer updated = jdbcTemplate.update(
+                    "UPDATE users SET role='parent' WHERE role='child' AND openid IS NOT NULL AND openid <> ''");
+            log.info("迁移298: 已迁移{}条有 openid 的 child 账号 role → parent", updated);
+        } catch (Exception e) {
+            log.warn("迁移298第一步失败", e);
+        }
+        try {
+            jdbcTemplate.execute(
+                "INSERT INTO family_members (family_id, user_id, nickname, gender, birthday, generation, show_to_family, total_points, created_at, updated_at) " +
+                "SELECT u.family_id, u.id, u.nickname, u.gender, u.birthday, -1, 1, 0, NOW(), NOW() " +
+                "FROM users u " +
+                "WHERE u.role='child' AND (u.openid IS NULL OR u.openid='') " +
+                "AND u.family_id IS NOT NULL " +
+                "AND NOT EXISTS (SELECT 1 FROM family_members fm WHERE fm.user_id = u.id)");
+            log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记录");
+        } catch (Exception e) {
+            log.warn("迁移298第二步失败", e);
+        }
+
+        // ===== 生产库对账候选风险记录(已核对生产库,2026-09,按用户决定不迁移/暂不动,仅记录)=====
+        // 注:以下为 entity 期望但生产库未对齐.项目,用户已决定保持现状或记为候选,勿重复迁移。
+        // 1. children 表缺 phone、id_card(Child 实体+ChildMapper 仍活跃,AccountCleanupService
+        //    exportUserData 全字段 SELECT 会报 Unknown column 'phone')。schema.sql 已将 children 标为
+        //    已废除/并入 family_members,用户决定不迁移。
+        // 2. user_trigger_rules:生产表仅剩 user_id(PK,auto_increment)/action_config/last_triggered_at,
+        //    实体期望 id/trigger_type/trigger_action/status/created_at 共 8 列,表 0 行且不在 schema.sql。
+        //    UserSegmentationService.getTriggerRules 会查 status,命中时会报 Unknown column 'status'。候选。
+        // 3. promotion_tier.rate_percent:生产用旧模型(tier/team_size_1st/2nd/3rd/total_*),实体期望新模型,
+        //    表 0 行。PromotionTier 服务广泛使用。候选。
+        // 4. promotion_tier_config:实体已废弃,改用 cf_rate_tier(迁移279),保持现状。
     }
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -185,7 +185,8 @@ public class JwtInterceptor implements HandlerInterceptor {
             // 避免前端带残留 token 时被静默降级为匿名,导致需登录接口返回
             // "请先登录"(code=500) 而前端仍认为已登录。前端收到 401 后
             // 会清理本地登录态并引导重新登录。
-            log.warn("Token解析失败, 拒绝请求: path={}, error={}", path, e.getMessage());
+            String tokenPreview = token != null ? token.substring(0, Math.min(30, token.length())) + "..." : "null";
+            log.warn("Token解析失败, 拒绝请求: path={}, error={}, token={}", path, e.getMessage(), tokenPreview);
             writeUnauthorized(response);
             return false;
         }

+ 43 - 6
cfc-backend/src/main/java/com/etotem/cfc/config/SortInterceptor.java

@@ -6,6 +6,7 @@ import org.springframework.web.servlet.HandlerInterceptor;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 
@@ -16,18 +17,54 @@ public class SortInterceptor implements HandlerInterceptor {
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         String sortJson = request.getParameter("sort");
         if (sortJson != null && !sortJson.isEmpty()) {
+            parseAndSetSort(sortJson);
+            return true;
+        }
+        byte[] cachedBody = com.etotem.cfc.config.RequestBodyCachingFilter.getCachedBody();
+        if (cachedBody != null && cachedBody.length > 0) {
             try {
                 @SuppressWarnings("unchecked")
-                List<Map<String, String>> sortSpecs = new com.fasterxml.jackson.databind.ObjectMapper()
-                        .readValue(sortJson, List.class);
-                SortContext.set(sortSpecs);
-            } catch (Exception e) {
-                // ignore invalid sort param
-            }
+                Map<String, Object> body = new com.fasterxml.jackson.databind.ObjectMapper()
+                        .readValue(cachedBody, Map.class);
+                Object sortObj = body.get("sort");
+                if (sortObj != null) {
+                    @SuppressWarnings("unchecked")
+                    List<Map<String, String>> sortSpecs = (List<Map<String, String>>) sortObj;
+                    // 白名单:只保留 column/order,过滤非法字段
+                    List<Map<String, String>> sanitized = Arrays.stream(sortSpecs.toArray(new Map[0]))
+                            .filter(m -> m != null)
+                            .map(m -> {
+                                Map<String, String> safe = new java.util.LinkedHashMap<>();
+                                Object col = m.get("column");
+                                Object order = m.get("order");
+                                if (col != null && order != null) {
+                                    safe.put("column", col.toString());
+                                    safe.put("order", order.toString().toLowerCase());
+                                }
+                                return safe;
+                            })
+                            .filter(m -> !m.isEmpty())
+                            .collect(java.util.stream.Collectors.toList());
+                    if (!sanitized.isEmpty()) {
+                        SortContext.set(sanitized);
+                    }
+                }
+            } catch (Exception ignored) {}
         }
         return true;
     }
 
+    private void parseAndSetSort(String sortJson) {
+        try {
+            @SuppressWarnings("unchecked")
+            List<Map<String, String>> sortSpecs = new com.fasterxml.jackson.databind.ObjectMapper()
+                    .readValue(sortJson, List.class);
+            SortContext.set(sortSpecs);
+        } catch (Exception ignored) {
+            // ignore invalid sort param
+        }
+    }
+
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
         SortContext.clear();

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

@@ -38,6 +38,7 @@ public class FamilyChallengeController {
     public Result<Void> updateProgress(@RequestBody Map<String, Object> params,
                                        @RequestAttribute(value = "role", required = false) String role,
                                        @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        // 新模型下 role 不再有 'child',此条件永远为 true(历史兼容)
         if (!"child".equals(role)) {
             return Result.error("仅孩子可打卡");
         }

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

@@ -33,7 +33,7 @@ public class HealthPlanController {
         // 付费墙拦截
         java.util.Map<String, Object> purchaseCheck = purchaseService.checkPurchase(userId);
         if (!Boolean.TRUE.equals(purchaseCheck.get("isActive"))) {
-            return Result.error("请先购买健康启航计划体验包");
+            return Result.error(4001, "请先购买健康启航计划体验包");
         }
         
         String memberIds = (String) params.get("memberIds");

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

@@ -180,7 +180,7 @@ public class HealthReportController {
         // 付费墙拦截:检查是否购买健康启航计划
         java.util.Map<String, Object> purchaseCheck = purchaseService.checkPurchase(userId);
         if (!Boolean.TRUE.equals(purchaseCheck.get("isActive"))) {
-            return Result.error("请先购买健康启航计划体验包");
+            return Result.error(4001, "请先购买健康启航计划体验包");
         }
 
         if (familyId == null) {

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

@@ -82,7 +82,7 @@ public class MarketController {
      */
     @Operation(summary = "购买并应用任务模板")
     @PostMapping("/packages/{id}/purchase")
-    public Result<TaskPlanInstance> purchasePackage(
+    public Result<Map<String, Object>> purchasePackage(
             @PathVariable Long id,
             @RequestBody ApplyPackageDTO dto,
             @RequestAttribute("userId") Long userId) {
@@ -91,8 +91,8 @@ public class MarketController {
             return Result.noFamily("请先创建或加入家庭");
         }
         dto.setPackageId(id);
-        TaskPlanInstance instance = planService.applyPackage(dto, familyId);
-        return Result.success(instance);
+        Map<String, Object> applied = planService.applyAndActivate(dto, familyId, userId);
+        return Result.success(applied);
     }
 
     /**

+ 6 - 24
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java

@@ -4,14 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ApplyPackageDTO;
 import com.etotem.cfc.entity.ProblemDomain;
-import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.TaskTemplateItem;
 import com.etotem.cfc.entity.TaskTemplatePackage;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.mapper.PackageOrderMapper;
 import com.etotem.cfc.mapper.ProblemDomainMapper;
-import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
 import com.etotem.cfc.mapper.UserIntentMapper;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.TaskPlanService;
@@ -45,9 +43,6 @@ public class ProblemPackageController {
     @Resource
     private TaskPlanService taskPlanService;
 
-    @Resource
-    private TaskPlanInstanceMapper planInstanceMapper;
-
     @Resource
     private UserIntentMapper userIntentMapper;
 
@@ -132,25 +127,12 @@ public class ProblemPackageController {
             return Result.error("请先购买套餐后再确认方案");
         }
 
-        // 幂等:同一家庭同一套餐已有激活中的方案实例则复用
-        TaskPlanInstance plan = planInstanceMapper.selectOne(
-                new LambdaQueryWrapper<TaskPlanInstance>()
-                        .eq(TaskPlanInstance::getFamilyId, familyId)
-                        .eq(TaskPlanInstance::getPackageId, packageId)
-                        .eq(TaskPlanInstance::getStatus, "active")
-                        .last("LIMIT 1"));
-        Long planId;
-        if (plan == null) {
-            ApplyPackageDTO dto = new ApplyPackageDTO();
-            dto.setPackageId(packageId);
-            TaskPlanInstance created = taskPlanService.applyPackage(dto, familyId);
-            planId = created.getId();
-        } else {
-            planId = plan.getId();
-        }
-
-        // 生成方案任务(memberOnly=1,幂等)
-        int taskCount = taskPlanService.generatePlanTasks(planId, familyId, userId);
+        // 应用套餐并激活(幂等复用 + 自助置 active + 统一生成任务)
+        ApplyPackageDTO dto = new ApplyPackageDTO();
+        dto.setPackageId(packageId);
+        Map<String, Object> applied = taskPlanService.applyAndActivate(dto, familyId, userId);
+        Long planId = (Long) applied.get("planId");
+        int taskCount = (Integer) applied.get("taskCount");
 
         // 推进旅程 stage=6(方案已确认,任务已生成)
         UserIntent intent = findIntent(userId);

+ 13 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -189,11 +189,22 @@ public class AuthController {
     @PostMapping("/set-password")
     public Result<Boolean> setPassword(@RequestBody SetPasswordDTO dto,
                                        @RequestAttribute("userId") Long userId) {
-        boolean success = userService.setPassword(userId, dto.getPassword());
+        boolean success = userService.setPassword(userId, dto.getPassword());
         return Result.success(success);
     }
 
-    /**
+    @Operation(summary = "设置切换保护密码")
+    @PostMapping("/set-switch-password")
+    public Result<Boolean> setSwitchPassword(@RequestBody SetPasswordDTO dto,
+                                             @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        boolean success = userService.setSwitchPassword(userId, dto.getPassword());
+        return Result.success(success);
+    }
+
+    /**
      * JWT Token 验证
      * 从 Authorization header 提取 Bearer token,验证并返回用户信息
      */

+ 13 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java

@@ -131,12 +131,22 @@ public class FamilyUserController {
         return Result.error(410, "该接口已废弃,请使用 /api/family/member/switch");
     }
 
-    @Operation(summary = "带密码校验切换回家长视角(已废弃,使用 /api/family/member/switch)")
-    @Deprecated
+    @Operation(summary = "带密码校验退出成员切换(返回原用户身份)")
     @PostMapping("/switch-back-verify")
     public Result<Boolean> switchBackVerify(@RequestAttribute("userId") Long userId,
         @RequestBody Map<String, String> body) {
-        return Result.error(410, "该接口已废弃,请使用 /api/family/member/switch");
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        String password = body != null ? body.get("password") : null;
+        if (password == null || password.isEmpty()) {
+            return Result.error("请输入密码");
+        }
+        boolean valid = userService.verifyPassword(userId, password);
+        if (!valid) {
+            return Result.error("密码错误");
+        }
+        return Result.success(true);
     }
 
     @Operation(summary = "创建孩子")

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java

@@ -57,6 +57,7 @@ public class EmotionCheckinController {
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
         int page = params.containsKey("page") ? Integer.parseInt(params.get("page").toString()) : 1;
         int size = params.containsKey("size") ? Integer.parseInt(params.get("size").toString()) : 20;
+        // 新模型下 role 不再有 'child',hidePhoto 永远为 false(历史兼容)
         boolean hidePhoto = !"child".equals(role);
         Object result = emotionCheckinService.getCheckinList(memberId, page, size, userId, hidePhoto);
         return Result.success(result);
@@ -68,6 +69,7 @@ public class EmotionCheckinController {
                             @RequestAttribute("role") String role,
                             @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        // 新模型下 role 不再有 'child',hidePhoto 永远为 false(历史兼容)
         boolean hidePhoto = !"child".equals(role);
         Object result = emotionCheckinService.getLatestCheckin(memberId, userId, hidePhoto);
         return Result.success(result);

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java

@@ -219,6 +219,7 @@ public class TianpanController {
                 return isFamilyMember(familyId, currentUserId, "parent");
             case "child":
                 // 孩子:访问自己家庭
+                // 非登录用户(不可登录成员)
                 return isFamilyMember(familyId, currentUserId, "child");
             case "teacher":
                 // 规划师:检查绑定关系

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/wish/WishController.java

@@ -35,6 +35,7 @@ public class WishController {
             @RequestBody CreateWishDTO dto) {
         try {
             Long wishId;
+            // 新模型下 user.role 不再有 'child',此分支为历史兼容(旧账号)
             if ("child".equals(role)) {
                 wishId = wishService.createByChild(userId, dto);
             } else {

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

@@ -71,4 +71,10 @@ public class CreateTaskDTO {
 
     // TASK-SYS-248: 最短持续秒数(专注/游戏类,未达标不计完成)
     private Integer minDurationSeconds;
+
+    // 来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase
+    private String sourceType;
+
+    // 来源ID(如 training_plans.id / task_plan_instances.id)
+    private Long sourceId;
 }

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

@@ -84,4 +84,7 @@ public class FamilyMemberVO {
 
     /** 是否为当前登录用户本人(用于前端控制移除/切换等操作权限) */
     private Boolean isSelf;
+
+    /** 是否可被切换视角(仅不可登录成员 true,由 service 层根据 openid 判定) */
+    private Boolean isSwitchable;
 }

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

@@ -16,4 +16,7 @@ public class SwitchMemberVO {
 
     /** 成员昵称 */
     private String nickname;
+
+    /** 是否可被切换(服务端已校验,仅不可登录成员返回 true) */
+    private Boolean isSwitchable;
 }

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

@@ -8,6 +8,10 @@ import lombok.Data;
 import java.io.Serializable;
 import java.util.Date;
 
+/**
+ * @Deprecated children 表已废除,数据已合并到 family_members 表。
+ * 保留实体类以兼容历史引用,新代码请使用 FamilyMember。
+ */
 @Data
 @TableName("children")
 public class Child implements Serializable {

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -158,4 +158,19 @@ public class FamilyMember implements Serializable {
         java.time.LocalDate now = java.time.LocalDate.now();
         return java.time.Period.between(birth, now).getYears();
     }
+
+    /**
+     * 成员类型: login=可登录成员(有openid) / local=不可登录成员(用户创建,无openid)
+     * 判定依据: userId 关联的 User 是否有 openid
+     * 注意: 该字段为 VO 层计算值,不落库;由 service 层传入 userId 对应的 openid 状态后设置
+     */
+    private String memberType;
+
+    /**
+     * 是否可被切换(不可登录成员才可被切换)
+     */
+    public boolean isSwitchable(String userOpenid) {
+        // userId 为空 或 user 无 openid → 不可登录 → 可切换
+        return userOpenid == null || userOpenid.isEmpty();
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ChildMapper.java

@@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.Child;
 import org.apache.ibatis.annotations.Mapper;
 
+/**
+ * @deprecated children 表已废除,数据已合并到 family_members 表。新代码请使用 FamilyMemberMapper。
+ */
 @Mapper
 public interface ChildMapper extends BaseMapper<Child> {
 }

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

@@ -165,10 +165,10 @@ public class AccountCleanupService {
         userInfo.put("memberLevel", user.getMemberLevel());
         data.put("userInfo", userInfo);
 
-        // 孩子信息
-        data.put("children", childMapper.selectList(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Child>()
-                        .eq(Child::getUserId, userId)
+        // 孩子信息(已迁移至 family_members 表)
+        data.put("children", familyMemberMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
         ));
 
         // 测评结果

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

@@ -319,7 +319,7 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
             task.setFamilyId(child.getFamilyId());
         }
         task.setCreatorId(userId);
-        task.setExecutorType("child");
+        task.setExecutorType("child"); // 非登录用户(不可登录成员)
         task.setExecutorId(memberId);
         task.setChildId(memberId);
         task.setTitle("活动签到: " + activity.getTitle());

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

@@ -248,7 +248,7 @@ public class ArticleService {
         Task task = new Task();
         task.setFamilyId(null); // will set after getting child
         task.setCreatorId(userId);
-        task.setExecutorType("child");
+        task.setExecutorType("child"); // 非登录用户(不可登录成员)
         task.setExecutorId(memberId);
         task.setChildId(memberId);
         task.setTitle("阅读文章: " + article.getTitle());
@@ -902,6 +902,7 @@ public class ArticleService {
     public Page<Article> getMyPosts(Long userId, int page, int size) {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getCreatedBy, userId)
+                // 非登录用户(不可登录成员)
                 .in(Article::getAuthorType, "parent", "child")
                 .orderByDesc(Article::getCreatedAt);
         return articleMapper.selectPage(new Page<>(page, size), wrapper);

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

@@ -96,6 +96,7 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
     public List<Map<String, Object>> discoverCircles(Long memberId) {
         LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
                 .eq(SocialCircleMember::getMemberId, memberId)
+                // 非登录用户(不可登录成员)
                 .eq(SocialCircleMember::getMemberType, "child");
         SortUtil.applySort(mw);
         List<SocialCircleMember> existing = socialCircleMemberMapper.selectList(mw);

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

@@ -95,8 +95,10 @@ public class CommissionService {
     // ==================== 邀请码相关 ====================
 
     public String getOrCreateReferralCode(Long userId) {
+        log.info("getOrCreateReferralCode: userId={}", userId);
         User user = userMapper.selectById(userId);
         if (user == null) {
+            log.warn("用户不存在: userId={}", userId);
             throw new RuntimeException("用户不存在");
         }
         if (user.getReferralCode() != null) {

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

@@ -311,9 +311,9 @@ public class CompatibilityService {
         FamilyMemberTraditionVO vo = new FamilyMemberTraditionVO();
         vo.setMemberId(member.getId());
         vo.setNickname(member.getNickname());
-        vo.setMemberType("child");
+        vo.setMemberType("child"); // 非登录用户(不可登录成员)
 
-        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(member.getId(), "child");
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(member.getId(), "child"); // 非登录用户(不可登录成员)
         if (attrs == null) {
             attrs = familyMemberAttributeService.getByMember(member.getId(), "parent");
             if (attrs != null) vo.setMemberType("parent");

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

@@ -420,7 +420,7 @@ public class EnergyService {
     private MemberEnergyDTO calcChildEnergy(FamilyMember child) {
         MemberEnergyDTO dto = new MemberEnergyDTO();
         dto.setMemberId(child.getId());
-        dto.setMemberType("child");
+        dto.setMemberType("child"); // 非登录用户(不可登录成员)
         dto.setName(child.getNickname());
         dto.setAvatar(null);  // 孩子没有独立头像,用默认
 

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

@@ -45,6 +45,7 @@ public class FamilyHealthScoreService {
             }
         }
 
+        // 新模型下 role 不再有 'child',此分支为历史兼容
         if ("child".equals(role)) {
             FamilyMember self = null;
             for (FamilyMember m : children) {

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

@@ -127,20 +127,52 @@ public class FamilyMemberService {
         member.setCreatedAt(new Date());
         member.setUpdatedAt(new Date());
 
-        // 手机号匹配:查找是否有同手机号的已注册用户
+        // 【重构】成员创建:同步创建 user 记录(无 openid = 不可登录)
+        // 1) 若填写了手机号且已存在同手机号用户 → 复用该 user(绑定 userId)
+        // 2) 若填写了手机号但不存在 → 创建无 openid 的 user,预留未来注册复用
+        // 3) 未填写手机号 → 创建无 openid 的 user
+        Long memberUserId = null;
         if (dto.getPhone() != null && !dto.getPhone().isEmpty()) {
             User matchedUser = userMapper.selectOne(
                     new LambdaQueryWrapper<User>().eq(User::getPhone, dto.getPhone()));
             if (matchedUser != null) {
-                member.setUserId(matchedUser.getId());
+                memberUserId = matchedUser.getId();
                 // 将匹配到的用户加入同一家庭(如果尚未加入)
                 if (matchedUser.getFamilyId() == null) {
                     matchedUser.setFamilyId(familyId);
                     matchedUser.setUpdatedAt(new Date());
                     userMapper.updateById(matchedUser);
                 }
+            } else {
+                // 创建预留用户(无 openid,后续同手机号注册时复用)
+                User newUser = new User();
+                newUser.setOpenid("");
+                newUser.setUnionid("");
+                newUser.setRole("parent"); // 无 child 角色,统一 parent 兜底(实际不可登录)
+                newUser.setNickname(dto.getNickname().trim());
+                newUser.setPhone(dto.getPhone());
+                newUser.setFamilyId(familyId);
+                newUser.setPassword(""); // 初始密码为空,切换保护密码后续由用户设置
+                newUser.setCreatedAt(new Date());
+                newUser.setUpdatedAt(new Date());
+                userMapper.insert(newUser);
+                memberUserId = newUser.getId();
             }
-        }
+        } else {
+            // 未填手机号:创建无 openid 的 user(昵称必须非空)
+            User newUser = new User();
+            newUser.setOpenid("");
+            newUser.setUnionid("");
+            newUser.setRole("parent");
+            newUser.setNickname(dto.getNickname().trim());
+            newUser.setFamilyId(familyId);
+            newUser.setPassword("");
+            newUser.setCreatedAt(new Date());
+            newUser.setUpdatedAt(new Date());
+            userMapper.insert(newUser);
+            memberUserId = newUser.getId();
+        }
+        member.setUserId(memberUserId);
 
         familyMemberMapper.insert(member);
 
@@ -244,6 +276,14 @@ public class FamilyMemberService {
             // 兼容旧前端字段
             vo.setMemberId(fm.getId());
             vo.setName(fm.getNickname());
+            // 设置 isSwitchable:仅无 openid 的不可登录成员可被切换
+            if (fm.getUserId() != null) {
+                User memberUser = userMapper.selectById(fm.getUserId());
+                String openid = memberUser != null ? memberUser.getOpenid() : null;
+                vo.setIsSwitchable(openid == null || openid.isEmpty());
+            } else {
+                vo.setIsSwitchable(true);
+            }
             result.add(vo);
         }
 
@@ -304,11 +344,20 @@ public class FamilyMemberService {
         // 从 family_members 查找
         FamilyMember member = familyMemberMapper.selectById(memberId);
         if (member != null && member.getFamilyId().equals(user.getFamilyId())) {
+            // 【重构】成员切换校验:仅不可登录成员可被切换
+            User memberUser = member.getUserId() != null ? userMapper.selectById(member.getUserId()) : null;
+            String openid = memberUser != null ? memberUser.getOpenid() : null;
+            boolean canLogin = openid != null && !openid.isEmpty();
+            if (canLogin) {
+                throw new RuntimeException("该成员可通过微信登录,不能切换为TA的身份");
+            }
+
             SwitchMemberVO vo = new SwitchMemberVO();
             vo.setMemberId(member.getId());
             vo.setSource("family_member");
             vo.setEffectiveRole(computeEffectiveRole(member));
             vo.setNickname(member.getNickname());
+            vo.setIsSwitchable(true);
             return vo;
         }
 
@@ -460,6 +509,14 @@ public class FamilyMemberService {
         if (member.getBirthday() != null) {
             vo.setAge(calculateAge(member.getBirthday()));
         }
+        // 设置 isSwitchable:仅无 openid 的不可登录成员可被切换
+        if (member.getUserId() != null) {
+            User memberUser = userMapper.selectById(member.getUserId());
+            String openid = memberUser != null ? memberUser.getOpenid() : null;
+            vo.setIsSwitchable(openid == null || openid.isEmpty());
+        } else {
+            vo.setIsSwitchable(true);
+        }
         return vo;
     }
 

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

@@ -88,7 +88,7 @@ public class FinanceCheckinService {
                         task.setFamilyId(child.getFamilyId());
                     }
                     task.setCreatorId(checkin.getUserId());
-                    task.setExecutorType("child");
+                    task.setExecutorType("child"); // 非登录用户(不可登录成员)
                     task.setExecutorId(memberId);
                     task.setChildId(memberId);
                     task.setTitle("理财打卡");

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

@@ -110,7 +110,7 @@ public class HealthCheckinService {
                         task.setFamilyId(child.getFamilyId());
                     }
                     task.setCreatorId(checkin.getUserId());
-                    task.setExecutorType("child");
+                    task.setExecutorType("child"); // 非登录用户(不可登录成员)
                     task.setExecutorId(childId);
                     task.setChildId(childId);
                     task.setTitle("健康打卡");

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

@@ -33,7 +33,7 @@ public interface HealthPlanService {
      */
     Long autoGenerateAndPublish(Long familyId, Long memberId, String reportType);
 
-    /** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
+    /** 查询方案分解后的任务列表(source_type=health_plan, source_id=planId) */
     List<Task> getPlanTasks(Long planId);
 
     /** 基于自检低分维度自动生成 draft 计划 */

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

@@ -163,7 +163,7 @@ private String generateCode(String type) {
         Map<String, Object> result = new HashMap<>();
         result.put("token", jwtConfig.generateToken(user.getId(), user.getRole()));
         result.put("userId", user.getId());
-        result.put("role", "child");
+        result.put("role", "parent"); // 不可登录成员 role 统一 parent(新模型)
         result.put("familyId", user.getFamilyId());
         return Result.success(result);
     }

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

@@ -246,7 +246,7 @@ public class MicroActionService {
                 task.setFamilyId(child.getFamilyId());
             }
             task.setCreatorId(userId);
-            task.setExecutorType("child");
+            task.setExecutorType("child"); // 非登录用户(不可登录成员)
             task.setExecutorId(childId);
             task.setChildId(childId);
             task.setTitle("微行动");

+ 6 - 83
cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java

@@ -1,15 +1,9 @@
 package com.etotem.cfc.service;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.etotem.cfc.entity.FamilyMember;
-import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.TaskPlanInstance;
-import com.etotem.cfc.entity.TaskTemplateItem;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
-import com.etotem.cfc.mapper.TaskTemplateItemMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -17,9 +11,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
-import java.util.Calendar;
 import java.util.Date;
-import java.util.List;
 
 /**
  * 方案激活服务。
@@ -33,9 +25,6 @@ public class PlanActivationService {
     @Resource
     private TaskPlanInstanceMapper planMapper;
 
-    @Resource
-    private TaskMapper taskMapper;
-
     @Resource
     private UserMapper userMapper;
 
@@ -43,14 +32,13 @@ public class PlanActivationService {
     private FamilyMemberMapper familyMemberMapper;
 
     @Resource
-    private TaskTemplateItemMapper templateItemMapper;
+    private TaskPlanService taskPlanService;
 
     /**
      * 激活方案:
      * 1. 校验状态必须为 reviewed
-     * 2. 生成一条引导性任务(方案概览/首日任务)
-     * 3. 若方案关联了模板包(packageId),按模板项批量生成日常任务(任务分解)
-     * 4. 标记方案为 active
+     * 2. 调用统一任务生成器生成引导任务 + 模板任务
+     * 3. 标记方案为 active
      */
     @Transactional
     public void activate(Long planId, Long operatorId) {
@@ -64,65 +52,8 @@ public class PlanActivationService {
                 || familyMemberMapper.selectById(plan.getChildId()) != null;
         if (!childExists) throw new IllegalStateException("孩子不存在");
 
-        // 生成首日引导任务
-        Task guideTask = new Task();
-        guideTask.setFamilyId(plan.getFamilyId());
-        guideTask.setChildId(plan.getChildId());
-        guideTask.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
-        guideTask.setCreatorId(operatorId);
-        guideTask.setExecutorType("child");
-        guideTask.setExecutorId(plan.getChildId());
-        guideTask.setTitle("方案启动: " + plan.getName());
-        guideTask.setDescription("请阅读并开始执行测评方案");
-        guideTask.setPoints(2);
-        guideTask.setCategory("成长");
-        guideTask.setStatus("pending");
-        guideTask.setSourceType("plan");
-        guideTask.setSourceId(plan.getId());
-        guideTask.setNeedReview(0);
-        guideTask.setIsTemplate(0);
-        guideTask.setMemberOnly(1); // B20: 方案/套餐生成的任务为会员专属
-        fillDeadline(guideTask);
-        guideTask.setCreatedAt(new Date());
-        guideTask.setUpdatedAt(new Date());
-        taskMapper.insert(guideTask);
-
-        // 按模板包任务项批量生成日常任务(任务分解)
-        if (plan.getPackageId() != null) {
-            List<TaskTemplateItem> items = templateItemMapper.selectList(
-                    new LambdaQueryWrapper<TaskTemplateItem>()
-                            .eq(TaskTemplateItem::getPackageId, plan.getPackageId())
-                            .orderByAsc(TaskTemplateItem::getSortOrder));
-            int itemCount = 0;
-            for (TaskTemplateItem item : items) {
-                if (item.getTaskName() == null || item.getTaskName().isEmpty()) continue;
-                Task t = new Task();
-                t.setFamilyId(plan.getFamilyId());
-                t.setChildId(plan.getChildId());
-                t.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
-                t.setCreatorId(operatorId);
-                t.setExecutorType("child");
-                t.setExecutorId(plan.getChildId());
-                t.setTitle(item.getTaskName());
-                t.setDescription(item.getDescription());
-                t.setPoints(item.getPoints() != null ? item.getPoints() : 2);
-                t.setCategory("成长");
-                t.setStatus("pending");
-                t.setSourceType("plan");
-                t.setSourceId(plan.getId());
-                t.setNeedReview(0);
-                t.setIsTemplate(0);
-                t.setMemberOnly(1);
-                t.setFrequency(item.getFrequency() != null ? item.getFrequency() : "daily");
-                t.setDuration(item.getDuration());
-                fillDeadline(t);
-                t.setCreatedAt(new Date());
-                t.setUpdatedAt(new Date());
-                taskMapper.insert(t);
-                itemCount++;
-            }
-            log.info("方案{}激活按模板包{}生成{}条任务", planId, plan.getPackageId(), itemCount);
-        }
+        // 统一任务生成器(引导任务 + 模板任务,幂等)
+        taskPlanService.generateTasksFromTemplate(plan, operatorId);
 
         // 标记方案已激活
         plan.setStatus("active");
@@ -132,14 +63,6 @@ public class PlanActivationService {
         plan.setUpdatedAt(new Date());
         planMapper.updateById(plan);
 
-        log.info("方案激活 planId={}, childId={}, taskId={}", planId, plan.getChildId(), guideTask.getId());
-    }
-
-    private void fillDeadline(Task task) {
-        Calendar cal = Calendar.getInstance();
-        cal.set(Calendar.HOUR_OF_DAY, 23);
-        cal.set(Calendar.MINUTE, 59);
-        cal.set(Calendar.SECOND, 59);
-        task.setDeadline(cal.getTime());
+        log.info("方案激活 planId={}, childId={}", planId, plan.getChildId());
     }
 }

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

@@ -98,7 +98,7 @@ public class ProblemCompletionService {
             return;
         }
 
-        // 2. 找这些方案生成的计划任务(source_type='plan')
+        // 2. 找这些方案生成的计划任务(source_type='plan',套餐/测评方案;健康方案已用 health_plan 隔离)
         List<Long> planIds = new ArrayList<>();
         for (TaskPlanInstance ins : instances) {
             planIds.add(ins.getId());

+ 166 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java

@@ -19,8 +19,11 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
+import java.util.Calendar;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -79,7 +82,7 @@ public class TaskPlanService implements TaskPlanServiceInterface {
         Date now = new Date();
         instance.setStartDate(now);
         instance.setEndDate(new Date(now.getTime() + (long) pkg.getCycleDays() * 24 * 60 * 60 * 1000));
-        instance.setStatus("active");
+        instance.setStatus("generated"); // 状态机: generated → reviewed → active(见统一设计)
         instance.setTotalDays(pkg.getCycleDays());
         instance.setCompletedDays(0);
         instance.setCreatedAt(now);
@@ -118,10 +121,13 @@ public class TaskPlanService implements TaskPlanServiceInterface {
 
     /**
      * 方案确认:将套餐计划实例生成可执行任务(B20 补全)
-     * 每个 plan item 生成一条 Task:memberOnly=1、sourceType=plan、sourceId=planId,
-     * 使 ProblemCompletionService 能按 (familyId, sourceType=plan) 统计完成度发奖。
-     * 幂等:同一计划已生成过任务则跳过。
+     *
+     * @deprecated 已被 {@link #generateTasksFromTemplate} 替代。
+     * 旧方法从 task_plan_items 读任务(已降级为审计快照),
+     * 新方法 generateTasksFromTemplate 从 task_template_items 读——两套数据源不一致,
+     * 若未来误调用会造成双轨。confirm 流程已改走 applyAndActivate → generateTasksFromTemplate。
      */
+    @Deprecated
     @Transactional
     public int generatePlanTasks(Long planId, Long familyId, Long userId) {
         TaskPlanInstance plan = instanceMapper.selectById(planId);
@@ -150,7 +156,7 @@ public class TaskPlanService implements TaskPlanServiceInterface {
             task.setFamilyId(familyId);
             task.setChildId(plan.getChildId());
             task.setCreatorId(userId);
-            task.setExecutorType("child");
+            task.setExecutorType("child"); // 非登录用户(不可登录成员)
             task.setExecutorId(plan.getChildId());
             task.setTitle(item.getTaskName());
             task.setDescription(item.getDescription());
@@ -179,6 +185,143 @@ public class TaskPlanService implements TaskPlanServiceInterface {
         return count;
     }
 
+    /**
+     * 统一方案任务生成器(设计: 2026-09-04-plan-to-task-unification-design.md)
+     * 引导任务 + 从 task_template_items(按 sort_order)批量生成日常任务。
+     * 幂等:sourceType=plan & sourceId=planId 已存在任务则跳过。
+     * deadline:当天 23:59:59;familyMemberId:统一设置;memberOnly=1。
+     */
+    @Transactional
+    public int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId) {
+        if (plan == null) {
+            throw new IllegalStateException("方案不存在");
+        }
+        Long planId = plan.getId();
+        // 幂等:该方案已生成过任务则跳过(排除 cancelled,避免 cancelPlan 后重新激活时漏生成)
+        Long existing = taskMapper.selectCount(
+                new LambdaQueryWrapper<Task>()
+                        .eq(Task::getSourceType, "plan")
+                        .eq(Task::getSourceId, planId)
+                        .ne(Task::getStatus, "cancelled"));
+        if (existing != null && existing > 0) {
+            return 0;
+        }
+
+        Date now = new Date();
+        Date deadline = buildTodayDeadline();
+        int count = 0;
+
+        // 1. 引导任务(方案启动)
+        Task guideTask = new Task();
+        guideTask.setFamilyId(plan.getFamilyId());
+        guideTask.setChildId(plan.getChildId());
+        guideTask.setFamilyMemberId(plan.getChildId());
+        guideTask.setCreatorId(operatorId);
+        guideTask.setExecutorType("child"); // 非登录用户(不可登录成员)
+        guideTask.setExecutorId(plan.getChildId());
+        guideTask.setTitle("方案启动: " + plan.getName());
+        guideTask.setDescription("请阅读并开始执行测评方案");
+        guideTask.setPoints(2);
+        guideTask.setCategory("成长");
+        guideTask.setStatus("pending");
+        guideTask.setSourceType("plan");
+        guideTask.setSourceId(planId);
+        guideTask.setNeedReview(0);
+        guideTask.setIsTemplate(0);
+        guideTask.setMemberOnly(1);
+        guideTask.setDeadline(deadline);
+        guideTask.setCreatedAt(now);
+        guideTask.setUpdatedAt(now);
+        taskMapper.insert(guideTask);
+        count++;
+
+        // 2. 模板任务(task_template_items 为唯一来源)
+        if (plan.getPackageId() != null) {
+            List<TaskTemplateItem> items = templateItemMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskTemplateItem>()
+                            .eq("package_id", plan.getPackageId())
+                            .orderByAsc("sort_order"));
+            for (TaskTemplateItem item : items) {
+                if (item.getTaskName() == null || item.getTaskName().isEmpty()) continue;
+                Task task = new Task();
+                task.setFamilyId(plan.getFamilyId());
+                task.setChildId(plan.getChildId());
+                task.setFamilyMemberId(plan.getChildId());
+                task.setCreatorId(operatorId);
+                task.setExecutorType("child"); // 非登录用户(不可登录成员)
+                task.setExecutorId(plan.getChildId());
+                task.setTitle(item.getTaskName());
+                task.setDescription(item.getDescription());
+                task.setPoints(item.getPoints() != null ? item.getPoints() : 2);
+                task.setCategory("成长");
+                task.setStatus("pending");
+                task.setSourceType("plan");
+                task.setSourceId(planId);
+                task.setNeedReview(0);
+                task.setIsTemplate(0);
+                task.setMemberOnly(1);
+                task.setFrequency(item.getFrequency() != null ? item.getFrequency() : "daily");
+                task.setDuration(item.getDuration());
+                task.setDeadline(deadline);
+                task.setCreatedAt(now);
+                task.setUpdatedAt(now);
+                taskMapper.insert(task);
+                count++;
+            }
+        }
+
+        log.info("方案统一生成任务: planId={}, familyId={}, taskCount={}", planId, plan.getFamilyId(), count);
+        return count;
+    }
+
+    /**
+     * 家长自助应用套餐并激活(confirm / purchase 共用):
+     * 1. 幂等:同家庭同套餐已有 active 实例则复用
+     * 2. 新建则 applyPackage(generated)→ 置 active(自助跳过审核)
+     * 3. 统一生成器生成任务
+     */
+    @Transactional
+    public Map<String, Object> applyAndActivate(ApplyPackageDTO dto, Long familyId, Long userId) {
+        TaskPlanInstance plan = findActiveByFamilyAndPackage(familyId, dto.getPackageId());
+        boolean created = false;
+        if (plan == null) {
+            plan = applyPackage(dto, familyId);
+            created = true;
+        }
+        if (created) {
+            Date now = new Date();
+            plan.setStatus("active");
+            plan.setActivatedAt(now);
+            plan.setUpdatedAt(now);
+            instanceMapper.updateById(plan);
+        }
+        int taskCount = generateTasksFromTemplate(plan, userId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("planId", plan.getId());
+        result.put("taskCount", taskCount);
+        return result;
+    }
+
+    /** 查询同家庭同套餐的 active 实例(幂等复用) */
+    public TaskPlanInstance findActiveByFamilyAndPackage(Long familyId, Long packageId) {
+        return instanceMapper.selectOne(
+                new LambdaQueryWrapper<TaskPlanInstance>()
+                        .eq(TaskPlanInstance::getFamilyId, familyId)
+                        .eq(TaskPlanInstance::getPackageId, packageId)
+                        .eq(TaskPlanInstance::getStatus, "active")
+                        .last("LIMIT 1"));
+    }
+
+    /** 当天 23:59:59 截止(与 TaskService.getTodayTasks 区间对齐) */
+    private Date buildTodayDeadline() {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+
     /**
      * 获取计划详情
      */
@@ -199,6 +342,7 @@ public class TaskPlanService implements TaskPlanServiceInterface {
     /**
      * 取消计划
      */
+    @Transactional
     public boolean cancelPlan(Long id) {
         TaskPlanInstance instance = instanceMapper.selectById(id);
         if (instance == null || !"active".equals(instance.getStatus())) {
@@ -206,7 +350,23 @@ public class TaskPlanService implements TaskPlanServiceInterface {
         }
         instance.setStatus("cancelled");
         instance.setUpdatedAt(new Date());
-        return instanceMapper.updateById(instance) > 0;
+        boolean updated = instanceMapper.updateById(instance) > 0;
+
+        // 级联清理:该方案生成的未完成任务置 cancelled
+        if (updated) {
+            List<Task> pendingTasks = taskMapper.selectList(
+                    new LambdaQueryWrapper<Task>()
+                            .eq(Task::getSourceType, "plan")
+                            .eq(Task::getSourceId, id)
+                            .in(Task::getStatus, "pending", "pending_review", "locked", "in_progress"));
+            for (Task t : pendingTasks) {
+                t.setStatus("cancelled");
+                t.setUpdatedAt(new Date());
+                taskMapper.updateById(t);
+            }
+            log.info("取消方案{}并级联清理{}条未完成任务", id, pendingTasks.size());
+        }
+        return updated;
     }
 
     /**

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

@@ -287,6 +287,9 @@ task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCate
         task.setStartCount(0);
         task.setMinDurationSeconds(dto.getMinDurationSeconds() != null ? dto.getMinDurationSeconds() : 0);
 
+        task.setSourceType(dto.getSourceType());
+        task.setSourceId(dto.getSourceId());
+
         return task;
     }
 

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

@@ -346,7 +346,7 @@ public class TianpanService {
             return null;
         }
 
-        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, "child");
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, "child"); // 非登录用户(不可登录成员)
         if (attrs == null) {
             attrs = familyMemberAttributeService.getByMember(memberId, "parent");
         }
@@ -560,9 +560,9 @@ public class TianpanService {
         FamilyMemberTraditionVO vo = new FamilyMemberTraditionVO();
         vo.setMemberId(member.getId());
         vo.setNickname(member.getNickname());
-        vo.setMemberType("child");
+        vo.setMemberType("child"); // 非登录用户(不可登录成员)
 
-        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(member.getId(), "child");
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(member.getId(), "child"); // 非登录用户(不可登录成员)
         if (attrs == null) {
             attrs = familyMemberAttributeService.getByMember(member.getId(), "parent");
             if (attrs != null) vo.setMemberType("parent");

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

@@ -147,6 +147,8 @@ public class TrainingPlanService {
 
         for (TrainingPlanItem item : items) {
             com.etotem.cfc.dto.CreateTaskDTO dto = new com.etotem.cfc.dto.CreateTaskDTO();
+            dto.setSourceType("training_plan");
+            dto.setSourceId(planId);
             dto.setTitle(item.getTaskTitle());
             dto.setDescription(item.getTaskDesc());
             dto.setMemberId(plan.getChildId());

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -209,6 +209,11 @@ private FamilyInvitationService familyInvitationService;
     createUserAndFamily(user);
     try { onboardingService.initForUser(user.getId()); onboardingService.completeTask(user.getId(), "REGISTER"); } catch (Exception e) { /* onboarding init failure should not block login */ }
   } else {
+    // 【重构】预留用户首次登录:补 openid,成为可登录成员
+    if (user.getOpenid() == null || user.getOpenid().isEmpty()) {
+      user.setOpenid("phone:" + phone);
+      user.setRole("parent");
+    }
     // 老用户,更新信息(如果提供了)
     if (dto.getNickname() != null && !dto.getNickname().isEmpty()) {
       user.setNickname(dto.getNickname());
@@ -342,6 +347,14 @@ private FamilyInvitationService familyInvitationService;
         return true;
     }
 
+    /**
+     * 设置切换保护密码(切换前由原用户设置,退出切换时校验)
+     * 复用 users.password 字段(用户决策:未单独设置时默认用登录密码)
+     */
+    public boolean setSwitchPassword(Long userId, String password) {
+        return setPassword(userId, password);
+    }
+
     public boolean verifyPassword(Long userId, String password) {
         User user = userMapper.selectById(userId);
         if (user == null || user.getPassword() == null || user.getPassword().isEmpty()) {
@@ -779,9 +792,11 @@ private FamilyInvitationService familyInvitationService;
             if (p.getId().equals(userId) || (p.getShowToFamily() != null && p.getShowToFamily() == 1)) {
                 FamilyMemberVO vo = new FamilyMemberVO();
                 vo.setId(p.getId());
+                vo.setMemberId(p.getId());
                 vo.setSource("parent");
                 vo.setUserId(p.getId());
                 vo.setNickname(p.getNickname());
+                vo.setName(p.getNickname());
                 vo.setAvatar(p.getAvatar());
                 vo.setPhone(p.getPhone());
                 vo.setGender(p.getGender());
@@ -1414,8 +1429,10 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
      * 为用户创建新家庭
      */
     public Long createFamilyForUser(Long userId, String familyName) {
+        log.info("createFamilyForUser: userId={}, familyName={}", userId, familyName);
         User user = userMapper.selectById(userId);
         if (user == null) {
+            log.warn("用户不存在: userId={}, familyName={}", userId, familyName);
             throw new RuntimeException("用户不存在");
         }
         // 创建新家庭

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

@@ -59,7 +59,7 @@ public class WishService {
         wish.setFamilyId(child.getFamilyId());
         wish.setChildId(memberId);
         wish.setCreatedBy(child.getUserId()); // 创建者为孩子关联的用户
-        wish.setCreatorType("child");
+        wish.setCreatorType("child"); // 非登录用户(不可登录成员)
         wish.setTitle(dto.getTitle());
         wish.setDescription(dto.getDescription());
         wish.setCategory(dto.getCategory());

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/api/TaskPlanServiceInterface.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.TaskPlanItem;
 
 import java.util.List;
+import java.util.Map;
 
 public interface TaskPlanServiceInterface {
     TaskPlanInstance applyPackage(ApplyPackageDTO dto, Long familyId);
@@ -13,5 +14,8 @@ public interface TaskPlanServiceInterface {
     List<TaskPlanItem> getPlanItems(Long instanceId);
     boolean cancelPlan(Long id);
     boolean updateCompletedDays(Long id, int days);
+    int generateTasksFromTemplate(TaskPlanInstance plan, Long operatorId);
+    Map<String, Object> applyAndActivate(ApplyPackageDTO dto, Long familyId, Long userId);
+    TaskPlanInstance findActiveByFamilyAndPackage(Long familyId, Long packageId);
     List<TaskPlanInstance> getChildPlans(Long memberId);
 }

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

@@ -11,6 +11,7 @@ public interface UserServiceInterface {
     LoginResultDTO silentLogin(String code);
     LoginResultDTO autoLoginByOpenid(String openid);
     boolean setPassword(Long userId, String password);
+    boolean setSwitchPassword(Long userId, String password);
     boolean verifyPassword(Long userId, String password);
     User getUserInfo(Long userId);
     boolean isPhoneRegistered(String phone);

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

@@ -193,7 +193,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             return java.util.Collections.emptyList();
         }
         QueryWrapper<Task> wrapper = new QueryWrapper<>();
-        wrapper.eq("source_type", "plan")
+        wrapper.eq("source_type", "health_plan")
                 .eq("source_id", planId)
                 .orderByAsc("id");
         return taskMapper.selectList(wrapper);
@@ -366,7 +366,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
             boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
                 .eq("family_id", familyId)
-                .eq("source_type", "plan")
+                .eq("source_type", "health_plan")
                 .eq("source_id", plan.getId())
                 .eq("title", draft.title)
                 .ge("deadline", todayStart.getTime())
@@ -375,7 +375,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
                     Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
                         .eq("family_id", familyId)
-                        .eq("source_type", "plan")
+                        .eq("source_type", "health_plan")
                         .eq("source_id", plan.getId())
                         .eq("title", draft.title)
                         .eq("category", "购买任务")
@@ -393,7 +393,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             task.setFamilyId(familyId);
             task.setFamilyMemberId(familyMemberId);
             task.setChildId(childId);
-            task.setExecutorType("child");
+            task.setExecutorType("child"); // 非登录用户(不可登录成员)
             task.setExecutorId(executorId);
             task.setCreatorId(executorId != null ? executorId : 0L);
             task.setTitle(draft.title);
@@ -401,7 +401,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             task.setPoints(10);
             task.setDeadline(deadline);
             task.setStatus("pending");
-            task.setSourceType("plan");
+            task.setSourceType("health_plan");
             task.setSourceId(plan.getId());
             task.setNeedReview(1);
             task.setCreatedAt(now);

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

@@ -44,6 +44,9 @@ CREATE TABLE IF NOT EXISTS users (
     INDEX idx_family_id (family_id),
     INDEX idx_phone (phone)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- 注:role 字段值 'child' 已废弃,存量数据通过迁移298统一归一为 'parent'。
+-- 可登录成员(有 openid)= 原 role='child' 迁移后的账号;
+-- 不可登录成员(无 openid)= 家长手动创建的子账号。
 
 -- ============================================================
 -- 【已废除】children 表已合并到 family_members
@@ -78,7 +81,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     family_id BIGINT NOT NULL,
     creator_id BIGINT NOT NULL,
     child_id BIGINT COMMENT '执行者ID(child任务时必填,parent任务时可空)',
-    executor_type VARCHAR(20) COMMENT '执行者类型: child/parent/member',
+    executor_type VARCHAR(20) COMMENT '执行者类型: child=非登录用户/parent=可登录家长/member=其他成员',
     executor_id BIGINT COMMENT '执行者ID',
     title VARCHAR(200) NOT NULL,
     description TEXT,
@@ -104,6 +107,8 @@ CREATE TABLE IF NOT EXISTS tasks (
     start_required TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)',
     start_count INT DEFAULT 0 COMMENT '已开始次数(累计)',
     min_duration_seconds INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)',
+    source_type VARCHAR(20) COMMENT '来源类型: manual/self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase',
+    source_id BIGINT COMMENT '来源ID(如 training_plans.id / task_plan_instances.id)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_child_id_deadline (child_id, deadline),
@@ -633,6 +638,15 @@ CREATE TABLE IF NOT EXISTS task_plan_instances (
     status VARCHAR(20) DEFAULT 'active',
     total_days INT,
     completed_days INT DEFAULT 0,
+    source VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template',
+    source_result_id BIGINT DEFAULT NULL COMMENT '关联测评结果ID',
+    remark TEXT COMMENT '方案备注',
+    reviewed_by BIGINT DEFAULT NULL COMMENT '审核人(规划师ID)',
+    reviewed_at DATETIME DEFAULT NULL COMMENT '审核时间',
+    review_comment TEXT COMMENT '审核意见',
+    confirmed_by BIGINT DEFAULT NULL COMMENT '确认人(家长ID)',
+    confirmed_at DATETIME DEFAULT NULL COMMENT '确认时间',
+    activated_at DATETIME DEFAULT NULL COMMENT '激活时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_family_id (family_id),

+ 158 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskPlanServiceTest.java

@@ -0,0 +1,158 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskPlanInstance;
+import com.etotem.cfc.entity.TaskTemplateItem;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.TaskPlanItemMapper;
+import com.etotem.cfc.mapper.TaskTemplateItemMapper;
+import com.etotem.cfc.service.TaskPlanService;
+import com.etotem.cfc.service.TaskTemplatePackageService;
+import com.etotem.cfc.dto.ApplyPackageDTO;
+import com.etotem.cfc.entity.TaskTemplatePackage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * 统一任务生成器单元测试(纯逻辑,mock Mapper,无需 DB)
+ */
+class TaskPlanServiceTest {
+
+    private TaskPlanService service;
+    private TaskMapper taskMapper;
+    private TaskPlanInstanceMapper instanceMapper;
+    private TaskPlanItemMapper planItemMapper;
+    private TaskTemplateItemMapper templateItemMapper;
+    private TaskTemplatePackageService packageService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new TaskPlanService();
+        taskMapper = mock(TaskMapper.class);
+        instanceMapper = mock(TaskPlanInstanceMapper.class);
+        planItemMapper = mock(TaskPlanItemMapper.class);
+        templateItemMapper = mock(TaskTemplateItemMapper.class);
+        packageService = mock(TaskTemplatePackageService.class);
+        inject("taskMapper", taskMapper);
+        inject("instanceMapper", instanceMapper);
+        inject("planItemMapper", planItemMapper);
+        inject("templateItemMapper", templateItemMapper);
+        inject("packageService", packageService);
+    }
+
+    private void inject(String field, Object value) throws Exception {
+        Field f = TaskPlanService.class.getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    @Test
+    void generateTasksFromTemplate_已生成过则跳过() {
+        TaskPlanInstance plan = new TaskPlanInstance();
+        plan.setId(1L);
+        plan.setFamilyId(10L);
+        plan.setChildId(20L);
+        plan.setName("测试方案");
+        when(taskMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(5L);
+
+        int count = service.generateTasksFromTemplate(plan, 99L);
+
+        assertEquals(0, count);
+        verify(taskMapper, never()).insert(any(Task.class));
+    }
+
+    @Test
+    void generateTasksFromTemplate_生成引导任务和模板任务() {
+        TaskPlanInstance plan = new TaskPlanInstance();
+        plan.setId(1L);
+        plan.setFamilyId(10L);
+        plan.setChildId(20L);
+        plan.setName("测试方案");
+        plan.setPackageId(30L);
+        when(taskMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
+
+        TaskTemplateItem item = new TaskTemplateItem();
+        item.setTaskName("每日运动");
+        item.setPoints(5);
+        item.setFrequency("daily");
+        when(templateItemMapper.selectList(any(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class)))
+                .thenReturn(Collections.singletonList(item));
+
+        int count = service.generateTasksFromTemplate(plan, 99L);
+
+        // 引导任务 + 1 模板任务
+        assertEquals(2, count);
+        verify(taskMapper, times(2)).insert(any(Task.class));
+    }
+
+    @Test
+    void cancelPlan_级联清理未完成任务() {
+        TaskPlanInstance plan = new TaskPlanInstance();
+        plan.setId(1L);
+        plan.setFamilyId(10L);
+        plan.setChildId(20L);
+        plan.setStatus("active");
+        when(instanceMapper.selectById(1L)).thenReturn(plan);
+        when(instanceMapper.updateById(any(TaskPlanInstance.class))).thenReturn(1);
+        when(taskMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(new Task()));
+
+        boolean ok = service.cancelPlan(1L);
+
+        assertEquals(true, ok);
+        verify(taskMapper, times(1)).updateById(any(Task.class));
+    }
+
+    @Test
+    void applyAndActivate_无active实例时新建并置active() {
+        ApplyPackageDTO dto = new ApplyPackageDTO();
+        dto.setPackageId(30L);
+        dto.setMemberId(20L);
+
+        // findActiveByFamilyAndPackage → null(无 active 实例)
+        when(instanceMapper.selectOne(any())).thenReturn(null);
+
+        // applyPackage 内部:packageService.getPackageById → 返回 mock package
+        TaskTemplatePackage pkg = new TaskTemplatePackage();
+        pkg.setId(30L);
+        pkg.setName("测试套餐");
+        pkg.setCycleDays(7);
+        when(packageService.getPackageById(30L)).thenReturn(pkg);
+        // templateItemMapper.selectList → 空列表(避免 NPE)
+        when(templateItemMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // instanceMapper.insert → 返回1,用 Answer 捕获实例并设 ID
+        TaskPlanInstance capturedInstance = new TaskPlanInstance();
+        when(instanceMapper.insert(any(TaskPlanInstance.class))).thenAnswer(invocation -> {
+            capturedInstance.setId(100L);
+            return 1;
+        });
+
+        // generateTasksFromTemplate → 幂等检查返回 0(无旧任务),生成 0 条模板任务
+        when(taskMapper.selectCount(any())).thenReturn(0L);
+        // buildTodayDeadline 用 Calendar,测试中不直接调用(private),但 generateTasksFromTemplate 内部会调用
+        // 由于 templateItemMapper 已 mock 返回空,generateTasksFromTemplate 只会生成引导任务(1条)
+        // 但引导任务也调 taskMapper.insert,需要 mock
+        when(taskMapper.insert(any(Task.class))).thenReturn(1);
+
+        Map<String, Object> result = service.applyAndActivate(dto, 10L, 99L);
+
+        assertNotNull(result);
+        assertEquals(100L, result.get("planId"));
+        assertEquals(1, result.get("taskCount")); // 引导任务 1 条
+        // 状态置 active:instanceMapper.updateById 被调用一次
+        verify(instanceMapper, times(1)).updateById(any(TaskPlanInstance.class));
+    }
+}

+ 4 - 4
cfc-frontend/components/FamilyMemberStrip.vue

@@ -169,9 +169,9 @@ export default {
   },
   computed: {
     canSwitch: function() {
-      // 仅家庭成员记录(source=family_member)且已关联账号时才能切换视角;
-      // 家长卡片(source=parent)是 users 表记录,switch 接口只查 family_members 表,切换会失败
-      return this.actionMember && this.actionMember.userId && this.actionMember.source === 'family_member'
+      // 仅不可登录成员(isSwitchable=true)且来源为 family_member 时可切换视角;
+      // 可登录成员(已通过微信登录)不能被切换使用
+      return this.actionMember && this.actionMember.isSwitchable && this.actionMember.source === 'family_member'
     },
     canEdit: function() {
       return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
@@ -227,7 +227,7 @@ export default {
     switchToMember: function() {
       var member = this.actionMember
       this.closeMenu()
-      if (member && member.userId) {
+      if (member && member.isSwitchable) {
         this.$emit('switch', member)
       }
     },

+ 1 - 1
cfc-frontend/components/MemberChipPicker.vue

@@ -51,7 +51,7 @@ export default {
     },
     goAddMember: function() {
       this.$emit('add-member')
-      uni.navigateTo({ url: '/pages/family/add-member' })
+      uni.navigateTo({ url: '/pages/profile-extra/family-members' })
     },
     toggleMember: function(id) {
       var strId = String(id)

+ 60 - 30
cfc-frontend/components/ProfileHeader.vue

@@ -19,7 +19,7 @@
 </template>
 
 <script>
-import { getChildren, verifyPassword } from '../utils/api.js'
+import { getChildren, verifyPassword, switchToFamilyMember } from '../utils/api.js'
 
 export default {
   name: 'ProfileHeader',
@@ -78,13 +78,13 @@ export default {
         this.doSwitchMode()
       }
     },
-    doSwitchMode() {
+    async doSwitchMode() {
       if (this.children.length === 0) {
         this.loadChildren()
         if (this.children.length === 0) {
           uni.showModal({
             title: '提示',
-            content: '您还没有添加孩子,请先在家庭成员中添加',
+            content: '您还没有添加成员,请先在家庭成员中添加',
             success: (res) => {
               if (res.confirm) {
                 uni.navigateTo({ url: '/pages/profile-extra/family-members' })
@@ -94,45 +94,75 @@ export default {
           return
         }
       }
-      if (this.children.length === 1) {
-        this.$store.commit('switchToChild', this.children[0].id)
-        this.role = 'child'
-        this.isSwitchedChild = true
-        uni.showToast({ title: '已切换为孩子模式', icon: 'success' })
+      // 优先显示可切换成员(不可登录成员),过滤掉可通过微信登录的成员
+      var switchable = this.children.filter(function(m) { return m.isSwitchable })
+      if (switchable.length === 0) {
+        uni.showToast({ title: '暂无可切换成员', icon: 'none' })
+        return
+      }
+      if (switchable.length === 1) {
+        var member = switchable[0]
+        uni.showModal({
+          title: '切换确认',
+          content: '切换到"' + member.nickname + '"后,退出时需要输入您的密码。是否继续?',
+          success: async (res) => {
+            if (!res.confirm) return
+            try {
+              var result = await switchToFamilyMember(member.id, member.source || 'family_member')
+              if (result.code === 200 && result.data) {
+                this.$store.commit('switchToMember', {
+                  memberId: result.data.memberId,
+                  effectiveRole: result.data.effectiveRole
+                })
+                this.isSwitchedChild = true
+                this.role = result.data.effectiveRole
+                uni.showToast({ title: '已切换', icon: 'success' })
+              } else {
+                uni.showToast({ title: result.message || '切换失败', icon: 'none' })
+              }
+            } catch (e) {
+              uni.showToast({ title: '切换失败', icon: 'none' })
+            }
+          }
+        })
         return
       }
       uni.showActionSheet({
-        itemList: this.children.map(c => c.nickname),
+        itemList: switchable.map(function(m) { return m.nickname }),
         success: (res) => {
-          const selectedChild = this.children[res.tapIndex]
-          this.$store.commit('switchToChild', selectedChild.id)
-          this.role = 'child'
+          if (res.tapIndex < 0) return
+          var member = switchable[res.tapIndex]
+          this.$store.commit('switchToMember', {
+            memberId: member.id,
+            effectiveRole: member.effectiveRole
+          })
           this.isSwitchedChild = true
-          uni.showToast({ title: `已切换为${selectedChild.nickname}模式`, icon: 'success' })
+          this.role = member.effectiveRole
+          uni.showToast({ title: '已切换', icon: 'success' })
         }
       })
     },
-    onExitSwitch() {
+    async onExitSwitch() {
       if (!this.isSwitchedChild) return
+      var self = this
       uni.showModal({
         title: '退出切换',
-        content: '请输入家长密码以确认退出',
+        content: '请输入原用户密码以确认退出',
         editable: true,
-        success: (res) => {
-          if (res.confirm && res.content) {
-            verifyPassword(res.content).then((result) => {
-              if (result.code === 200) {
-                this.$store.commit('switchBackToParent')
-                this.isSwitchedChild = false
-                this.role = 'parent'
-                uni.showToast({ title: '已退出切换', icon: 'success' })
-                uni.reLaunch({ url: '/pages/index-home/index' })
-              } else {
-                uni.showToast({ title: '密码错误', icon: 'none' })
-              }
-            }).catch(() => {
-              uni.showToast({ title: '验证失败', icon: 'none' })
-            })
+        success: async function(res) {
+          if (!res.confirm || !res.content) return
+          try {
+            var ok = await self.$store.dispatch('switchBackWithPassword', res.content)
+            if (ok) {
+              self.isSwitchedChild = false
+              self.role = 'parent'
+              uni.showToast({ title: '已退出切换', icon: 'success' })
+              uni.reLaunch({ url: '/pages/index-home/index' })
+            } else {
+              uni.showToast({ title: '密码错误', icon: 'none' })
+            }
+          } catch (e) {
+            uni.showToast({ title: '验证失败', icon: 'none' })
           }
         }
       })

+ 73 - 3
cfc-frontend/pages/health/health-plan-summary.vue

@@ -532,6 +532,12 @@ selectedMembers: function() {
       if (this.expandedGroups.length === 0) {
         this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
       }
+      // 多成员调研:确保 activeFormMemberId 指向第一个选中成员,并加载其表单
+      if (!this.activeFormMemberId && this.selectedMemberIds.length > 0) {
+        this.activeFormMemberId = String(this.selectedMemberIds[0])
+        var self = this
+        setTimeout(function() { self.loadHealthForm(self.activeFormMemberId) }, 100)
+      }
       this.step = 2
     },
     skipToGenerate: function() {
@@ -888,6 +894,19 @@ selectedMembers: function() {
           } else {
             uni.showToast({ title: '生成失败,请重试', icon: 'none' })
           }
+        } else if (res.code === 4001) {
+          // 付费墙:未购买健康启航计划体验包,提示购买
+          uni.showModal({
+            title: '需要购买体验会员',
+            content: '生成健康方案需先购买「健康启航计划体验会员」(¥199/7天),是否前往购买?',
+            confirmText: '去购买',
+            cancelText: '暂不',
+            success: function(modalRes) {
+              if (modalRes.confirm) {
+                uni.navigateTo({ url: '/pages/purchase/index' })
+              }
+            }
+          })
         } else {
           uni.showToast({ title: res && res.message ? res.message : '生成失败', icon: 'none' })
         }
@@ -1268,13 +1287,13 @@ selectedMembers: function() {
 /* 目标输入 */
 .goal-textarea {
   width: 100%;
-  min-height: 160rpx;
+  min-height: 320rpx;
   font-size: 28rpx;
   color: #333;
   background: #F5F7FA;
   border: none;
   border-radius: 16rpx;
-  padding: 20rpx 24rpx;
+  padding: 24rpx 28rpx;
   line-height: 1.7;
   box-sizing: border-box;
 }
@@ -1282,9 +1301,18 @@ selectedMembers: function() {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-top: 16rpx;
+  margin-top: 20rpx;
 }
 .goal-count { font-size: 24rpx; color: #bbb; }
+.goal-footer .btn-next {
+  width: auto;
+  min-width: 200rpx;
+  height: 72rpx;
+  line-height: 72rpx;
+  padding: 0 48rpx;
+  border-radius: 36rpx;
+  font-size: 28rpx;
+}
 
 /* 按钮 */
 .btn-next, .btn-confirm, .btn-edit, .btn-done, .btn-teacher, .btn-skip, .btn-generate {
@@ -1514,6 +1542,48 @@ selectedMembers: function() {
 .success-title { font-size: 36rpx; font-weight: bold; color: #333; margin-bottom: 12rpx; }
 .success-sub { font-size: 28rpx; color: #888; margin-bottom: 40rpx; }
 
+/* ===== 多成员调研 tab 页签 ===== */
+.member-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-bottom: 16rpx;
+}
+.member-tab {
+  padding: 14rpx 32rpx;
+  border-radius: 32rpx;
+  background: #F1F5F9;
+  border: 2rpx solid transparent;
+  font-size: 26rpx;
+  color: #334155;
+  font-weight: 500;
+  transition: all 0.2s;
+}
+.member-tab.active {
+  background: #FFF7ED;
+  border-color: #F97316;
+  color: #F97316;
+  font-weight: 600;
+}
+.form-member-hint {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+  padding: 16rpx 20rpx;
+  background: #FFF7ED;
+  border-radius: 12rpx;
+}
+.form-member-label {
+  font-size: 26rpx;
+  color: #92400E;
+  font-weight: 600;
+}
+.form-member-save {
+  font-size: 22rpx;
+  color: #B45309;
+}
+
 /* ===== 健康问卷样式 (从 health-status-form.vue 移植) ===== */
 .form-section {
   margin: 0 0 24rpx;

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

@@ -83,7 +83,7 @@
 </template>
 
 <script>
-import { getAvailableGuides, getGuidePackage } from '../../utils/api'
+import { getAvailableTeachers } from '../../utils/api'
 
 export default {
   data: function() {
@@ -121,7 +121,7 @@ export default {
     loadGuides: function() {
       var self = this
       self.loading = true
-      getAvailableGuides({}).then(function(res) {
+      getAvailableTeachers(self.familyId).then(function(res) {
         self.loading = false
         if (res && res.code === 200 && res.data) {
           var list = res.data instanceof Array ? res.data : []

+ 24 - 16
cfc-frontend/pages/home-pages/parent-index.vue

@@ -1032,23 +1032,31 @@ export default {
     },
     async onFamilyMemberSwitch(member) {
       if (!member || !member.id) return
-      try {
-        var res = await switchToFamilyMember(member.id, member.source || 'family_member')
-        if (res.code === 200 && res.data) {
-          store.commit('switchToMember', {
-            memberId: res.data.memberId,
-            effectiveRole: res.data.effectiveRole
-          })
-          uni.showToast({ title: '已切换到' + (member.nickname || '该成员'), icon: 'success' })
-          setTimeout(function() {
-            uni.reLaunch({ url: '/pages/index-home/index' })
-          }, 1000)
-        } else {
-          uni.showToast({ title: res.message || '切换失败', icon: 'none' })
+      var self = this
+      uni.showModal({
+        title: '切换确认',
+        content: '切换到"' + (member.nickname || '该成员') + '"后,退出时需要输入您的密码。是否继续?',
+        success: async function(res) {
+          if (!res.confirm) return
+          try {
+            var result = await switchToFamilyMember(member.id, member.source || 'family_member')
+            if (result.code === 200 && result.data) {
+              store.commit('switchToMember', {
+                memberId: result.data.memberId,
+                effectiveRole: result.data.effectiveRole
+              })
+              uni.showToast({ title: '已切换到' + (member.nickname || '该成员'), icon: 'success' })
+              setTimeout(function() {
+                uni.reLaunch({ url: '/pages/index-home/index' })
+              }, 1000)
+            } else {
+              uni.showToast({ title: result.message || '切换失败', icon: 'none' })
+            }
+          } catch (e) {
+            uni.showToast({ title: '切换失败', icon: 'none' })
+          }
         }
-      } catch (e) {
-        uni.showToast({ title: '切换失败', icon: 'none' })
-      }
+      })
     },
     onMicroActionCompleted() {
       this.loadData()

+ 38 - 11
cfc-frontend/pages/purchase/index.vue

@@ -90,7 +90,8 @@
 </template>
 
 <script>
-import { checkPurchase, createPurchaseOrder } from '../../utils/api.js'
+import { checkPurchase, createPurchaseOrder, getPurchasePayParams } from '../../utils/api.js'
+import { requestVirtualPayment } from '../../utils/api.js'
 
 export default {
   data() {
@@ -131,18 +132,44 @@ export default {
       var self = this
       self.submitting = true
       createPurchaseOrder().then(function(res) {
-        self.submitting = false
-        if (res.data && res.data.success) {
-          // 跳转到支付页面(这里先提示,实际需对接支付)
-          uni.showModal({
-            title: '确认支付',
-            content: '支付199元开启7天全功能体验,完成挑战可返还199元CF值',
-            success: function(modalRes) {
-              if (modalRes.confirm) {
-                uni.showToast({ title: '支付功能开发中', icon: 'none' })
-              }
+        if (res.data && res.data.success && res.data.orderNo) {
+          var orderNo = res.data.orderNo
+          // 获取虚拟支付参数并拉起支付
+          getPurchasePayParams(orderNo).then(function(payRes) {
+            var dto = payRes.data || {}
+            if (dto.orderInfo && dto.sign) {
+              requestVirtualPayment(dto, {
+                success: function() {
+                  self.submitting = false
+                  uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+                  setTimeout(function() {
+                    uni.redirectTo({ url: '/pages/growth/challenge-progress' })
+                  }, 800)
+                },
+                fail: function(err) {
+                  self.submitting = false
+                  var errCode = err && err.errCode
+                  var msg = '支付取消'
+                  if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录'
+                  else if (err && err.errMsg) msg = err.errMsg
+                  uni.showToast({ title: msg, icon: 'none' })
+                }
+              })
+            } else {
+              self.submitting = false
+              uni.showToast({ title: payRes.message || '支付参数获取失败', icon: 'none' })
+            }
+          }).catch(function(err) {
+            self.submitting = false
+            if (err && err.code === 46001) {
+              uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
+            } else {
+              uni.showToast({ title: (err && err.message) || '支付参数获取失败', icon: 'none' })
             }
           })
+        } else {
+          self.submitting = false
+          uni.showToast({ title: (res.data && res.data.message) || '创建订单失败', icon: 'none' })
         }
       }).catch(function(err) {
         self.submitting = false

+ 29 - 13
cfc-frontend/store/index.js

@@ -5,6 +5,7 @@ import tianpan from './modules/tianpan'
 import feedback from './feedback'
 import healthAlert from './health-alert'
 import healthScore from './health-score'
+import { switchBackVerify } from '../utils/api.js'
 
 Vue.use(Vuex)
 
@@ -80,7 +81,8 @@ const store = new Vuex.Store({
       uni.setStorageSync('isSwitchedChild', false)
       uni.setStorageSync('currentView', 'self')
     },
-    // 通过family_members切换到某个成员
+    // 通过family_members切换到某个成员(member 语义统一入口)
+    // 注:effectiveRole 为 'child' 时同步设置 currentChildId/isSwitchedChild 兼容旧页面
     switchToMember(state, payload) {
       state.currentRole = payload.effectiveRole
       state.currentMemberId = payload.memberId
@@ -181,32 +183,46 @@ const store = new Vuex.Store({
     updateChildren({ commit }, children) {
       commit('setChildren', children)
     },
-    // 切换孩子前检查家庭中是否有孩子成员
+    // 兼容旧 action:切换成员前检查(effectiveRole 年龄语义保留,用于过滤未成年成员)
     switchToChildWithCheck({ commit }, { memberId, members }) {
       var hasChildren = members && members.some(function(m) {
         return m.effectiveRole === 'child'
       })
       if (!hasChildren) {
-        uni.showToast({ title: '家庭中暂无孩子', icon: 'none' })
+        uni.showToast({ title: '家庭中暂无可切换的未成年成员', icon: 'none' })
         return false
       }
       commit('switchToChild', memberId)
       return true
     },
-    // 切换到家庭成员前检查(仅 child 角色需要前置检查)
+    // 切换到家庭成员前检查(仅不可登录成员可切换)
     switchToMemberWithCheck({ commit }, payload) {
-      if (payload.effectiveRole === 'child') {
-        var members = payload.members || []
-        var hasChildren = members.some(function(m) {
-          return m.effectiveRole === 'child'
-        })
-        if (!hasChildren) {
-          uni.showToast({ title: '家庭中暂无孩子', icon: 'none' })
-          return false
-        }
+      var member = payload.member || {}
+      if (member.isSwitchable === false) {
+        uni.showToast({ title: '该成员可通过微信登录,不能切换为TA的身份', icon: 'none' })
+        return false
       }
       commit('switchToMember', payload)
       return true
+    },
+    // 带密码校验退出成员切换(返回原用户身份)
+    async switchBackWithPassword({ commit }, password) {
+      if (!password) {
+        uni.showToast({ title: '请输入原用户密码', icon: 'none' })
+        return false
+      }
+      try {
+        const res = await switchBackVerify(password)
+        if (res.code === 200) {
+          commit('switchBackFromMember')
+          return true
+        }
+        uni.showToast({ title: res.message || '密码错误', icon: 'none' })
+        return false
+      } catch (e) {
+        uni.showToast({ title: '校验失败', icon: 'none' })
+        return false
+      }
     }
   }
 ,

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

@@ -987,6 +987,11 @@ export const getAvailableGuides = (packageId) => {
   return request('/api/package/available-guides', 'POST', { packageId })
 }
 
+// 获取健康方案可选的规划师列表(无需 packageId,返回 id/name/isBound 等字段)
+export const getAvailableTeachers = (familyId) => {
+  return request('/api/health-plan/available-teachers', 'POST', { familyId })
+}
+
 
 // 获取订单状态
 export const getOrderStatus = (orderNo) => {
@@ -2890,6 +2895,27 @@ export function getRenewalInfo() {
   })
 }
 
+// 健康启航计划:获取虚拟支付参数(下单后调用)
+export function getPurchasePayParams(orderNo) {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.request({
+      url: config.API_BASE_URL + '/api/purchase/pay-params',
+      method: 'POST',
+      data: { orderNo: orderNo },
+      header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+      success: (res) => {
+        if (res.data && res.data.code === 200) {
+          resolve(res.data)
+        } else {
+          reject(res.data || { code: -1, message: '获取支付参数失败' })
+        }
+      },
+      fail: (err) => reject(err)
+    })
+  })
+}
+
 export function getChallengeProgress(memberId) {
   return request('/api/purchase/challenge/progress', 'POST', { memberId: memberId })
 }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-086c79041ecabecfa4331f6936372518213fcc3a
+b43316c66c310a985cd6182fa64cbc10151cb731

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,148 @@
 
 ---
 
+## v1.0.1308 (2026-09-05)
+
+### Bug 修复
+- Critical 修复——isSwitchable 设置 + ProfileHeader 走新切换流程
+- 退出切换密码校验失败时补充用户提示
+
+### 重构
+- switchToChildWithCheck 文案与注释对齐 member 语义
+- children 表下线,ChildMapper 标注废弃,AccountCleanup 迁移至 family_members
+- role=child 判断清理(历史兼容注释 + InviteCard role 改为 parent)
+- FamilyMemberStrip 切换入口仅对不可登录成员开放
+- 切换视图统一 member 语义,退出切换接入密码校验
+
+### 新功能
+- 存量 role=child 账号强制迁移为成员模型(迁移298)
+- 切换入口确认提示 + 退出切换统一密码校验
+- 恢复退出切换接口 switch-back-verify(带密码校验)
+- 新增切换保护密码设置接口 set-switch-password
+- phoneLogin 复用无 openid 的预留成员用户并补 openid
+- addMember 同步创建无 openid 的 user 记录(不可登录成员)
+- switchToMember 仅允许切换不可登录成员
+- FamilyMemberVO 新增 isSwitchable 字段
+- FamilyMember 新增 memberType 成员类型字段与可切换判定
+
+### 文档
+- executorType/creatorType/memberType 业务字段注释统一为『非登录用户』语义
+- 成员模型重构——清理孩子/child角色定义实现计划
+
+
+## v1.0.1307 (2026-09-05)
+
+### Bug 修复
+- 修复选择规划师页调用available-guides缺少packageId报错
+
+### 其他
+- packageId 缺失报错,且返回字段 guideId/nickname 与页面模板 id/name 不匹配)。
+- 改用后端已有的 /api/health-plan/available-teachers 接口,返回 id/name/isBound
+- 等字段,与页面模板完全匹配。api.js 新增 getAvailableTeachers(familyId) 封装。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1306 (2026-09-05)
+
+### Bug 修复
+- 最终审查6项修复——废弃旧方法/修正迁移296/补充状态机测试/更新注释
+- cancelPlan级联清理方案未完成任务
+- purchasePackage补幂等复用并生成任务,消除active空壳
+
+### 测试
+- 统一任务生成器幂等/生成/级联清理单元测试
+
+### 新功能
+- 迁移296/297修正存量健康/认知训练方案任务source_type
+- 认知训练方案任务补设source_type=training_plan可溯源
+- 健康方案任务source_type改为health_plan,消除与plan的ID域冲突
+- 状态机收敛generated→active,confirm改走applyAndActivate
+- 新增统一任务生成器generateTasksFromTemplate
+
+### 重构
+- activate改用统一任务生成器,删除重复模板生成逻辑
+
+
+## v1.0.1305 (2026-09-04)
+
+### 新功能
+- 迁移295补齐task_plan_instances审核/确认/激活列
+- 后台全部列表页支持点击列头排序
+
+### 其他
+- - SortInterceptor 从缓存 body 读 sort 写入 SortContext(原只读 URL query,
+-   前端发 body 永远读不到),激活代码库 307 处已有 SortUtil.applySort 无参调用
+- 前端:
+- - SortMixin 补 onSortToggle 方法(修复 20 个文件 onSortToggle is not a function)
+- - SortMixin 新增 reloadAfterSort,兼容 loadList/loadData/loadTasks/loadPlans
+-   四种加载函数名,解决点击表头不刷新问题
+- - 107 个列表页接入 SortMixin(admin/teacher/nutritionist/root 各模块)
+- - 3 个位置参数 API 封装(guidePackage/guideTask/teacherMessage)加可选 sort 参数
+- 跳过:配置页/详情页/组件/仪表盘/树形表等非列表页(23 个)
+- 
+
+
+## v1.0.1304 (2026-09-04)
+
+### 文档
+- 方案转任务统一设计 + 实现计划(source_type拆分 + 统一生成器 + 状态机收敛)
+
+### 其他
+- - CommissionService/UserService: 用户不存在场景补充 warn 日志
+- - DatabaseInitializer: 生产库对账候选风险记录(注释)
+- - AGENTS.md: 环境区分说明(测试库 vs 生产库)
+- - cfc-web: 版本号 1.0.1300 + CHANGELOG 更新
+- 
+
+
+## v1.0.1303 (2026-09-04)
+
+### 文档
+- 新增 xhs-daily-report 计划文档
+- 新增小红书便秘腹泻帐号起号方案与计划
+- 新增小红书内容 D33/D34 与发布检查清单
+
+### 新功能
+- 日报采集点赞/收藏/分享字段,统计未回复评论数
+- 长任务进度上报与无限等待,bot 接入进度回调
+
+### Bug 修复
+- 修复家长成员VO缺少memberId/name字段,导致生成方案页面无法正确识别家长成员
+
+
+## v1.0.1302 (2026-09-04)
+
+### 文档
+- 统一任务系统实现计划(9任务:数据层/服务层/迁移/前端)
+
+
+## v1.0.1301 (2026-09-04)
+
+### 文档
+- 统一任务系统设计规格(多套任务体系合并为单表 tasks)
+
+### 新功能
+- 调研页多成员 tab 页签样式 + 进入问卷时初始化当前成员表单
+
+
+## v1.0.1300 (2026-09-04)
+
+### 新功能
+- 美化方案制定页 - 目标输入框加大、下一步按钮缩小、空成员跳转成员管理页
+
+
+## v1.0.1298 (2026-09-04)
+
+### 新功能
+- 合并重复的文章列表页为知识中心,统一入口到 article-center
+
+### 其他
+- 废弃 mind-extra/articles 和 mind-detail/articles,6 处入口重定向并删除注册
+- 
+
+
 ## v1.0.1297 (2026-09-04)
 
 ### Bug 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1297
+> 当前版本: v1.0.1308
 
 ## 历史版本
 
@@ -8,6 +8,148 @@
 
 ---
 
+## v1.0.1308 (2026-09-05)
+
+### Bug 修复
+- Critical 修复——isSwitchable 设置 + ProfileHeader 走新切换流程
+- 退出切换密码校验失败时补充用户提示
+
+### 重构
+- switchToChildWithCheck 文案与注释对齐 member 语义
+- children 表下线,ChildMapper 标注废弃,AccountCleanup 迁移至 family_members
+- role=child 判断清理(历史兼容注释 + InviteCard role 改为 parent)
+- FamilyMemberStrip 切换入口仅对不可登录成员开放
+- 切换视图统一 member 语义,退出切换接入密码校验
+
+### 新功能
+- 存量 role=child 账号强制迁移为成员模型(迁移298)
+- 切换入口确认提示 + 退出切换统一密码校验
+- 恢复退出切换接口 switch-back-verify(带密码校验)
+- 新增切换保护密码设置接口 set-switch-password
+- phoneLogin 复用无 openid 的预留成员用户并补 openid
+- addMember 同步创建无 openid 的 user 记录(不可登录成员)
+- switchToMember 仅允许切换不可登录成员
+- FamilyMemberVO 新增 isSwitchable 字段
+- FamilyMember 新增 memberType 成员类型字段与可切换判定
+
+### 文档
+- executorType/creatorType/memberType 业务字段注释统一为『非登录用户』语义
+- 成员模型重构——清理孩子/child角色定义实现计划
+
+
+## v1.0.1307 (2026-09-05)
+
+### Bug 修复
+- 修复选择规划师页调用available-guides缺少packageId报错
+
+### 其他
+- packageId 缺失报错,且返回字段 guideId/nickname 与页面模板 id/name 不匹配)。
+- 改用后端已有的 /api/health-plan/available-teachers 接口,返回 id/name/isBound
+- 等字段,与页面模板完全匹配。api.js 新增 getAvailableTeachers(familyId) 封装。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1306 (2026-09-05)
+
+### Bug 修复
+- 最终审查6项修复——废弃旧方法/修正迁移296/补充状态机测试/更新注释
+- cancelPlan级联清理方案未完成任务
+- purchasePackage补幂等复用并生成任务,消除active空壳
+
+### 测试
+- 统一任务生成器幂等/生成/级联清理单元测试
+
+### 新功能
+- 迁移296/297修正存量健康/认知训练方案任务source_type
+- 认知训练方案任务补设source_type=training_plan可溯源
+- 健康方案任务source_type改为health_plan,消除与plan的ID域冲突
+- 状态机收敛generated→active,confirm改走applyAndActivate
+- 新增统一任务生成器generateTasksFromTemplate
+
+### 重构
+- activate改用统一任务生成器,删除重复模板生成逻辑
+
+
+## v1.0.1305 (2026-09-04)
+
+### 新功能
+- 迁移295补齐task_plan_instances审核/确认/激活列
+- 后台全部列表页支持点击列头排序
+
+### 其他
+- - SortInterceptor 从缓存 body 读 sort 写入 SortContext(原只读 URL query,
+-   前端发 body 永远读不到),激活代码库 307 处已有 SortUtil.applySort 无参调用
+- 前端:
+- - SortMixin 补 onSortToggle 方法(修复 20 个文件 onSortToggle is not a function)
+- - SortMixin 新增 reloadAfterSort,兼容 loadList/loadData/loadTasks/loadPlans
+-   四种加载函数名,解决点击表头不刷新问题
+- - 107 个列表页接入 SortMixin(admin/teacher/nutritionist/root 各模块)
+- - 3 个位置参数 API 封装(guidePackage/guideTask/teacherMessage)加可选 sort 参数
+- 跳过:配置页/详情页/组件/仪表盘/树形表等非列表页(23 个)
+- 
+
+
+## v1.0.1304 (2026-09-04)
+
+### 文档
+- 方案转任务统一设计 + 实现计划(source_type拆分 + 统一生成器 + 状态机收敛)
+
+### 其他
+- - CommissionService/UserService: 用户不存在场景补充 warn 日志
+- - DatabaseInitializer: 生产库对账候选风险记录(注释)
+- - AGENTS.md: 环境区分说明(测试库 vs 生产库)
+- - cfc-web: 版本号 1.0.1300 + CHANGELOG 更新
+- 
+
+
+## v1.0.1303 (2026-09-04)
+
+### 文档
+- 新增 xhs-daily-report 计划文档
+- 新增小红书便秘腹泻帐号起号方案与计划
+- 新增小红书内容 D33/D34 与发布检查清单
+
+### 新功能
+- 日报采集点赞/收藏/分享字段,统计未回复评论数
+- 长任务进度上报与无限等待,bot 接入进度回调
+
+### Bug 修复
+- 修复家长成员VO缺少memberId/name字段,导致生成方案页面无法正确识别家长成员
+
+
+## v1.0.1302 (2026-09-04)
+
+### 文档
+- 统一任务系统实现计划(9任务:数据层/服务层/迁移/前端)
+
+
+## v1.0.1301 (2026-09-04)
+
+### 文档
+- 统一任务系统设计规格(多套任务体系合并为单表 tasks)
+
+### 新功能
+- 调研页多成员 tab 页签样式 + 进入问卷时初始化当前成员表单
+
+
+## v1.0.1300 (2026-09-04)
+
+### 新功能
+- 美化方案制定页 - 目标输入框加大、下一步按钮缩小、空成员跳转成员管理页
+
+
+## v1.0.1298 (2026-09-04)
+
+### 新功能
+- 合并重复的文章列表页为知识中心,统一入口到 article-center
+
+### 其他
+- 废弃 mind-extra/articles 和 mind-detail/articles,6 处入口重定向并删除注册
+- 
+
+
 ## v1.0.1297 (2026-09-04)
 
 ### Bug 修复

+ 3 - 2
cfc-web/src/api/guidePackage.js

@@ -1,10 +1,11 @@
 import request from '@/utils/request'
 
 // 获取我的任务模板列表
-export function getMyPackages() {
+export function getMyPackages(data) {
   return request({
     url: '/api/packages/my',
-    method: 'post'
+    method: 'post',
+    data: data || {}
   })
 }
 

+ 4 - 2
cfc-web/src/api/guideTask.js

@@ -17,8 +17,10 @@ export function fetchFamilyOverview(familyId) {
 /**
  * 成长规划师获取孩子任务列表
  */
-export function fetchChildTasks(familyId, memberId, page = 1, size = 10, status) {
-  return request.post(`/api/guide/families/${familyId}/children/${memberId}/tasks`, { page, size, status })
+export function fetchChildTasks(familyId, memberId, page = 1, size = 10, status, sort) {
+  const data = { page, size, status }
+  if (sort) data.sort = sort
+  return request.post(`/api/guide/families/${familyId}/children/${memberId}/tasks`, data)
 }
 
 /**

+ 4 - 2
cfc-web/src/api/teacherMessage.js

@@ -17,8 +17,10 @@ export function replyConsultMessage(messageId, replyContent) {
 /**
  * 成长规划师获取咨询消息列表
  */
-export function getConsultMessages(page = 1, size = 10, status) {
-  return request.post('/api/guide/messages/list', { page, size, status })
+export function getConsultMessages(page = 1, size = 10, status, sort) {
+  const data = { page, size, status }
+  if (sort) data.sort = sort
+  return request.post('/api/guide/messages/list', data)
 }
 
 /**

+ 21 - 1
cfc-web/src/mixins/SortMixin.js

@@ -54,7 +54,27 @@ export default {
         // 新列:追加到排序末尾(多列排序)
         this.sortState.push({ column: col, order: 'asc' })
       }
-      if (typeof this.loadList === 'function') this.loadList()
+      this.reloadAfterSort()
+    },
+    onSortToggle(col) {
+      const idx = this.sortState.findIndex(s => s.column === col)
+      if (idx >= 0) {
+        this.sortState.splice(idx, 1)
+      } else {
+        this.sortState.push({ column: col, order: 'asc' })
+      }
+      this.reloadAfterSort()
+    },
+    reloadAfterSort() {
+      if (typeof this.loadList === 'function') {
+        this.loadList()
+      } else if (typeof this.loadData === 'function') {
+        this.loadData()
+      } else if (typeof this.loadTasks === 'function') {
+        this.loadTasks()
+      } else if (typeof this.loadPlans === 'function') {
+        this.loadPlans()
+      }
     },
     clearSort() {
       this.sortState = []

+ 25 - 1
cfc-web/src/views/Children.vue

@@ -40,7 +40,11 @@
 
         <!-- 孩子列表 -->
         <el-table :data="tableData" border v-loading="loading" style="width: 100%" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
         <el-table-column prop="age" label="年龄" width="80"></el-table-column>
         <el-table-column prop="totalPoints" label="当前积分" width="120">
@@ -65,6 +69,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="180" fixed="right">
           <template slot-scope="{ row }">
@@ -145,10 +152,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getChildrenList, adjustChildPoints, updateChild } from '@/api/admin'
 
 export default {
   name: 'Children',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -159,6 +168,7 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       searchForm: {
         nickname: '',
         familyId: ''
@@ -203,6 +213,9 @@ export default {
     this.loadChildren()
   },
   methods: {
+    loadList() {
+      this.loadChildren()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'adjustPoints': this.handleAdjustPoints(row); break;
@@ -217,6 +230,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getChildrenList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -310,4 +324,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/Families.vue

@@ -45,6 +45,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="250" fixed="right">
           <template slot-scope="{ row }">
@@ -282,11 +285,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 export default {
   name: 'Families',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -297,6 +302,7 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: {
         name: ''
       },
@@ -349,6 +355,9 @@ export default {
     this.loadFamilies()
   },
   methods: {
+    loadList() {
+      this.loadFamilies()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'viewMembers': this.handleViewMembers(row); break;
@@ -367,6 +376,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getFamilyList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -588,4 +598,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 24 - 2
cfc-web/src/views/GuideFamilyTask.vue

@@ -98,7 +98,11 @@
             <p>暂无任务数据</p>
           </div>
         </template>
-        <el-table-column prop="id" label="ID" width="60"></el-table-column>
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="title" label="任务标题" min-width="150"></el-table-column>
         <el-table-column prop="points" label="积分" width="80"></el-table-column>
         <el-table-column prop="deadline" label="截止时间" width="160">
@@ -196,10 +200,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { fetchBoundFamilies, fetchFamilyOverview, fetchChildTasks, fetchPendingReviewTasks, reviewTask, updateTask, deleteTask } from '@/api/guideTask'
 
 export default {
   name: 'GuideFamilyTask',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -213,6 +219,7 @@ taskLoading: false,
     families: [],
       children: [],
       tasks: [],
+      sortableColumns: { id: 'ID' },
       familyOverview: null,
       total: 0,
       searchForm: {
@@ -285,6 +292,9 @@ taskLoading: false,
       this.searchForm.memberId = child.memberId
       this.loadChildTasks()
     },
+    loadList() {
+      this.loadChildTasks()
+    },
     async loadChildTasks() {
       if (!this.searchForm.memberId) {
         this.tasks = []
@@ -298,7 +308,8 @@ taskLoading: false,
           this.searchForm.memberId,
           this.pagination.page,
           this.pagination.size,
-          this.searchForm.status
+          this.searchForm.status,
+          this.sortState.length > 0 ? this.sortState : null
         )
         this.tasks = res.data.records || []
         this.total = res.data.total || 0
@@ -439,4 +450,15 @@ taskLoading: false,
 .empty-table-state {
   margin: 20px 0;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 1
cfc-web/src/views/GuidePackages.vue

@@ -49,6 +49,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="280" fixed="right">
           <template slot-scope="{ row }">
@@ -189,10 +192,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getMyPackages, getGuidePackage, getGuidePackageItems, createPackage, updatePackage, submitPackageForReview, publishPackage, addPackageItem, updatePackageItem, deletePackageItem } from '@/api/guidePackage'
 
 export default {
   name: 'GuidePackages',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -203,6 +208,7 @@ export default {
       loading: false,
       saving: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: { status: '' },
       dialogVisible: false,
         dialogTitle: '创建任务模板',
@@ -251,10 +257,13 @@ export default {
         case 'share': this.handleShare(row); break;
       }
     },
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await getMyPackages()
+        const res = await getMyPackages(this.sortState.length > 0 ? { sort: this.sortState } : {})
         let data = res.data || []
         if (this.searchForm.status) {
           data = data.filter(p => p.status === this.searchForm.status)
@@ -449,4 +458,15 @@ export default {
   height: 148px;
   display: block;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 22 - 3
cfc-web/src/views/OperationLogs.vue

@@ -60,6 +60,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">操作时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="80" fixed="right">
           <template slot-scope="{ row }">
@@ -99,11 +102,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import axios from '@/utils/request'
 import { getOperationLogs } from '@/api/operationLog'
 
 export default {
   name: 'OperationLogs',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -113,6 +118,7 @@ export default {
     return {
       loading: false,
       logs: [],
+      sortableColumns: { createdAt: '操作时间' },
       filterForm: {
         userId: null,
         operationType: null
@@ -131,6 +137,9 @@ export default {
     this.fetchLogs()
   },
   methods: {
+    loadList() {
+      this.fetchLogs()
+    },
     async fetchLogs() {
       this.loading = true
       try {
@@ -142,6 +151,7 @@ export default {
           startDate: this.dateRange ? this.dateRange[0] : undefined,
           endDate: this.dateRange ? this.dateRange[1] : undefined
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getOperationLogs(params)
         if (res.code === 200) {
           this.logs = res.data.records || []
@@ -193,9 +203,7 @@ export default {
 <style scoped>
 .operation-logs {
   padding: 20px;
-}
-
-.filter-card {
+}.filter-card {
   margin-bottom: 20px;
 }
 
@@ -207,4 +215,15 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/Points.vue

@@ -68,6 +68,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
       </el-table>
 
@@ -87,10 +90,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getPointsLogs } from '@/api/admin'
 
 export default {
   name: 'Points',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -100,6 +105,7 @@ export default {
     return {
       loading: false,
       tableData: [],
+      sortableColumns: { createdAt: '时间' },
       searchForm: {
         memberId: '',
         type: ''
@@ -115,6 +121,9 @@ export default {
     this.loadPointsLogs()
   },
   methods: {
+    loadList() {
+      this.loadPointsLogs()
+    },
     async loadPointsLogs() {
       this.loading = true
       try {
@@ -123,6 +132,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getPointsLogs(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -189,4 +199,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/Rewards.vue

@@ -93,6 +93,9 @@
               <template slot-scope="scope">
                 {{ formatDate(scope.row.createdAt) }}
               </template>
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+              </template>
             </el-table-column>
             <el-table-column label="操作" width="200" fixed="right">
               <template slot-scope="{ row }">
@@ -185,10 +188,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getRewardsList, getRewardTemplates, createRewardTemplate, deleteRewardTemplate, approveReward, getFamilyList } from '@/api/admin'
 
 export default {
   name: 'Rewards',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -201,6 +206,7 @@ export default {
       loadingTemplates: false,
       tableData: [],
       templates: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: {
         status: '',
         familyId: '',
@@ -234,6 +240,9 @@ export default {
     this.loadRewards()
   },
   methods: {
+    loadList() {
+      this.loadRewards()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'approve': this.handleApprove(row, true); break;
@@ -250,6 +259,7 @@ export default {
           status: this.searchForm.status,
           familyId: this.searchForm.familyId || undefined
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getRewardsList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -423,4 +433,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/TaskTemplates.vue

@@ -120,6 +120,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="180" fixed="right">
           <template slot-scope="scope">
@@ -257,10 +260,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getTaskTemplateList, createTaskTemplate, updateTaskTemplate, deleteTaskTemplate } from '@/api/admin'
 
 export default {
   name: 'TaskTemplates',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -271,6 +276,7 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: {
         category: '',
         difficulty: '',
@@ -326,6 +332,9 @@ export default {
     this.loadTemplates()
   },
   methods: {
+    loadList() {
+      this.loadTemplates()
+    },
     async loadTemplates() {
       this.loading = true
       try {
@@ -334,6 +343,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getTaskTemplateList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -511,4 +521,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 1
cfc-web/src/views/TeacherConsult.vue

@@ -38,6 +38,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="咨询时间" width="160">
           <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">咨询时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="150" fixed="right">
           <template slot-scope="{ row }">
@@ -87,16 +90,19 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getConsultMessages, replyConsultMessage } from '@/api/teacherMessage'
 
 export default {
   name: 'TeacherConsult',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       replyLoading: false,
       messages: [],
       total: 0,
+      sortableColumns: { createdAt: '咨询时间' },
       searchForm: {
         status: ''
       },
@@ -124,10 +130,13 @@ export default {
     this.loadMessages()
   },
   methods: {
+    loadList() {
+      this.loadMessages()
+    },
     async loadMessages() {
       this.loading = true
       try {
-        const res = await getConsultMessages(this.pagination.page, this.pagination.size, this.searchForm.status)
+        const res = await getConsultMessages(this.pagination.page, this.pagination.size, this.searchForm.status, this.sortState.length > 0 ? this.sortState : null)
         this.messages = res.data.records || []
         this.total = res.data.total || 0
       } catch (e) {
@@ -185,4 +194,15 @@ export default {
   background: #f5f7fa;
   border-radius: 4px;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 0
cfc-web/src/views/Wishes.vue

@@ -241,6 +241,9 @@
               <template slot-scope="scope">
                 {{ formatDate(scope.row.createdAt) }}
               </template>
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+              </template>
             </el-table-column>
           </el-table>
 
@@ -277,6 +280,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { 
   getWishesList, 
   getPendingWishes, 
@@ -289,12 +293,14 @@ import {
 
 export default {
   name: 'Wishes',
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'pending',
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       pendingWishes: [],
       searchForm: {
         status: '',
@@ -346,6 +352,9 @@ export default {
     this.loadPendingWishes()
   },
   methods: {
+    loadList() {
+      this.loadWishes()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'price': this.showPriceDialog(row); break;
@@ -379,6 +388,7 @@ export default {
           familyId: this.searchForm.familyId || undefined,
           status: this.searchForm.status || undefined
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getWishesList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -573,4 +583,15 @@ export default {
   color: #999;
   font-size: 12px;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 31 - 3
cfc-web/src/views/admin/Activities.vue

@@ -58,14 +58,22 @@
         <el-button size="mini" @click="resetSearch">重置</el-button>
       </div>
         <el-table :data="activities" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="封面" width="80">
           <template slot-scope="{ row }">
             <img v-if="row.coverImage" :src="row.coverImage" style="width:40px;height:40px;object-fit:cover;border-radius:4px;" />
             <span v-else style="color:#999">无</span>
           </template>
         </el-table-column>
-        <el-table-column prop="title" label="活动标题" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="title" label="活动标题" min-width="160" show-overflow-tooltip>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('title', $event)">活动标题<span v-if="sortIndex('title') !== null" class="sort-index" :class="sortClass('title') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('title') }}</span>{{ sortIcon('title') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="dimensionCode" label="维度" width="100">
           <template slot-scope="{ row }">{{ dimensionLabel(row.dimensionCode) }}</template>
         </el-table-column>
@@ -161,6 +169,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getActivityList, publishActivity, endActivity, getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
 
 const STATUS_MAP = {
@@ -182,6 +191,7 @@ const DIMENSION_MAP = {
 }
 
 export default {
+  mixins: [SortMixin],
   name: 'Activities',
   computed: {
     tableHeight() {
@@ -205,7 +215,8 @@ export default {
       regDialogVisible: false,
       regLoading: false,
       currentActivity: null,
-      registrations: []
+      registrations: [],
+      sortableColumns: { id: 'ID', title: '活动标题', createdAt: '创建时间' }
     }
   },
   created() {
@@ -243,6 +254,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getActivityList(params)
         this.activities = (res.data && res.data.records) || []
         this.total = (res.data && res.data.total) || 0
@@ -299,6 +311,7 @@ export default {
       this.filter.page = 1
       this.loadActivities()
     },
+    loadList() { this.loadActivities() },
   async handlePublish(row) {
     try {
       await this.$confirm('确认发布该活动?', '提示')
@@ -423,4 +436,19 @@ export default {
 .header-search {
   width: 220px;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 23 - 1
cfc-web/src/views/admin/ActivityRegistrationReview.vue

@@ -18,7 +18,11 @@
 
     <div class="table-scroll-wrap-sm">
       <el-table :max-height="tableHeight" :data="filteredList" v-loading="loading" border stripe>
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="registrantName" label="报名人" width="100" />
       <el-table-column prop="registrantPhone" label="手机号" width="120" />
       <el-table-column prop="status" label="状态" width="90">
@@ -29,6 +33,9 @@
         </template>
       </el-table-column>
       <el-table-column prop="registeredAt" label="报名时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('registeredAt', $event)">报名时间<span v-if="sortIndex('registeredAt') !== null" class="sort-index" :class="sortClass('registeredAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('registeredAt') }}</span>{{ sortIcon('registeredAt') }}</span>
+        </template>
         <template slot-scope="{ row }">
           {{ formatTime(row.registeredAt || row.createdAt) }}
         </template>
@@ -56,11 +63,14 @@
 <script>
 import { getActivityList } from '@/api/activity'
 import { getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ActivityRegistrationReview',
+  mixins: [SortMixin],
   data() {
     return {
+      sortableColumns: { id: 'ID', registeredAt: '报名时间' },
       activityList: [],
       list: [],
       loading: false,
@@ -107,6 +117,7 @@ export default {
       try {
         const params = { activityId: this.activityId }
         if (this.statusFilter) params.status = this.statusFilter
+        this.applySortToParams(params)
         const res = await getRegistrationList(params)
         if (res.code === 200) this.list = res.data || []
       } catch (e) { console.error(e) }
@@ -149,3 +160,14 @@ export default {
 .header h2 { margin: 0; }
 .filters { display: flex; align-items: center; gap: 12px; }
 </style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
+</style>

+ 28 - 2
cfc-web/src/views/admin/ActivityReview.vue

@@ -24,10 +24,18 @@
         </div>
       </div>
         <el-table :data="pendingActivities" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="id" label="ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="title" label="活动标题" min-width="200" show-overflow-tooltip />
       <el-table-column prop="applicant" label="申请人" width="120" />
-      <el-table-column prop="createdAt" label="申请时间" width="180" />
+      <el-table-column prop="createdAt" label="申请时间" width="180">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="状态" width="100">
         <template slot-scope="{ row }">
           <el-tag v-if="row.status === 'draft'" type="warning">待审核</el-tag>
@@ -69,9 +77,11 @@
 
 <script>
 import { getActivityList, reviewActivity } from '@/api/activity'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ActivityReview',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -79,6 +89,7 @@ export default {
   },
   data() {
     return {
+      sortableColumns: { id: 'ID', createdAt: '申请时间' },
       loading: false,
       pendingActivities: [],
       filter: {
@@ -95,6 +106,9 @@ export default {
     this.loadPending()
   },
   methods: {
+    loadList() {
+      this.loadPending()
+    },
     async loadPending() {
       this.loading = true
       try {
@@ -105,6 +119,7 @@ export default {
           keyword: this.filter.keyword || undefined,
           dimensionCode: this.filter.dimensionCode || undefined
         }
+        this.applySortToParams(params)
         const res = await getActivityList(params)
         this.pendingActivities = (res.data && res.data.records) || []
         this.total = (res.data && res.data.total) || 0
@@ -161,3 +176,14 @@ export default {
   color: #ccc;
 }
 </style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
+</style>

+ 28 - 3
cfc-web/src/views/admin/ArticleCategory.vue

@@ -10,13 +10,21 @@
       </div>
       <div class="table-scroll-wrap-sm">
       <el-table :data="filteredData" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="图标" width="80">
           <template slot-scope="{ row }">
             <span style="font-size: 22px">{{ row.icon || '📄' }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="name" label="名称" min-width="140" />
+        <el-table-column prop="name" label="名称" min-width="140">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">名称{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="颜色" width="100">
           <template slot-scope="{ row }">
             <span v-if="row.color" :style="{ display: 'inline-block', width: '20px', height: '20px', backgroundColor: row.color, borderRadius: '4px', verticalAlign: 'middle', border: '1px solid #ddd' }"></span>
@@ -91,14 +99,17 @@ import {
   adminArticleCategoryUpdate,
   adminArticleCategoryDelete
 } from '@/api/article.js'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ArticleCategory',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       submitting: false,
       categories: [],
+      sortableColumns: { id: 'ID', name: '名称' },
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
@@ -129,10 +140,15 @@ export default {
     }
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await adminArticleCategoriesList()
+        var params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await adminArticleCategoriesList(params)
         if (res.data) {
           this.categories = res.data
         }
@@ -227,4 +243,13 @@ export default {
 .article-category {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 25 - 2
cfc-web/src/views/admin/AssessmentAssign.vue

@@ -7,7 +7,11 @@
       </div>
 
         <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe empty-text="暂无可分配记录">
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="用户" width="120">
             <template slot-scope="{ row }">{{ row.userName }}</template>
           </el-table-column>
@@ -29,6 +33,9 @@
             <template slot-scope="{ row }">{{ row.notes || '-' }}</template>
           </el-table-column>
           <el-table-column label="创建时间" width="160">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
             <template slot-scope="{ row }">{{ row.createdAt }}</template>
           </el-table-column>
           <el-table-column label="操作" width="220" fixed="right">
@@ -84,9 +91,11 @@
 
 <script>
 import { getPendingAssessmentList, getAvailableGuides, assignGuide } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'AssessmentAssign',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -94,6 +103,7 @@ export default {
   },
   data() {
     return {
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       loading: false,
       list: [],
       page: 1,
@@ -116,7 +126,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getPendingAssessmentList({ page: this.page, size: this.size })
+        const params = { page: this.page, size: this.size }
+        this.applySortToParams(params)
+        const res = await getPendingAssessmentList(params)
         this.list = res.data.records || []
         this.total = res.data.total || 0
       } catch (e) {
@@ -182,4 +194,15 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+</style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
 </style>

+ 22 - 1
cfc-web/src/views/admin/AssessmentOrders.vue

@@ -57,6 +57,9 @@
         </el-table-column>
         <el-table-column label="创建时间" width="155">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="130" fixed="right">
           <template slot-scope="{ row }">
@@ -124,10 +127,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getAssessmentOrderList, changeAssessmentGuide, getAvailableGuides } from '@/api/admin'
 
 export default {
   name: 'AssessmentOrders',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 320
@@ -148,7 +153,8 @@ export default {
       selectedGuideId: null,
       guides: [],
       guidesLoading: false,
-      changing: false
+      changing: false,
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -169,6 +175,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await getAssessmentOrderList(params)
         this.list = res.data.records || []
         this.total = res.data.total || 0
@@ -255,6 +262,20 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 
 
 </style>

+ 23 - 3
cfc-web/src/views/admin/AssessmentProducts.vue

@@ -27,7 +27,11 @@
       </div>
 
       <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="商品图片" width="80">
           <template slot-scope="{ row }">
             <img v-if="row.coverImage" :src="row.coverImage" class="product-thumb"
@@ -99,9 +103,11 @@
 
 <script>
 import { getProductList, reviewProduct, shelveProduct } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'AssessmentProducts',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -109,6 +115,7 @@ export default {
   },
   data() {
     return {
+      sortableColumns: { id: 'ID' },
       list: [],
       loading: false,
       page: 1,
@@ -129,13 +136,15 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getProductList({
+        const params = {
           page: this.page,
           size: this.size,
           productType: 'assessment',
           keyword: this.filters.keyword || undefined,
           status: this.filters.status || undefined
-        })
+        }
+        this.applySortToParams(params)
+        const res = await getProductList(params)
         if (res.code === 200 && res.data) {
           this.list = res.data.records || []
           this.total = res.data.total || 0
@@ -200,3 +209,14 @@ export default {
 .product-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; }
 .pagination-wrap { margin-top: 16px; text-align: right; }
 </style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
+</style>

+ 33 - 4
cfc-web/src/views/admin/BadgeManage.vue

@@ -26,7 +26,11 @@
       </el-form>
 
       <el-table :data="list" border stripe v-loading="loading" empty-text="暂无数据" style="width:100%" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="name" label="名称" min-width="120" />
         <el-table-column prop="category" label="分类" width="90" />
         <el-table-column label="等级" width="80">
@@ -49,7 +53,12 @@
           </template>
         </el-table-column>
         <el-table-column prop="sortOrder" label="排序" width="60" align="center" />
-        <el-table-column prop="createdAt" label="创建时间" width="160" />
+        <el-table-column prop="createdAt" label="创建时间" width="160">
+          <template slot-scope="{ row }">{{ formatTime ? formatTime(row.createdAt) : row.createdAt }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="160" fixed="right">
           <template slot-scope="{ row }">
             <el-button type="text" @click="editRow(row)">编辑</el-button>
@@ -146,9 +155,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getBadgeList, createBadge, updateBadge, deleteBadge } from '@/api/badge'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -201,7 +212,8 @@ export default {
           _active: true,
           _needApproval: false
         }
-      }
+      },
+      sortableColumns: { id: 'ID', createdAt: '创建时间', name: '名称' }
     }
   },
   mounted() { this.fetchList() },
@@ -213,7 +225,9 @@ export default {
     async fetchList() {
       this.loading = true
       try {
-        const res = await getBadgeList()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getBadgeList(params)
         var data = res.data || []
         // Apply client-side filters
         if (this.filters.name) {
@@ -234,6 +248,7 @@ export default {
         this.loading = false
       }
     },
+    loadList() { this.fetchList() },
     showAddDialog() {
       this.dialog.isEdit = false
       this.dialog.form = {
@@ -344,4 +359,18 @@ export default {
 .badge-manage-page {
   padding: 16px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 27 - 3
cfc-web/src/views/admin/BaziConfigs.vue

@@ -10,7 +10,11 @@
       </div>
       <div class="table-scroll-wrap">
       <el-table :data="filteredData" stripe :max-height="tableHeight"
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="stemOrBranch" label="天干/地支" width="100"></el-table-column>
         <el-table-column prop="type" label="类型" width="80">
           <template slot-scope="scope">
@@ -101,9 +105,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listBaziConfigs, createBaziConfig, updateBaziConfig, deleteBaziConfig } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'BaziConfigs',
   data() {
     return {
@@ -113,7 +119,8 @@ export default {
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID' }
     }
   },
   created() {
@@ -148,12 +155,15 @@ export default {
     },
     async loadData() {
       try {
-        const res = await listBaziConfigs()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await listBaziConfigs(params)
         if (res.data) this.configs = res.data
       } catch (e) {
         console.error(e)
       }
     },
+    loadList() { this.loadData() },
     handleSearch() {
     },
     handleCreate() {
@@ -212,4 +222,18 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 27 - 3
cfc-web/src/views/admin/BloodTypeConfigs.vue

@@ -9,7 +9,11 @@
         </div>
       </div>
       <div class="table-scroll-wrap"><el-table :data="filteredData" stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="bloodType" label="血型" width="80"></el-table-column>
         <el-table-column prop="description" label="性格描述" min-width="200"></el-table-column>
         <el-table-column prop="mindBase" label="心基础分" width="100"></el-table-column>
@@ -79,9 +83,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listBloodTypeConfigs, createBloodTypeConfig, updateBloodTypeConfig, deleteBloodTypeConfig } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'BloodTypeConfigs',
   data() {
     return {
@@ -91,7 +97,8 @@ export default {
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID' }
     }
   },
   created() {
@@ -124,12 +131,15 @@ export default {
     },
     async loadData() {
       try {
-        const res = await listBloodTypeConfigs()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await listBloodTypeConfigs(params)
         if (res.data) this.configs = res.data
       } catch (e) {
         console.error(e)
       }
     },
+    loadList() { this.loadData() },
     handleSearch() {
     },
     handleCreate() {
@@ -188,4 +198,18 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 29 - 2
cfc-web/src/views/admin/ButlerAssignments.vue

@@ -5,7 +5,11 @@
         <span>管家分配管理</span>
       </div>
       <el-table v-loading="loading" :data="records" border stripe>
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="familyId" label="家庭ID" width="100" />
         <el-table-column prop="butlerNickname" label="管家" min-width="140" />
         <el-table-column prop="butlerUserId" label="管家用户ID" width="110" />
@@ -16,6 +20,9 @@
         </el-table-column>
         <el-table-column label="绑定时间" width="170">
           <template slot-scope="{ row }">{{ formatTime(row.assignedAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('assignedAt', $event)">绑定时间{{ sortIcon('assignedAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="120" fixed="right">
           <template slot-scope="{ row }">
@@ -37,13 +44,16 @@
 
 <script>
 import { getButlerAssignments, unassignButler } from '@/api/butler'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ButlerAssignments',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       records: [],
+      sortableColumns: { id: 'ID', assignedAt: '绑定时间' },
       total: 0,
       pageNum: 1,
       pageSize: 10
@@ -53,13 +63,18 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     formatTime(iso) {
       if (!iso) return '-'
       return iso.substring(0, 19).replace('T', ' ')
     },
     loadData() {
       this.loading = true
-      getButlerAssignments({ pageNum: this.pageNum, pageSize: this.pageSize })
+      var params = { pageNum: this.pageNum, pageSize: this.pageSize }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getButlerAssignments(params)
         .then(res => {
           const data = res.data || {}
           this.records = data.records || []
@@ -85,3 +100,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 40 - 6
cfc-web/src/views/admin/CfValueManage.vue

@@ -11,7 +11,11 @@
     <el-tabs v-model="activeTab">
       <el-tab-pane label="余额列表" name="balances">
         <el-table :data="balanceList" v-loading="loadingBalance" border stripe>
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="用户" width="140">
             <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
           </el-table-column>
@@ -36,7 +40,11 @@
           <el-button type="primary" @click="searchLogs">查询流水</el-button>
         </div>
         <el-table :data="logList" v-loading="loadingLog" border stripe>
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="用户" width="120">
             <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
           </el-table-column>
@@ -50,7 +58,11 @@
           <el-table-column prop="refType" label="来源" width="130" />
           <el-table-column prop="refId" label="关联ID" width="80" />
           <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
-          <el-table-column prop="createdAt" label="时间" width="160" />
+          <el-table-column prop="createdAt" label="时间" width="160">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
+          </el-table-column>
         </el-table>
         <el-pagination v-if="logTotal > 0" :current-page="logPage" :page-size="logSize" :total="logTotal" layout="prev, pager, next" @current-change="pageLogs" />
       </el-tab-pane>
@@ -59,14 +71,17 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getCfValueBalance, getCfValueLogs, getCfValueAllBalances, searchUsers } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'balances',
       searchUserId: '',
       logUserId: '',
+      sortableColumns: { id: 'ID', createdAt: '时间' },
       // Balances
       balanceList: [],
       loadingBalance: false,
@@ -116,9 +131,13 @@ export default {
       const map = { earn: 'success', spend: 'warning', freeze: 'info', unfreeze: 'info', withdraw: 'primary' }
       return map[type] || 'info'
     },
+    loadList() { this.loadBalances() },
+    loadData() { this.searchLogs() },
     loadBalances() {
       this.loadingBalance = true
-      getCfValueAllBalances({ page: this.balancePage, size: this.balanceSize }).then(async res => {
+      const params = { page: this.balancePage, size: this.balanceSize }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getCfValueAllBalances(params).then(async res => {
         if (res.code === 200) {
           this.balanceList = res.data.records || []
           this.balanceTotal = res.data.total || 0
@@ -150,7 +169,9 @@ getCfValueBalance({ userId: this.searchUserId }).then(async res => {
       if (!this.logUserId) return
       this.loadingLog = true
       this.logPage = 1
-getCfValueLogs({ userId: this.logUserId, page: 1, size: this.logSize }).then(async res => {
+      const params = { userId: this.logUserId, page: 1, size: this.logSize }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getCfValueLogs(params).then(async res => {
           if (res.code === 200) {
             this.logList = res.data.records || []
             this.logTotal = res.data.total || 0
@@ -164,4 +185,17 @@ getCfValueLogs({ userId: this.logUserId, page: 1, size: this.logSize }).then(asy
     }
   }
 }
-</script>
+</script>
+
+<style scoped>
+.cf-value { padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 23 - 2
cfc-web/src/views/admin/CommentReview.vue

@@ -14,7 +14,11 @@
       </div>
     </div>
     <el-table :data="list" v-loading="loading" border stripe>
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="用户" width="120">
         <template slot-scope="{ row }">
           <span>{{ row.userName || '未知' }}</span>
@@ -37,7 +41,11 @@
           </el-tag>
         </template>
       </el-table-column>
-      <el-table-column prop="createdAt" label="时间" width="160" />
+      <el-table-column prop="createdAt" label="时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
         <template slot-scope="{ row }">
           <el-button size="mini" type="success" @click="audit(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
@@ -67,10 +75,13 @@
 <script>
 import { adminCommentList } from '@/api/article'
 import request from '@/utils/request'
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
+      sortableColumns: { id: 'ID', createdAt: '时间' },
       loading: false,
       total: 0,
       listQuery: { status: 'pending', articleId: '', page: 1, size: 20 },
@@ -87,6 +98,7 @@ export default {
         var params = { page: this.listQuery.page, size: this.listQuery.size }
         if (this.listQuery.status) params.status = this.listQuery.status
         if (this.listQuery.articleId) params.articleId = parseInt(this.listQuery.articleId)
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await adminCommentList(params)
         if (res.code === 200) {
           this.list = res.data.records || []
@@ -130,4 +142,13 @@ export default {
 <style scoped>
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
 .filters { display: flex; gap: 10px; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 4
cfc-web/src/views/admin/CouponGrantLog.vue

@@ -29,7 +29,12 @@
           <el-table-column prop="source" label="触发来源" min-width="160" show-overflow-tooltip>
             <template slot-scope="{ row }">{{ row.source || '-' }}</template>
           </el-table-column>
-          <el-table-column prop="createdAt" label="发放时间" width="180" />
+          <el-table-column prop="createdAt" label="发放时间" width="180">
+            <template slot-scope="{ row }">{{ row.createdAt }}</template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">发放时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
+          </el-table-column>
         </el-table>
       </div>
 
@@ -46,10 +51,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyCouponGrantLog } from '@/api/coupon'
 import { getFamilyDetail } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'CouponGrantLog',
   computed: {
     tableHeight() {
@@ -64,7 +71,8 @@ export default {
       size: 20,
       total: 0,
       familyId: '',
-      couponId: ''
+      couponId: '',
+      sortableColumns: { createdAt: '发放时间', id: 'ID' }
     }
   },
   created() {
@@ -82,12 +90,14 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await getFamilyCouponGrantLog({
+        const params = {
           page: this.page,
           size: this.size,
           familyId: this.familyId || undefined,
           couponId: this.couponId || undefined
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getFamilyCouponGrantLog(params)
         if (res.data) {
           this.list = res.data
           this.total = this.list.length
@@ -136,4 +146,18 @@ export default {
 
 <style scoped>
 .coupon-grant-log { padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 39 - 6
cfc-web/src/views/admin/DanExecution.vue

@@ -59,6 +59,9 @@
         </el-table-column>
         <el-table-column label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="160" fixed="right">
           <template slot-scope="{ row }">
@@ -151,9 +154,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { searchUsers } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'DanExecution',
   data() {
     return {
@@ -165,7 +170,8 @@ export default {
       statusFilter: '',
       detailDialogVisible: false,
       detailLoading: false,
-      currentRecord: null
+      currentRecord: null,
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   created() {
@@ -175,12 +181,14 @@ export default {
     async loadData() {
       this.loading = true
       try {
+        const params = {
+          page: this.page,
+          size: this.size,
+          status: this.statusFilter
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
         // TODO: 调用后端 API 获取执行记录列表
-        // const res = await this.$axios.post('/api/admin/dan-execution/list', {
-        //   page: this.page,
-        //   size: this.size,
-        //   status: this.statusFilter
-        // })
+        // const res = await this.$axios.post('/api/admin/dan-execution/list', params)
         // this.list = res.data.data.records
         // this.total = res.data.data.total
         this.list = []
@@ -192,6 +200,7 @@ export default {
         this.loading = false
       }
     },
+    loadList() { this.loadData() },
     async resolveUserNames(items, idField) {
       if (!items || items.length === 0) return
       var ids = []
@@ -297,6 +306,15 @@ export default {
 .admin-page {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 
 .admin-page-header {
   display: flex;
@@ -328,4 +346,19 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 40 - 4
cfc-web/src/views/admin/DanSettlement.vue

@@ -115,6 +115,9 @@
         <el-table-column label="交易ID" prop="transactionId" min-width="150" show-overflow-tooltip />
         <el-table-column label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="150" fixed="right">
           <template slot-scope="{ row }">
@@ -186,8 +189,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'DanSettlement',
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -207,7 +213,9 @@ export default {
       },
       detailDialogVisible: false,
       detailLoading: false,
-      currentRecord: null
+      currentRecord: null,
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
+      sortableColumns: { createdAt: '创建时间', id: 'ID' }
     }
   },
   created() {
@@ -218,10 +226,12 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await this.$axios.post('/api/admin/dan-settlement/list', {
+        const params = {
           status: this.statusFilter,
           providerType: this.providerTypeFilter
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await this.$axios.post('/api/admin/dan-settlement/list', params)
         if (res.data.code === 200) {
           this.list = res.data.data || []
           this.total = this.list.length
@@ -235,6 +245,7 @@ export default {
         this.loading = false
       }
     },
+    loadList() { this.loadData() },
     async loadStats() {
       try {
         const res = await this.$axios.post('/api/admin/dan-settlement/stats')
@@ -245,7 +256,8 @@ export default {
         console.error('加载统计数据失败', e)
       }
     },
-    async viewDetail(row) {
+      loadList() { this.loadData() },
+      async viewDetail(row) {
       this.detailDialogVisible = true
       this.detailLoading = true
       try {
@@ -333,6 +345,15 @@ export default {
 .admin-page {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 
 .admin-page-header {
   display: flex;
@@ -428,4 +449,19 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 32 - 4
cfc-web/src/views/admin/EcomSupplierManage.vue

@@ -10,8 +10,16 @@
 
     <div class="table-scroll-wrap">
     <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
-      <el-table-column prop="name" label="供应商名称" min-width="160" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="name" label="供应商名称" min-width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('name', $event)">供应商名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="contactName" label="联系人" width="120" />
       <el-table-column prop="contactPhone" label="联系电话" width="140" />
       <el-table-column label="启用状态" width="100">
@@ -65,9 +73,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEcomSupplierList, saveEcomSupplier, updateEcomSupplier, deleteEcomSupplier } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -86,11 +96,13 @@ export default {
         status: 1,
         remark: ''
       },
+      sortableColumns: { name: '供应商名称', createdAt: '创建时间', id: 'ID' },
       rules: {
         name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
         contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
         contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }]
-      }
+      },
+      sortableColumns: { id: 'ID', name: '供应商名称', createdAt: '创建时间' }
     }
   },
   computed: {
@@ -108,7 +120,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getEcomSupplierList({ page: this.page, size: this.size, keyword: this.keyword })
+        const params = { page: this.page, size: this.size, keyword: this.keyword }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getEcomSupplierList(params)
         this.list = res.data.records || res.data || []
         this.total = res.data.total || 0
       } catch (e) {
@@ -204,4 +218,18 @@ export default {
 .ecom-supplier-manage {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 22 - 1
cfc-web/src/views/admin/EmotionAlertManage.vue

@@ -35,6 +35,9 @@
         <template slot-scope="{ row }">
           {{ formatTime(row.createdAt) }}
         </template>
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">@dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
       </el-table-column>
       <el-table-column label="处理时间" width="160">
         <template slot-scope="{ row }">
@@ -67,10 +70,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEmotionAlertList, resolveEmotionAlert } from '@/api/admin'
 
 export default {
   name: 'EmotionAlertManage',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -84,7 +89,8 @@ export default {
       size: 20,
       total: 0,
       keyword: '',
-      resolvedFilter: ''
+      resolvedFilter: '',
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -101,6 +107,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await getEmotionAlertList(params)
         this.list = res.data && res.data.list ? res.data.list : []
         this.total = res.data && res.data.total ? res.data.total : 0
@@ -183,4 +190,18 @@ export default {
 .resolved-text {
   color: #909399;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 21 - 3
cfc-web/src/views/admin/EnergyBehaviorConfig.vue

@@ -13,7 +13,11 @@
     </div>
 
     <el-table :data="list" v-loading="loading" border stripe highlight-current-row>
-      <el-table-column prop="behaviorCode" label="行为编码" width="160" />
+      <el-table-column prop="behaviorCode" label="行为编码" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('behaviorCode', $event)">行为编码<span v-if="sortIndex('behaviorCode') !== null" class="sort-index" :class="sortClass('behaviorCode') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('behaviorCode') }}</span>{{ sortIcon('behaviorCode') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="behaviorName" label="行为名称" width="130" />
       <el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
       <el-table-column label="默认值" width="80">
@@ -128,6 +132,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEnergyBehaviorList, updateEnergyBehavior, toggleEnergyBehavior } from '@/api/energy'
 
 const DIMENSION_MAP = {
@@ -139,6 +144,7 @@ const DIMENSION_MAP = {
 }
 
 export default {
+  mixins: [SortMixin],
   name: 'EnergyBehaviorConfig',
   data() {
     return {
@@ -149,7 +155,8 @@ export default {
       dialogVisible: false,
       form: {},
       dimAssignList: [],
-      dimensions: Object.entries(DIMENSION_MAP).map(([code, v]) => ({ code, name: v.name }))
+      dimensions: Object.entries(DIMENSION_MAP).map(([code, v]) => ({ code, name: v.name })),
+      sortableColumns: { behaviorCode: '行为编码', createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -159,7 +166,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getEnergyBehaviorList()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getEnergyBehaviorList(params)
         if (res.code === 200) {
           this.list = (res.data || []).filter(r => {
             if (this.keyword) {
@@ -234,4 +243,13 @@ export default {
 <style scoped>
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
 .filters { display: flex; gap: 10px; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 2
cfc-web/src/views/admin/EnergyRuleManagement.vue

@@ -13,7 +13,11 @@
       </div>
     <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
       <el-table-column prop="id" label="ID" width="60" />
-      <el-table-column prop="ruleCode" label="规则编码" width="140" />
+      <el-table-column prop="ruleCode" label="规则编码" width="140">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('ruleCode', $event)">规则编码<span v-if="sortIndex('ruleCode') !== null" class="sort-index" :class="sortClass('ruleCode') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('ruleCode') }}</span>{{ sortIcon('ruleCode') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="ruleName" label="规则名称" min-width="160" show-overflow-tooltip />
       <el-table-column prop="eventType" label="事件类型" width="120" />
       <el-table-column prop="dimensionCode" label="维度" width="80" />
@@ -98,10 +102,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEnergyRuleList, createEnergyRule, updateEnergyRule, deleteEnergyRule, toggleEnergyRule, getEnergyDimensions } from '@/api/energy'
 
 export default {
+  mixins: [SortMixin],
   name: 'EnergyRuleManagement',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -119,7 +126,9 @@ export default {
       dimensionFilter: '',
       dialogVisible: false,
       saving: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID', ruleCode: '规则编码', createdAt: '创建时间' },
+      sortableColumns: { ruleCode: '规则编码', createdAt: '创建时间', id: 'ID' }
     }
   },
   mounted() {
@@ -142,6 +151,7 @@ export default {
         const params = { page: this.page, size: this.size }
         if (this.dimensionFilter) params.dimensionCode = this.dimensionFilter
         if (this.keyword) params.keyword = this.keyword
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getEnergyRuleList(params)
         if (res.code === 200 && res.data) {
           this.list = res.data.records || []
@@ -217,4 +227,13 @@ export default {
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
 .header h2 { margin: 0; }
 .filters { display: flex; align-items: center; gap: 12px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 29 - 3
cfc-web/src/views/admin/FamilyEarnings.vue

@@ -10,7 +10,11 @@
 
     <div class="table-scroll-wrap">
       <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight" @row-click="showDetail">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">@dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="familyId" label="家庭ID" width="100" />
         <el-table-column label="累计推荐收入" width="140">
           <template slot-scope="{ row }">
@@ -36,6 +40,9 @@
           <template slot-scope="{ row }">
             {{ row.updatedAt ? formatTime(row.updatedAt) : '-' }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('updatedAt', $event)">@dblclick.stop="onSortToggle('updatedAt')">更新时间<span v-if="sortIndex('updatedAt') !== null" class="sort-index" :class="sortClass('updatedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('updatedAt') }}</span>{{ sortIcon('updatedAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="100" fixed="right">
           <template slot-scope="{ row }">
@@ -92,9 +99,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyEarningsList, getFamilyEarningsDetail } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -107,7 +116,8 @@ export default {
       searchKeyword: '',
       detailVisible: false,
       detailLoading: false,
-      detail: null
+      detail: null,
+      sortableColumns: { id: 'ID', updatedAt: '更新时间' }
     }
   },
   mounted() {
@@ -117,7 +127,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        var res = await getFamilyEarningsList()
+        var params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        var res = await getFamilyEarningsList(params)
         this.list = res.data || []
       } catch (e) {
         this.$message.error('加载失败')
@@ -164,4 +176,18 @@ export default {
   font-weight: bold;
   color: #67c23a;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 24 - 2
cfc-web/src/views/admin/FamilyEarningsWithdraw.vue

@@ -37,6 +37,9 @@
           <template slot-scope="{ row }">
             {{ row.createdAt ? formatTime(row.createdAt) : '-' }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">@dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
       </el-table>
     </div>
@@ -53,9 +56,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyEarningsRecords } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -68,7 +73,8 @@ export default {
       page: 1,
       size: 20,
       total: 0,
-      typeFilter: ''
+      typeFilter: '',
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -78,7 +84,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        var res = await getFamilyEarningsRecords(0, this.typeFilter)
+        var params = { type: this.typeFilter }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        var res = await getFamilyEarningsRecords(0, params)
         if (res.data) {
           this.list = Array.isArray(res.data) ? res.data : [res.data]
           this.total = this.list.length
@@ -127,4 +135,18 @@ export default {
   font-weight: bold;
   color: #67c23a;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 12 - 2
cfc-web/src/views/admin/FileManage.vue

@@ -80,7 +80,12 @@
           </template>
         </el-table-column>
         <el-table-column prop="description" label="描述" min-width="120" show-overflow-tooltip></el-table-column>
-        <el-table-column prop="createdAt" label="上传时间" width="160"></el-table-column>
+        <el-table-column prop="createdAt" label="上传时间" width="160">
+          <template slot-scope="scope">{{ scope.row.createdAt }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">上传时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="120" fixed="right">
           <template slot-scope="scope">
             <el-button size="mini" type="text" @click="openEdit(scope.row)">编辑</el-button>
@@ -118,20 +123,24 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listFiles, updateFile, deleteFile, getFileTags, getFileStats } from '@/api/file'
 
 export default {
   name: 'FileManage',
+  mixins: [SortMixin],
   data() {
     return {
       tableData: [], total: 0, loading: false, stats: {}, tagOptions: [], dateRange: null,
       query: { page: 1, pageSize: 20, category: '', fileType: '', tagIds: [], search: '', dateFrom: '', dateTo: '' },
       editVisible: false, saving: false,
-      editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] }
+      editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] },
+      sortableColumns: { createdAt: '上传时间' }
     }
   },
   mounted() { this.loadStats(); this.loadTags(); this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     async loadData() {
       this.loading = true
       try {
@@ -139,6 +148,7 @@ export default {
           this.query.dateFrom = this.dateRange[0].toISOString().slice(0, 10)
           this.query.dateTo = this.dateRange[1].toISOString().slice(0, 10)
         } else { this.query.dateFrom = ''; this.query.dateTo = '' }
+        if (this.sortState.length > 0) this.query.sort = this.sortState
         const res = await listFiles(this.query)
         if (res.data.code === 200) { this.tableData = res.data.data.records; this.total = res.data.data.total }
       } catch (e) { this.$message.error('加载失败') }

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio