Ver código fonte

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

liaoxg 2 meses atrás
pai
commit
0273a758c5
34 arquivos alterados com 1577 adições e 522 exclusões
  1. 53 32
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  3. 145 10
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  4. 7 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
  5. 5 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java
  6. 5 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSubscriptionController.java
  7. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
  8. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/BloodTypePairing.java
  9. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
  10. 0 2
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java
  11. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java
  12. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ZodiacPairing.java
  13. 90 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  14. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java
  15. 11 2
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  16. 12 5
      cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java
  17. 37 0
      cfc-backend/src/main/resources/schema.sql
  18. 0 169
      cfc-frontend/pages/index-home/index.vue
  19. 93 218
      cfc-frontend/pages/login/login.vue
  20. 1 1
      cfc-web/.last_build_commit
  21. 2 2
      cfc-web/package-lock.json
  22. 1 1
      cfc-web/package.json
  23. 94 0
      cfc-web/public/CHANGELOG-v1.0.md
  24. 95 1
      cfc-web/public/CHANGELOG.md
  25. 8 0
      cfc-web/src/api/admin.js
  26. 4 0
      cfc-web/src/api/article.js
  27. 47 30
      cfc-web/src/views/Families.vue
  28. 28 2
      cfc-web/src/views/admin/ArticleManage.vue
  29. 1 26
      cfc-web/src/views/admin/AssessmentOrders.vue
  30. 1 16
      cfc-web/src/views/admin/SubscriptionManagement.vue
  31. 1 0
      docs/superpowers/PROJECT-OVERVIEW.md
  32. 1 0
      docs/superpowers/api/API_REFERENCE.md
  33. 609 0
      docs/superpowers/plans/2026-09-04-article-sort-order.md
  34. 181 0
      docs/superpowers/specs/2026-09-04-article-sort-order-design.md

+ 53 - 32
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10271,43 +10271,32 @@ public class DatabaseInitializer implements CommandLineRunner {
         ensureColumn("health_reports", "report_format",
                 "VARCHAR(32) DEFAULT NULL COMMENT '报告格式: pdf/image'");
 
-        // 迁移290: 批量补齐实体已含但 DB 从未迁移的真实缺列(全字段 SELECT 防 Unknown column)
-        // 审计核实:以下列不在任何历史 ADD COLUMN/ensureColumn 中,属真实缺口
-        // 涉及 12 表 33 列(food_nutrients 命名问题单列迁移291;articles/health_reports 等解析后无缺口)
-        ensureColumn("activities", "visibility", "VARCHAR(20) DEFAULT NULL COMMENT 'visibility'");
-        ensureColumn("activities", "visible_to", "TEXT DEFAULT NULL COMMENT 'visibleTo'");
-        ensureColumn("blood_type_pairing", "blood_type1", "VARCHAR(255) DEFAULT NULL COMMENT 'bloodType1'");
-        ensureColumn("blood_type_pairing", "blood_type2", "VARCHAR(255) DEFAULT NULL COMMENT 'bloodType2'");
+        // 迁移290: 补齐实体已含但 DB 从未迁移的真实缺列(全字段 SELECT 防 Unknown column)
+        // 逐表核实(对照 schema.sql + 历史 ADD COLUMN/ensureColumn),仅含确认不存在的列,共 6 表 21 列
+        // 风险预留:promotion_tier_config 实体与 DB 为两套不同晋级模型(已废弃,由 cf_rate_tier 迁移279 替代),
+        // 按用户决定保持现状不迁移,避免与旧种子数据冲突;后续清理旧实体依赖(CommissionService等)时再处理
+        ensureColumn("activities", "visibility", "VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private'");
+        ensureColumn("activities", "visible_to", "TEXT DEFAULT NULL COMMENT '私密指定人群JSON'");
         ensureColumn("dan_knowledge_base", "file_url", "VARCHAR(500) DEFAULT NULL COMMENT 'fileUrl'");
         ensureColumn("dan_knowledge_base", "file_name", "VARCHAR(255) DEFAULT NULL COMMENT 'fileName'");
         ensureColumn("dan_knowledge_base", "source_url", "VARCHAR(500) DEFAULT NULL COMMENT 'sourceUrl'");
         ensureColumn("dan_knowledge_base", "source_type", "VARCHAR(20) DEFAULT NULL COMMENT 'sourceType'");
         ensureColumn("distribution_relations", "invite_code", "VARCHAR(64) DEFAULT NULL COMMENT 'inviteCode'");
-        ensureColumn("family_members", "birth_hour", "VARCHAR(20) DEFAULT NULL COMMENT 'birthHour'");
-        ensureColumn("family_members", "birth_weight", "INT DEFAULT NULL COMMENT 'birthWeight'");
-        ensureColumn("family_members", "birth_place", "VARCHAR(255) DEFAULT NULL COMMENT 'birthPlace'");
-        ensureColumn("family_members", "is_cesarean", "INT DEFAULT NULL COMMENT 'isCesarean'");
-        ensureColumn("food_recommend_idx", "index_score", "INT DEFAULT NULL COMMENT 'indexScore'");
-        ensureColumn("health_indicators", "symptoms", "VARCHAR(2000) DEFAULT NULL COMMENT 'symptoms'");
-        ensureColumn("numsoul_config", "keywords", "VARCHAR(2000) DEFAULT NULL COMMENT 'keywords'");
-        ensureColumn("numsoul_detail_config", "keywords", "VARCHAR(2000) DEFAULT NULL COMMENT 'keywords'");
-        ensureColumn("product_orders", "tracking_number", "VARCHAR(100) DEFAULT NULL COMMENT 'trackingNumber'");
-        ensureColumn("product_orders", "express_company", "VARCHAR(100) DEFAULT NULL COMMENT 'expressCompany'");
-        ensureColumn("product_orders", "delivery_method", "INT DEFAULT NULL COMMENT 'deliveryMethod'");
-        ensureColumn("product_orders", "pickup_code", "VARCHAR(64) DEFAULT NULL COMMENT 'pickupCode'");
-        ensureColumn("product_orders", "confirm_time", "DATETIME DEFAULT NULL COMMENT 'confirmTime'");
-        ensureColumn("product_orders", "refund_status", "INT DEFAULT NULL COMMENT 'refundStatus'");
-        ensureColumn("product_orders", "refund_reason", "VARCHAR(255) DEFAULT NULL COMMENT 'refundReason'");
-        ensureColumn("product_orders", "refund_amount", "INT DEFAULT NULL COMMENT 'refundAmount'");
-        ensureColumn("product_orders", "refund_time", "DATETIME DEFAULT NULL COMMENT 'refundTime'");
-        ensureColumn("promotion_tier_config", "min_team_size", "INT DEFAULT NULL COMMENT 'minTeamSize'");
-        ensureColumn("promotion_tier_config", "min_referral_earnings", "INT DEFAULT NULL COMMENT 'minReferralEarnings'");
-        ensureColumn("promotion_tier_config", "min_share_earnings", "INT DEFAULT NULL COMMENT 'minShareEarnings'");
-        ensureColumn("promotion_tier_config", "profit_share_percent", "INT DEFAULT NULL COMMENT 'profitSharePercent'");
-        ensureColumn("promotion_tier_config", "enabled", "INT DEFAULT NULL COMMENT 'enabled'");
-        ensureColumn("zodiac_pairing", "zodiac_code1", "VARCHAR(255) DEFAULT NULL COMMENT 'zodiacCode1'");
-        ensureColumn("zodiac_pairing", "zodiac_code2", "VARCHAR(255) DEFAULT NULL COMMENT 'zodiacCode2'");
-        log.info("已补齐真实缺列 (迁移290, 12表33列)");
+        ensureColumn("family_members", "birth_hour", "VARCHAR(10) DEFAULT NULL COMMENT '出生时辰'");
+        ensureColumn("family_members", "birth_weight", "INT DEFAULT NULL COMMENT '出生体重(g)'");
+        ensureColumn("family_members", "birth_place", "VARCHAR(255) DEFAULT NULL COMMENT '出生地'");
+        ensureColumn("family_members", "is_cesarean", "TINYINT(1) DEFAULT 0 COMMENT '是否剖腹产: 0否 1是'");
+        ensureColumn("health_indicators", "symptoms", "TEXT DEFAULT NULL COMMENT '症状描述'");
+        ensureColumn("product_orders", "tracking_number", "VARCHAR(100) DEFAULT NULL COMMENT '快递单号'");
+        ensureColumn("product_orders", "express_company", "VARCHAR(100) DEFAULT NULL COMMENT '快递公司'");
+        ensureColumn("product_orders", "delivery_method", "INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人'");
+        ensureColumn("product_orders", "pickup_code", "VARCHAR(64) DEFAULT NULL COMMENT '自提码'");
+        ensureColumn("product_orders", "confirm_time", "DATETIME DEFAULT NULL COMMENT '确认收货时间'");
+        ensureColumn("product_orders", "refund_status", "INT DEFAULT NULL COMMENT '退款状态'");
+        ensureColumn("product_orders", "refund_reason", "VARCHAR(255) DEFAULT NULL COMMENT '退款原因'");
+        ensureColumn("product_orders", "refund_amount", "INT DEFAULT NULL COMMENT '退款金额(分)'");
+        ensureColumn("product_orders", "refund_time", "DATETIME DEFAULT NULL COMMENT '退款时间'");
+        log.info("已补齐真实缺列 (迁移290, 6表21列)");
 
         // 迁移291: food_nutrients 列名对齐(amount_per_100g -> amount_per100g)
         // FoodNutrient 实体字段 amountPer100g 经 MyBatis-Plus camel->snake 映射为 amount_per100g,
@@ -10324,5 +10313,37 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (Exception e) {
             // 表不存在或列不存在,忽略
         }
+
+        // 迁移292: health_status 补 member_id 并改为成员级归档(Unknown column 'member_id')
+        // 迁移239 建表只有 user_id,schema.sql 与实体已含 member_id(成员级档案),生产缺列导致 selectOne 报错。
+        // 原 UNIQUE KEY uk_user(user_id) 限制每账号仅一行,与成员级多行归档冲突,改为复合索引 idx_user_member。
+        ensureColumn("health_status", "member_id", "BIGINT DEFAULT NULL COMMENT '家庭成员ID(NULL=账号级,非空=成员级归档)'");
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_status DROP INDEX uk_user");
+            log.info("已移除 health_status.uk_user 唯一键(允许成员级多行归档)");
+        } catch (Exception e) {
+            // 索引不存在,忽略
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_status ADD INDEX idx_user_member (user_id, member_id)");
+            log.info("已添加 health_status.idx_user_member 复合索引");
+        } catch (Exception e) {
+            // 索引已存在,忽略
+        }
+
+        // 迁移293: family_members 添加 is_admin 列(后台家庭成员仅区分管理员/成员,不再区分家长/孩子)
+        ensureColumn("family_members", "is_admin", "TINYINT(1) DEFAULT 0 COMMENT '是否家庭管理员: 1=管理员, 0=普通成员'");
+        // 回填:将每个家庭的创建者(families.creator_id 匹配 family_members.user_id)设为管理员
+        try {
+            jdbcTemplate.execute("UPDATE family_members fm JOIN families f ON fm.family_id = f.id " +
+                    "SET fm.is_admin = 1 WHERE fm.user_id = f.creator_id AND (fm.is_admin IS NULL OR fm.is_admin = 0)");
+            log.info("迁移293: 已回填家庭创建者为管理员");
+        } catch (Exception e) {
+            log.warn("迁移293: 回填家庭创建者为管理员失败: {}", e.getMessage());
+        }
+
+        // 迁移294: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
+        ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
+        log.info("已添加 articles.sort_order 列(迁移294)");
     }
 }

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -270,6 +270,28 @@ public class AdminArticleController {
         return Result.success("操作成功");
     }
 
+    @PostMapping("/move")
+    public Result<String> move(@RequestBody Map<String, Object> body,
+                                @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = ParamUtils.getLong(body.get("id"));
+        String direction = (String) body.get("direction");
+        if (id == null) {
+            return Result.error("缺少文章ID");
+        }
+        if (direction == null || (!"up".equals(direction) && !"down".equals(direction))) {
+            return Result.error("direction 只能是 up 或 down");
+        }
+        try {
+            articleService.moveSortOrder(id, direction);
+            return Result.success("已调整顺序");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     @PostMapping("/audit")
     public Result<String> audit(@RequestBody Map<String, Object> body,
                                  @RequestAttribute("userId") Long adminId) {

+ 145 - 10
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -287,7 +287,11 @@ public class AdminController {
             wrapper.eq(Task::getFamilyId, familyId);
         }
         wrapper.orderByDesc(Task::getCreatedAt);
-        SortUtil.applySort(wrapper);
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> taskSortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        SortUtil.applySort(wrapper, taskSortSpecs, null);
         Page<Task> result = taskMapper.selectPage(pageParam, wrapper);
 
         // 为每条任务填充执行者名称
@@ -605,26 +609,153 @@ public class AdminController {
             return Result.error("家庭不存在");
         }
 
-        // 查询家长
+        // 统一成员列表:family_members(含创建者)+ users 中 role=parent 且未落入 family_members 的成员
+        // 仅区分「管理员 / 成员」,不区分家长 / 孩子
+        LambdaQueryWrapper<FamilyMember> memberWrapper = new LambdaQueryWrapper<>();
+        memberWrapper.eq(FamilyMember::getFamilyId, id);
+        SortUtil.applySort(memberWrapper);
+        java.util.List<FamilyMember> familyMembers = familyMemberMapper.selectList(memberWrapper);
+
+        // 收集已落入 family_members 的 user_id,避免与 users 家长重复
+        java.util.Set<Long> coveredUserIds = new java.util.HashSet<>();
+        for (FamilyMember fm : familyMembers) {
+            if (fm.getUserId() != null) {
+                coveredUserIds.add(fm.getUserId());
+            }
+        }
+
+        // 通过邀请码加入、未写入 family_members 的家长
         LambdaQueryWrapper<User> parentWrapper = new LambdaQueryWrapper<>();
         parentWrapper.eq(User::getFamilyId, id).eq(User::getRole, "parent");
         SortUtil.applySort(parentWrapper);
         java.util.List<User> parents = userMapper.selectList(parentWrapper);
 
-        // 查询孩子
-        LambdaQueryWrapper<FamilyMember> childWrapper = new LambdaQueryWrapper<>();
-        childWrapper.eq(FamilyMember::getFamilyId, id);
-        SortUtil.applySort(childWrapper);
-        java.util.List<FamilyMember> children = familyMemberMapper.selectList(childWrapper);
+        Long creatorId = family.getCreatorId();
+        java.util.List<Map<String, Object>> members = new ArrayList<>();
+
+        // family_members 来源成员
+        for (FamilyMember fm : familyMembers) {
+            boolean isAdmin = (fm.getIsAdmin() != null && fm.getIsAdmin() == 1)
+                    || (creatorId != null && creatorId.equals(fm.getUserId()));
+            Map<String, Object> m = new HashMap<>();
+            m.put("memberId", fm.getId());
+            m.put("userId", fm.getUserId());
+            m.put("source", "family_member");
+            m.put("nickname", fm.getNickname());
+            m.put("phone", fm.getPhone());
+            m.put("avatar", fm.getAvatar());
+            m.put("gender", fm.getGender());
+            if (fm.getBirthday() != null) {
+                m.put("age", fm.getAge());
+            }
+            m.put("totalPoints", fm.getTotalPoints());
+            m.put("isAdmin", isAdmin);
+            m.put("role", isAdmin ? "admin" : "member");
+            m.put("createdAt", fm.getCreatedAt());
+            members.add(m);
+        }
+
+        // users 来源家长(未落入 family_members)
+        for (User u : parents) {
+            if (coveredUserIds.contains(u.getId())) {
+                continue;
+            }
+            boolean isAdmin = creatorId != null && creatorId.equals(u.getId());
+            Map<String, Object> m = new HashMap<>();
+            m.put("memberId", null);
+            m.put("userId", u.getId());
+            m.put("source", "user");
+            m.put("nickname", u.getNickname());
+            m.put("phone", u.getPhone());
+            m.put("avatar", u.getAvatar());
+            m.put("gender", u.getGender());
+            m.put("totalPoints", u.getTotalPoints() != null ? u.getTotalPoints() : 0);
+            m.put("isAdmin", isAdmin);
+            m.put("role", isAdmin ? "admin" : "member");
+            m.put("createdAt", u.getCreatedAt());
+            members.add(m);
+        }
 
         Map<String, Object> result = new HashMap<>();
         result.put("family", family);
-        result.put("parents", parents);
-        result.put("children", children);
+        result.put("members", members);
+
+        // 兼容字段:TeacherMessages.vue 依赖 parents/children 结构,沿用旧查询
+        java.util.List<User> compatParents = new ArrayList<>();
+        LambdaQueryWrapper<User> parentCompatWrapper = new LambdaQueryWrapper<>();
+        parentCompatWrapper.eq(User::getFamilyId, id).eq(User::getRole, "parent");
+        SortUtil.applySort(parentCompatWrapper);
+        compatParents.addAll(userMapper.selectList(parentCompatWrapper));
+
+        java.util.List<FamilyMember> compatChildren = new ArrayList<>();
+        LambdaQueryWrapper<FamilyMember> childCompatWrapper = new LambdaQueryWrapper<>();
+        childCompatWrapper.eq(FamilyMember::getFamilyId, id);
+        SortUtil.applySort(childCompatWrapper);
+        compatChildren.addAll(familyMemberMapper.selectList(childCompatWrapper));
+
+        result.put("parents", compatParents);
+        result.put("children", compatChildren);
 
         return Result.success(result);
     }
 
+    @PostMapping("/families/{id}/members/set-admin")
+    public Result<Boolean> setMemberAdminRole(@PathVariable Long id,
+                                              @RequestBody Map<String, Object> body,
+                                              @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Family family = familyMapper.selectById(id);
+        if (family == null) {
+            return Result.error("家庭不存在");
+        }
+        Long memberId = ParamUtils.getLong(body.get("memberId"));
+        Long userId = ParamUtils.getLong(body.get("userId"));
+        boolean setAdmin = Boolean.TRUE.equals(body.get("isAdmin"));
+
+        FamilyMember target = null;
+        if (memberId != null) {
+            target = familyMemberMapper.selectById(memberId);
+            if (target == null || !id.equals(target.getFamilyId())) {
+                return Result.error("成员不存在或不属于该家庭");
+            }
+        } else if (userId != null) {
+            // users 来源成员:在 family_members 中查找或创建记录,以便持久化管理员角色
+            LambdaQueryWrapper<FamilyMember> q = new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getFamilyId, id)
+                    .eq(FamilyMember::getUserId, userId)
+                    .last("LIMIT 1");
+            target = familyMemberMapper.selectOne(q);
+            if (target == null) {
+                User u = userMapper.selectById(userId);
+                if (u == null || !id.equals(u.getFamilyId())) {
+                    return Result.error("用户不存在或不属于该家庭");
+                }
+                target = new FamilyMember();
+                target.setFamilyId(id);
+                target.setUserId(userId);
+                target.setNickname(u.getNickname() != null ? u.getNickname() : "成员");
+                target.setPhone(u.getPhone());
+                target.setAvatar(u.getAvatar());
+                target.setGender(u.getGender());
+                target.setGeneration(0);
+                target.setShowToFamily(1);
+                target.setTotalPoints(0);
+                target.setCreatedAt(new Date());
+                target.setUpdatedAt(new Date());
+                familyMemberMapper.insert(target);
+            }
+        } else {
+            return Result.error("缺少成员标识");
+        }
+
+        target.setIsAdmin(setAdmin ? 1 : 0);
+        target.setUpdatedAt(new Date());
+        familyMemberMapper.updateById(target);
+        return Result.success(true);
+    }
+
     @PostMapping("/families/{id}/update")
     public Result<Boolean> updateFamily(@PathVariable Long id, @RequestBody Family family,
                                         @RequestAttribute("role") String role) {
@@ -1565,7 +1696,11 @@ public class AdminController {
         }
         wrapper.orderByDesc(AssessmentAppointment::getCreatedAt);
 
-        SortUtil.applySort(wrapper);
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        SortUtil.applySort(wrapper, sortSpecs, null);
         Page<AssessmentAppointment> result = assessmentAppointmentMapper.selectPage(pageParam, wrapper);
 
         java.util.List<Map<String, Object>> list = new java.util.ArrayList<>();

+ 7 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -45,11 +45,16 @@ public class AdminCouponController {
     private FamilyCouponMapper familyCouponMapper;
 
     @PostMapping("/list")
-    public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
+    public Result<List<Coupon>> list(@RequestBody(required = false) Map<String, Object> params,
+                                     @RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
             return Result.error("无权限");
         }
-        return Result.success(couponService.listAll());
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params != null && params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        return Result.success(couponService.listAll(sortSpecs));
     }
 
     @PostMapping("/create")

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java

@@ -24,7 +24,11 @@ public class AdminRecipeController {
     @PostMapping("/list")
     public Result<List<Recipe>> list(@RequestBody Map<String, Object> params) {
         String mealType = (String) params.get("mealType");
-        return Result.success(recipeService.listByMealType(mealType));
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        return Result.success(recipeService.listByMealType(mealType, sortSpecs));
     }
 
     @Operation(summary = "\u65b0\u589e\u98df\u8c31")

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSubscriptionController.java

@@ -40,7 +40,11 @@ public class AdminSubscriptionController {
         }
         wrapper.orderByDesc(MemberSubscription::getCreatedAt);
 
-        SortUtil.applySort(wrapper);
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        SortUtil.applySort(wrapper, sortSpecs, null);
         List<MemberSubscription> subs = subscriptionMapper.selectList(wrapper);
         List<Map<String, Object>> list = new ArrayList<>();
         for (MemberSubscription sub : subs) {

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -47,6 +47,8 @@ public class Article implements Serializable {
     /** 审核时间 */
     private Date auditedAt;
     private Integer isFeatured;
+    /** 推荐排序值(越小越靠前) */
+    private Integer sortOrder;
     private Date publishedAt;
     private Integer viewCount;
     /** 总阅读时长(秒,瞬态字段,由 article_reading_records 聚合) */

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -15,8 +16,10 @@ public class BloodTypePairing implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
+    @TableField("blood_type_1")
     private String bloodType1;
 
+    @TableField("blood_type_2")
     private String bloodType2;
 
     private Integer compatibilityScore;

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

@@ -132,6 +132,9 @@ public class FamilyMember implements Serializable {
     /** 周末晚餐同餐: 1=同餐 */
     private Integer mealWithDinnerWeekend;
 
+    /** 是否家庭管理员: 1=管理员, 0=普通成员 */
+    private Integer isAdmin;
+
     /**
      * 是否男性
      */

+ 0 - 2
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -83,8 +83,6 @@ public class HealthReport implements Serializable {
     /** 报告文件URL */
     private String fileUrl;
 
-    private String reportFormat;
-
     /** 状态: active/archived */
     private String status;
 

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

@@ -59,7 +59,7 @@ public class HealthReportDraft implements Serializable {
     /** 异步解析失败原因 */
     private String parseError;
 
-    /** 草稿过期时间(创建时间+24h) */
+    /** 草稿过期时间(创建时间+72h) */
     private Date expireAt;
 
     /** 确认后对应的真实报告ID */

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -15,8 +16,10 @@ public class ZodiacPairing implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
+    @TableField("zodiac_code_1")
     private String zodiacCode1;
 
+    @TableField("zodiac_code_2")
     private String zodiacCode2;
 
     private String pairingType;

+ 90 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -88,6 +88,7 @@ public class ArticleService {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getStatus, "published")
                 .orderByDesc(Article::getIsFeatured)
+                .orderByAsc(Article::getSortOrder)
                 .orderByDesc(Article::getPublishedAt);
 
         if (categoryId != null && categoryId > 0) {
@@ -126,6 +127,7 @@ public class ArticleService {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getStatus, "published")
                 .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
                 .orderByDesc(Article::getPublishedAt)
                 .last("LIMIT " + Math.max(size, 50));
 
@@ -544,9 +546,97 @@ public class ArticleService {
         if (article == null) return;
         article.setIsFeatured(isFeatured);
         article.setUpdatedAt(new Date());
+        if (isFeatured == 1) {
+            // 设为精选时追加到队尾,不抢占既有顺序
+            Integer maxSort = getMaxSortOrder();
+            article.setSortOrder((maxSort == null ? 0 : maxSort) + 1);
+        }
         articleMapper.updateById(article);
     }
 
+    /**
+     * 查询已发布且精选的文章中最大的 sort_order;若没有精选文章返回 null。
+     */
+    private Integer getMaxSortOrder() {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .select(Article::getSortOrder)
+                .orderByDesc(Article::getSortOrder)
+                .last("LIMIT 1");
+        Article top = articleMapper.selectOne(wrapper);
+        return top != null ? top.getSortOrder() : null;
+    }
+
+    /**
+     * 上移/下移精选文章的推荐顺序。direction: 'up' 或 'down'。
+     */
+    @Transactional
+    public String moveSortOrder(Long id, String direction) {
+        Article target = articleMapper.selectById(id);
+        if (target == null) {
+            throw new RuntimeException("文章不存在");
+        }
+        if (!"published".equals(target.getStatus()) || !Integer.valueOf(1).equals(target.getIsFeatured())) {
+            throw new RuntimeException("仅已发布且精选的文章可调整顺序");
+        }
+        if (!"up".equals(direction) && !"down".equals(direction)) {
+            throw new RuntimeException("direction 只能是 up 或 down");
+        }
+
+        LambdaQueryWrapper<Article> listWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByDesc(Article::getPublishedAt);
+        List<Article> featured = articleMapper.selectList(listWrapper);
+
+        int idx = -1;
+        for (int i = 0; i < featured.size(); i++) {
+            if (featured.get(i).getId().equals(id)) {
+                idx = i;
+                break;
+            }
+        }
+        if (idx == -1) {
+            throw new RuntimeException("文章不存在");
+        }
+        if ("up".equals(direction) && idx <= 0) {
+            throw new RuntimeException("已在最前面");
+        }
+        if ("down".equals(direction) && idx >= featured.size() - 1) {
+            throw new RuntimeException("已在最后面");
+        }
+
+        int swapIdx = "up".equals(direction) ? idx - 1 : idx + 1;
+
+        Article a = featured.get(idx);
+        Article b = featured.get(swapIdx);
+        Integer tmpSort = a.getSortOrder();
+        a.setSortOrder(b.getSortOrder());
+        b.setSortOrder(tmpSort);
+        a.setUpdatedAt(new Date());
+        b.setUpdatedAt(new Date());
+        articleMapper.updateById(a);
+        articleMapper.updateById(b);
+
+        // 全局重编号 1..N,消除并列与空洞
+        LambdaQueryWrapper<Article> renumWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByDesc(Article::getPublishedAt);
+        List<Article> sorted = articleMapper.selectList(renumWrapper);
+        Date now = new Date();
+        for (int i = 0; i < sorted.size(); i++) {
+            sorted.get(i).setSortOrder(i + 1);
+            sorted.get(i).setUpdatedAt(now);
+            articleMapper.updateById(sorted.get(i));
+        }
+
+        return "已调整顺序";
+    }
+
     public ReadingStatsDTO getReadingStats(Long memberId) {
         ReadingStatsDTO dto = new ReadingStatsDTO();
 

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

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
@@ -21,6 +22,7 @@ import java.util.List;
 import java.util.Map;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import com.etotem.cfc.util.SortUtil;
 
 @Service
 public class CouponService {
@@ -227,6 +229,15 @@ public class CouponService {
         return couponMapper.selectList(null);
     }
 
+    public List<Coupon> listAll(List<Map<String, String>> sortSpecs) {
+        if (sortSpecs == null || sortSpecs.isEmpty()) {
+            return listAll();
+        }
+        QueryWrapper<Coupon> wrapper = new QueryWrapper<>();
+        SortUtil.applySort(wrapper, sortSpecs, null);
+        return couponMapper.selectList(wrapper);
+    }
+
     public Coupon getById(Long couponId) {
         return couponMapper.selectById(couponId);
     }

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

@@ -65,7 +65,14 @@ public class HealthReportDraftService {
             return null;
         }
         if (draft.getExpireAt() != null && draft.getExpireAt().before(new Date())) {
-            return null;
+            // expire_at 可能被 ON UPDATE CURRENT_TIMESTAMP 在解析写回时重置;
+            // 仍以 created_at + TTL 为准,避免刚解析完的草稿被误判过期。
+            Date created = draft.getCreatedAt();
+            if (created == null || System.currentTimeMillis() - created.getTime() > DRAFT_TTL_MS) {
+                return null;
+            }
+            log.warn("草稿 expire_at 已过期但 created_at 仍在 TTL 内,放行: draftId={}, expireAt={}, createdAt={}",
+                    draftId, draft.getExpireAt(), created);
         }
         return draft;
     }
@@ -112,10 +119,12 @@ public class HealthReportDraftService {
      * 更新草稿载荷 JSON(解析完成后写入)
      */
     public void updatePayload(Long draftId, String payloadJson) {
+        Date now = new Date();
         LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
         uw.eq(HealthReportDraft::getId, draftId)
           .set(HealthReportDraft::getPayloadJson, payloadJson)
-          .set(HealthReportDraft::getUpdatedAt, new Date());
+          .set(HealthReportDraft::getExpireAt, new Date(now.getTime() + DRAFT_TTL_MS))
+          .set(HealthReportDraft::getUpdatedAt, now);
         draftMapper.update(null, uw);
     }
     public void updateNote(Long draftId, String note) {

+ 12 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java

@@ -6,8 +6,11 @@ import com.etotem.cfc.mapper.RecipeMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
+import com.etotem.cfc.util.SortUtil;
+
 import javax.annotation.Resource;
 import java.util.List;
+import java.util.Map;
 
 @Slf4j
 @Service
@@ -17,11 +20,15 @@ public class RecipeService {
     private RecipeMapper recipeMapper;
 
     public List<Recipe> listByMealType(String mealType) {
-        return recipeMapper.selectList(
-                new LambdaQueryWrapper<Recipe>()
-                        .eq(Recipe::getStatus, "active")
-                        .eq(mealType != null, Recipe::getMealType, mealType)
-        );
+        return listByMealType(mealType, null);
+    }
+
+    public List<Recipe> listByMealType(String mealType, List<Map<String, String>> sortSpecs) {
+        LambdaQueryWrapper<Recipe> wrapper = new LambdaQueryWrapper<Recipe>()
+                .eq(Recipe::getStatus, "active")
+                .eq(mealType != null, Recipe::getMealType, mealType);
+        SortUtil.applySort(wrapper, sortSpecs, null);
+        return recipeMapper.selectList(wrapper);
     }
 
     public Recipe getById(Long id) {

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

@@ -1263,6 +1263,15 @@ auditor_id BIGINT COMMENT '审核人ID',
 audited_at DATETIME COMMENT '审核时间',
 audit_reason TEXT COMMENT '审核拒绝原因',
     shipping_cost INT DEFAULT 0 COMMENT '快递费(分)',
+    tracking_number VARCHAR(100) DEFAULT NULL COMMENT '快递单号',
+    express_company VARCHAR(100) DEFAULT NULL COMMENT '快递公司',
+    delivery_method INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
+    pickup_code VARCHAR(64) DEFAULT NULL COMMENT '自提码',
+    confirm_time DATETIME DEFAULT NULL COMMENT '确认收货时间',
+    refund_status INT DEFAULT NULL COMMENT '退款状态',
+    refund_reason VARCHAR(255) DEFAULT NULL COMMENT '退款原因',
+    refund_amount INT DEFAULT NULL COMMENT '退款金额(分)',
+    refund_time DATETIME DEFAULT NULL COMMENT '退款时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_buyer_id (buyer_id),
@@ -1411,6 +1420,7 @@ CREATE TABLE IF NOT EXISTS articles (
     status VARCHAR(20) DEFAULT 'draft' COMMENT '状态:draft/published/archived',
     growth_category VARCHAR(50) DEFAULT NULL COMMENT '成长分类',
     is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
     published_at DATETIME DEFAULT NULL COMMENT '发布时间',
     view_count INT DEFAULT 0 COMMENT '浏览次数',
     created_by BIGINT DEFAULT 0 COMMENT '发布人 adminID',
@@ -1784,6 +1794,7 @@ CREATE TABLE IF NOT EXISTS health_indicators (
     unit VARCHAR(50) COMMENT '单位',
     ref_range VARCHAR(200) COMMENT '参考范围',
     status VARCHAR(50) COMMENT '评估状态: 正常/偏低/偏高/过多/注意/低风险/未检出',
+    symptoms TEXT COMMENT '症状描述',
     sort_order INT DEFAULT 0 COMMENT '显示排序',
     collection_date DATETIME COMMENT '指标采集日期(报告检测日期)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -1996,6 +2007,8 @@ CREATE TABLE IF NOT EXISTS activities (
     feedback_dimensions TEXT COMMENT '评价维度列表JSON',
     family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除',
     platform_points INT DEFAULT 0 COMMENT '活动奖励CF值(0=不发)',
+    visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限:public/login/private',
+    visible_to TEXT COMMENT '私密指定人群JSON',
     created_at       DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at       DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_dimension (dimension_code),
@@ -2045,6 +2058,7 @@ CREATE TABLE IF NOT EXISTS family_members (
     birthday         DATE         COMMENT '出生日期',
     generation INT COMMENT '辈分值(0=家庭创建者,+n=向上,-n=向下)',
     is_spouse TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈中)',
+    is_admin TINYINT(1) DEFAULT 0 COMMENT '是否家庭管理员: 1=管理员, 0=普通成员',
     show_to_family INT DEFAULT 1 COMMENT '对家庭成员可见: 1=可见, 0=不可见',
     total_points INT DEFAULT 0 COMMENT '总积分',
     -- 孩子专属字段(原children表合并)
@@ -2065,6 +2079,10 @@ CREATE TABLE IF NOT EXISTS family_members (
     meal_with_breakfast_weekend TINYINT(1) DEFAULT 0 COMMENT '周末早餐同餐',
     meal_with_lunch_weekend TINYINT(1) DEFAULT 0 COMMENT '周末午餐同餐',
     meal_with_dinner_weekend TINYINT(1) DEFAULT 0 COMMENT '周末晚餐同餐',
+    birth_hour VARCHAR(10) DEFAULT NULL COMMENT '出生时辰',
+    birth_weight INT DEFAULT NULL COMMENT '出生体重(g)',
+    birth_place VARCHAR(255) DEFAULT NULL COMMENT '出生地',
+    is_cesarean TINYINT(1) DEFAULT 0 COMMENT '是否剖腹产: 0否 1是',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_family_id (family_id),
@@ -2253,6 +2271,7 @@ CREATE TABLE IF NOT EXISTS `distribution_relations` (
     `user_id` BIGINT NOT NULL COMMENT '服务商用户ID',
     `parent_id` BIGINT COMMENT '上级服务商用户ID',
     `depth` INT DEFAULT 0 COMMENT '层级深度(0=root)',
+    `invite_code` VARCHAR(64) DEFAULT NULL COMMENT '邀请码',
     `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_system (system_id),
     INDEX idx_user (user_id),
@@ -4525,6 +4544,20 @@ CREATE TABLE IF NOT EXISTS foods (
     INDEX idx_price (price_level)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材库表';
 
+-- 食材营养素表(每食材多营养素)
+CREATE TABLE IF NOT EXISTS food_nutrients (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    food_id BIGINT NOT NULL COMMENT '食材ID',
+    nutrient_code VARCHAR(20) NOT NULL COMMENT '营养素编码',
+    amount_per100g DECIMAL(10,4) NOT NULL COMMENT '每100g含量',
+    unit VARCHAR(20) NOT NULL COMMENT '单位',
+    source VARCHAR(50) DEFAULT 'importer' COMMENT '数据来源',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_food_nutrient (food_id, nutrient_code),
+    INDEX idx_nutrient (nutrient_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材营养素表';
+
 -- 饮食偏好调研表(每成员一份)
 CREATE TABLE IF NOT EXISTS diet_preferences (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -5199,6 +5232,10 @@ CREATE TABLE IF NOT EXISTS dan_knowledge_base (
     sort INT DEFAULT 0 COMMENT '排序',
     status TINYINT(1) DEFAULT 1 COMMENT '状态: 0禁用 1启用',
     remark VARCHAR(500) COMMENT '备注',
+    file_url VARCHAR(500) COMMENT 'fileUrl',
+    file_name VARCHAR(255) COMMENT 'fileName',
+    source_url VARCHAR(500) COMMENT 'sourceUrl',
+    source_type VARCHAR(20) COMMENT 'sourceType',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库表';

+ 0 - 169
cfc-frontend/pages/index-home/index.vue

@@ -238,75 +238,6 @@
       </view>
     </view>
 
-    <!-- 主动健康闭环 -->
-    <view class="section">
-      <view class="section-header">
-        <text class="section-title">🔄 主动健康闭环</text>
-      </view>
-      <view class="loop-cards">
-        <view class="loop-card">
-          <view class="loop-card-icon loop-icon-indigo"><text>🔍</text></view>
-          <text class="loop-card-name">提前识别</text>
-          <text class="loop-card-desc">AI 多维评估,绘制健康基线</text>
-        </view>
-        <view class="loop-card">
-          <view class="loop-card-icon loop-icon-green"><text>🛡️</text></view>
-          <text class="loop-card-name">风险管控</text>
-          <text class="loop-card-desc">分级管控,不让隐患成问题</text>
-        </view>
-        <view class="loop-card">
-          <view class="loop-card-icon loop-icon-amber"><text>⚡</text></view>
-          <text class="loop-card-name">主动干预</text>
-          <text class="loop-card-desc">任务与能量追踪,融入日常</text>
-        </view>
-        <view class="loop-card">
-          <view class="loop-card-icon loop-icon-red"><text>📊</text></view>
-          <text class="loop-card-name">效果评估</text>
-          <text class="loop-card-desc">档案记录轨迹,数据持续优化</text>
-        </view>
-      </view>
-    </view>
-
-    <!-- 五维能量 -->
-    <view class="section">
-      <view class="section-header">
-        <text class="section-title">🌐 五维能量 · 五行相生</text>
-      </view>
-      <view class="dims-cards">
-        <view class="dims-card-row">
-          <view class="dims-card-item">
-            <text class="dims-card-emoji">🌏</text>
-            <text class="dims-card-name">身 · 根基永固</text>
-            <text class="dims-card-desc">精准营养 · 健康报告 · 体质调理</text>
-          </view>
-          <view class="dims-card-item">
-            <text class="dims-card-emoji">⚡</text>
-            <text class="dims-card-name">智 · 赋能未来</text>
-            <text class="dims-card-desc">心智测评 · 素质培养 · 成长档案</text>
-          </view>
-        </view>
-        <view class="dims-card-row">
-          <view class="dims-card-item">
-            <text class="dims-card-emoji">💧</text>
-            <text class="dims-card-name">富 · 利他创富</text>
-            <text class="dims-card-desc">创客生态 · 积分奖励</text>
-          </view>
-          <view class="dims-card-item">
-            <text class="dims-card-emoji">🌿</text>
-            <text class="dims-card-name">行 · 人际和谐</text>
-            <text class="dims-card-desc">亲子关系 · 夫妻情感 · 社交人脉</text>
-          </view>
-        </view>
-        <view class="dims-card-row">
-          <view class="dims-card-item dims-card-item-full">
-            <text class="dims-card-emoji">🔥</text>
-            <text class="dims-card-name">心 · 大爱相随</text>
-            <text class="dims-card-desc">情绪疏导 · 关系心理学</text>
-          </view>
-        </view>
-      </view>
-    </view>
-
     <!-- 五行沙盘预览 -->
     <wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />
 
@@ -1415,106 +1346,6 @@ membershipDays: 0,
   white-space: nowrap;
 }
 
-/* ===== 主动健康闭环 ===== */
-.loop-cards {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: center;
-  gap: 16rpx;
-  margin: 20rpx 30rpx;
-}
-
-.loop-card {
-  width: 47%;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx 20rpx;
-  text-align: center;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-  border: 1rpx solid #F3F4F6;
-}
-
-.loop-card-icon {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 34rpx;
-  margin-bottom: 12rpx;
-}
-
-.loop-icon-indigo { background: rgba(99,102,241,0.15); }
-.loop-icon-green { background: rgba(16,185,129,0.15); }
-.loop-icon-amber { background: rgba(245,158,11,0.15); }
-.loop-icon-red { background: rgba(239,68,68,0.15); }
-
-.loop-card-name {
-  display: block;
-  font-size: 28rpx;
-  font-weight: bold;
-  color: #1F2937;
-  margin-bottom: 6rpx;
-}
-
-.loop-card-desc {
-  display: block;
-  font-size: 22rpx;
-  color: #6B7280;
-  line-height: 1.5;
-}
-
-/* ===== 五维能量 ===== */
-.dims-cards {
-  display: flex;
-  flex-direction: column;
-  gap: 16rpx;
-  margin: 20rpx 30rpx;
-}
-
-.dims-card-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 16rpx;
-}
-
-.dims-card-item {
-  flex: 1;
-  min-width: 280rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx 20rpx;
-  text-align: center;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-  border: 1rpx solid #F3F4F6;
-}
-
-.dims-card-item-full {
-  flex-basis: 100%;
-}
-
-.dims-card-emoji {
-  display: block;
-  font-size: 44rpx;
-  margin-bottom: 10rpx;
-}
-
-.dims-card-name {
-  display: block;
-  font-size: 28rpx;
-  font-weight: bold;
-  color: #1F2937;
-  margin-bottom: 6rpx;
-}
-
-.dims-card-desc {
-  display: block;
-  font-size: 22rpx;
-  color: #6B7280;
-  line-height: 1.5;
-}
-
 /* ===== 品牌定位语(保留旧样式,不再使用) ===== */
 .brand-strap {
   margin: 16rpx 30rpx 0;

+ 93 - 218
cfc-frontend/pages/login/login.vue

@@ -1,57 +1,35 @@
 <template>
   <view class="login-container">
-    <!-- Logo 区 -->
-    <view class="logo-section">
+    <!-- 顶部品牌区 -->
+    <view class="brand-section">
       <image class="logo" src="/static/logo.png" mode="aspectFit" @error="handleImageError"></image>
       <text class="app-name">浠艾福</text>
       <text class="slogan">给全家的一站式主动健康方案</text>
-      <text class="powered">亿图腾科技出品</text>
     </view>
 
-    <!-- 主动健康理念 -->
+    <!-- 主动健康理念 + 闭环 -->
     <view class="concept-card">
-      <view class="concept-title">什么是主动健康</view>
       <view class="concept-desc">
-        不等问题发生,先一步<span class="concept-highlight">识别风险</span>、<span class="concept-highlight">主动干预</span>、<span class="concept-highlight">持续评估</span>——把全家健康管理融入每一天。
+        不等问题发生,先一步<text class="concept-highlight">识别风险 · 主动干预 · 持续评估</text>,把全家健康管理融入每一天。
       </view>
-    </view>
-
-    <!-- 主动健康闭环 -->
-    <view class="section-block">
-      <view class="section-label">主动健康闭环</view>
-      <view
-        v-for="(step, i) in loopSteps"
-        :key="step.name"
-        class="loop-step-wrap"
-      >
-        <view class="loop-step" :class="step.color">
-          <view class="loop-step-icon"><text>{{ step.icon }}</text></view>
-          <view class="loop-step-text">
-            <text class="loop-step-name">{{ step.name }}</text>
-            <text class="loop-step-desc">{{ step.desc }}</text>
-          </view>
-        </view>
-        <view v-if="i < loopSteps.length - 1" class="loop-arrow"><text>↓</text></view>
+      <view class="loop-row">
+        <view class="loop-pill loop-pill-indigo"><text>🔍</text><text class="loop-pill-text">提前识别</text></view>
+        <text class="loop-arrow">→</text>
+        <view class="loop-pill loop-pill-green"><text>🛡️</text><text class="loop-pill-text">风险管控</text></view>
+        <text class="loop-arrow">→</text>
+        <view class="loop-pill loop-pill-amber"><text>⚡</text><text class="loop-pill-text">主动干预</text></view>
+        <text class="loop-arrow">→</text>
+        <view class="loop-pill loop-pill-red"><text>📊</text><text class="loop-pill-text">效果评估</text></view>
       </view>
-      <view class="loop-cycle"><text>🔄 持续优化 · 循环往复,主动健康永不止步</text></view>
     </view>
 
-    <!-- 五维能量 -->
-    <view class="section-block">
-      <view class="section-label">五维能量 · 五行相生</view>
-      <view class="dims-card">
-        <view
-          v-for="dim in dimensions"
-          :key="dim.name"
-          class="dim-item"
-        >
-          <view class="dim-item-icon" :class="dim.color"><text>{{ dim.emoji }}</text></view>
-          <view class="dim-item-text">
-            <text class="dim-item-name">{{ dim.name }} · {{ dim.tag }}</text>
-            <text class="dim-item-desc">{{ dim.desc }}</text>
-          </view>
-        </view>
-      </view>
+    <!-- 五维标签 -->
+    <view class="dims-row">
+      <view class="dim-tag dim-tag-body"><text>🌏</text><text>身·健康</text></view>
+      <view class="dim-tag dim-tag-wisdom"><text>⚡</text><text>智·赋能</text></view>
+      <view class="dim-tag dim-tag-wealth"><text>💧</text><text>富·创富</text></view>
+      <view class="dim-tag dim-tag-action"><text>🌿</text><text>行·和谐</text></view>
+      <view class="dim-tag dim-tag-mind"><text>🔥</text><text>心·大爱</text></view>
     </view>
 
     <!-- 登录表单 -->
@@ -97,20 +75,7 @@ export default {
       agreed: false,
       nickname: '',
       avatar: '',
-      redirectUrl: '',
-      loopSteps: [
-        { icon: '🔍', name: '提前识别', desc: 'AI 多维评估,绘制健康基线', color: 'loop-indigo' },
-        { icon: '🛡️', name: '风险管控', desc: '分级管控,不让隐患成问题', color: 'loop-green' },
-        { icon: '⚡', name: '主动干预', desc: '任务与能量追踪,融入日常', color: 'loop-amber' },
-        { icon: '📊', name: '效果评估', desc: '档案记录轨迹,数据持续优化', color: 'loop-red' }
-      ],
-      dimensions: [
-        { emoji: '🌏', name: '身', tag: '根基永固', desc: '精准营养 · 健康报告 · 体质调理', color: 'dim-body' },
-        { emoji: '⚡', name: '智', tag: '赋能未来', desc: '心智测评 · 素质培养 · 成长档案', color: 'dim-wisdom' },
-        { emoji: '💧', name: '富', tag: '利他创富', desc: '创客生态 · 积分奖励', color: 'dim-wealth' },
-        { emoji: '🌿', name: '行', tag: '人际和谐', desc: '亲子关系 · 夫妻情感 · 社交人脉', color: 'dim-action' },
-        { emoji: '🔥', name: '心', tag: '大爱相随', desc: '情绪疏导 · 关系心理学', color: 'dim-mind' }
-      ]
+      redirectUrl: ''
     }
   },
   onLoad(options) {
@@ -310,226 +275,136 @@ export default {
 <style scoped>
 .login-container {
   min-height: 100vh;
-  background: linear-gradient(160deg, #FFF7ED 0%, #FED7AA 55%, #FB923C 100%);
-  padding: 48rpx 40rpx 40rpx;
+  background: linear-gradient(180deg, #FFF9F0 0%, #FFEEDB 100%);
+  padding: 40rpx 36rpx 32rpx;
+  display: flex;
+  flex-direction: column;
 }
 
-/* ===== Logo 区 ===== */
-.logo-section {
+/* ===== 顶部品牌区 ===== */
+.brand-section {
   text-align: center;
-  margin-bottom: 40rpx;
+  margin-bottom: 32rpx;
 }
 
 .logo {
-  width: 120rpx;
-  height: 120rpx;
-  border-radius: 60rpx;
+  width: 104rpx;
+  height: 104rpx;
+  border-radius: 52rpx;
   background: #fff;
-  box-shadow: 0 8rpx 32rpx rgba(249,115,22,0.2);
+  box-shadow: 0 8rpx 24rpx rgba(249,115,22,0.2);
 }
 
 .app-name {
   display: block;
-  font-size: 52rpx;
+  font-size: 44rpx;
   font-weight: bold;
-  color: #fff;
-  margin-top: 16rpx;
-  letter-spacing: 8rpx;
+  color: #7C2D12;
+  margin-top: 14rpx;
+  letter-spacing: 6rpx;
 }
 
 .slogan {
   display: block;
-  font-size: 26rpx;
-  color: rgba(255, 255, 255, 0.88);
+  font-size: 24rpx;
+  color: #9A3412;
   margin-top: 8rpx;
 }
 
-.powered {
-  display: block;
-  font-size: 20rpx;
-  color: rgba(255, 255, 255, 0.55);
-  margin-top: 12rpx;
-}
-
-/* ===== 主动健康理念 ===== */
+/* ===== 主动健康理念 + 闭环 ===== */
 .concept-card {
-  background: rgba(255, 255, 255, 0.22);
-  border: 1rpx solid rgba(255, 255, 255, 0.35);
+  background: #fff;
   border-radius: 20rpx;
-  padding: 28rpx 32rpx;
-  margin-bottom: 36rpx;
-}
-
-.concept-title {
-  display: block;
-  font-size: 30rpx;
-  font-weight: bold;
-  color: #fff;
-  margin-bottom: 12rpx;
+  padding: 26rpx 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.08);
 }
 
 .concept-desc {
-  font-size: 25rpx;
-  color: rgba(255, 255, 255, 0.92);
-  line-height: 1.7;
+  font-size: 24rpx;
+  color: #4B5563;
+  line-height: 1.6;
+  margin-bottom: 20rpx;
 }
 
 .concept-highlight {
-  color: #FFD700;
+  color: #B45309;
   font-weight: 600;
 }
 
-/* ===== 章节块 ===== */
-.section-block {
-  margin-bottom: 36rpx;
-}
-
-.section-label {
-  display: block;
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #fff;
-  margin-bottom: 20rpx;
-  padding-left: 4rpx;
-}
-
-/* ===== 主动健康闭环 ===== */
-.loop-step-wrap {
-  margin-bottom: 6rpx;
-}
-
-.loop-step {
+.loop-row {
   display: flex;
   align-items: center;
-  gap: 20rpx;
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 22rpx 26rpx;
-  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
-  border-left: 6rpx solid #6366F1;
+  gap: 6rpx;
 }
 
-.loop-indigo { border-left-color: #6366F1; }
-.loop-green { border-left-color: #10B981; }
-.loop-amber { border-left-color: #F59E0B; }
-.loop-red { border-left-color: #EF4444; }
-
-.loop-step-icon {
-  width: 64rpx;
-  height: 64rpx;
-  border-radius: 50%;
+.loop-pill {
+  flex: 1;
   display: flex;
   align-items: center;
   justify-content: center;
-  font-size: 32rpx;
-  flex-shrink: 0;
-}
-
-.loop-indigo .loop-step-icon { background: rgba(99,102,241,0.12); }
-.loop-green .loop-step-icon { background: rgba(16,185,129,0.12); }
-.loop-amber .loop-step-icon { background: rgba(245,158,11,0.12); }
-.loop-red .loop-step-icon { background: rgba(239,68,68,0.12); }
-
-.loop-step-text {
-  flex: 1;
-  display: flex;
+  gap: 4rpx;
   flex-direction: column;
-  min-width: 0;
+  font-size: 20rpx;
+  padding: 10rpx 4rpx;
+  border-radius: 12rpx;
+  line-height: 1.3;
 }
 
-.loop-step-name {
-  font-size: 29rpx;
-  font-weight: bold;
-  color: #1F2937;
-  margin-bottom: 4rpx;
+.loop-pill-text {
+  font-size: 20rpx;
+  font-weight: 500;
 }
 
-.loop-step-desc {
-  font-size: 23rpx;
-  color: #6B7280;
-  line-height: 1.5;
-}
+.loop-pill-indigo { background: #EEF2FF; color: #4F46E5; }
+.loop-pill-green { background: #D1FAE5; color: #047857; }
+.loop-pill-amber { background: #FEF3C7; color: #B45309; }
+.loop-pill-red { background: #FEE2E2; color: #B91C1C; }
 
 .loop-arrow {
-  text-align: center;
-  padding: 8rpx 0;
-  font-size: 28rpx;
-  color: rgba(255,255,255,0.75);
-}
-
-.loop-cycle {
-  text-align: center;
-  margin-top: 18rpx;
-  font-size: 22rpx;
-  color: rgba(255,255,255,0.85);
-}
-
-/* ===== 五维能量 ===== */
-.dims-card {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 28rpx;
-  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
-}
-
-.dim-item {
-  display: flex;
-  align-items: center;
-  gap: 20rpx;
-  padding: 12rpx 0;
-}
-
-.dim-item + .dim-item {
-  border-top: 1rpx solid #F3F4F6;
+  font-size: 24rpx;
+  color: #D1D5DB;
+  flex-shrink: 0;
 }
 
-.dim-item-icon {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 16rpx;
+/* ===== 五维标签 ===== */
+.dims-row {
   display: flex;
-  align-items: center;
   justify-content: center;
-  font-size: 34rpx;
-  flex-shrink: 0;
+  gap: 8rpx;
+  margin-bottom: 24rpx;
+  flex-wrap: wrap;
 }
 
-.dim-body .dim-item-icon { background: rgba(255,140,66,0.15); }
-.dim-wisdom .dim-item-icon { background: rgba(99,102,241,0.15); }
-.dim-wealth .dim-item-icon { background: rgba(245,158,11,0.15); }
-.dim-action .dim-item-icon { background: rgba(16,185,129,0.15); }
-.dim-mind .dim-item-icon { background: rgba(255,107,157,0.15); }
-
-.dim-item-text {
-  flex: 1;
+.dim-tag {
   display: flex;
-  flex-direction: column;
-  min-width: 0;
-}
-
-.dim-item-name {
-  font-size: 29rpx;
-  font-weight: bold;
-  color: #1F2937;
-  margin-bottom: 4rpx;
+  align-items: center;
+  gap: 4rpx;
+  font-size: 20rpx;
+  color: #fff;
+  font-weight: 500;
+  padding: 8rpx 14rpx;
+  border-radius: 24rpx;
+  white-space: nowrap;
 }
 
-.dim-item-desc {
-  font-size: 23rpx;
-  color: #6B7280;
-  line-height: 1.5;
-}
+.dim-tag-body { background: #F97316; }
+.dim-tag-wisdom { background: #6366F1; }
+.dim-tag-wealth { background: #D97706; }
+.dim-tag-action { background: #10B981; }
+.dim-tag-mind { background: #EC4899; }
 
 /* ===== 登录表单 ===== */
 .login-form {
   background: #fff;
   border-radius: 20rpx;
-  padding: 40rpx;
+  padding: 36rpx 36rpx 28rpx;
+  margin-top: auto;
   box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.06);
 }
 
 .phone-section {
-  margin-bottom: 28rpx;
+  margin-bottom: 24rpx;
 }
 
 .phone-btn {
@@ -558,23 +433,23 @@ export default {
 /* ===== 跳过登录 ===== */
 .skip-section {
   text-align: center;
-  margin-bottom: 24rpx;
+  margin-bottom: 20rpx;
 }
 
 .skip-btn {
-  font-size: 28rpx;
+  font-size: 26rpx;
   color: #F97316;
   font-weight: 500;
-  padding: 12rpx 24rpx;
+  padding: 10rpx 20rpx;
   background: rgba(249, 115, 22, 0.08);
   border-radius: 30rpx;
 }
 
 /* ===== 协议 ===== */
 .agreement {
-  margin-top: 8rpx;
+  margin-top: 4rpx;
   text-align: center;
-  font-size: 24rpx;
+  font-size: 22rpx;
   line-height: 1.6;
 }
 

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c3e3a946750ed2fd407841d91ef46e34d9654257
+086c79041ecabecfa4331f6936372518213fcc3a

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

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

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

@@ -4,6 +4,100 @@
 
 ---
 
+## v1.0.1297 (2026-09-04)
+
+### Bug 修复
+- moveSortOrder 排序方向与 getFeatured 对齐(published_at DESC)
+
+### 新功能
+- 文章推荐排序——精选顺序可定义(sort_order + 上移/下移)
+
+### 其他
+- - getFeatured/getPublicList 改按 sort_order ASC 排序
+- - toggleFeatured 设为精选时追加队尾,不抢占既有顺序
+- - 新增 POST /api/admin/articles/move 上移/下移 + 全局重编号
+- - 管理端 ArticleManage 精选行显示上移/下移 + 序号徽章
+- 
+
+
+## v1.0.1296 (2026-09-04)
+
+### 文档
+- 文章推荐排序实现计划
+
+### 新功能
+- 后台家庭成员改为仅区分管理员/成员
+
+### 其他
+- - AdminController.getFamilyMembers 返回统一 members 列表(合并 family_members + users 家长),保留 parents/children 兼容字段
+- - 新增 setMemberAdminRole 接口:设/撤管理员,users 来源成员自动建记录持久化角色
+- - Families.vue 查看成员弹窗合并为单一列表 + 角色标签/操作按钮
+- 
+
+
+## v1.0.1295 (2026-09-04)
+
+### 文档
+- 文章推荐排序实现计划
+
+### Bug 修复
+- 修复4个后台列表页点击排序失效,清理2个页面的死SortMixin代码
+
+### 其他
+- 导致 AdminRecipe、AdminCoupon、AdminAssessmentOrder、AdminTask、
+- AdminSubscription 五个接口排序不生效。
+- 后端修复(5个文件):
+- - AdminRecipeController / AdminCouponController / AdminSubscriptionController:
+-   从@RequestBody读sortSpecs,调用SortUtil.applySort(wrapper, sortSpecs, null)
+- - AdminRecipeController / RecipeService:新增listByMealType(mealType, sortSpecs)重载,
+-   原无参方法保留供MealRecommendService调用
+- - AdminController.getTasks() / getAssessmentOrderList():同模式替换SortUtil.applySort(wrapper)
+- - CouponService:新增listAll(sortSpecs)重载,使用QueryWrapper+SortUtil
+- 前端修复(2个文件):
+- - AssessmentOrders.vue / SubscriptionManagement.vue:
+-   移除死SortMixin(无排序触发逻辑),清理sort-header/sort-tag CSS
+- 
+
+
+## v1.0.1294 (2026-09-04)
+
+### Bug 修复
+- 移除 HealthReport 重复的 reportFormat 字段,修复 Lombok 编译失败
+- 登录页改浅色背景深色文字单页布局,首页移除主动健康闭环与五维能量区块
+
+### 文档
+- 文章推荐排序(精选顺序可定义)设计规格
+
+
+## v1.0.1293 (2026-09-04)
+
+### 文档
+- 文章推荐排序(精选顺序可定义)设计规格
+
+### Bug 修复
+- 迁移290修正(6表21列) + 迁移292 health_status 补 member_id 成员级归档
+- 迁移290修正 - 实体补@TableField列名映射 + schema同步 + auto bump
+
+
+## v1.0.1289 (2026-09-04)
+
+### Bug 修复
+- 迁移290 批量补齐12表33列真实缺列(防Unknown column)+ auto bump
+
+### 样式
+- 右上角个人/全家切换按钮更醒目(渐变胶囊+阴影+放大)
+
+### 其他
+- redesign: 重构登录前首页与登录页,引入主动健康闭环×五维框架
+
+### 新功能
+- 个人/全家切换移到控件右上角,替换原徽章文案
+- 首页能量沙盘个人/全家切换始终显示,成员<2时点全家弹提示引导添加
+
+### 文档
+- 添加 log-to-human 团队级技能文档
+
+
 ## v1.0.1288 (2026-09-04)
 
 ### 样式

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1288
+> 当前版本: v1.0.1297
 
 ## 历史版本
 
@@ -8,6 +8,100 @@
 
 ---
 
+## v1.0.1297 (2026-09-04)
+
+### Bug 修复
+- moveSortOrder 排序方向与 getFeatured 对齐(published_at DESC)
+
+### 新功能
+- 文章推荐排序——精选顺序可定义(sort_order + 上移/下移)
+
+### 其他
+- - getFeatured/getPublicList 改按 sort_order ASC 排序
+- - toggleFeatured 设为精选时追加队尾,不抢占既有顺序
+- - 新增 POST /api/admin/articles/move 上移/下移 + 全局重编号
+- - 管理端 ArticleManage 精选行显示上移/下移 + 序号徽章
+- 
+
+
+## v1.0.1296 (2026-09-04)
+
+### 文档
+- 文章推荐排序实现计划
+
+### 新功能
+- 后台家庭成员改为仅区分管理员/成员
+
+### 其他
+- - AdminController.getFamilyMembers 返回统一 members 列表(合并 family_members + users 家长),保留 parents/children 兼容字段
+- - 新增 setMemberAdminRole 接口:设/撤管理员,users 来源成员自动建记录持久化角色
+- - Families.vue 查看成员弹窗合并为单一列表 + 角色标签/操作按钮
+- 
+
+
+## v1.0.1295 (2026-09-04)
+
+### 文档
+- 文章推荐排序实现计划
+
+### Bug 修复
+- 修复4个后台列表页点击排序失效,清理2个页面的死SortMixin代码
+
+### 其他
+- 导致 AdminRecipe、AdminCoupon、AdminAssessmentOrder、AdminTask、
+- AdminSubscription 五个接口排序不生效。
+- 后端修复(5个文件):
+- - AdminRecipeController / AdminCouponController / AdminSubscriptionController:
+-   从@RequestBody读sortSpecs,调用SortUtil.applySort(wrapper, sortSpecs, null)
+- - AdminRecipeController / RecipeService:新增listByMealType(mealType, sortSpecs)重载,
+-   原无参方法保留供MealRecommendService调用
+- - AdminController.getTasks() / getAssessmentOrderList():同模式替换SortUtil.applySort(wrapper)
+- - CouponService:新增listAll(sortSpecs)重载,使用QueryWrapper+SortUtil
+- 前端修复(2个文件):
+- - AssessmentOrders.vue / SubscriptionManagement.vue:
+-   移除死SortMixin(无排序触发逻辑),清理sort-header/sort-tag CSS
+- 
+
+
+## v1.0.1294 (2026-09-04)
+
+### Bug 修复
+- 移除 HealthReport 重复的 reportFormat 字段,修复 Lombok 编译失败
+- 登录页改浅色背景深色文字单页布局,首页移除主动健康闭环与五维能量区块
+
+### 文档
+- 文章推荐排序(精选顺序可定义)设计规格
+
+
+## v1.0.1293 (2026-09-04)
+
+### 文档
+- 文章推荐排序(精选顺序可定义)设计规格
+
+### Bug 修复
+- 迁移290修正(6表21列) + 迁移292 health_status 补 member_id 成员级归档
+- 迁移290修正 - 实体补@TableField列名映射 + schema同步 + auto bump
+
+
+## v1.0.1289 (2026-09-04)
+
+### Bug 修复
+- 迁移290 批量补齐12表33列真实缺列(防Unknown column)+ auto bump
+
+### 样式
+- 右上角个人/全家切换按钮更醒目(渐变胶囊+阴影+放大)
+
+### 其他
+- redesign: 重构登录前首页与登录页,引入主动健康闭环×五维框架
+
+### 新功能
+- 个人/全家切换移到控件右上角,替换原徽章文案
+- 首页能量沙盘个人/全家切换始终显示,成员<2时点全家弹提示引导添加
+
+### 文档
+- 添加 log-to-human 团队级技能文档
+
+
 ## v1.0.1288 (2026-09-04)
 
 ### 样式

+ 8 - 0
cfc-web/src/api/admin.js

@@ -121,6 +121,14 @@ export function getFamilyMembers(id) {
   })
 }
 
+export function setMemberAdminRole(familyId, memberId, isAdmin) {
+  return request({
+    url: `/api/admin/families/${familyId}/members/set-admin`,
+    method: 'post',
+    data: { memberId, isAdmin }
+  })
+}
+
 // 任务模板管理
 export function getTaskTemplateList(params) {
   return request({

+ 4 - 0
cfc-web/src/api/article.js

@@ -26,6 +26,10 @@ export function adminArticleToggleFeatured(data) {
   return request({ url: '/api/admin/articles/toggle-featured', method: 'post', data })
 }
 
+export function adminArticleMove(data) {
+  return request({ url: '/api/admin/articles/move', method: 'post', data })
+}
+
 export function adminArticleUploadImage(file) {
   const formData = new FormData()
   formData.append('file', file)

+ 47 - 30
cfc-web/src/views/Families.vue

@@ -99,7 +99,7 @@
     <el-dialog
       title="家庭成员"
       :visible.sync="membersDialogVisible"
-      width="800px"
+      width="900px"
     >
       <div v-if="membersData.family">
         <el-descriptions :column="2" border style="margin-bottom: 20px">
@@ -107,43 +107,51 @@
           <el-descriptions-item label="邀请码">{{ membersData.family.inviteCode }}</el-descriptions-item>
         </el-descriptions>
 
-        <el-divider content-position="left">家长</el-divider>
-        <el-table
-          :data="membersData.parents"
-          border
-          style="width: 100%; margin-bottom: 20px"
-          :max-height="300"
-          v-if="membersData.parents && membersData.parents.length > 0"
-        >
-          <el-table-column prop="id" label="ID" width="80"></el-table-column>
-          <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
-          <el-table-column prop="realName" label="真实姓名" width="120"></el-table-column>
-          <el-table-column prop="phone" label="手机号" width="150"></el-table-column>
-        </el-table>
-        <el-empty v-else description="暂无家长" :image-size="60"></el-empty>
-
-        <el-divider content-position="left">孩子</el-divider>
+        <el-divider content-position="left">成员列表(仅区分管理员 / 成员)</el-divider>
         <el-table
-          :data="membersData.children"
+          :data="membersData.members"
           border
           style="width: 100%"
-          :max-height="300"
-          v-if="membersData.children && membersData.children.length > 0"
+          :max-height="350"
+          v-if="membersData.members && membersData.members.length > 0"
         >
-          <el-table-column prop="id" label="ID" width="80"></el-table-column>
           <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
-          <el-table-column prop="totalPoints" label="总积分" width="100">
+          <el-table-column prop="phone" label="手机号" width="150"></el-table-column>
+          <el-table-column prop="gender" label="性别" width="80">
+            <template slot-scope="scope">
+              {{ scope.row.gender === 'male' ? '男' : scope.row.gender === 'female' ? '女' : '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="age" label="年龄" width="80">
+            <template slot-scope="scope">
+              {{ scope.row.age || '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="totalPoints" label="总积分" width="90">
             <template slot-scope="scope">
               <el-tag type="success">{{ scope.row.totalPoints || 0 }}</el-tag>
             </template>
           </el-table-column>
-          <el-table-column prop="createdAt" label="创建时间">
+          <el-table-column label="角色" width="100">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'" size="small">
+                {{ scope.row.role === 'admin' ? '管理员' : '成员' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="140">
             <template slot-scope="scope">
-              {{ formatDate(scope.row.createdAt) }}
+              <el-button
+                size="mini"
+                :type="scope.row.role === 'admin' ? '' : 'primary'"
+                @click="toggleAdmin(scope.row)"
+              >
+                {{ scope.row.role === 'admin' ? '撤销管理员' : '设为管理员' }}
+              </el-button>
             </template>
           </el-table-column>
         </el-table>
-        <el-empty v-else description="暂无孩子" :image-size="60"></el-empty>
+        <el-empty v-else description="暂无成员" :image-size="60"></el-empty>
       </div>
     </el-dialog>
 
@@ -274,7 +282,7 @@
 </template>
 
 <script>
-import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
+import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 export default {
@@ -310,8 +318,7 @@ export default {
       membersDialogVisible: false,
       membersData: {
         family: null,
-        parents: [],
-        children: []
+        members: []
       },
       couponDialogVisible: false,
       issueDialogVisible: false,
@@ -439,8 +446,7 @@ export default {
         const res = await getFamilyMembers(familyId)
         this.membersData = {
           family: res.data.family,
-          parents: res.data.parents || [],
-          children: res.data.children || []
+          members: res.data.members || []
         }
         this.membersDialogVisible = true
       } catch (error) {
@@ -449,6 +455,17 @@ export default {
         this.loading = false
       }
     },
+    async toggleAdmin(row) {
+      if (!this.membersData.family) return
+      const targetIsAdmin = row.role !== 'admin'
+      try {
+        await setMemberAdminRole(this.membersData.family.id, row.memberId, targetIsAdmin)
+        this.$message.success(targetIsAdmin ? '已设为管理员' : '已撤销管理员')
+        await this.handleViewMembersById(this.membersData.family.id)
+      } catch (error) {
+        this.$message.error(error.message || '操作失败')
+      }
+    },
     async handleIssueCoupon(row) {
       this.couponForm = {
         familyId: row.id,

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

@@ -61,7 +61,7 @@
         <el-table-column label="标题" min-width="220">
           <template slot-scope="{ row }">
             <span>{{ row.title }}</span>
-            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
+            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选{{ row.sortOrder && row.sortOrder > 0 ? ' ' + toChineseNum(row.sortOrder) : '' }}</el-tag>
           </template>
         </el-table-column>
           <el-table-column label="可见性" width="90">
@@ -186,6 +186,8 @@
                 <el-dropdown-menu slot="dropdown">
                   <el-dropdown-item command="withdraw" icon="el-icon-download">下架</el-dropdown-item>
                   <el-dropdown-item command="toggleFeatured" icon="el-icon-star-on">精选/取消精选</el-dropdown-item>
+                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
+                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
                   <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
                 </el-dropdown-menu>
               </el-dropdown>
@@ -306,7 +308,8 @@ import {
   adminArticleSubmitReview,
   adminArticleWithdraw,
   adminArticleReDraft,
-  adminArticleReadingRecords
+  adminArticleReadingRecords,
+  adminArticleMove
 } from '@/api/article.js'
 
 import { getTagList } from '@/api/dimension.js'
@@ -473,6 +476,19 @@ export default {
         if (e !== 'cancel') this.$message.error(e.message || '操作失败')
       }
     },
+    async handleMove(row, direction) {
+      try {
+        await this.$confirm(
+          direction === 'up' ? '确认将《' + row.title + '》上移一位?' : '确认将《' + row.title + '》下移一位?',
+          '提示'
+        )
+        await adminArticleMove({ id: row.id, direction })
+        this.$message.success('已调整顺序')
+        this.loadList()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error(e.message || '调整顺序失败')
+      }
+    },
     handleDelete(row) {
       this.$confirm(`确定删除文章「${row.title}」吗?`, '提示', {
         confirmButtonText: '确定',
@@ -611,8 +627,18 @@ export default {
         case 'reDraft': this.handleReDraft(row); break;
         case 'withdraw': this.handleWithdraw(row); break;
         case 'toggleFeatured': this.handleToggleFeatured(row); break;
+        case 'moveUp': this.handleMove(row, 'up'); break;
+        case 'moveDown': this.handleMove(row, 'down'); break;
         case 'rePublish': this.handlePublishDirect(row); break;
       }
+    },
+    toChineseNum(n) {
+      var digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
+      if (n <= 10) return digits[n]
+      var tens = Math.floor(n / 10)
+      var ones = n % 10
+      if (tens === 1) return '十' + (ones === 0 ? '' : digits[ones])
+      return digits[tens] + '十' + (ones === 0 ? '' : digits[ones])
     }
   }
 }

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

@@ -21,16 +21,6 @@
             <el-option label="已取消" :value="3" />
             <el-option label="未到" :value="4" />
           </el-select>
-          <el-tag
-                v-if="sortState.length > 0"
-                closable
-                size="mini"
-                class="sort-tag"
-                @close="clearSort"
-                @click="clearSort"
-              >
-                {{ sortLabel }}
-              </el-tag>
           <el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
         </div>
       </div>
@@ -134,11 +124,9 @@
 </template>
 
 <script>
-import SortMixin from '@/mixins/SortMixin'
 import { getAssessmentOrderList, changeAssessmentGuide, getAvailableGuides } from '@/api/admin'
 
 export default {
-  mixins: [SortMixin],
   name: 'AssessmentOrders',
   computed: {
     tableHeight() {
@@ -268,18 +256,5 @@ export default {
   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>

+ 1 - 16
cfc-web/src/views/admin/SubscriptionManagement.vue

@@ -170,11 +170,9 @@
 </template>
 
 <script>
-import SortMixin from '@/mixins/SortMixin'
 import { getSubscriptionList, activateSubscription, cancelSubscriptionAdmin } from '@/api/admin'
 
 export default {
-  mixins: [SortMixin],
   name: 'SubscriptionManagement',
   computed: {
     tableHeight() {
@@ -348,18 +346,5 @@ export default {
   align-items: center;
 }
 
-.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>

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

@@ -180,6 +180,7 @@
 | 文章发布系统(运营审核流程) | ✅ 已实施(AdminArticleController + ReviewCenterController + ArticleService.auditArticle) | `plans/2026-07-22-comprehensive-implementation.md` |
 | 文章与活动发布系统(合并版) | ✅ 已实施(同上) | `specs/2026-06-09-article-activity-publishing-design.md` |
 | 文章阅读追踪增强(阅读数显示+匿名/登录区分+后台阅读记录查询) | ✅ 已实施(detail 接口递增 view_count;匿名不显示时长;`POST /api/admin/articles/reading-records`;后台弹窗) | `specs/2026-07-28-article-reading-time-tracking-design.md` + `plans/2026-08-30-article-reading-tracking-enhancement.md` |
+| 文章推荐排序(精选顺序可定义) | ✅ 已实施(articles.sort_order 迁移294 + getFeatured/getPublicList 按 sort_order 排序 + toggleFeatured 追加队尾 + `POST /api/admin/articles/move` 上移/下移 + 管理端序号徽章) | `specs/2026-09-04-article-sort-order-design.md` + `plans/2026-09-04-article-sort-order.md` |
 | 公众号内容发布(LangGraph工作流) | 🔄 已废弃(由LangGraph + Python发布脚本替代) | — |
 
 ---

+ 1 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -590,6 +590,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | 路径前缀 | 说明 |
 |----------|------|
 | `/api/admin/articles/*` | 文章管理 |
+| `POST /api/admin/articles/move` | 调整精选文章的推荐顺序(`{ id, direction: 'up'|'down' }`,已边界时返回 400) |
 | `/api/admin/articles/reading-records` | 按文章查询阅读记录明细(分页,含孩子昵称/时长) |
 | `/api/admin/assessment/*` | 测评材料管理 |
 | `/api/admin/bazi-config/*` | 八字配置 |

+ 609 - 0
docs/superpowers/plans/2026-09-04-article-sort-order.md

@@ -0,0 +1,609 @@
+# 文章推荐排序(精选顺序可定义)实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 让运营在管理端可定义精选文章在首页/知识中心的展示顺序,提供上移/下移操作,无需前端额外开发。
+
+**架构:** 在 `articles` 表新增 `sort_order` 列(整数,越小越靠前);`getFeatured()` 和 `getPublicList()` 查询改用 `sort_order ASC`;管理端新增 `/api/admin/articles/move` 接口支持相邻交换 + 全局重编号;管理端列表对精选文章显示上移/下移按钮和序号徽章。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus、Vue 2 + Element UI、MySQL
+
+---
+
+## 文件清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `cfc-backend/.../entity/Article.java` | 修改 | 加 `sortOrder` 字段 |
+| `cfc-backend/.../service/ArticleService.java` | 修改 | 改排序 + 改 toggleFeatured + 新增 moveSortOrder / getMaxSortOrder |
+| `cfc-backend/.../controller/admin/AdminArticleController.java` | 修改 | 新增 POST /move 接口 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 修改 | 迁移293 加 sort_order 列 |
+| `cfc-backend/src/main/resources/schema.sql` | 修改 | articles CREATE TABLE 加 sort_order 列 |
+| `cfc-web/src/api/article.js` | 修改 | 新增 adminArticleMove |
+| `cfc-web/src/views/admin/ArticleManage.vue` | 修改 | 精选行显示上移/下移 + 序号徽章 |
+| `docs/superpowers/api/API_REFERENCE.md` | 修改 | 新增 /move 接口文档 |
+
+---
+
+## 任务 1:数据库迁移 — 新增 sort_order 列
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+
+- [ ] **步骤 1:在 DatabaseInitializer.java 末尾(迁移292 之后)追加迁移293**
+
+打开 `DatabaseInitializer.java`,在:
+```
+        // 迁移292: health_status 补 member_id 并改为成员级归档
+```
+块结束后(约第 10330 行,`}` 后)追加:
+
+```java
+        // 迁移293: articles 表加 sort_order 列(精选文章推荐排序,越小越靠前)
+        ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
+        log.info("已添加 articles.sort_order 列(迁移293)");
+```
+
+- [ ] **步骤 2:更新 schema.sql 的 articles 建表语句**
+
+打开 `cfc-backend/src/main/resources/schema.sql`,找到 `CREATE TABLE IF NOT EXISTS articles (`(约第 1395 行),在 `is_featured` 列之后、`published_at` 之前插入:
+
+```sql
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
+```
+
+当前 schema.sql 原文(需要修改的位置):
+```sql
+    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
+    published_at DATETIME DEFAULT NULL COMMENT '发布时间',
+```
+改为:
+```sql
+    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)',
+    published_at DATETIME DEFAULT NULL COMMENT '发布时间',
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git add cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(articles): 迁移293 为 articles 表添加 sort_order 列"
+```
+
+---
+
+## 任务 2:Article 实体加 sortOrder 字段
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java`
+
+- [ ] **步骤 1:在 Article.java 的 isFeatured 字段后加 sortOrder**
+
+找到:
+```java
+    private Integer isFeatured;
+    private Date publishedAt;
+```
+改为:
+```java
+    private Integer isFeatured;
+    /** 推荐排序值(越小越靠前,仅精选文章参与排序) */
+    private Integer sortOrder;
+    private Date publishedAt;
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
+git commit -m "feat(articles): Article 实体加 sortOrder 字段"
+```
+
+---
+
+## 任务 3:ArticleService — 改排序逻辑 + toggleFeatured 追加队尾 + 新增 moveSortOrder
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
+
+### 步骤 1:改 getFeatured() 排序
+
+找到 `ArticleService.java` 第 125 行 `getFeatured` 方法,将:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT " + Math.max(size, 50));
+```
+改为:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT " + Math.max(size, 50));
+```
+
+### 步骤 2:改 getPublicList() 排序
+
+找到 `ArticleService.java` 第 86 行 `getPublicList` 方法,将:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByDesc(Article::getPublishedAt);
+```
+改为:
+```java
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByAsc(Article::getSortOrder)
+                .orderByDesc(Article::getPublishedAt);
+```
+
+### 步骤 3:改 toggleFeatured() 设为精选时追加队尾
+
+找到第 541-548 行 `toggleFeatured` 方法,将:
+```java
+    @Transactional
+    public void toggleFeatured(Long id, int isFeatured) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return;
+        article.setIsFeatured(isFeatured);
+        article.setUpdatedAt(new Date());
+        articleMapper.updateById(article);
+    }
+```
+改为:
+```java
+    @Transactional
+    public void toggleFeatured(Long id, int isFeatured) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return;
+        article.setIsFeatured(isFeatured);
+        article.setUpdatedAt(new Date());
+        if (isFeatured == 1) {
+            // 设为精选时追加到队尾,不抢占既有顺序
+            Integer maxSort = getMaxSortOrder();
+            article.setSortOrder((maxSort == null ? 0 : maxSort) + 1);
+        }
+        articleMapper.updateById(article);
+    }
+```
+
+### 步骤 4:新增 getMaxSortOrder() 和 moveSortOrder() 两个方法
+
+在 `toggleFeatured()` 方法**之后**(约第 548 行后)插入以下两个方法:
+
+```java
+    /**
+     * 查询已发布且精选的文章中最大的 sort_order;若没有精选文章返回 null。
+     */
+    private Integer getMaxSortOrder() {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .select(Article::getSortOrder)
+                .orderByDesc(Article::getSortOrder)
+                .last("LIMIT 1");
+        Article top = articleMapper.selectOne(wrapper);
+        return top != null ? top.getSortOrder() : null;
+    }
+
+    /**
+     * 上移/下移精选文章的推荐顺序。
+     * direction: 'up' 或 'down'。
+     * 边界(已在最前/最后)抛 RuntimeException,调用方会返回 400。
+     */
+    @Transactional
+    public String moveSortOrder(Long id, String direction) {
+        Article target = articleMapper.selectById(id);
+        if (target == null) {
+            throw new RuntimeException("文章不存在");
+        }
+        if (!"published".equals(target.getStatus()) || !Integer.valueOf(1).equals(target.getIsFeatured())) {
+            throw new RuntimeException("仅已发布且精选的文章可调整顺序");
+        }
+        if (!"up".equals(direction) && !"down".equals(direction)) {
+            throw new RuntimeException("direction 只能是 up 或 down");
+        }
+
+        // 按展示序(sort_order ASC, published_at ASC)取全部精选文章列表
+        // published_at ASC 用于并列时稳定排序(sort_order 已唯一后不影响结果)
+        LambdaQueryWrapper<Article> listWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByAsc(Article::getPublishedAt);
+        List<Article> featured = articleMapper.selectList(listWrapper);
+
+        int idx = -1;
+        for (int i = 0; i < featured.size(); i++) {
+            if (featured.get(i).getId().equals(id)) {
+                idx = i;
+                break;
+            }
+        }
+        if (idx == -1) {
+            throw new RuntimeException("文章不存在");
+        }
+        if ("up".equals(direction) && idx <= 0) {
+            throw new RuntimeException("已在最前面");
+        }
+        if ("down".equals(direction) && idx >= featured.size() - 1) {
+            throw new RuntimeException("已在最后面");
+        }
+
+        int swapIdx = "up".equals(direction) ? idx - 1 : idx + 1;
+        Long swapId = featured.get(swapIdx).getId();
+
+        // 交换 sort_order(先交换,避免覆盖)
+        Article a = featured.get(idx);
+        Article b = featured.get(swapIdx);
+        Integer tmpSort = a.getSortOrder();
+        a.setSortOrder(b.getSortOrder());
+        b.setSortOrder(tmpSort);
+        a.setUpdatedAt(new Date());
+        b.setUpdatedAt(new Date());
+        articleMapper.updateById(a);
+        articleMapper.updateById(b);
+
+        // 全局重编号 1..N,消除并列与空洞
+        LambdaQueryWrapper<Article> renumWrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByAsc(Article::getSortOrder)
+                .orderByAsc(Article::getPublishedAt);
+        List<Article> sorted = articleMapper.selectList(renumWrapper);
+        Date now = new Date();
+        for (int i = 0; i < sorted.size(); i++) {
+            sorted.get(i).setSortOrder(i + 1);
+            sorted.get(i).setUpdatedAt(now);
+            articleMapper.updateById(sorted.get(i));
+        }
+
+        return "已调整顺序";
+    }
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
+git commit -m "feat(articles): getFeatured/getPublicList 改按 sort_order 排序,toggleFeatured 追加队尾,新增 moveSortOrder"
+```
+
+---
+
+## 任务 4:AdminArticleController 新增 POST /move 接口
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java`
+
+- [ ] **步骤 1:在 toggleFeatured 方法后插入 move 接口**
+
+找到 `AdminArticleController.java` 第 265-271 行 `toggleFeatured` 方法之后,插入:
+
+```java
+    @PostMapping("/move")
+    public Result<String> move(@RequestBody Map<String, Object> body,
+                                @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = ParamUtils.getLong(body.get("id"));
+        String direction = (String) body.get("direction");
+        if (id == null) {
+            return Result.error("缺少文章ID");
+        }
+        if (direction == null || (!"up".equals(direction) && !"down".equals(direction))) {
+            return Result.error("direction 只能是 up 或 down");
+        }
+        try {
+            articleService.moveSortOrder(id, direction);
+            return Result.success("已调整顺序");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -10
+```
+预期:无 ERROR 行,退出码 0。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
+git commit -m "feat(articles): AdminArticleController 新增 POST /move 接口"
+```
+
+---
+
+## 任务 5:cfc-web — api/article.js 加 adminArticleMove
+
+**文件:**
+- 修改:`cfc-web/src/api/article.js`
+
+- [ ] **步骤 1:在 adminArticleToggleFeatured 后新增 adminArticleMove**
+
+找到 `cfc-web/src/api/article.js` 中 `adminArticleToggleFeatured` 函数(约第 25-27 行),在其后插入:
+
+```js
+export function adminArticleMove(data) {
+  return request({ url: '/api/admin/articles/move', method: 'post', data })
+}
+```
+
+完整 api/article.js 关键片段(修改后):
+```js
+export function adminArticleToggleFeatured(data) {
+  return request({ url: '/api/admin/articles/toggle-featured', method: 'post', data })
+}
+
+export function adminArticleMove(data) {
+  return request({ url: '/api/admin/articles/move', method: 'post', data })
+}
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+cd /sc-data/cfc
+git add cfc-web/src/api/article.js
+git commit -m "feat(web): article API 新增 adminArticleMove"
+```
+
+---
+
+## 任务 6:cfc-web — ArticleManage.vue 加上移/下移按钮与序号徽章
+
+**文件:**
+- 修改:`cfc-web/src/views/admin/ArticleManage.vue`
+
+### 步骤 1:在 data() 中引入 adminArticleMove
+
+在 `<script>` 的 import 块末尾(第 309-310 行之后)追加:
+```js
+import {
+  adminArticleList,
+  adminArticlePublish,
+  adminArticleToggleFeatured,
+  adminArticleDelete,
+  adminArticleCategoriesList,
+  adminArticleAudit,
+  adminArticleDetail,
+  adminArticleCreate,
+  adminArticleSubmitReview,
+  adminArticleWithdraw,
+  adminArticleReDraft,
+  adminArticleReadingRecords,
+  adminArticleMove
+} from '@/api/article.js'
+```
+
+### 步骤 2:为发布中 + 精选行新增 handleMove
+
+在 ArticleManage.vue 的 `methods` 对象内,紧接 `handleToggleFeatured` 方法(约第 465-475 行)之后插入:
+
+```js
+    async handleMove(row, direction) {
+      try {
+        await this.$confirm(
+          direction === 'up' ? '确认将《' + row.title + '》上移一位?' : '确认将《' + row.title + '》下移一位?',
+          '提示'
+        )
+        await adminArticleMove({ id: row.id, direction })
+        this.$message.success('已调整顺序')
+        this.loadList()
+      } catch (e) {
+        if (e !== 'cancel') {
+          this.$message.error(e.message || '调整顺序失败')
+        }
+      }
+    },
+```
+
+### 步骤 3:在 handleActionCmd 的 toggleFeatured 分支旁增加 move 分支(可选,通过下拉菜单)
+
+在 `handleActionCmd` 方法的 switch 块中,`case 'toggleFeatured'` 之后追加:
+```js
+        case 'moveUp': this.handleMove(row, 'up'); break;
+        case 'moveDown': this.handleMove(row, 'down'); break;
+```
+
+### 步骤 4:为发布中行的下拉菜单新增上移/下移项
+
+找到 `ArticleManage.vue` 第 182-191 行("发布中"模板)的 `<el-dropdown-menu>` 内,在"精选/取消精选"菜单项之后追加两个项:
+
+```html
+                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
+                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
+```
+
+完整发布中下拉菜单最终代码:
+```html
+            <!-- 发布中:编辑 + 下架 + 精选 -->
+            <template v-else-if="row.status === 'published'">
+              <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
+              <el-button size="mini" @click="openReadRecords(row)">阅读记录</el-button>
+              <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
+                <el-button size="mini">
+                  更多<i class="el-icon-arrow-down el-icon--right"></i>
+                </el-button>
+                <el-dropdown-menu slot="dropdown">
+                  <el-dropdown-item command="withdraw" icon="el-icon-download">下架</el-dropdown-item>
+                  <el-dropdown-item command="toggleFeatured" icon="el-icon-star-on">精选/取消精选</el-dropdown-item>
+                  <el-dropdown-item command="moveUp" icon="el-icon-top">上移</el-dropdown-item>
+                  <el-dropdown-item command="moveDown" icon="el-icon-bottom">下移</el-dropdown-item>
+                  <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
+                </el-dropdown-menu>
+              </el-dropdown>
+            </template>
+```
+
+### 步骤 5:在标题列展示精选序号徽章
+
+找到 `ArticleManage.vue` 第 61-66 行(标题列):
+```html
+        <el-table-column label="标题" min-width="220">
+          <template slot-scope="{ row }">
+            <span>{{ row.title }}</span>
+            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
+          </template>
+        </el-table-column>
+```
+改为(序号徽章用橙色,仅排序值 > 0 时展示):
+```html
+        <el-table-column label="标题" min-width="220">
+          <template slot-scope="{ row }">
+            <span>{{ row.title }}</span>
+            <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选{{ row.sortOrder && row.sortOrder > 0 ? ' ' + toChineseNum(row.sortOrder) : '' }}</el-tag>
+          </template>
+        </el-table-column>
+```
+
+### 步骤 6:添加 toChineseNum 辅助方法
+
+在 `methods` 对象末尾(`handleActionCmd` 之前)插入:
+```js
+    toChineseNum(n) {
+      var digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
+      if (n <= 10) return digits[n]
+      var tens = Math.floor(n / 10)
+      var ones = n % 10
+      if (tens === 1) return '十' + (ones === 0 ? '' : digits[ones])
+      return digits[tens] + '十' + (ones === 0 ? '' : digits[ones])
+    },
+```
+
+### 步骤 7:保存并提交
+
+```bash
+cd /sc-data/cfc
+git add cfc-web/src/views/admin/ArticleManage.vue
+git commit -m "feat(web): ArticleManage 精选行增加上移/下移 + 序号徽章"
+```
+
+---
+
+## 任务 7:更新 API_REFERENCE.md
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
+
+- [ ] **步骤 1:在 `/api/admin/articles/*` 表格后追加一行**
+
+找到第 592 行附近:
+```markdown
+| `/api/admin/articles/*` | 文章管理 |
+```
+改为:
+```markdown
+| `/api/admin/articles/*` | 文章管理 |
+| `POST /api/admin/articles/move` | 调整精选文章的推荐顺序(`{ id, direction: 'up'|'down' }`,已边界时返回 400) |
+```
+
+- [ ] **步骤 2:更新 PROJECT-OVERVIEW.md(文档增删需同步)**
+
+打开 `docs/superpowers/PROJECT-OVERVIEW.md`,在"文档索引"或相应章节追加一行:
+```
+- `specs/2026-09-04-article-sort-order-design.md` — 文章推荐排序(精选顺序可定义)设计规格
+```
+
+(如 PROJECT-OVERVIEW.md 无专门规格章节,追加到文档索引表格末尾即可)
+
+- [ ] **步骤 3:Commit**
+
+```bash
+cd /sc-data/cfc
+git add docs/superpowers/api/API_REFERENCE.md
+git add docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 更新 API_REFERENCE 与 PROJECT-OVERVIEW(article sort order)"
+```
+
+---
+
+## 任务 8:端到端验证
+
+- [ ] **步骤 1:后端编译**
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile -q 2>&1 | grep -E "ERROR|BUILD"
+```
+预期:无 ERROR 行,或仅 `[WARNING]`,BUILD SUCCESS。
+
+- [ ] **步骤 2:小程序前端编译(无实际改动,语法检查)**
+```bash
+cd /sc-data/cfc/cfc-frontend && node -e "
+const fs = require('fs');
+const src = fs.readFileSync('pages/index-home/index.vue', 'utf8');
+const m = src.match(/<script>([\s\S]*?)<\/script>/);
+if (!m) { console.log('no script'); process.exit(0); }
+try { new Function(m[1]); console.log('index-home script OK'); } catch(e) { console.error('ERROR:', e.message); process.exit(1); }
+"
+```
+
+- [ ] **步骤 3:提交 git status 检查**
+```bash
+cd /sc-data/cfc && git status --short
+```
+预期:仅 `cfc-web/.last_build_commit` 与 `cfc-web/package-lock.json` 有未提交变更(属 cfc-web 构建产物,与本次无关)。
+
+---
+
+## 规格自检
+
+| 规格要求 | 对应任务 |
+|---------|---------|
+| 迁移 293 加 sort_order 列 | 任务 1 ✓ |
+| Article 实体加 sortOrder | 任务 2 ✓ |
+| getFeatured() 排序改 sort_order ASC | 任务 3 步骤 1 ✓ |
+| getPublicList() 排序改 sort_order ASC | 任务 3 步骤 2 ✓ |
+| toggleFeatured 设为精选追加队尾 | 任务 3 步骤 3 ✓ |
+| 新增 moveSortOrder 交换+重编号 | 任务 3 步骤 4 ✓ |
+| 新增 POST /move 接口 | 任务 4 ✓ |
+| api/article.js 新增 adminArticleMove | 任务 5 ✓ |
+| ArticleManage.vue 上移/下移菜单 | 任务 6 步骤 4 ✓ |
+| 标题列序号徽章 | 任务 6 步骤 5 ✓ |
+| API_REFERENCE.md 同步 | 任务 7 ✓ |
+| PROJECT-OVERVIEW.md 同步 | 任务 7 步骤 2 ✓ |
+| 边界:非精选文章拒绝 | moveSortOrder 中判断 ✓ |
+| 边界:已在最前/最后提示 | throw RuntimeException ✓ |
+| 边界:非法 direction 拒绝 | moveSortOrder 中判断 ✓ |
+
+全部规格要求已覆盖,无遗漏。

+ 181 - 0
docs/superpowers/specs/2026-09-04-article-sort-order-design.md

@@ -0,0 +1,181 @@
+# 文章推荐排序(精选顺序可定义)设计
+
+> 日期:2026-09-04
+> 状态:已批准(用户确认:不做独立置顶,仅使用精选 + 列表内上移/下移)
+
+## 背景与目标
+
+首页「推荐阅读」与知识中心「书册式推荐」当前展示的内容由 `is_featured=1`(精选)决定,但**顺序完全由 `published_at` 决定**,运营无法控制哪些精选文章排在前面。
+
+目标:让运营在管理端**定义精选文章在首页/知识中心的展示顺序**,从而做到"首页放的内容可定义"。
+
+## 现状梳理
+
+### 后端
+
+| 方法 | 位置 | 当前排序逻辑 |
+|------|------|-------------|
+| `getFeatured()` | `ArticleService.java:125` | `WHERE status=published AND is_featured=1 ORDER BY published_at DESC LIMIT 50` |
+| `getPublicList()` | `ArticleService.java:86` | `WHERE status=published ORDER BY is_featured DESC, published_at DESC` |
+| `toggleFeatured()` | `ArticleService.java:542` | 直接写 `is_featured`,无顺序关联 |
+| 管理端列表 `getAdminList()` | `ArticleService.java:386` | `ORDER BY created_at DESC` + `SortUtil.applySort` |
+
+### 管理端(cfc-web)
+
+- `ArticleManage.vue`:文章管理列表,发布中的行下拉菜单已有「精选/取消精选」
+- `api/article.js`:已有 `adminArticleToggleFeatured`
+- 接口:`POST /api/admin/articles/toggle-featured`
+
+### 小程序端(cfc-frontend)
+
+- 首页「推荐阅读」:`getFeaturedArticles({ size: 5 })` → `getFeatured`
+- 知识中心「书册式推荐」:`getFeaturedArticles({ size: 8 })` → `getFeatured`
+- 顺序完全由后端返回决定,**前端无需改动**
+
+## 需求确认(用户决策)
+
+1. **不做**独立的置顶(is_top)字段 → 继续使用 `is_featured` 精选标记"要上首页的内容"
+2. **新增**排序能力 → 精选文章顺序可由运营控制
+3. **操作方式**:管理端文章列表内 **上移/下移** 按钮
+
+## 设计
+
+### 1. 数据库(迁移 293)
+
+`articles` 表新增一列:
+
+```sql
+sort_order INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'
+```
+
+- 迁移位置:`DatabaseInitializer.runMigrations()`,使用 `ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'")`(幂等)
+- 同步更新 `schema.sql` 的 `CREATE TABLE articles`(在 `is_featured` 后加 `sort_order` 列)
+
+### 2. 后端
+
+#### 2a. 实体 `Article.java`
+新增字段:
+```java
+/** 推荐排序值(越小越靠前,仅精选文章参与排序) */
+private Integer sortOrder;
+```
+
+#### 2b. `ArticleService.getFeatured()`
+排序由 `ORDER BY published_at DESC` 改为:
+
+```
+ORDER BY sort_order ASC, published_at DESC
+```
+
+效果:精选文章顺序由运营的 sort_order 控制,未显式设置过 sort_order 的精选(默认 0)排在前面,与新精选的追加逻辑配合避免"新精选抢占首位"。
+
+#### 2c. `ArticleService.getPublicList()`
+排序由 `ORDER BY is_featured DESC, published_at DESC` 改为:
+
+```
+ORDER BY is_featured DESC, sort_order ASC, published_at DESC
+```
+
+效果:知识中心"全部"列表中精选文章也按运营顺序靠前排布。
+
+#### 2d. `ArticleService.toggleFeatured()`
+设为精选(`isFeatured=1`)时,自动将 `sort_order` 置为 `当前最大 sort_order + 1`,使新精选**追加到队列末尾**,不抢占既有顺序。
+
+```java
+if (isFeatured == 1) {
+    // 新精选追加到队尾,不抢占已有顺序
+    Integer maxSort = getMaxSortOrder();
+    article.setSortOrder((maxSort == null ? 0 : maxSort) + 1);
+}
+```
+
+辅助方法 `getMaxSortOrder()`:查询 `articles WHERE status='published' AND is_featured=1` 的 `MAX(sort_order)`。
+
+#### 2e. 新增接口 `POST /api/admin/articles/move`
+
+在 `AdminArticleController` 新增:
+
+```
+POST /api/admin/articles/move
+body: { id: Long, direction: 'up' | 'down' }
+```
+
+**逻辑(`ArticleService.moveSortOrder(id, direction)`):**
+
+1. 查询"当前已发布且精选"文章列表,按 `sort_order ASC, published_at ASC` 排序(注意:与展示序一致取 ASC,保证交换后顺序与前端一致)
+2. 找到目标文章位置 `idx`
+3. `direction=up` 且 `idx <= 0` → 返回「已在最前面」;`direction=down` 且 `idx >= len-1` → 返回「已在最后面」
+4. 与相邻元素交换 sort_order
+5. 交换后对整个列表**重新编号** `sort_order = 1..N`(消除并列与空洞,保证顺序绝对稳定)
+6. 批量更新
+
+**实现细节**:交换相邻的 sort_order 可能出现并列——为防止并列排序不稳定,执行重编号(步骤 5)。
+
+**响应**:`Result<String>`,边界返回 `Result.error(400, "已在最前/最后")`。
+
+### 3. 管理端(cfc-web)
+
+#### 3a. `api/article.js`
+新增:
+```js
+export function adminArticleMove(data) {
+  return request({ url: '/api/admin/articles/move', method: 'post', data })
+}
+```
+
+#### 3b. `ArticleManage.vue`
+- 发布中下拉菜单「精选/取消精选」旁,当 `row.isFeatured === 1` 时增加「上移」「下移」菜单项(或行内小按钮)
+- 操作列:`row.status === 'published' && row.isFeatured === 1` 时显示上移/下移
+- 点击调用 `adminArticleMove({ id, direction })` 成功后刷新列表
+- 标题区精选标签旁显示顺序序号:`精选 ①`(用 `sortOrder` 换算:非零序号直接显示,0 显示为队尾追加的未明确序号可不显示或显示 `精选`)
+
+**边界**:非精选文章不显示上移/下移;列表排序按 `created_at DESC` 展示,与推荐顺序无关(运营通过按钮逐个调整)。
+
+### 4. 小程序端(cfc-frontend)
+
+无需改动。首页「推荐阅读」与知识中心书册数据均由 `getFeatured()` 返回,顺序自动受控。
+
+## 数据流
+
+```
+运营在管理端点「上移/下移」
+    → POST /api/admin/articles/move {id, direction}
+    → ArticleService.moveSortOrder() 交换 + 重编号 sort_order=1..N
+    → 小程序 getFeatured() 按 sort_order ASC 返回
+    → 首页「推荐阅读」/知识中心书册顺序更新
+```
+
+## 边界与异常处理
+
+| 场景 | 处理 |
+|------|------|
+| 目标文章不是精选 | `move` 拒绝:「仅精选文章可调整顺序」 |
+| 已是最前/最后 | 返回 400 提示「已在最前面/最后面」 |
+| 文章不存在 | 返回 400「文章不存在」 |
+| 方向非法 | 返回 400「direction 只能是 up/down」 |
+| 并发操作 | 事务 + 重编号天然收敛(最后一次操作者胜出) |
+| 新设精选 | 自动追加队尾,不抢占 |
+
+## 验证
+
+1. 后端:`mvn clean compile` 通过
+2. 管理端:`npm run build` 通过
+3. 手工验证流程:
+   - 设 2+ 篇文章为精选 → 首页顺序按发布时间的从新到旧(sort_order 追加序)
+   - 管理端点「上移/下移」→ 刷新列表顺位变化
+   - 首页/知识中心书册顺序与调整后一致
+   - 边界:第一文章点上移 → 提示;最后文章点下移 → 提示
+4. 迁移幂等:重复执行 `runMigrations()` 不报错
+
+## 文件改动清单
+
+| 文件 | 改动 |
+|------|------|
+| `cfc-backend/.../config/DatabaseInitializer.java` | 迁移293 加 sort_order 列 |
+| `cfc-backend/.../resources/schema.sql` | articles 建表加 sort_order 列 |
+| `cfc-backend/.../entity/Article.java` | 加 sortOrder 字段 |
+| `cfc-backend/.../service/ArticleService.java` | getFeatured/getPublicList 排序、toggleFeatured 追加队尾、新增 moveSortOrder + getMaxSortOrder |
+| `cfc-backend/.../controller/admin/AdminArticleController.java` | 新增 POST /move |
+| `cfc-web/src/api/article.js` | 新增 adminArticleMove |
+| `cfc-web/src/views/admin/ArticleManage.vue` | 上移/下移按钮 + 精选序号展示 |
+| `docs/superpowers/api/API_REFERENCE.md` | 同步记录 /move 接口 |