Procházet zdrojové kódy

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

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
Sisyphus před 5 dny
rodič
revize
482134740e
33 změnil soubory, kde provedl 3102 přidání a 325 odebrání
  1. 61 7
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 102 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  3. 85 37
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  4. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  5. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  6. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTransferRequest.java
  7. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportTransferRequestMapper.java
  8. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  9. 166 5
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  10. 37 4
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  11. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  12. 327 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportTransferService.java
  13. 20 1
      cfc-backend/src/main/resources/schema.sql
  14. 52 2
      cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceTest.java
  15. 262 0
      cfc-backend/src/test/java/com/etotem/cfc/service/MembershipServiceTest.java
  16. 182 0
      cfc-frontend/pages/action-detail/index.vue
  17. 110 0
      cfc-frontend/pages/body-detail/index.vue
  18. 166 4
      cfc-frontend/pages/health/report-list.vue
  19. 27 2
      cfc-frontend/pages/health/report-upload.vue
  20. 169 0
      cfc-frontend/pages/mind-detail/index.vue
  21. 11 5
      cfc-frontend/pages/vendor/team-customers/team-customers.vue
  22. 7 1
      cfc-frontend/pages/vendor/team-system/team-system.vue
  23. 161 1
      cfc-frontend/pages/wealth/index.vue
  24. 182 0
      cfc-frontend/pages/wisdom-detail/index.vue
  25. 6 0
      cfc-frontend/utils/api.js
  26. 1 1
      cfc-web/.last_build_commit
  27. 2 2
      cfc-web/package-lock.json
  28. 1 1
      cfc-web/package.json
  29. 114 0
      cfc-web/public/CHANGELOG-v1.0.md
  30. 115 1
      cfc-web/public/CHANGELOG.md
  31. 16 6
      cfc-web/src/views/admin/CategoryManage.vue
  32. 17 6
      cfc-web/src/views/admin/ProductEdit.vue
  33. 565 238
      cfc-web/src/views/admin/ProductManage.vue

+ 61 - 7
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10932,6 +10932,9 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         seedTaskReminderTemplate();
         backfillHealthPlanTaskPrerequisite();
         backfillTemplateInstanceFamilyMemberId();
+        createReportTransferRequestsTable();
+        addProductMinDiscountLevel();
+        backfillMembershipLevelOrder();
 
         createProductDimensionMappingTable();
         createProductRecommendationLogTable();
@@ -11188,15 +11191,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
 
         // 迁移321: 商品/分类可见性由「角色」改为「按会员级别」控制
-        // - membership_levels.level_order: 级别数字(数字越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5
-        // - products.min_visible_level: 最低可见会员级别编码(NULL=全部可见)
-        // - shop_categories.min_visible_level: 分类最低可见会员级别编码(NULL=全部可见)
+        // - membership_levels.level_order: 级别数字(数字越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6
+        // - products.min_visible_level: 最低可见会员级别编码(NULL=所有用户含未登录可见)
+        // - shop_categories.min_visible_level: 分类最低可见会员级别编码(NULL=所有用户含未登录可见)
         ensureColumn("membership_levels", "level_order",
-                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5'");
+                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6'");
         try {
             jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
-                    "WHEN 'FREE' THEN 1 WHEN 'FAMILY' THEN 2 WHEN 'PROVIDER' THEN 3 " +
-                    "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 ELSE level_order END");
+                    "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 4 " +
+                    "WHEN 'PREMIUM' THEN 5 WHEN 'LIFETIME' THEN 6 ELSE level_order END");
             log.info("迁移321: 已回填 membership_levels.level_order");
         } catch (Exception e) {
             log.warn("迁移321: 回填 membership_levels.level_order 失败: {}", e.getMessage());
@@ -11744,4 +11747,55 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                 "VARCHAR(32) DEFAULT NULL COMMENT '推荐来源类型:dimension/co_purchase/repurchase/profile_gap/manual'");
         log.info("迁移349: product_recommendation_log 已添加 ab_test_group / recommendation_type 列");
     }
-}
+    // 迁移349: 创建 report_transfer_requests 表(健康报告跨家庭转移申请)
+    // 同一份报告(file_hash 相同)被其他家庭上传时,申请方家庭发起转移申请,
+    // 原归属家庭同意后 health_reports.family_id 转移至申请方,原家庭不再可见。
+    private void createReportTransferRequestsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_transfer_requests (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "report_id BIGINT NOT NULL COMMENT '报告ID(health_reports.id)'," +
+                    "from_family_id BIGINT NOT NULL COMMENT '原归属家庭ID(需同意方)'," +
+                    "to_family_id BIGINT NOT NULL COMMENT '申请转入家庭ID(申请方)'," +
+                    "requester_user_id BIGINT NOT NULL COMMENT '申请人用户ID(users.id)'," +
+                    "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=待同意 approved=已同意 denied=已拒绝'," +
+                    "handled_by BIGINT DEFAULT NULL COMMENT '处理人用户ID'," +
+                    "handled_at DATETIME DEFAULT NULL COMMENT '处理时间'," +
+                    "remark VARCHAR(200) DEFAULT NULL COMMENT '备注(拒绝原因)'," +
+                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "INDEX idx_report_id (report_id)," +
+                    "INDEX idx_from_family_status (from_family_id, status)," +
+                    "INDEX idx_to_family_status (to_family_id, status)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告跨家庭转移申请'");
+            log.info("迁移349: 已创建 report_transfer_requests 表");
+        } catch (Exception e) {
+            log.warn("迁移349: 创建 report_transfer_requests 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移350: products 表添加 min_discount_level 列(商品粒度控制会员价门槛,NULL=所有会员均可享会员价)
+    private void addProductMinDiscountLevel() {
+        try {
+            jdbcTemplate.execute("ALTER TABLE products ADD COLUMN min_discount_level VARCHAR(50) DEFAULT NULL " +
+                    "COMMENT '享受会员价所需的最低会员级别编码(FREE/BASIC/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level");
+            log.info("迁移350: 已添加 products.min_discount_level 列");
+        } catch (Exception e) {
+            // 列已存在,忽略
+            log.debug("迁移350: products.min_discount_level 列已存在,跳过");
+        }
+    }
+
+    // 迁移351: membership_levels.level_order 顺延,为 BASIC 让出独立档位
+    // 会员级别包含关系(数字越小级别越低、可见范围越大): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6
+    private void backfillMembershipLevelOrder() {
+        try {
+            int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
+                    "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 4 " +
+                    "WHEN 'PREMIUM' THEN 5 WHEN 'LIFETIME' THEN 6 ELSE level_order END");
+            log.info("迁移351: 已顺延 membership_levels.level_order({} 行),BASIC=2", updated);
+        } catch (Exception e) {
+            log.warn("迁移351: 顺延 membership_levels.level_order 失败: {}", e.getMessage());
+        }
+    }
+}

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

@@ -20,6 +20,7 @@ import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.ReportTransferRequest;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
 import com.etotem.cfc.entity.User;
@@ -37,6 +38,7 @@ import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.ReportCollectService;
+import com.etotem.cfc.service.ReportTransferService;
 import com.etotem.cfc.service.ReportBlockAssembler;
 import com.etotem.cfc.service.ReportBlockService;
 import com.etotem.cfc.service.StorageService;
@@ -102,6 +104,9 @@ public class HealthReportController {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private ReportTransferService reportTransferService;
+
     @Resource
     private PdfParseService pdfParseService;
 
@@ -698,7 +703,17 @@ public class HealthReportController {
                 Map<String, Object> dup = new LinkedHashMap<>();
                 dup.put("duplicate", true);
                 dup.put("reportId", existingReportId);
-                dup.put("message", "该报告已上传过,无需重复解析");
+                HealthReport existingReport = healthReportService.getReportById(existingReportId);
+                boolean crossFamily = existingReport != null
+                        && existingReport.getFamilyId() != null
+                        && (familyId == null || !familyId.equals(existingReport.getFamilyId()));
+                if (crossFamily) {
+                    // 同一份报告已被其他家庭上传:需原家庭同意后方可转入本家庭,禁止直接跳详情
+                    dup.put("crossFamily", true);
+                    dup.put("message", "该报告已由其他家庭上传,发起转移申请并经对方同意后,报告将转入您的家庭");
+                } else {
+                    dup.put("message", "该报告已上传过,无需重复解析");
+                }
                 return Result.success(dup);
             }
             Long collectingDraftId = healthReportService.findCollectingDraftIdByFileHash(fileHash);
@@ -1390,6 +1405,92 @@ public class HealthReportController {
         return Result.success("归属已更新");
     }
 
+    /**
+     * 发起报告跨家庭转移申请(申请方家庭)
+     *
+     * <p>同一份报告(文件MD5相同)已被其他家庭上传时,上传接口返回 crossFamily=true,
+     * 前端弹窗确认后调用本接口发起申请;原家庭在「健康报告」列表同意后报告转入本家庭。</p>
+     *
+     * @param params   请求体:reportId (必填)
+     * @param familyId 当前登录用户的家庭ID(申请方,由 JwtInterceptor 注入)
+     * @param userId   当前登录用户ID
+     * @return 转移申请摘要 {requestId, status}
+     */
+    @PostMapping("/report/transfer/request")
+    public Result<Map<String, Object>> requestReportTransfer(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        try {
+            ReportTransferRequest request = reportTransferService.createRequest(reportId, familyId, userId);
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("requestId", request.getId());
+            data.put("status", request.getStatus());
+            return Result.success(data);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 同意转移(原归属家庭):报告 family_id 转入申请方,本家庭不再可见
+     */
+    @PostMapping("/report/transfer/approve")
+    public Result<String> approveReportTransfer(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long requestId = ParamUtils.getLong(params.get("requestId"));
+        try {
+            reportTransferService.approve(requestId, familyId, userId);
+            return Result.success("已同意转移,报告已转入对方家庭");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 拒绝转移(原归属家庭):报告留在本家庭
+     */
+    @PostMapping("/report/transfer/deny")
+    public Result<String> denyReportTransfer(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long requestId = ParamUtils.getLong(params.get("requestId"));
+        String remark = params.get("remark") != null ? String.valueOf(params.get("remark")) : null;
+        try {
+            reportTransferService.deny(requestId, familyId, userId, remark);
+            return Result.success("已拒绝该转移申请");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询本家庭相关的转移申请列表
+     *
+     * @return {incoming: 待本家庭处理(原归属方), outgoing: 本家庭发起的(申请方)}
+     */
+    @PostMapping("/report/transfer/list")
+    public Result<Map<String, Object>> listReportTransfers(
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        return Result.success(reportTransferService.listRequests(familyId));
+    }
+
     /**
      * 获取某家庭中所有未绑定成员的报告列表
      */

+ 85 - 37
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.MembershipService;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.TypeReference;
 import javax.annotation.Resource;
@@ -87,6 +88,9 @@ public class AdminController {
     @Resource
     private LifetimeStatsMapper lifetimeStatsMapper;
 
+    @Resource
+    private MembershipService membershipService;
+
     // 终身会员达标阈值(分)
     private static final long LIFETIME_CONSUME_THRESHOLD = 100_000_000L;  // 消费 ≥ 100万元
     private static final long LIFETIME_SALES_THRESHOLD = 200_000_000L;    // 销售 ≥ 200万元
@@ -1627,11 +1631,12 @@ public class AdminController {
         }
         String fromLevel = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
 
-        // 1. 升级用户为 LIFETIME(memberExpireTime=null 永久有效)
-        user.setMemberLevel("LIFETIME");
-        user.setMemberExpireTime(null);
-        user.setUpdatedAt(new Date());
-        userMapper.updateById(user);
+        // 1. 方案B:会员属于家庭 — 写入 family_memberships 事实源并同步全家 users 缓存。
+        //    原先只直写 users.member_level,导致读 family_memberships 的付费墙(如健康方案生成)误判 FREE。
+        //    LIFETIME 以远期到期时间表示永久(getCurrentLevel 依赖 endDate 判定有效性)。
+        Long familyId = membershipService.getOrCreateUserFamilyId(targetUserId);
+        membershipService.setFamilyMembershipLevel(familyId, "LIFETIME", resolveGrantExpireTime("LIFETIME"),
+                "admin", "lifetime-grant:" + adminUserId);
 
         // 2. 写入升级记录 upgradeType='lifetime'
         MemberUpgradeRecord record = new MemberUpgradeRecord();
@@ -1650,6 +1655,16 @@ public class AdminController {
         return Result.success();
     }
 
+    /**
+     * 后台授予会员的目标到期时间:LIFETIME 用远期时间表示永久,其余等级默认 365 天(与支付/赠送默认一致)。
+     */
+    private Date resolveGrantExpireTime(String levelCode) {
+        if ("LIFETIME".equals(levelCode)) {
+            return java.sql.Timestamp.valueOf(java.time.LocalDateTime.of(2099, 12, 31, 23, 59, 59));
+        }
+        return new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000);
+    }
+
     /**
      * 会员维护:按用户ID修改会员等级和推荐人
      * body: { userId, memberLevel?, referrerId? }
@@ -1674,6 +1689,8 @@ public class AdminController {
         StringBuilder changes = new StringBuilder();
         String oldLevel = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
         String newLevel = null;
+        // 用户行仅剩「推荐人」「无家庭时的 FREE 回退」需要直写;会员等级统一走家庭维度(方案B)
+        boolean userRowChanged = false;
         LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
         wrapper.eq(User::getId, targetUserId);
 
@@ -1683,6 +1700,7 @@ public class AdminController {
                 if (user.getReferrerId() != null) {
                     wrapper.set(User::getReferrerId, null);
                     changes.append("清除推荐人; ");
+                    userRowChanged = true;
                 }
             } else {
                 try {
@@ -1692,6 +1710,7 @@ public class AdminController {
                     }
                     wrapper.set(User::getReferrerId, referrerId);
                     changes.append("推荐人=").append(referrerId).append("; ");
+                    userRowChanged = true;
                 } catch (NumberFormatException e) {
                     return Result.error("推荐人ID格式不正确");
                 }
@@ -1715,47 +1734,76 @@ public class AdminController {
                 return Result.error("无效的会员等级: " + level);
             }
             newLevel = level;
-            wrapper.set(User::getMemberLevel, newLevel);
-            if ("LIFETIME".equals(newLevel)) {
-                wrapper.set(User::getMemberExpireTime, null);
-            }
             if (!oldLevel.equals(newLevel)) {
                 changes.append("等级=").append(oldLevel).append("->").append(newLevel).append("; ");
             }
         }
 
-        if (changes.length() > 0) {
+        // 会员等级变更:写入事实源 family_memberships 并同步全家 users 缓存(方案B:会员属于家庭)。
+        // 不再直写 users.member_level,否则读 family_memberships 的付费墙会误判 FREE(如 /api/health/plan/generate 报 4001)。
+        if (newLevel != null) {
+            Long targetFamilyId = user.getFamilyId();
+            if (!"FREE".equals(newLevel) && (targetFamilyId == null || targetFamilyId == 0L)) {
+                // 无家庭自动建家:会员以家庭为单位,无家庭无法承载会员权益
+                targetFamilyId = membershipService.getOrCreateUserFamilyId(targetUserId);
+                changes.append("已自动创建家庭id=").append(targetFamilyId).append("; ");
+            }
+            if ("FREE".equals(newLevel)) {
+                if (targetFamilyId != null && targetFamilyId != 0L) {
+                    // 注意:家庭会员为全家共享,改用户为 FREE 会撤销整个家庭的会员并全家降级
+                    String desc = membershipService.setFamilyMembershipLevel(
+                            targetFamilyId, "FREE", null, "admin", "admin:" + adminUserId);
+                    if (desc != null && !desc.isEmpty()) {
+                        changes.append(desc).append("; ");
+                    }
+                } else {
+                    // 无家庭:无可撤销的家庭会员,直接降级用户缓存(兼容旧行为)
+                    wrapper.set(User::getMemberLevel, "FREE");
+                    userRowChanged = true;
+                }
+            } else {
+                String desc = membershipService.setFamilyMembershipLevel(
+                        targetFamilyId, newLevel, resolveGrantExpireTime(newLevel),
+                        "admin", "admin:" + adminUserId);
+                if (desc != null && !desc.isEmpty()) {
+                    changes.append(desc).append("; ");
+                }
+            }
+        }
+
+        if (userRowChanged) {
             wrapper.set(User::getUpdatedAt, new Date());
             userMapper.update(null, wrapper);
+        }
 
-            if (newLevel != null && !oldLevel.equals(newLevel)) {
-                MemberUpgradeRecord record = new MemberUpgradeRecord();
-                record.setUserId(targetUserId);
-                record.setFromLevel(oldLevel);
-                record.setToLevel(newLevel);
-                record.setUpgradeType("admin");
-                record.setApproveUserId(adminUserId);
-                record.setApproveStatus("approved");
-                record.setApproveTime(new Date());
-                record.setApproveRemark("管理员会员维护");
-                record.setCreatedAt(new Date());
-                memberUpgradeRecordMapper.insert(record);
-
-                // 升级到付费等级(非 FREE)时自动发放 JOIN 赠券(幂等:family_coupon 唯一键防重)
-                if (!"FREE".equals(newLevel)) {
-                    User changedUser = userMapper.selectById(targetUserId);
-                    if (changedUser != null && changedUser.getFamilyId() != null) {
-                        try {
-                            couponService.grantFamilyJoinCoupons(
-                                    changedUser.getFamilyId(), newLevel,
-                                    "admin:" + adminUserId);
-                            changes.append("发放").append(newLevel).append("JOIN券; ");
-                        } catch (Exception ex) {
-                            changes.append("发放").append(newLevel).append("JOIN券异常:").append(ex.getMessage()).append("; ");
-                        }
-                    } else {
-                        changes.append("升级").append(newLevel).append("(无家庭,跳过发券); ");
+        if (newLevel != null && !oldLevel.equals(newLevel)) {
+            MemberUpgradeRecord record = new MemberUpgradeRecord();
+            record.setUserId(targetUserId);
+            record.setFromLevel(oldLevel);
+            record.setToLevel(newLevel);
+            record.setUpgradeType("admin");
+            record.setApproveUserId(adminUserId);
+            record.setApproveStatus("approved");
+            record.setApproveTime(new Date());
+            record.setApproveRemark("管理员会员维护");
+            record.setCreatedAt(new Date());
+            memberUpgradeRecordMapper.insert(record);
+
+            // 升级到付费等级(非 FREE)时自动发放 JOIN 赠券(幂等:family_coupon 唯一键防重)
+            if (!"FREE".equals(newLevel)) {
+                User changedUser = userMapper.selectById(targetUserId);
+                Long changedFamilyId = changedUser != null ? changedUser.getFamilyId() : null;
+                if (changedFamilyId != null && changedFamilyId != 0L) {
+                    try {
+                        couponService.grantFamilyJoinCoupons(
+                                changedFamilyId, newLevel,
+                                "admin:" + adminUserId);
+                        changes.append("发放").append(newLevel).append("JOIN券; ");
+                    } catch (Exception ex) {
+                        changes.append("发放").append(newLevel).append("JOIN券异常:").append(ex.getMessage()).append("; ");
                     }
+                } else {
+                    changes.append("升级").append(newLevel).append("(无家庭,跳过发券); ");
                 }
             }
         }

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

@@ -34,6 +34,7 @@ public class ProductDTO {
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
     private List<String> visibleRoles;     // 可见角色列表,NULL=全部角色可见(已废弃,保留兼容)
     private String minVisibleLevel;        // 最低可见会员级别编码,NULL=全部可见
+    private String minDiscountLevel;       // 享受会员价所需的最低会员级别,NULL=所有会员均可
     private List<String> imageList;
     private Long energyDimensionId;
     private Integer effectivePpoint;     // 有效P点(基点,如1000=10%)
@@ -123,6 +124,7 @@ public class ProductDTO {
             }
         }
         d.minVisibleLevel = p.getMinVisibleLevel();
+        d.minDiscountLevel = p.getMinDiscountLevel();
         d.createdAt = p.getCreatedAt();
         d.updatedAt = p.getUpdatedAt();
         // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)

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

@@ -55,6 +55,9 @@ public class Product implements Serializable {
     /** 最低可见会员级别编码(NULL=全部可见): FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME */
     private String minVisibleLevel;
 
+    /** 享受会员价所需的最低会员级别编码(NULL=所有会员均可;BASIC/FAMILY/PREMIUM等) */
+    private String minDiscountLevel;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 
@@ -99,6 +102,7 @@ public class Product implements Serializable {
     public Integer getSortOrder() { return sortOrder; }
     public String getVisibleRoles() { return visibleRoles; }
     public String getMinVisibleLevel() { return minVisibleLevel; }
+    public String getMinDiscountLevel() { return minDiscountLevel; }
     public Date getCreatedAt() { return createdAt; }
     public Date getUpdatedAt() { return updatedAt; }
 }

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTransferRequest.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 健康报告跨家庭转移申请
+ *
+ * <p>同一份报告(file_hash 相同)被不同家庭上传时,申请方家庭发起转移申请,
+ * 原归属家庭同意后 health_reports.family_id 转移至申请方,原家庭不再可见。</p>
+ */
+@Data
+@TableName("report_transfer_requests")
+public class ReportTransferRequest implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 状态:待同意 */
+    public static final String STATUS_PENDING = "pending";
+    /** 状态:已同意(报告已转入申请方家庭) */
+    public static final String STATUS_APPROVED = "approved";
+    /** 状态:已拒绝 */
+    public static final String STATUS_DENIED = "denied";
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 报告ID(health_reports.id) */
+    private Long reportId;
+
+    /** 原归属家庭ID(需同意方) */
+    private Long fromFamilyId;
+
+    /** 申请转入家庭ID(申请方) */
+    private Long toFamilyId;
+
+    /** 申请人用户ID(users.id) */
+    private Long requesterUserId;
+
+    /** 状态: pending=待同意 approved=已同意 denied=已拒绝 */
+    private String status;
+
+    /** 处理人用户ID */
+    private Long handledBy;
+
+    /** 处理时间 */
+    private Date handledAt;
+
+    /** 备注(拒绝原因) */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportTransferRequestMapper.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ReportTransferRequest;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 健康报告跨家庭转移申请 Mapper
+ */
+@Mapper
+public interface ReportTransferRequestMapper extends BaseMapper<ReportTransferRequest> {
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -315,6 +315,7 @@ public class HealthReportService {
         }
         Set<Long> ids = new HashSet<>();
         ids.add(memberId);
+        Long familyId = null;
         try {
             FamilyMember fm = familyMemberMapper.selectById(memberId);
             if (fm != null) {
@@ -322,21 +323,36 @@ public class HealthReportService {
                 if (fm.getUserId() != null) {
                     ids.add(fm.getUserId());
                 }
+                familyId = fm.getFamilyId();
             }
             // memberId 可能是 users.id,反查其关联的 family_members 行
             List<FamilyMember> linked = familyMemberMapper.selectList(
                     new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, memberId));
             for (FamilyMember item : linked) {
                 ids.add(item.getId());
+                if (familyId == null) {
+                    familyId = item.getFamilyId();
+                }
             }
         } catch (Exception e) {
             log.warn("归一家庭成员ID失败 memberId={}: {}", memberId, e.getMessage());
         }
+
+        // 家庭隔离:报告必须归属该成员所在家庭。
+        // 此前只按 subject_id/user_id 匹配、完全不带 family_id 条件,会把其他家庭的
+        // 报告带进成员列表(实测家庭72成员返回7条中5条越界)。小程序据此显示
+        // "取消归属",点击后在 reassignReport 权限校验处抛"报告不属于该家庭"。
+        // 列表侧必须与权限侧遵守同一条家庭边界。
+        if (familyId == null) {
+            log.warn("无法解析成员所属家庭,返回空报告列表 memberId={}", memberId);
+            return Collections.emptyList();
+        }
         return healthReportMapper.selectList(
                 new LambdaQueryWrapper<HealthReport>()
                         .and(w -> w.in(HealthReport::getSubjectId, ids)
                                 .or(w2 -> w2.isNull(HealthReport::getSubjectId)
                                         .in(HealthReport::getUserId, ids)))
+                        .eq(HealthReport::getFamilyId, familyId)
                         .eq(HealthReport::getStatus, "active")
                         .orderByDesc(HealthReport::getCreatedAt)
         );
@@ -1729,6 +1745,21 @@ public class HealthReportService {
         if (scoreSource.get("shannonIndex") != null) {
             oldReport.setShannonIndex(scoreSource.get("shannonIndex").toString());
         }
+        // 用户可手动修正报告日期(AI 解析结果不准确时)
+        Object reportDateObj = scoreSource.get("reportDate");
+        if (reportDateObj != null && !String.valueOf(reportDateObj).trim().isEmpty()) {
+            String reportDateStr = String.valueOf(reportDateObj).trim();
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                java.util.Date parsedDate = sdf.parse(reportDateStr);
+                if (parsedDate != null) {
+                    oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
+                    log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
+                }
+            } catch (Exception e) {
+                log.warn("reportDate 格式解析失败 reportId={}, value={}: {}", reportId, reportDateStr, e.getMessage());
+            }
+        }
         oldReport.setUpdatedAt(new Date());
         healthReportMapper.updateById(oldReport);
 

+ 166 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -923,6 +923,10 @@ public class MembershipService implements MembershipServiceInterface {
         Date now = new Date();
         for (User u : familyUsers) {
             String fromLevel = u.getMemberLevel() != null ? u.getMemberLevel() : "FREE";
+            // 家庭会员同步只升不降:成员已有更高级别的个人会员身份时保留(避免覆盖)
+            if (getLevelOrder(levelCode) < getLevelOrder(fromLevel)) {
+                continue;
+            }
             // 只有等级变化或到期时间延长时才写入(避免不必要的降级覆盖)
             if (levelCode.equals(u.getMemberLevel())
                     && u.getMemberExpireTime() != null
@@ -958,10 +962,28 @@ public class MembershipService implements MembershipServiceInterface {
                 new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId));
         if (familyUsers == null || familyUsers.isEmpty()) return;
 
+        // 家庭历史最高会员级别:仅当成员当前级别不高于该级别时才可判定其身份来自家庭并降级,
+        // 更高级别(如个人购买的 LIFETIME/PREMIUM)保留,避免误清个人会员身份
+        int maxFamilyOrder = 0;
+        List<FamilyMembership> allMemberships = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, familyId));
+        if (allMemberships != null) {
+            for (FamilyMembership m : allMemberships) {
+                if (m.getLevelCode() != null) {
+                    maxFamilyOrder = Math.max(maxFamilyOrder, getLevelOrder(m.getLevelCode()));
+                }
+            }
+        }
+
         Date now = new Date();
         int downgraded = 0;
         for (User u : familyUsers) {
             if (!"FREE".equals(u.getMemberLevel())) {
+                if (getLevelOrder(u.getMemberLevel()) > maxFamilyOrder) {
+                    log.info("保留成员更高级别个人会员: userId={}, level={}", u.getId(), u.getMemberLevel());
+                    continue;
+                }
                 u.setMemberLevel("FREE");
                 u.setMemberExpireTime(null);
                 u.setUpdatedAt(now);
@@ -986,6 +1008,10 @@ public class MembershipService implements MembershipServiceInterface {
         User u = userMapper.selectById(userId);
         if (u == null) return;
         String fromLevel = u.getMemberLevel() != null ? u.getMemberLevel() : "FREE";
+        // 家庭身份只升不降:成员已有更高级别的个人会员身份时保留
+        if (getLevelOrder(active.getLevelCode()) < getLevelOrder(fromLevel)) {
+            return;
+        }
         // 只有当前等级更低或到期更早时才同步升级
         if (active.getLevelCode().equals(u.getMemberLevel())
                 && u.getMemberExpireTime() != null
@@ -1035,6 +1061,95 @@ public class MembershipService implements MembershipServiceInterface {
         }
     }
 
+    /**
+     * 按家庭维度设置会员等级(后台授予/调整的唯一入口)。
+     * 写入事实源 family_memberships,并全套同步 users 缓存(方案B:会员属于家庭)。
+     * 幂等:等级未变且到期不早于目标值时只同步缓存,不产生新记录。
+     * @param levelCode  目标等级;"FREE" 表示撤销家庭会员并全家降级
+     * @param expireTime 目标到期时间(levelCode=FREE 时可传 null)
+     * @return 操作描述(供后台变更提示展示),无实际变更时返回空字符串
+     */
+    @Transactional
+    public String setFamilyMembershipLevel(Long familyId, String levelCode, Date expireTime,
+                                           String upgradeType, String sourceDesc) {
+        if (familyId == null || levelCode == null || levelCode.isEmpty()) {
+            return "";
+        }
+        Date now = new Date();
+
+        // 该家庭当前所有有效记录(paid 且未过期)
+        List<FamilyMembership> actives = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, familyId)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, now)
+                        .orderByDesc(FamilyMembership::getEndDate));
+
+        // FREE:撤销家庭会员并全家降级
+        if ("FREE".equals(levelCode)) {
+            boolean hadActive = !actives.isEmpty();
+            for (FamilyMembership m : actives) {
+                m.setPaymentStatus("refunded");
+                m.setEndDate(now);
+                m.setUpdatedAt(now);
+                membershipMapper.updateById(m);
+            }
+            // 即使无有效记录也执行降级:修复历史上只写了 users 缓存、家庭无记录的脏数据
+            downgradeFamilyMembers(familyId);
+            return hadActive ? "已撤销家庭会员,全家降级 FREE" : "";
+        }
+
+        if (expireTime == null) {
+            return "";
+        }
+
+        String desc;
+        FamilyMembership active = actives.isEmpty() ? null : actives.get(0);
+        if (active != null && levelCode.equals(active.getLevelCode())) {
+            if (!expireTime.after(active.getEndDate())) {
+                // 已是目标等级且到期不早于目标值:只同步缓存,不产生新记录
+                syncMembershipToFamilyMembers(familyId, levelCode, active.getEndDate(), upgradeType, null);
+                return "家庭已有" + levelCode + "会员(至 " + formatDateTime(active.getEndDate()) + "),仅同步缓存";
+            }
+            // 同等级延长到期时间
+            active.setEndDate(expireTime);
+            active.setUpdatedAt(now);
+            membershipMapper.updateById(active);
+            desc = "已延长" + levelCode + "会员至 " + formatDateTime(expireTime);
+        } else {
+            // 等级变化或无有效记录:先停用原有效记录(同时收缩 endDate,避免 getFamilyMembership
+            // 按 max(end_date) 选到已撤销记录遮蔽新记录),再插入新记录
+            boolean deactivated = false;
+            for (FamilyMembership m : actives) {
+                m.setPaymentStatus("refunded");
+                m.setEndDate(now);
+                m.setUpdatedAt(now);
+                membershipMapper.updateById(m);
+                deactivated = true;
+            }
+            FamilyMembership membership = new FamilyMembership();
+            membership.setFamilyId(familyId);
+            membership.setLevelCode(levelCode);
+            membership.setStartDate(now);
+            membership.setEndDate(expireTime);
+            membership.setPaymentMethod("admin");
+            membership.setPaymentStatus("paid");
+            membership.setOrderNo(sourceDesc);
+            membership.setAmount(0);
+            membershipMapper.insert(membership);
+            desc = (deactivated ? "已停用原会员记录;" : "")
+                    + "已设置家庭会员 " + levelCode + " 至 " + formatDateTime(expireTime);
+        }
+
+        // 方案B:全家同步会员身份
+        syncMembershipToFamilyMembers(familyId, levelCode, expireTime, upgradeType, null);
+        return desc;
+    }
+
+    private String formatDateTime(Date d) {
+        return d == null ? "" : new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(d);
+    }
+
     // ==================== 会员升级与过期 ====================
 
     /**
@@ -1056,6 +1171,21 @@ public class MembershipService implements MembershipServiceInterface {
         String fromLevel = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
 
         Date expireTime = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000);
+        // 补建家庭会员事实源:消费达标升级此前只写 users 缓存,导致 getCurrentLevel 付费墙
+        // (健康方案/任务创建/商城折扣)误判 FREE 拦截。同家庭多名成员同批达标只建一条
+        // (无有效记录才插入);家庭已有其他有效付费会员则不覆盖其等级。
+        if (getActiveFamilyMembership(familyId) == null) {
+            FamilyMembership membership = new FamilyMembership();
+            membership.setFamilyId(familyId);
+            membership.setLevelCode("FAMILY");
+            membership.setStartDate(new Date());
+            membership.setEndDate(expireTime);
+            membership.setPaymentMethod("consumption");
+            membership.setPaymentStatus("paid");
+            membership.setOrderNo("CONSUMPTION-" + userId);
+            membership.setAmount(0);
+            membershipMapper.insert(membership);
+        }
         // 方案B:全家同步会员身份
         syncMembershipToFamilyMembers(familyId, "FAMILY", expireTime, upgradeType, orderId);
         log.info("消费达标自动升级全家: familyId={}, triggerUserId={}", familyId, userId);
@@ -1162,7 +1292,10 @@ public class MembershipService implements MembershipServiceInterface {
     }
 
     /**
-     * 获取用户当前有效会员等级(检查有效期)
+     * 获取用户当前有效会员等级(检查有效期)。
+     * 会员身份包含两个维度:个人会员身份(users.member_level + member_expire_time)
+     * 与家庭会员身份(family_memberships 有效付费记录)。两者不一致时,取级别更高者
+     * 作为实际有效身份(级别对照 membership_levels.level_order)。
      */
     public String getMemberLevel(Long userId) {
         User user = userMapper.selectById(userId);
@@ -1170,13 +1303,19 @@ public class MembershipService implements MembershipServiceInterface {
             return "FREE";
         }
 
-        String level = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
-        if (("FAMILY".equals(level) || "BASIC".equals(level)) && user.getMemberExpireTime() != null) {
+        // 个人会员身份(含有效期校验)
+        String personal = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
+        if (("FAMILY".equals(personal) || "BASIC".equals(personal)) && user.getMemberExpireTime() != null) {
             if (user.getMemberExpireTime().before(new Date())) {
-                return "FREE";
+                personal = "FREE";
             }
         }
-        return level;
+
+        // 家庭会员身份(family_memberships 有效付费记录,含有效期校验)
+        String family = user.getFamilyId() != null ? getFamilyEffectiveLevel(user.getFamilyId()) : "FREE";
+
+        // 个人与家庭身份不一致时,取级别更高者
+        return resolveEffectiveLevel(personal, family);
     }
 
     /**
@@ -1197,6 +1336,28 @@ public class MembershipService implements MembershipServiceInterface {
         }
     }
 
+    /**
+     * 会员判定:取个人会员身份与家庭会员身份中级别更高者作为实际有效身份。
+     * 级别对照 membership_levels.level_order(数字越大级别越高)。
+     * 级别数字相同时(如 FREE 与 BASIC 同为 1),优先非 FREE 的一侧,避免同级覆盖。
+     */
+    public String resolveEffectiveLevel(String personalLevel, String familyLevel) {
+        String personal = (personalLevel == null || personalLevel.isEmpty()) ? "FREE" : personalLevel;
+        String family = (familyLevel == null || familyLevel.isEmpty()) ? "FREE" : familyLevel;
+        int personalOrder = getLevelOrder(personal);
+        int familyOrder = getLevelOrder(family);
+        if (familyOrder > personalOrder) {
+            return family;
+        }
+        if (personalOrder > familyOrder) {
+            return personal;
+        }
+        if ("FREE".equals(personal) && !"FREE".equals(family)) {
+            return family;
+        }
+        return personal;
+    }
+
     /**
      * 获取用户可见的会员等级编码集合:级别数字 &lt;= 用户级别数字 的所有等级编码(低级别可见则高级别也可见)。
      * 游客/匿名(userId 为空或&lt;=0)返回空集合,仅可见 min_visible_level IS NULL(全部可见)的商品/分类。

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

@@ -240,8 +240,8 @@ public class ProductOrderService {
         order.setBuyerId(buyerId);
         order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
         order.setQuantity(dto.getQuantity());
-        order.setUnitPrice(product.getPrice());
-        order.setTotalAmount(product.getPrice() * dto.getQuantity());
+        order.setUnitPrice(getEligibleMemberPrice(product, buyerId));
+        order.setTotalAmount(getEligibleMemberPrice(product, buyerId) * dto.getQuantity());
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
@@ -496,7 +496,7 @@ public class ProductOrderService {
                     return Result.error(bundleCheck.getMessage());
                 }
             }
-            int unitPrice = product.getPrice();
+            int unitPrice = getEligibleMemberPrice(product, buyerId);
             if (item.getSkuId() != null) {
                 ProductSku sku = productSkuService.getById(item.getSkuId());
                 if (sku != null) {
@@ -1570,4 +1570,37 @@ public class ProductOrderService {
         result.put("changeLog", changeLog.toString());
         return Result.success(result);
     }
-}
+
+    /**
+     * 获取用户有效的会员价。
+     * 规则:如果产品 min_discount_level 为 NULL,所有会员均可享受 memberPrice;
+     * 如果产品 min_discount_level 设置(如 BASIC/FAMILY/PREMIUM),
+     * 则仅当用户会员等级 >= min_discount_level 时才返回 memberPrice,
+     * 否则返回原价 product.getPrice()。
+     */
+    private Integer getEligibleMemberPrice(Product product, Long buyerId) {
+        if (product == null || product.getMemberPrice() == null || product.getMemberPrice() <= 0) {
+            return product != null ? product.getPrice() : 0;
+        }
+        // 产品未设置最低级别,所有会员均可享会员价
+        if (product.getMinDiscountLevel() == null) {
+            return product.getMemberPrice();
+        }
+        // 已设置最低级别,检查用户会员等级
+        String userLevel = "FREE";
+        if (buyerId != null) {
+            User user = userMapper.selectById(buyerId);
+            if (user != null) {
+                userLevel = membershipService.getMemberLevel(buyerId);
+            }
+        }
+        // 比较级别数字:级别数字越小越低
+        int userLevelOrder = membershipService.getLevelOrder(userLevel);
+        int minLevelOrder = membershipService.getLevelOrder(product.getMinDiscountLevel());
+        // 用户级别 >= 最低要求级别,则可享会员价
+        if (userLevelOrder >= minLevelOrder) {
+            return product.getMemberPrice();
+        }
+        return product.getPrice();
+    }
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
@@ -452,6 +453,9 @@ wrapper.in(Product::getId, productIds)
      * 管理员创建商品(直接上架)
      */
     public Result<ProductDTO> adminCreate(Product product) {
+        // 最低可见级别:空串规整为 NULL(NULL=所有用户含未登录可见),
+        // 避免管理端 el-select 清空提交 '' 导致商品对所有用户不可见
+        normalizeMinVisibleLevel(product);
         product.setStatus("on_shelf");
         product.setCreatedAt(new Date());
         product.setUpdatedAt(new Date());
@@ -481,10 +485,32 @@ wrapper.in(Product::getId, productIds)
         product.setUpdatedAt(new Date());
         // 保留原状态不变,避免被覆盖
         product.setStatus(existing.getStatus());
+        // 最低可见级别:空串规整为 NULL(NULL=所有用户含未登录可见),
+        // 避免管理端 el-select 清空提交 '' 导致商品对所有用户不可见
+        boolean clearMinVisibleLevel = normalizeMinVisibleLevel(product);
         productMapper.updateById(product);
+        // updateById 默认忽略 null 字段,清空时需显式将 min_visible_level 置 NULL
+        if (clearMinVisibleLevel) {
+            productMapper.update(null, new LambdaUpdateWrapper<Product>()
+                    .eq(Product::getId, product.getId())
+                    .set(Product::getMinVisibleLevel, null));
+        }
         return Result.success(ProductDTO.from(productMapper.selectById(product.getId())));
     }
 
+    /**
+     * 最低可见级别空串归一化:'' → NULL。
+     *
+     * @return true 表示发生了空串清空(需要显式把列置为 NULL,因为 updateById 忽略 null 字段)
+     */
+    private boolean normalizeMinVisibleLevel(Product product) {
+        if (product.getMinVisibleLevel() != null && product.getMinVisibleLevel().trim().isEmpty()) {
+            product.setMinVisibleLevel(null);
+            return true;
+        }
+        return false;
+    }
+
     /**
      * 管理员更新商品(含测评扩展信息)
      */

+ 327 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportTransferService.java

@@ -0,0 +1,327 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.ReportTransferRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.ReportTransferRequestMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 健康报告跨家庭转移服务
+ *
+ * <p>同一份报告(file_hash 相同)被不同家庭上传时的归属转移审批:</p>
+ * <ol>
+ *   <li>申请方家庭发起转移申请(createRequest)→ 通知原家庭</li>
+ *   <li>原归属家庭同意(approve)→ health_reports.family_id 转移至申请方、subject_id 置空、
+ *       原成员重算维度分与食材推荐、原家庭不再可见(列表按 family_id 过滤)</li>
+ *   <li>原归属家庭拒绝(deny)→ 报告留在原家庭</li>
+ * </ol>
+ */
+@Slf4j
+@Service
+public class ReportTransferService {
+
+    @Resource
+    private ReportTransferRequestMapper transferRequestMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
+    @Resource
+    private FoodRecommendService foodRecommendService;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    /**
+     * 发起跨家庭报告转移申请(申请方家庭)
+     *
+     * @param reportId          报告ID(上传重复检测返回的既有报告)
+     * @param requesterFamilyId 申请方家庭ID(当前登录用户所属家庭)
+     * @param requesterUserId   申请方用户ID
+     * @return 创建的转移申请
+     */
+    public ReportTransferRequest createRequest(Long reportId, Long requesterFamilyId, Long requesterUserId) {
+        if (reportId == null || requesterFamilyId == null) {
+            throw new RuntimeException("参数不完整");
+        }
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report == null || !"active".equals(report.getStatus())) {
+            throw new RuntimeException("报告不存在或已删除");
+        }
+        if (report.getFamilyId() == null) {
+            throw new RuntimeException("报告未归属任何家庭,无需转移");
+        }
+        if (requesterFamilyId.equals(report.getFamilyId())) {
+            throw new RuntimeException("该报告已归属当前家庭");
+        }
+        // 同报告同申请家庭只允许一条待处理申请
+        List<ReportTransferRequest> pending = transferRequestMapper.selectList(
+                new LambdaQueryWrapper<ReportTransferRequest>()
+                        .eq(ReportTransferRequest::getReportId, reportId)
+                        .eq(ReportTransferRequest::getToFamilyId, requesterFamilyId)
+                        .eq(ReportTransferRequest::getStatus, ReportTransferRequest.STATUS_PENDING)
+                        .last("LIMIT 1"));
+        if (!pending.isEmpty()) {
+            throw new RuntimeException("转移申请已提交,等待原家庭同意");
+        }
+
+        ReportTransferRequest request = new ReportTransferRequest();
+        request.setReportId(reportId);
+        request.setFromFamilyId(report.getFamilyId());
+        request.setToFamilyId(requesterFamilyId);
+        request.setRequesterUserId(requesterUserId);
+        request.setStatus(ReportTransferRequest.STATUS_PENDING);
+        request.setCreatedAt(new Date());
+        request.setUpdatedAt(new Date());
+        transferRequestMapper.insert(request);
+        log.info("跨家庭报告转移申请已创建: requestId={}, reportId={}, fromFamily={}, toFamily={}",
+                request.getId(), reportId, report.getFamilyId(), requesterFamilyId);
+
+        // 通知原家庭(含报告上传者)
+        notifyFamilyUsers(report.getFamilyId(), report.getUserId(), "report_transfer_request",
+                "报告转移申请",
+                "有其他家庭申请将您的健康报告转入其家庭,请到「健康报告」列表处理。",
+                request.getId());
+        return request;
+    }
+
+    /**
+     * 同意转移(原归属家庭)
+     *
+     * <p>报告 family_id 转移至申请方、subject_id 置空(由新家庭重新归属);
+     * 原成员重算维度分与食材推荐;同报告其他待处理申请改由新归属家庭处理。</p>
+     *
+     * @param requestId         转移申请ID
+     * @param approverFamilyId  同意方家庭ID(必须为申请的原归属家庭)
+     * @param approverUserId    同意方用户ID
+     */
+    @Transactional
+    public void approve(Long requestId, Long approverFamilyId, Long approverUserId) {
+        ReportTransferRequest request = getRequest(requestId);
+        if (!ReportTransferRequest.STATUS_PENDING.equals(request.getStatus())) {
+            throw new RuntimeException("该申请已处理,请勿重复操作");
+        }
+        if (!request.getFromFamilyId().equals(approverFamilyId)) {
+            throw new RuntimeException("无权处理该申请");
+        }
+        HealthReport report = healthReportMapper.selectById(request.getReportId());
+        if (report == null || !"active".equals(report.getStatus())) {
+            throw new RuntimeException("报告不存在或已删除");
+        }
+        if (!request.getFromFamilyId().equals(report.getFamilyId())) {
+            throw new RuntimeException("报告归属已变更,请刷新后重试");
+        }
+
+        Long oldSubjectId = report.getSubjectId();
+
+        // 1. 报告转入申请方家庭,解除原成员归属(JdbcTemplate 以支持 subject_id = NULL)
+        jdbcTemplate.update(
+                "UPDATE health_reports SET family_id = ?, subject_id = NULL, updated_at = NOW() WHERE id = ?",
+                request.getToFamilyId(), report.getId());
+
+        // 2. 原成员重算维度分与食材推荐(移除该报告影响,与 deleteReport 同惯例)
+        if (oldSubjectId != null) {
+            try {
+                dimensionScoreService.refreshFromGutReport(oldSubjectId, report.getId());
+                log.info("报告转移后已重算原成员{}的7维健康评分", oldSubjectId);
+            } catch (Exception e) {
+                log.warn("报告转移后刷新原成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
+            }
+            try {
+                foodRecommendService.recalculateForUser(oldSubjectId);
+            } catch (Exception e) {
+                log.warn("报告转移后刷新原成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
+            }
+        }
+
+        // 3. 同报告其他待处理申请改由新归属家庭处理(原家庭已不再拥有该报告)
+        transferRequestMapper.update(null, new LambdaUpdateWrapper<ReportTransferRequest>()
+                .eq(ReportTransferRequest::getReportId, report.getId())
+                .eq(ReportTransferRequest::getStatus, ReportTransferRequest.STATUS_PENDING)
+                .ne(ReportTransferRequest::getId, request.getId())
+                .set(ReportTransferRequest::getFromFamilyId, request.getToFamilyId())
+                .set(ReportTransferRequest::getUpdatedAt, new Date()));
+
+        // 4. 更新本申请状态
+        request.setStatus(ReportTransferRequest.STATUS_APPROVED);
+        request.setHandledBy(approverUserId);
+        request.setHandledAt(new Date());
+        request.setUpdatedAt(new Date());
+        transferRequestMapper.updateById(request);
+
+        // 5. 通知申请方
+        notifyFamilyUsers(request.getToFamilyId(), request.getRequesterUserId(), "report_transfer_result",
+                "报告转移申请已同意",
+                "您申请的健康报告已转入您的家庭,请到「健康报告」列表查看。",
+                request.getId());
+        log.info("跨家庭报告转移申请已同意: requestId={}, reportId={}, fromFamily={}, toFamily={}",
+                request.getId(), report.getId(), request.getFromFamilyId(), request.getToFamilyId());
+    }
+
+    /**
+     * 拒绝转移(原归属家庭)
+     *
+     * @param requestId        转移申请ID
+     * @param approverFamilyId 拒绝方家庭ID(必须为申请的原归属家庭)
+     * @param approverUserId   拒绝方用户ID
+     * @param remark           拒绝备注(可空)
+     */
+    public void deny(Long requestId, Long approverFamilyId, Long approverUserId, String remark) {
+        ReportTransferRequest request = getRequest(requestId);
+        if (!ReportTransferRequest.STATUS_PENDING.equals(request.getStatus())) {
+            throw new RuntimeException("该申请已处理,请勿重复操作");
+        }
+        if (!request.getFromFamilyId().equals(approverFamilyId)) {
+            throw new RuntimeException("无权处理该申请");
+        }
+        request.setStatus(ReportTransferRequest.STATUS_DENIED);
+        request.setHandledBy(approverUserId);
+        request.setHandledAt(new Date());
+        request.setRemark(remark);
+        request.setUpdatedAt(new Date());
+        transferRequestMapper.updateById(request);
+
+        // 通知申请方
+        notifyFamilyUsers(request.getToFamilyId(), request.getRequesterUserId(), "report_transfer_result",
+                "报告转移申请被拒绝",
+                "您申请的健康报告转移未获对方家庭同意。",
+                request.getId());
+        log.info("跨家庭报告转移申请已拒绝: requestId={}, reportId={}, byFamily={}",
+                request.getId(), request.getReportId(), approverFamilyId);
+    }
+
+    /**
+     * 查询本家庭相关的转移申请
+     *
+     * @param familyId 当前登录用户所属家庭
+     * @return {incoming: 待本家庭处理(原归属方), outgoing: 本家庭发起的(申请方)}
+     */
+    public Map<String, Object> listRequests(Long familyId) {
+        List<ReportTransferRequest> incoming = transferRequestMapper.selectList(
+                new LambdaQueryWrapper<ReportTransferRequest>()
+                        .eq(ReportTransferRequest::getFromFamilyId, familyId)
+                        .eq(ReportTransferRequest::getStatus, ReportTransferRequest.STATUS_PENDING)
+                        .orderByDesc(ReportTransferRequest::getCreatedAt));
+        List<ReportTransferRequest> outgoing = transferRequestMapper.selectList(
+                new LambdaQueryWrapper<ReportTransferRequest>()
+                        .eq(ReportTransferRequest::getToFamilyId, familyId)
+                        .orderByDesc(ReportTransferRequest::getCreatedAt)
+                        .last("LIMIT 50"));
+        Map<String, Object> result = new HashMap<>();
+        result.put("incoming", enrichWithReport(incoming));
+        result.put("outgoing", enrichWithReport(outgoing));
+        return result;
+    }
+
+    private ReportTransferRequest getRequest(Long requestId) {
+        if (requestId == null) {
+            throw new RuntimeException("申请ID不能为空");
+        }
+        ReportTransferRequest request = transferRequestMapper.selectById(requestId);
+        if (request == null) {
+            throw new RuntimeException("申请不存在");
+        }
+        return request;
+    }
+
+    /**
+     * 批量拼接报告摘要(personName/reportType/reportDate/overallScore),供前端列表展示
+     */
+    private List<Map<String, Object>> enrichWithReport(List<ReportTransferRequest> requests) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        if (requests == null || requests.isEmpty()) {
+            return list;
+        }
+        Set<Long> reportIds = new LinkedHashSet<>();
+        for (ReportTransferRequest r : requests) {
+            reportIds.add(r.getReportId());
+        }
+        Map<Long, HealthReport> reportMap = new HashMap<>();
+        List<HealthReport> reports = healthReportMapper.selectBatchIds(reportIds);
+        for (HealthReport r : reports) {
+            reportMap.put(r.getId(), r);
+        }
+
+        for (ReportTransferRequest r : requests) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", r.getId());
+            m.put("reportId", r.getReportId());
+            m.put("status", r.getStatus());
+            m.put("remark", r.getRemark());
+            m.put("createdAt", r.getCreatedAt());
+            m.put("handledAt", r.getHandledAt());
+            HealthReport report = reportMap.get(r.getReportId());
+            if (report != null) {
+                m.put("personName", report.getPersonName());
+                m.put("reportType", report.getReportType());
+                m.put("reportDate", report.getReportDate());
+                m.put("overallScore", report.getOverallScore());
+                m.put("reportDeleted", false);
+            } else {
+                m.put("personName", "报告已删除");
+                m.put("reportType", null);
+                m.put("reportDate", null);
+                m.put("overallScore", null);
+                m.put("reportDeleted", true);
+            }
+            list.add(m);
+        }
+        return list;
+    }
+
+    /**
+     * 站内通知指定家庭的全部可登录用户(兜底额外通知者,如报告上传者)
+     *
+     * <p>模板缺失时回退默认文案,恒走 in_app 通道;通知失败不影响主流程。</p>
+     */
+    private void notifyFamilyUsers(Long familyId, Long fallbackUserId, String type,
+                                   String title, String content, Long refId) {
+        try {
+            Set<Long> userIds = new LinkedHashSet<>();
+            if (familyId != null) {
+                List<User> users = userMapper.selectList(new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId));
+                for (User u : users) {
+                    if (u.getId() != null) {
+                        userIds.add(u.getId());
+                    }
+                }
+            }
+            if (fallbackUserId != null) {
+                userIds.add(fallbackUserId);
+            }
+            for (Long uid : userIds) {
+                notificationDispatchService.sendByTemplate(type, uid, "report_transfer_request", refId, title, content);
+            }
+        } catch (Exception e) {
+            log.warn("报告转移通知发送失败: {}", e.getMessage());
+        }
+    }
+}

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

@@ -1189,6 +1189,7 @@ CREATE TABLE IF NOT EXISTS products (
     family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除',
     visible_roles JSON DEFAULT NULL COMMENT '可见角色: ["parent","child","teacher","admin"],NULL=全部可见',
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
+    min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/BASIC/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -1907,6 +1908,24 @@ CREATE TABLE IF NOT EXISTS health_report_edit_logs (
     INDEX idx_report_id (report_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕';
 
+-- 健康报告跨家庭转移申请表(同一份报告被不同家庭上传时的归属转移审批)
+CREATE TABLE IF NOT EXISTS report_transfer_requests (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    report_id BIGINT NOT NULL COMMENT '报告ID(health_reports.id)',
+    from_family_id BIGINT NOT NULL COMMENT '原归属家庭ID(需同意方)',
+    to_family_id BIGINT NOT NULL COMMENT '申请转入家庭ID(申请方)',
+    requester_user_id BIGINT NOT NULL COMMENT '申请人用户ID(users.id)',
+    status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=待同意 approved=已同意 denied=已拒绝',
+    handled_by BIGINT DEFAULT NULL COMMENT '处理人用户ID',
+    handled_at DATETIME DEFAULT NULL COMMENT '处理时间',
+    remark VARCHAR(200) DEFAULT NULL COMMENT '备注(拒绝原因)',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_report_id (report_id),
+    INDEX idx_from_family_status (from_family_id, status),
+    INDEX idx_to_family_status (to_family_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告跨家庭转移申请';
+
 -- 菌群检测专项数据表(针对 gut_flora 类型报告)
 CREATE TABLE IF NOT EXISTS health_gut_flora (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
@@ -3926,7 +3945,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
     ai_review_enabled TINYINT(1) DEFAULT 0 COMMENT 'AI审核功能',
     priority_support TINYINT(1) DEFAULT 0 COMMENT '优先客服',
     teacher_consultation TINYINT(1) DEFAULT 0 COMMENT '老师咨询',
-    level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5',
+    level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_level_code (level_code)

+ 52 - 2
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceTest.java

@@ -29,6 +29,9 @@ public class HealthReportServiceTest {
     @Mock
     private com.etotem.cfc.mapper.HealthReportPermissionMapper healthReportPermissionMapper;
 
+    @Mock
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
     @Mock
     private DimensionScoreService dimensionScoreService;
 
@@ -43,9 +46,11 @@ public class HealthReportServiceTest {
     @BeforeEach
     public void setUp() {
         MockitoAnnotations.openMocks(this);
+        initMybatisPlusMetadata();
         service = new HealthReportService();
         setField(service, "healthReportMapper", healthReportMapper);
         setField(service, "healthReportPermissionMapper", healthReportPermissionMapper);
+        setField(service, "familyMemberMapper", familyMemberMapper);
         setField(service, "dimensionScoreService", dimensionScoreService);
         setField(service, "foodRecommendService", foodRecommendService);
         setField(service, "jdbcTemplate", jdbcTemplate);
@@ -61,6 +66,20 @@ public class HealthReportServiceTest {
         }
     }
 
+    /**
+     * 纯单测没有 Spring/MyBatis 初始化,LambdaQueryWrapper.getSqlSegment() 解析
+     * @TableField 需要实体元数据缓存,否则抛 can not find lambda cache。
+     * 这里显式注册,使 SQL 片段可被断言。
+     */
+    private void initMybatisPlusMetadata() {
+        org.apache.ibatis.builder.MapperBuilderAssistant assistant =
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new com.baomidou.mybatisplus.core.MybatisConfiguration(), "");
+        assistant.setCurrentNamespace("health-report-service-test");
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                assistant, HealthReport.class);
+    }
+
     private HealthReport report(Long id, Long familyId, Long subjectId, String status) {
         HealthReport r = new HealthReport();
         r.setId(id);
@@ -91,7 +110,8 @@ public class HealthReportServiceTest {
 
         verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
                 eq(null), eq(1L));
-        verify(dimensionScoreService).refreshFromGutReport(eq(20L), eq(1L));
+        verify(dimensionScoreService).rebuildFromMemberReports(eq(20L));
+        verify(dimensionScoreService, never()).refreshFromGutReport(eq(20L), anyLong());
         verify(foodRecommendService).recalculateForUser(eq(20L));
     }
 
@@ -115,7 +135,7 @@ public class HealthReportServiceTest {
 
         verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
                 eq(30L), eq(1L));
-        verify(dimensionScoreService).refreshFromGutReport(eq(20L), eq(1L));
+        verify(dimensionScoreService).rebuildFromMemberReports(eq(20L));
         verify(dimensionScoreService).refreshFromGutReport(eq(30L), eq(1L));
         verify(foodRecommendService).recalculateForUser(eq(20L));
         verify(foodRecommendService).recalculateForUser(eq(30L));
@@ -140,6 +160,36 @@ public class HealthReportServiceTest {
         verify(jdbcTemplate, never()).update(anyString(), any(), anyLong());
     }
 
+    // ── getMemberReports:家庭隔离 ─────────────────────────────────────
+
+    /**
+     * 回归:getMemberReports 曾只按 subject_id/user_id 匹配,完全不带 family_id 条件,
+     * 导致家庭72的成员列表混入家庭67/70的报告(实测 7 条中 5 条越界)。
+     * 小程序照单显示"取消归属",点击后在 reassignReport 权限校验处抛
+     * "报告不属于该家庭"。列表侧必须与权限侧保持同一条家庭边界。
+     */
+    @Test
+    void getMemberReports_constrainsToMemberFamily() {
+        com.etotem.cfc.entity.FamilyMember fm = new com.etotem.cfc.entity.FamilyMember();
+        fm.setId(21L);
+        fm.setFamilyId(72L);
+        fm.setUserId(12L);
+        when(familyMemberMapper.selectById(21L)).thenReturn(fm);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.getMemberReports(21L);
+
+        org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper> captor =
+            org.mockito.ArgumentCaptor.forClass(
+                com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
+        verify(healthReportMapper).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("family_id"), "getMemberReports 必须限定 family_id,实际SQL: " + sql);
+        assertTrue(captor.getValue().getParamNameValuePairs().containsValue(72L),
+            "查询条件必须携带成员所在家庭ID 72,实际: " + captor.getValue().getParamNameValuePairs());
+    }
+
     // ── getUnlinkedReports ──────────────────────────────────────────────
 
     @Test

+ 262 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/MembershipServiceTest.java

@@ -0,0 +1,262 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMembership;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * MembershipService 单元测试(纯单元测试,无 SpringBootTest,避免 MySQL 连接)。
+ *
+ * 测试范围:
+ * - getMemberLevel: 会员判定取个人/家庭身份中更高级别(含有效期、FREE/BASIC 平级 tie-break)
+ * - resolveEffectiveLevel: 级别比较核心逻辑
+ * - syncMembershipToFamilyMembers: 家庭同步只升不降,不覆盖更高级别个人身份
+ * - downgradeFamilyMembers: 降级时保留高于家庭历史最高级别的个人身份
+ *
+ * 说明:level_code → level_order 的映射属于配置数据而非被测逻辑,通过 spy stub
+ * getLevelOrder 提供(与生产 membership_levels 数据一致:FREE=1 BASIC=1 FAMILY=2
+ * PROVIDER=3 PREMIUM=4 LIFETIME=5)。
+ */
+public class MembershipServiceTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMembershipMapper membershipMapper;
+
+    @Mock
+    private MembershipLevelMapper levelMapper;
+
+    @Mock
+    private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
+
+    private MembershipService service;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        MembershipService raw = new MembershipService();
+        setField(raw, "userMapper", userMapper);
+        setField(raw, "membershipMapper", membershipMapper);
+        setField(raw, "levelMapper", levelMapper);
+        setField(raw, "memberUpgradeRecordMapper", memberUpgradeRecordMapper);
+        service = Mockito.spy(raw);
+        doReturn(1).when(service).getLevelOrder("FREE");
+        doReturn(1).when(service).getLevelOrder("BASIC");
+        doReturn(2).when(service).getLevelOrder("FAMILY");
+        doReturn(3).when(service).getLevelOrder("PROVIDER");
+        doReturn(4).when(service).getLevelOrder("PREMIUM");
+        doReturn(5).when(service).getLevelOrder("LIFETIME");
+    }
+
+    private void setField(Object target, String fieldName, Object value) {
+        try {
+            Field f = target.getClass().getDeclaredField(fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private User user(Long id, String level, Date expire, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setMemberLevel(level);
+        u.setMemberExpireTime(expire);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private FamilyMembership activeMembership(String code) {
+        FamilyMembership fm = new FamilyMembership();
+        fm.setLevelCode(code);
+        fm.setPaymentStatus("paid");
+        fm.setEndDate(future());
+        return fm;
+    }
+
+    private FamilyMembership membership(String code, Date end) {
+        FamilyMembership fm = new FamilyMembership();
+        fm.setLevelCode(code);
+        fm.setEndDate(end);
+        return fm;
+    }
+
+    private Date future() {
+        return new Date(System.currentTimeMillis() + 86_400_000L);
+    }
+
+    private Date past() {
+        return new Date(System.currentTimeMillis() - 86_400_000L);
+    }
+
+    // ── getMemberLevel ─────────────────────────────────────────────────
+
+    @Test
+    void getMemberLevel_nullUser_returnsFree() {
+        when(userMapper.selectById(any())).thenReturn(null);
+        assertEquals("FREE", service.getMemberLevel(999L));
+    }
+
+    @Test
+    void getMemberLevel_personalFamily_noFamilyRecord_returnsFamily() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", future(), null));
+        assertEquals("FAMILY", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFamily_expired_returnsFree() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", past(), null));
+        assertEquals("FREE", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFree_familyPremiumActive_returnsPremium() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FREE", null, 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("PREMIUM"));
+        assertEquals("PREMIUM", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalPremium_familyFamily_returnsPremium() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "PREMIUM", future(), 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("FAMILY"));
+        assertEquals("PREMIUM", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFamily_familyPremium_returnsPremium() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FAMILY", future(), 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("PREMIUM"));
+        assertEquals("PREMIUM", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalBasic_tieBreakKeepsBasic() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "BASIC", future(), 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(null);
+        assertEquals("BASIC", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalFree_familyBasic_tieBreakReturnsBasic() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "FREE", null, 10L));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("BASIC"));
+        assertEquals("BASIC", service.getMemberLevel(1L));
+    }
+
+    @Test
+    void getMemberLevel_personalLifetime_noFamily_returnsLifetime() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "LIFETIME", null, null));
+        assertEquals("LIFETIME", service.getMemberLevel(1L));
+    }
+
+    // ── resolveEffectiveLevel ──────────────────────────────────────────
+
+    @Test
+    void resolveEffectiveLevel_higherFamilyWins() {
+        assertEquals("FAMILY", service.resolveEffectiveLevel("FREE", "FAMILY"));
+    }
+
+    @Test
+    void resolveEffectiveLevel_higherPersonalWins() {
+        assertEquals("PREMIUM", service.resolveEffectiveLevel("PREMIUM", "FAMILY"));
+    }
+
+    @Test
+    void resolveEffectiveLevel_tie_prefersNonFree() {
+        assertEquals("BASIC", service.resolveEffectiveLevel("FREE", "BASIC"));
+        assertEquals("BASIC", service.resolveEffectiveLevel("BASIC", "FREE"));
+    }
+
+    @Test
+    void resolveEffectiveLevel_nullInputs_returnsFree() {
+        assertEquals("FREE", service.resolveEffectiveLevel(null, null));
+        assertEquals("FREE", service.resolveEffectiveLevel("FREE", null));
+    }
+
+    // ── syncMembershipToFamilyMembers ──────────────────────────────────
+
+    @Test
+    void syncMembership_neverOverwritesHigherPersonalLevel() {
+        User premium = user(1L, "PREMIUM", future(), 10L);
+        when(userMapper.selectList(any())).thenReturn(Collections.singletonList(premium));
+        callSync("FAMILY");
+        verify(userMapper, never()).updateById(premium);
+    }
+
+    @Test
+    void syncMembership_writesWhenFamilyLevelHigher() {
+        User freeUser = user(1L, "FREE", null, 10L);
+        when(userMapper.selectList(any())).thenReturn(Collections.singletonList(freeUser));
+        callSync("FAMILY");
+        verify(userMapper).updateById(freeUser);
+    }
+
+    private void callSync(String levelCode) {
+        try {
+            Method m = MembershipService.class.getDeclaredMethod(
+                    "syncMembershipToFamilyMembers", Long.class, String.class, Date.class, String.class, Long.class);
+            m.setAccessible(true);
+            m.invoke(service, 10L, levelCode, future(), "pay", 100L);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    // ── downgradeFamilyMembers ─────────────────────────────────────────
+
+    @Test
+    void downgradeFamilyMembers_keepsHigherPersonalLevel() {
+        User lifetime = user(1L, "LIFETIME", null, 10L);
+        User family = user(2L, "FAMILY", future(), 10L);
+        when(userMapper.selectList(any())).thenReturn(Arrays.asList(lifetime, family));
+        when(membershipMapper.selectList(any())).thenReturn(
+                Collections.singletonList(membership("FAMILY", past())));
+        callDowngrade();
+        verify(userMapper, never()).updateById(lifetime);
+        verify(userMapper).updateById(family);
+    }
+
+    @Test
+    void downgradeFamilyMembers_noFamilyHistory_keepsAllPaidMembers() {
+        User premium = user(1L, "PREMIUM", future(), 10L);
+        when(userMapper.selectList(any())).thenReturn(Collections.singletonList(premium));
+        when(membershipMapper.selectList(any())).thenReturn(Collections.emptyList());
+        callDowngrade();
+        verify(userMapper, never()).updateById(premium);
+    }
+
+    private void callDowngrade() {
+        try {
+            Method m = MembershipService.class.getDeclaredMethod("downgradeFamilyMembers", Long.class);
+            m.setAccessible(true);
+            m.invoke(service, 10L);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+}

+ 182 - 0
cfc-frontend/pages/action-detail/index.vue

@@ -47,6 +47,32 @@
       </view>
       <text class="explain-text">{{ actionScore >= 80 ? '家庭关系和谐,家人沟通顺畅。' : actionScore >= 60 ? '家庭关系一般,建议多花时间陪伴家人。' : '家庭关系需要改善,建议进行家庭沟通活动。' }}</text>
     </view>
+    <!-- 行维度画像 - 总体说明 -->
+    <view class="dimension-portrait-section" v-if="isLoggedIn && actionPortrait">
+      <view class="section-header">
+        <text class="section-title">🧭 行维度画像</text>
+        <text class="portrait-tier">{{ actionPortrait.tierLabel }}</text>
+      </view>
+      <view class="portrait-summary">
+        <text class="portrait-desc">{{ actionPortrait.summary }}</text>
+      </view>
+      <view class="portrait-dimensions">
+        <view class="portrait-dim-item" v-for="dim in actionPortrait.dims" :key="dim.code">
+          <view class="portrait-dim-header">
+            <view class="portrait-dim-icon" :style="{ background: dim.color }">{{ dim.icon }}</view>
+            <text class="portrait-dim-name">{{ dim.name }}</text>
+            <text class="portrait-dim-score">{{ dim.score }}</text>
+          </view>
+          <view class="portrait-dim-bar-track">
+            <view class="portrait-dim-bar-fill" :style="{ width: dim.score + '%', background: dim.color }"></view>
+          </view>
+          <text class="portrait-dim-desc">{{ dim.desc }}</text>
+        </view>
+      </view>
+      <view class="portrait-tip" v-if="actionPortrait.tip">
+        <text class="portrait-tip-text">💡 {{ actionPortrait.tip }}</text>
+      </view>
+    </view>
     <!-- 关系六维 -->
     <dimension-sub-dims
       v-if="isLoggedIn && actionSubDims.length > 0"
@@ -396,6 +422,7 @@ export default {
       }
       return scores
     },
+<<<<<<< Updated upstream
 
     /** 关系解读状态:good(≥80) | warn(≥60) | bad(<60) */
     scoreExplainLevel: function() {
@@ -408,6 +435,73 @@ export default {
     /** 关系解读卡片状态类(用于 :class 绑定) */
     scoreExplainClass: function() {
       return 'score-explain-' + this.scoreExplainLevel
+=======
+    actionPortrait: function() {
+      var self = this
+      if (!this.isLoggedIn) return null
+      var member = this.currentMemberEnergies
+      if (!member) return null
+      var role = this.role || uni.getStorageSync('currentRole') || ''
+      var isParent = (role === 'parent')
+      var parentDims = [
+        { key: 'actionParentChild', label: '亲子关系', color: '#10B981', desc: '与子女的沟通与陪伴质量' },
+        { key: 'actionMarital', label: '夫妻关系', color: '#FF6B9D', desc: '夫妻间的理解与支持' },
+        { key: 'actionParental', label: '父母关系', color: '#F59E0B', desc: '与长辈的相处模式' },
+        { key: 'actionLeadership', label: '领导关系', color: '#6366F1', desc: '职场上下级沟通能力' },
+        { key: 'actionSociality', label: '社会性', color: '#FF8C42', desc: '社会交往与参与度' },
+        { key: 'actionInterpersonal', label: '人际韧性', color: '#06B6D4', desc: '应对人际关系挫折的能力' }
+      ]
+      var childDims = [
+        { key: 'actionChildParentChild', label: '亲子关系', color: '#10B981', desc: '与父母的互动质量' },
+        { key: 'actionSibling', label: '同胞关系', color: '#FF6B9D', desc: '兄弟姐妹间的关系' },
+        { key: 'actionRelative', label: '亲属关系', color: '#F59E0B', desc: '与亲戚的交往频率' },
+        { key: 'actionTeacherStudent', label: '师生关系', color: '#6366F1', desc: '与老师的沟通情况' },
+        { key: 'actionChildSociality', label: '社会性', color: '#FF8C42', desc: '学校与社会参与度' },
+        { key: 'actionChildInterpersonal', label: '人际韧性', color: '#06B6D4', desc: '应对社交困难的能力' }
+      ]
+      var dims = isParent ? parentDims : childDims
+      var result = { overall: 0, tierLabel: '', summary: '', dims: [], tip: '' }
+      var total = 0
+      var count = 0
+      var dimList = []
+      for (var i = 0; i < dims.length; i++) {
+        var key = dims[i].key
+        var score = member[key] || 0
+        if (isNaN(score)) score = 0
+        score = Math.max(0, Math.min(100, Number(score)))
+        total += score
+        count++
+        dimList.push({
+          code: key,
+          name: dims[i].label,
+          score: Math.round(score),
+          color: dims[i].color,
+          desc: dims[i].desc
+        })
+      }
+      if (count === 0) return null
+      result.overall = Math.round(total / count)
+      result.dims = dimList
+      var o = result.overall
+      if (o >= 80) {
+        result.tierLabel = '关系大师'
+        result.summary = '您的家庭关系和谐稳定,与家人建立了深厚的信任纽带。'
+        result.tip = '继续保持沟通习惯,也可分享经验帮助其他家庭'
+      } else if (o >= 60) {
+        result.tierLabel = '关系进取者'
+        result.summary = '您的家庭关系处于良好水平,具备基本的沟通能力。'
+        result.tip = '多倾听家人心声,主动表达关爱,持续提升关系质量'
+      } else if (o >= 40) {
+        result.tierLabel = '关系探索者'
+        result.summary = '您的家庭关系正在建立基础,需要更多沟通与理解。'
+        result.tip = '建议定期进行家庭活动,增进彼此了解与信任'
+      } else {
+        result.tierLabel = '关系关怀者'
+        result.summary = '您可能面临家庭关系挑战,需要更多关注与努力。'
+        result.tip = '建议寻求专业家庭辅导,或从简单的家庭对话开始改善'
+      }
+      return result
+>>>>>>> Stashed changes
     }
   },
   onShow() {
@@ -1073,4 +1167,92 @@ export default {
 .bottom-spacer {
   height: 40rpx;
 }
+
+/* 维度画像样式 */
+.dimension-portrait-section {
+  margin: 24rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 32rpx 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(16, 185, 129, 0.12);
+  border: 2rpx solid rgba(16, 185, 129, 0.1);
+}
+.portrait-tier {
+  font-size: 26rpx;
+  color: #10B981;
+  font-weight: bold;
+  margin-left: 16rpx;
+}
+.portrait-summary {
+  margin-bottom: 24rpx;
+}
+.portrait-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+.portrait-dimensions {
+  margin-bottom: 20rpx;
+}
+.portrait-dim-item {
+  margin-bottom: 18rpx;
+}
+.portrait-dim-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6rpx;
+}
+.portrait-dim-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 18rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10rpx;
+  font-size: 20rpx;
+  background: linear-gradient(135deg, #D1FAE5, #A7F3D0);
+}
+.portrait-dim-name {
+  font-size: 24rpx;
+  color: #333;
+  flex: 1;
+}
+.portrait-dim-score {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #10B981;
+  margin-right: 12rpx;
+}
+.portrait-dim-bar-track {
+  height: 10rpx;
+  background: #D1FAE5;
+  border-radius: 5rpx;
+  overflow: hidden;
+  margin-bottom: 4rpx;
+  flex: 1;
+}
+.portrait-dim-bar-fill {
+  height: 100%;
+  border-radius: 5rpx;
+  transition: width 0.3s ease;
+}
+.portrait-dim-desc {
+  font-size: 20rpx;
+  color: #666;
+  display: block;
+}
+.portrait-tip {
+  padding: 16rpx 20rpx;
+  background: linear-gradient(135deg, #F0FFF4, #E6FFF2);
+  border-radius: 12rpx;
+  border-left: 4rpx solid #10B981;
+  margin-top: 16rpx;
+}
+.portrait-tip-text {
+  font-size: 22rpx;
+  color: #065F46;
+  line-height: 1.5;
+}
 </style>

+ 110 - 0
cfc-frontend/pages/body-detail/index.vue

@@ -6,6 +6,33 @@
     <!-- 品牌头部 -->
     <PageBanner theme="body" tagline="科学管理全家健康" quote="身体健康是幸福的基础" />
 
+    <!-- 身维度画像 - 总体说明 -->
+    <view class="dimension-portrait-section" v-if="isLoggedIn && bodyPortrait">
+      <view class="section-header">
+        <text class="section-title">🧭 身维度画像</text>
+        <text class="portrait-tier">{{ bodyPortrait.tierLabel }}</text>
+      </view>
+      <view class="portrait-summary">
+        <text class="portrait-desc">{{ bodyPortrait.summary }}</text>
+      </view>
+      <view class="portrait-dimensions">
+        <view class="portrait-dim-item" v-for="dim in bodyPortrait.dims" :key="dim.code">
+          <view class="portrait-dim-header">
+            <view class="portrait-dim-icon" :style="{ background: dim.color }">{{ dim.icon }}</view>
+            <text class="portrait-dim-name">{{ dim.name }}</text>
+            <text class="portrait-dim-score">{{ dim.score }}</text>
+          </view>
+          <view class="portrait-dim-bar-track">
+            <view class="portrait-dim-bar-fill" :style="{ width: dim.score + '%', background: dim.color }"></view>
+          </view>
+          <text class="portrait-dim-desc">{{ dim.desc }}</text>
+        </view>
+      </view>
+      <view class="portrait-tip" v-if="bodyPortrait.tip">
+        <text class="portrait-tip-text">💡 {{ bodyPortrait.tip }}</text>
+      </view>
+    </view>
+
     <!-- 健康概览卡片:关键物理指标一眼看清 -->
     <view class="health-overview-card">
       <view class="overview-header">
@@ -990,6 +1017,89 @@ export default {
 .risk-normal .risk-level { color: #999; }
 
 /* ─── 月引导语 ─── */
+.dimension-portrait-section {
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 28rpx 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(255, 140, 66, 0.12);
+  border: 2rpx solid rgba(255, 140, 66, 0.1);
+}
+.portrait-tier {
+  font-size: 26rpx;
+  color: #FF8C42;
+  font-weight: bold;
+  margin-left: 16rpx;
+}
+.portrait-summary {
+  margin-bottom: 20rpx;
+}
+.portrait-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+.portrait-dimensions {
+  margin-bottom: 16rpx;
+}
+.portrait-dim-item {
+  margin-bottom: 16rpx;
+}
+.portrait-dim-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.portrait-dim-icon {
+  width: 40rpx;
+  height: 40rpx;
+  border-radius: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 12rpx;
+  font-size: 22rpx;
+}
+.portrait-dim-name {
+  font-size: 26rpx;
+  color: #333;
+  flex: 1;
+}
+.portrait-dim-score {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #FF8C42;
+  margin-right: 16rpx;
+}
+.portrait-dim-bar-track {
+  height: 12rpx;
+  background: #FFF0E4;
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 6rpx;
+}
+.portrait-dim-bar-fill {
+  height: 100%;
+  border-radius: 6rpx;
+  transition: width 0.3s ease;
+}
+.portrait-dim-desc {
+  font-size: 22rpx;
+  color: #999;
+  display: block;
+}
+.portrait-tip {
+  padding: 16rpx 20rpx;
+  background: linear-gradient(135deg, #FFF7ED, #FFF0E4);
+  border-radius: 12rpx;
+  border-left: 4rpx solid #FF8C42;
+}
+.portrait-tip-text {
+  font-size: 24rpx;
+  color: #C2410C;
+  line-height: 1.5;
+}
 .monthly-guidance {
   display: flex;
   align-items: center;

+ 166 - 4
cfc-frontend/pages/health/report-list.vue

@@ -20,6 +20,31 @@
       </scroll-view>
     </view>
 
+    <!-- 跨家庭报告转移申请 -->
+    <view class="transfer-wrap" v-if="transferIncoming.length > 0 || transferOutgoing.length > 0">
+      <text class="transfer-section-title">报告转移申请</text>
+      <!-- 待本家庭处理:对方家庭申请将本家庭报告转入其家庭 -->
+      <view class="transfer-card" v-for="item in transferIncoming" :key="item.id">
+        <view class="transfer-info">
+          <text class="transfer-person">{{ item.personName || '健康报告' }}</text>
+          <text class="transfer-meta">报告日期:{{ formatDate(item.reportDate) || '--' }}</text>
+          <text class="transfer-meta">申请时间:{{ formatDateTime(item.createdAt) }}</text>
+        </view>
+        <view class="transfer-actions">
+          <view class="transfer-btn transfer-deny" @click.stop="denyTransfer(item)">拒绝</view>
+          <view class="transfer-btn transfer-approve" @click.stop="approveTransfer(item)">同意</view>
+        </view>
+      </view>
+      <!-- 本家庭发起的申请 -->
+      <view class="transfer-card" v-for="item in transferOutgoing" :key="item.id">
+        <view class="transfer-info">
+          <text class="transfer-person">{{ item.personName || '健康报告' }}</text>
+          <text class="transfer-meta">申请时间:{{ formatDateTime(item.createdAt) }}</text>
+          <text class="transfer-status" :class="'status-' + item.status">{{ transferStatusText(item.status) }}</text>
+        </view>
+      </view>
+    </view>
+
     <!-- 采集中草稿 -->
     <view class="collecting-wrap" v-if="collectingReports.length > 0">
       <view class="collecting-card" v-for="item in collectingReports" :key="item.draftId">
@@ -135,7 +160,7 @@
 
 <script>
 import { parseDate } from '../../utils/format.js'
-import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports, reassignReport } from '../../utils/api.js'
+import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports, reassignReport, getReportTransferList, approveReportTransfer, denyReportTransfer } from '../../utils/api.js'
 
 export default {
   data() {
@@ -166,15 +191,21 @@ export default {
       candidates: [],
       showAutoCreateForm: false,
       newMemberName: '',
-      newMemberGender: 'male'
+      newMemberGender: 'male',
+      // 跨家庭报告转移申请
+      transferIncoming: [],
+      transferOutgoing: []
     }
   },
   onLoad(options) {
-    // 导航参数可带初始 memberId(预选对应成员);未传时默认"全部"
-    this.memberId = options.memberId ? parseInt(options.memberId) : null
+    // 从 storage 获取当前用户ID(优先使用 currentChildId,其次 currentMemberId)
+    var currentUserId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
+    this.memberId = options.memberId ? parseInt(options.memberId) : (currentUserId ? parseInt(currentUserId) : null)
+    // 默认显示当前用户的报告;"全部"可切换查看所有成员
     this.selectedMemberId = this.memberId
     this.loadMembers()
     this.loadData()
+    this.loadTransferRequests()
     this.startCollectPolling()
   },
   onShow() {
@@ -182,6 +213,7 @@ export default {
     if (this._pageShown) {
       this.loadData()
       this.loadCollectingReports()
+      this.loadTransferRequests()
     }
     this._pageShown = true
   },
@@ -328,6 +360,66 @@ export default {
         self.loading = false
       })
     },
+    // 跨家庭报告转移申请列表(incoming=待本家庭处理 / outgoing=本家庭发起)
+    loadTransferRequests() {
+      var self = this
+      getReportTransferList().then(function(res) {
+        if (res && res.code === 200) {
+          var data = res.data || {}
+          self.transferIncoming = data.incoming instanceof Array ? data.incoming : []
+          self.transferOutgoing = data.outgoing instanceof Array ? data.outgoing : []
+        }
+      }).catch(function(e) {
+        console.log('获取转移申请失败', e)
+      })
+    },
+    transferStatusText(status) {
+      if (status === 'approved') return '对方已同意,报告已转入'
+      if (status === 'denied') return '对方已拒绝'
+      return '等待对方同意'
+    },
+    approveTransfer(item) {
+      var self = this
+      uni.showModal({
+        title: '同意转移',
+        content: '同意后该报告将转入对方家庭,您的家庭将不再显示此报告。是否同意?',
+        confirmColor: '#F56C6C',
+        success: function(res) {
+          if (!res.confirm) return
+          approveReportTransfer(item.id).then(function(r) {
+            if (r && r.code === 200) {
+              uni.showToast({ title: '已同意,报告已转入对方家庭', icon: 'none' })
+              self.loadTransferRequests()
+              self.loadData()
+            } else {
+              uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '操作失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    denyTransfer(item) {
+      var self = this
+      uni.showModal({
+        title: '拒绝转移',
+        content: '拒绝后该报告继续留在您的家庭。',
+        success: function(res) {
+          if (!res.confirm) return
+          denyReportTransfer(item.id, '').then(function(r) {
+            if (r && r.code === 200) {
+              uni.showToast({ title: '已拒绝', icon: 'none' })
+              self.loadTransferRequests()
+            } else {
+              uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '操作失败', icon: 'none' })
+          })
+        }
+      })
+    },
     genderText(gender) {
       if (gender === 'male') return '男'
       if (gender === 'female') return '女'
@@ -490,6 +582,76 @@ export default {
   color: #fff;
 }
 
+/* ===== 跨家庭报告转移申请 ===== */
+.transfer-wrap {
+  margin: 20rpx 30rpx 0;
+}
+.transfer-section-title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 12rpx;
+}
+.transfer-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  background: #fff;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 16rpx;
+}
+.transfer-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.transfer-person {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.transfer-meta {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-top: 6rpx;
+}
+.transfer-actions {
+  display: flex;
+  flex-direction: row;
+  flex-shrink: 0;
+}
+.transfer-btn {
+  font-size: 24rpx;
+  padding: 10rpx 28rpx;
+  border-radius: 24rpx;
+  margin-left: 12rpx;
+}
+.transfer-deny {
+  background: #F1F5F9;
+  color: #64748B;
+}
+.transfer-approve {
+  background: #5B9BD5;
+  color: #fff;
+}
+.transfer-status {
+  font-size: 24rpx;
+  margin-top: 6rpx;
+}
+.status-pending {
+  color: #F0B429;
+}
+.status-approved {
+  color: #34C759;
+}
+.status-denied {
+  color: #94A3B8;
+}
+
 /* ===== 加载状态 ===== */
 .loading-wrap {
   display: flex;

+ 27 - 2
cfc-frontend/pages/health/report-upload.vue

@@ -137,7 +137,7 @@
 </template>
 
 <script>
-import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList } from '../../utils/api.js'
+import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList, requestReportTransfer } from '../../utils/api.js'
 
 export default {
   data() {
@@ -338,8 +338,12 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
             self.collectError = data.message || '该报告正在解析中,请稍候'
             self.collectFileName = fileName || '报告文件'
             self.startCollectPolling(data.draftId)
+          } else if (data.crossFamily) {
+            // 同一份报告已被其他家庭上传:弹窗确认后向原家庭发起转移申请,不跳详情
+            self.confirmCrossFamilyTransfer(data)
+            return
           } else {
-            // 已完成,跳转详情
+            // 本家庭已完成,跳转详情
             var reportId = data.reportId
             if (reportId) {
               self.goToDetail(reportId, 'gut_flora')
@@ -365,6 +369,27 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
         uni.showToast({ title: msg, icon: 'none' })
       })
     },
+    // 跨家庭重复上传:弹窗确认后向原家庭发起转移申请(原家庭同意后报告才转入本家庭)
+    confirmCrossFamilyTransfer: function(data) {
+      uni.showModal({
+        title: '申请转移报告',
+        content: '该报告已由其他家庭上传。发起申请后需对方家庭同意,报告才会转入您的家庭;同意前您无法查看该报告。是否发起申请?',
+        confirmText: '发起申请',
+        cancelText: '取消',
+        success: function(res) {
+          if (!res.confirm) return
+          requestReportTransfer(data.reportId).then(function(r) {
+            if (r && r.code === 200) {
+              uni.showToast({ title: '申请已提交,等待对方家庭同意', icon: 'none', duration: 3000 })
+            } else {
+              uni.showToast({ title: (r && r.message) || '申请失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '申请失败,请重试', icon: 'none' })
+          })
+        }
+      })
+    },
     stopCollectPolling: function() {
       if (this.collectTimer) {
         clearInterval(this.collectTimer)

+ 169 - 0
cfc-frontend/pages/mind-detail/index.vue

@@ -105,6 +105,33 @@
           </view>
         </view>
 
+<!-- 心维度画像 - 总体说明 -->
+        <view class="dimension-portrait-section" v-if="isLoggedIn && mindPortrait">
+          <view class="section-header">
+            <text class="section-title">🧭 心维度画像</text>
+            <text class="portrait-tier">{{ mindPortrait.tierLabel }}</text>
+          </view>
+          <view class="portrait-summary">
+            <text class="portrait-desc">{{ mindPortrait.summary }}</text>
+          </view>
+          <view class="portrait-dimensions">
+            <view class="portrait-dim-item" v-for="dim in mindPortrait.dims" :key="dim.code">
+              <view class="portrait-dim-header">
+                <view class="portrait-dim-icon" :style="{ background: dim.color }">{{ dim.icon }}</view>
+                <text class="portrait-dim-name">{{ dim.name }}</text>
+                <text class="portrait-dim-score">{{ dim.score }}</text>
+              </view>
+              <view class="portrait-dim-bar-track">
+                <view class="portrait-dim-bar-fill" :style="{ width: dim.score + '%', background: dim.color }"></view>
+              </view>
+              <text class="portrait-dim-desc">{{ dim.desc }}</text>
+            </view>
+          </view>
+          <view class="portrait-tip" v-if="mindPortrait.tip">
+            <text class="portrait-tip-text">💡 {{ mindPortrait.tip }}</text>
+          </view>
+        </view>
+
         <!-- 肠脑轴情绪卡片 -->
         <view class="section" v-if="gutIndicators && gutIndicators.length > 0">
           <view class="section-header">
@@ -504,6 +531,61 @@ var descList = descMap[this.dominantElement] || descMap.wood
         if (m && (m.memberId === targetId)) return m
       }
       return members[0] || null
+    },
+    mindPortrait: function() {
+      var self = this
+      if (!this.isLoggedIn || !this.emiData) return null
+      var e = this.emiData
+      var dims = [
+        { key: 'emotionStability', label: '情绪稳定性', color: '#FF6B9D', desc: '情绪识别与调控能力' },
+        { key: 'opennessScore', label: '开放性', color: '#6366F1', desc: '对新事物的接受程度' },
+        { key: 'conscientiousnessScore', label: '尽责性', color: '#F59E0B', desc: '做事有条理、自律程度' },
+        { key: 'extraversionScore', label: '外向性', color: '#10B981', desc: '社交活跃与外向程度' },
+        { key: 'agreeablenessScore', label: '宜人性', color: '#FF8C42', desc: '与人相处的和谐程度' },
+        { key: 'neuroticismScore', label: '神经质', color: '#8B5CF6', desc: '情绪波动程度(反向)' }
+      ]
+      var result = { overall: 0, tierLabel: '', summary: '', dims: [], tip: '' }
+      var total = 0
+      var count = 0
+      var dimList = []
+      for (var i = 0; i < dims.length; i++) {
+        var key = dims[i].key
+        var score = e[key] || 0
+        if (isNaN(score)) score = 0
+        score = Math.max(0, Math.min(100, Number(score)))
+        total += score
+        count++
+        var matched = dims.find(function(d) { return d.key === key })
+        dimList.push({
+          code: key,
+          name: matched ? matched.label : key,
+          score: Math.round(score),
+          color: matched ? matched.color : '#FF6B9D',
+          desc: matched ? matched.desc : ''
+        })
+      }
+      if (count === 0) return null
+      result.overall = Math.round(total / count)
+      result.dims = dimList
+      var o = result.overall
+      if (o >= 80) {
+        result.tierLabel = '心灵智者'
+        result.summary = '您的心理状态非常健康,情绪稳定、人格成熟且内心强大。'
+        result.tip = '保持内心平衡,可分享经验帮助家人共同成长'
+      } else if (o >= 60) {
+        result.tierLabel = '心灵进取者'
+        result.summary = '您的心理状态良好,具备较强的情绪调节能力和自我认知。'
+        result.tip = '关注情绪波动,练习正念冥想,持续提升心理韧性'
+      } else if (o >= 40) {
+        result.tierLabel = '心灵探索者'
+        result.summary = '您的心理状态正在建立基础,情绪管理有待加强。'
+        result.tip = '从每日情绪打卡开始,逐步建立情绪觉察习惯'
+      } else {
+        result.tierLabel = '心灵关怀者'
+        result.summary = '您可能正处于情绪波动期,需要更多关注与支持。'
+        result.tip = '建议寻求专业心理帮助,或向家人朋友倾诉'
+      }
+      return result
     }
   },
   onShow() {
@@ -2256,4 +2338,91 @@ var descList = descMap[this.dominantElement] || descMap.wood
   margin-bottom: 16rpx;
   padding-left: 10rpx;
 }
+
+/* 维度画像样式 */
+.dimension-portrait-section {
+  margin: 24rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 32rpx 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(255, 107, 157, 0.12);
+  border: 2rpx solid rgba(255, 107, 157, 0.1);
+}
+.portrait-tier {
+  font-size: 26rpx;
+  color: #FF6B9D;
+  font-weight: bold;
+  margin-left: 16rpx;
+}
+.portrait-summary {
+  margin-bottom: 24rpx;
+}
+.portrait-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+.portrait-dimensions {
+  margin-bottom: 20rpx;
+}
+.portrait-dim-item {
+  margin-bottom: 18rpx;
+}
+.portrait-dim-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6rpx;
+}
+.portrait-dim-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 18rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10rpx;
+  font-size: 20rpx;
+}
+.portrait-dim-name {
+  font-size: 24rpx;
+  color: #333;
+  flex: 1;
+}
+.portrait-dim-score {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #FF6B9D;
+  margin-right: 12rpx;
+}
+.portrait-dim-bar-track {
+  height: 10rpx;
+  background: #F0F6F7;
+  border-radius: 5rpx;
+  overflow: hidden;
+  margin-bottom: 4rpx;
+  flex: 1;
+}
+.portrait-dim-bar-fill {
+  height: 100%;
+  border-radius: 5rpx;
+  transition: width 0.3s ease;
+}
+.portrait-dim-desc {
+  font-size: 20rpx;
+  color: #999;
+  display: block;
+}
+.portrait-tip {
+  padding: 16rpx 20rpx;
+  background: linear-gradient(135deg, #FFF0F5, #FDF2F7);
+  border-radius: 12rpx;
+  border-left: 4rpx solid #FF6B9D;
+  margin-top: 16rpx;
+}
+.portrait-tip-text {
+  font-size: 22rpx;
+  color: #C2410C;
+  line-height: 1.5;
+}
 </style>

+ 11 - 5
cfc-frontend/pages/vendor/team-customers/team-customers.vue

@@ -109,14 +109,20 @@ export default {
     this.systemId = options.systemId || ''
     this.parentId = options.parentId || ''
     this.loadCustomers()
+    this._onLoadFired = true
   },
 
   onShow() {
-    // 切Tab返回时刷新
-    this.page = 1
-    this.customers = []
-    this.hasMore = true
-    this.loadCustomers()
+    if (this._onLoadFired) {
+      // 首次进入:onLoad 已加载,跳过避免双发
+      this._onLoadFired = false
+    } else {
+      // 切Tab返回时刷新
+      this.page = 1
+      this.customers = []
+      this.hasMore = true
+      this.loadCustomers()
+    }
   },
 
   methods: {

+ 7 - 1
cfc-frontend/pages/vendor/team-system/team-system.vue

@@ -154,10 +154,16 @@ export default {
 
   onLoad() {
     this.loadData()
+    this._onLoadFired = true
   },
 
   onShow() {
-    this.loadData()
+    if (this._onLoadFired) {
+      // 首次进入:onLoad 已加载,跳过避免双发
+      this._onLoadFired = false
+    } else {
+      this.loadData()
+    }
   },
 
   methods: {

+ 161 - 1
cfc-frontend/pages/wealth/index.vue

@@ -139,6 +139,33 @@
         </view>
       </view>
 
+      <!-- 富维度画像 - 总体说明 -->
+      <view class="dimension-portrait-section" v-if="isLoggedIn && wealthPortrait">
+        <view class="section-header">
+          <text class="section-title">🧭 富维度画像</text>
+          <text class="portrait-tier">{{ wealthPortrait.tierLabel }}</text>
+        </view>
+        <view class="portrait-summary">
+          <text class="portrait-desc">{{ wealthPortrait.summary }}</text>
+        </view>
+        <view class="portrait-dimensions">
+          <view class="portrait-dim-item" v-for="dim in wealthPortrait.dims" :key="dim.code">
+            <view class="portrait-dim-header">
+              <view class="portrait-dim-icon" :style="{ background: dim.color }">{{ dim.icon }}</view>
+              <text class="portrait-dim-name">{{ dim.name }}</text>
+              <text class="portrait-dim-score">{{ dim.score }}</text>
+            </view>
+            <view class="portrait-dim-bar-track">
+              <view class="portrait-dim-bar-fill" :style="{ width: dim.score + '%', background: dim.color }"></view>
+            </view>
+            <text class="portrait-dim-desc">{{ dim.desc }}</text>
+          </view>
+        </view>
+        <view class="portrait-tip" v-if="wealthPortrait.tip">
+          <text class="portrait-tip-text">💡 {{ wealthPortrait.tip }}</text>
+        </view>
+      </view>
+
       <!-- 财富数据卡(家长) -->
       <view class="section" v-if="role === 'parent'">
         <view class="disc-card">
@@ -353,6 +380,52 @@ export default {
         if (s > 0 && s < 60) result.push({ label: d.label, score: s })
       }
       return result
+    },
+    wealthPortrait: function() {
+      var self = this
+      if (!this.isLoggedIn || !this.wealthSubDims || this.wealthSubDims.length === 0) return null
+      var dims = this.wealthSubDims
+      var colors = ['#F59E0B', '#FF8C42', '#6366F1', '#10B981']
+      var result = { overall: 0, tierLabel: '', summary: '', dims: [], tip: '' }
+      var total = 0
+      var count = 0
+      var dimList = []
+      for (var i = 0; i < dims.length; i++) {
+        var d = dims[i]
+        var score = Number(d.score) || 0
+        score = Math.max(0, Math.min(100, score))
+        total += score
+        count++
+        dimList.push({
+          code: 'wealth' + i,
+          name: d.label,
+          score: Math.round(score),
+          color: colors[i % colors.length],
+          desc: d.desc || ''
+        })
+      }
+      if (count === 0) return null
+      result.overall = Math.round(total / count)
+      result.dims = dimList
+      var o = result.overall
+      if (o >= 80) {
+        result.tierLabel = '财富掌舵者'
+        result.summary = '您的财富管理能力突出,能够很好地规划家庭财务,为家人创造稳定的财富基础。'
+        result.tip = '保持财富积累节奏,可探索更多元化的投资渠道'
+      } else if (o >= 60) {
+        result.tierLabel = '财富进取者'
+        result.summary = '您的财富管理处于良好水平,具备一定的财务规划能力。'
+        result.tip = '坚持记账与储蓄习惯,逐步扩大财富积累'
+      } else if (o >= 40) {
+        result.tierLabel = '财富探索者'
+        result.summary = '您的财富管理正在起步阶段,正在学习如何规划家庭财务。'
+        result.tip = '从记账开始,培养财务规划意识,为家庭建立储蓄习惯'
+      } else {
+        result.tierLabel = '财富启蒙者'
+        result.summary = '您的财富管理意识刚刚萌发,需要更多财务知识的积累。'
+        result.tip = '建议学习基础理财知识,从家庭收支记录开始'
+      }
+      return result
     }
   },
   onShow() {
@@ -1152,7 +1225,94 @@ export default {
   font-size: 22rpx;
   color: #F59E0B;
 }
-</style>
+
+/* 维度画像样式 */
+.dimension-portrait-section {
+  margin: 24rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 32rpx 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(245, 158, 11, 0.12);
+  border: 2rpx solid rgba(245, 158, 11, 0.1);
+}
+.portrait-tier {
+  font-size: 26rpx;
+  color: #F59E0B;
+  font-weight: bold;
+  margin-left: 16rpx;
+}
+.portrait-summary {
+  margin-bottom: 24rpx;
+}
+.portrait-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+.portrait-dimensions {
+  margin-bottom: 20rpx;
+}
+.portrait-dim-item {
+  margin-bottom: 18rpx;
+}
+.portrait-dim-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6rpx;
+}
+.portrait-dim-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 18rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10rpx;
+  font-size: 20rpx;
+  background: linear-gradient(135deg, #FFF7ED, #FFF3E8);
+}
+.portrait-dim-name {
+  font-size: 24rpx;
+  color: #333;
+  flex: 1;
+}
+.portrait-dim-score {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #F59E0B;
+  margin-right: 12rpx;
+}
+.portrait-dim-bar-track {
+  height: 10rpx;
+  background: #FFF7ED;
+  border-radius: 5rpx;
+  overflow: hidden;
+  margin-bottom: 4rpx;
+  flex: 1;
+}
+.portrait-dim-bar-fill {
+  height: 100%;
+  border-radius: 5rpx;
+  transition: width 0.3s ease;
+}
+.portrait-dim-desc {
+  font-size: 20rpx;
+  color: #666;
+  display: block;
+}
+.portrait-tip {
+  padding: 16rpx 20rpx;
+  background: linear-gradient(135deg, #FFF7ED, #FFF3E8);
+  border-radius: 12rpx;
+  border-left: 4rpx solid #F59E0B;
+  margin-top: 16rpx;
+}
+.portrait-tip-text {
+  font-size: 22rpx;
+  color: #C2410C;
+  line-height: 1.5;
+}
 
 /* 月度引导语 */
 .monthly-guidance {

+ 182 - 0
cfc-frontend/pages/wisdom-detail/index.vue

@@ -117,6 +117,33 @@
       </view>
     </view>
 
+    <!-- 心维度画像 - 总体说明 -->
+    <view class="dimension-portrait-section" v-if="isLoggedIn && wisdomPortrait">
+      <view class="section-header">
+        <text class="section-title">🧭 心维度画像</text>
+        <text class="portrait-tier">{{ wisdomPortrait.tierLabel }}</text>
+      </view>
+      <view class="portrait-summary">
+        <text class="portrait-desc">{{ wisdomPortrait.summary }}</text>
+      </view>
+      <view class="portrait-dimensions">
+        <view class="portrait-dim-item" v-for="dim in wisdomPortrait.dims" :key="dim.code">
+          <view class="portrait-dim-header">
+            <view class="portrait-dim-icon" :style="{ background: dim.color }">{{ dim.icon }}</view>
+            <text class="portrait-dim-name">{{ dim.name }}</text>
+            <text class="portrait-dim-score">{{ dim.score }}</text>
+          </view>
+          <view class="portrait-dim-bar-track">
+            <view class="portrait-dim-bar-fill" :style="{ width: dim.score + '%', background: dim.color }"></view>
+          </view>
+          <text class="portrait-dim-desc">{{ dim.desc }}</text>
+        </view>
+      </view>
+      <view class="portrait-tip" v-if="wisdomPortrait.tip">
+        <text class="portrait-tip-text">💡 {{ wisdomPortrait.tip }}</text>
+      </view>
+    </view>
+
     <!-- ===== DAN 报告(登录后) ===== -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
@@ -340,6 +367,74 @@ export default {
         '十二月:年度总结,规划来年学习'
       ]
       return messages[month] || messages[0]
+    },
+    wisdomPortrait: function() {
+      var self = this
+      if (!this.isLoggedIn || !this.cognitiveReport) return null
+      var cognitiveReport = this.cognitiveReport
+      var dims = [
+        { key: 'perceptionScore', label: '感知能力', color: '#FF6B9D', desc: '感官获取信息的能力' },
+        { key: 'focusScore', label: '专注力', color: '#F59E0B', desc: '保持注意力集中的能力' },
+        { key: 'memoryScore', label: '记忆力', color: '#6366F1', desc: '记忆和回忆信息的能力' },
+        { key: 'logicScore', label: '逻辑思维', color: '#10B981', desc: '分析和推理能力' },
+        { key: 'spatialScore', label: '空间思维', color: '#F59E0B', desc: '操作空间关系的能力' },
+        { key: 'processingSpeedScore', label: '加工速度', color: '#FF8C42', desc: '快速处理信息的能力' },
+        { key: 'growthMindset', label: '成长型思维', color: '#EC4899', desc: '相信能力可提升的信念' }
+      ]
+      var result = { overall: 0, tierLabel: '', summary: '', dims: [], tip: '' }
+      var total = 0
+      var count = 0
+      var dimList = []
+      for (var i = 0; i < dims.length; i++) {
+        var key = dims[i].key
+        var score = 0
+        if (key === 'growthMindset' && cognitiveReport.structuredAnalysis) {
+          try {
+            var analysis = JSON.parse(cognitiveReport.structuredAnalysis)
+            if (analysis && analysis.extra && analysis.extra.growthMindset) {
+              var gm = analysis.extra.growthMindset
+              var vals = Object.values(gm)
+              if (vals.length > 0) score = parseInt(vals[0]) || 0
+            }
+          } catch (e) { /* ignore parse error */ }
+        } else {
+          score = cognitiveReport[key] || 0
+        }
+        if (isNaN(score)) score = 0
+        score = Math.max(0, Math.min(100, Number(score)))
+        total += score
+        count++
+        var matched = dims.find(function(d) { return d.key === key })
+        dimList.push({
+          code: key,
+          name: matched ? matched.label : key,
+          score: Math.round(score),
+          color: matched ? matched.color : '#FF6B9D',
+          desc: matched ? matched.desc : ''
+        })
+      }
+      if (count === 0) return null
+      result.overall = Math.round(total / count)
+      result.dims = dimList
+      var o = result.overall
+      if (o >= 80) {
+        result.tierLabel = '认知卓越者'
+        result.summary = '您的认知能力卓越,各项能力均衡强劲,学习效率高。'
+        result.tip = '保持学习节奏,可进一步拓展更广的知识边界'
+      } else if (o >= 60) {
+        result.tierLabel = '认知进取者'
+        result.summary = '您的认知能力处于良好水平,具备较强的学习潜力。'
+        result.tip = '针对薄弱维度进行针对性训练,逐步突破瓶颈'
+      } else if (o >= 40) {
+        result.tierLabel = '认知探索者'
+        result.summary = '您的认知能力正在建立基础,关注学习习惯的养成很重要。'
+        result.tip = '从最容易提升的维度开始,每天投入少量学习时间'
+      } else {
+        result.tierLabel = '认知起步者'
+        result.summary = '您的认知能力需要更多关注和引导,建议从基础认能训练开始。'
+        result.tip = '建议咨询专业教育者,制定个性化认知发展计划'
+      }
+      return result
     }
   },
   onShow() {
@@ -1024,4 +1119,91 @@ export default {
   line-height: 1.6;
   margin: 0;
 }
+
+/* 维度画像样式 */
+.dimension-portrait-section {
+  margin: 24rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 32rpx 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(99, 102, 241, 0.12);
+  border: 2rpx solid rgba(99, 102, 241, 0.1);
+}
+.portrait-tier {
+  font-size: 26rpx;
+  color: #6366F1;
+  font-weight: bold;
+  margin-left: 16rpx;
+}
+.portrait-summary {
+  margin-bottom: 24rpx;
+}
+.portrait-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+.portrait-dimensions {
+  margin-bottom: 20rpx;
+}
+.portrait-dim-item {
+  margin-bottom: 18rpx;
+}
+.portrait-dim-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6rpx;
+}
+.portrait-dim-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 18rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10rpx;
+  font-size: 20rpx;
+}
+.portrait-dim-name {
+  font-size: 24rpx;
+  color: #333;
+  flex: 1;
+}
+.portrait-dim-score {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #6366F1;
+  margin-right: 12rpx;
+}
+.portrait-dim-bar-track {
+  height: 10rpx;
+  background: #F0F4FF;
+  border-radius: 5rpx;
+  overflow: hidden;
+  margin-bottom: 4rpx;
+  flex: 1;
+}
+.portrait-dim-bar-fill {
+  height: 100%;
+  border-radius: 5rpx;
+  transition: width 0.3s ease;
+}
+.portrait-dim-desc {
+  font-size: 20rpx;
+  color: #999;
+  display: block;
+}
+.portrait-tip {
+  padding: 16rpx 20rpx;
+  background: linear-gradient(135deg, #F0F4FF, #E8F1FE);
+  border-radius: 12rpx;
+  border-left: 4rpx solid #6366F1;
+  margin-top: 16rpx;
+}
+.portrait-tip-text {
+  font-size: 22rpx;
+  color: #1D1D1F;
+  line-height: 1.5;
+}
 </style>

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

@@ -2371,6 +2371,12 @@ export const reassignReport = (reportId, subjectId) => request('/api/health/repo
 
 export const deleteReport = (reportId) => request('/api/health/report/delete', 'POST', { reportId })
 
+// 跨家庭报告转移:同一份报告(MD5相同)被其他家庭上传时,申请转入本家庭(需原家庭同意后报告才转入)
+export const requestReportTransfer = (reportId) => request('/api/health/report/transfer/request', 'POST', { reportId })
+export const approveReportTransfer = (requestId) => request('/api/health/report/transfer/approve', 'POST', { requestId })
+export const denyReportTransfer = (requestId, remark) => request('/api/health/report/transfer/deny', 'POST', { requestId, remark })
+export const getReportTransferList = () => request('/api/health/report/transfer/list', 'POST', {})
+
 export const downloadReportFile = (reportId) => request('/api/health/report/download', 'POST', { reportId })
 
 export const queryKnowledgeBase = (itemType, itemName) => request('/api/health/knowledge/query', 'POST', { itemType, itemName })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-367089ec209692e5c80abbe110e633ce4289324b
+85ffba6aa1976012eb0d42554419fcfc4014102e

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

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

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

@@ -4,6 +4,120 @@
 
 ---
 
+## v1.0.1533 (2026-10-07)
+
+### Bug 修复
+- 后台会员等级写入家庭维度事实源,修复健康方案生成误报未升级
+
+### 其他
+- 
+
+
+## v1.0.1532 (2026-10-07)
+
+### Bug 修复
+- 修复远程 b3e18f84e 引入的 ProductOrderService 编译错误
+
+### 其他
+- - getEligibleMemberPrice 方法被插入到类中错误位置, 提前用 } 闭合类,
+-   导致原"7.组装返回"代码段孤立在类外 (class/interface/enum expected)
+- - 方法内引用未定义的 userId, 应为参数 buyerId
+- 修复: 恢复"6.更新订单时间→7.组装返回"方法完整结构,
+- getEligibleMemberPrice 移到类内末尾, userId 改为 buyerId
+- 
+- 会员可见级别模型: 增加所有用户档(含未登录)+BASIC独立档位
+-   FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6,
+-   BASIC 从与 FREE 同级调整为独立档位(FREE 与 FAMILY 之间)
+- - 前端: ProductEdit/CategoryManage 可见级别下拉增加"所有用户(含未登录)"
+-   选项(值空串,提交转null), 显示全部级别含FREE; ProductManage
+-   levelLabel NULL 显示"所有用户"
+- - schema.sql: membership_levels.level_order 注释同步
+- 
+
+
+## v1.0.1531 (2026-10-07)
+
+### Bug 修复
+- 会员判定取个人与家庭身份中更高级别,防止家庭同步覆盖更高个人会员
+
+### 其他
+-   max 比较取更高者,覆盖全部用户侧调用方(折扣/可见等级/权限/赠礼前置校验等)
+- - resolveEffectiveLevel: level_order 比较,平级时优先非 FREE(当前 FREE=BASIC=1 防同级覆盖)
+- - syncMembershipToFamilyMembers/syncMembershipForNewMember: 家庭同步只升不降,
+-   成员已有更高级别个人身份时保留,避免覆盖
+- - downgradeFamilyMembers: 按家庭历史最高 level_order 守卫,仅降级不高于该级别的成员,
+-   保留 LIFETIME/PREMIUM 等纯个人身份
+- - 新增 MembershipServiceTest 17 用例(纯 Mockito)
+- 
+
+
+## v1.0.1530 (2026-10-07)
+
+### Bug 修复
+- min_visible_level 空串归一化为 NULL,避免管理端清空可见级别导致商品对所有用户不可见
+
+
+## v1.0.1529 (2026-10-07)
+
+### 样式
+- 商品管理页企业级视觉重构
+
+### 其他
+- - 筛选区改为「标签+控件」结构,去除 el-card 内套 filter-bar 的卡中卡阴影
+- - 数值列右对齐+等宽数字+售价千分位;状态标签标准化为浅底深字+同色点
+- - 操作列由 280px 缩减为 176px 并降为文本按钮,主色覆盖率收敛至 5% 内
+- - 新增批量选中提示条;弹窗区块标题/圆角/分割线统一;表格高度改为响应式
+- Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
+- 
+
+
+## v1.0.1528 (2026-10-06)
+
+### Bug 修复
+- team 页面 onLoad/onShow 首次加载守卫,消除审计 LIFECYCLE_DOUBLE 报错
+- 编辑报告时同步更新 report_date 字段
+
+### 新功能
+- 跨家庭报告转移申请,同MD5报告需原家庭同意
+
+
+## v1.0.1527 (2026-10-06)
+
+### Bug 修复
+- 报告列表补家庭隔离,修复解绑时"报告不属于该家庭"
+
+### 其他
+- 因此会把其他家庭的报告带进成员列表。实测家庭72成员返回7条中有5条来自
+- 家庭67/70;小程序照单显示"取消归属",点击后在 reassignReport 权限校验
+- (报告必须属于该家庭)处抛异常。列表侧与权限侧必须遵守同一条家庭边界。
+- - getMemberReports 在归一化 id 集合时同步解析成员所在家庭,
+-   查询追加 family_id = ? 约束;无法解析家庭时返回空列表,
+-   与 /report/list "无家庭信息返回空列表" 的既有语义一致
+- - 该方法的另两个调用方(FamilyMemberNutritionProfileService 营养画像)
+-   同步受益,避免营养画像跨家庭取报告
+- - 同步修复 HealthReportServiceTest 中 2 条被上次提交破坏的断言
+-   (旧成员重算已由 refreshFromGutReport 改为 rebuildFromMemberReports;
+-   pom 全局 skipTests=true 导致当时未被发现)
+- - 新增 getMemberReports_constrainsToMemberFamily 回归测试
+- 实测 member 21 列表由 7 条收敛为 2 条(均 family_id=72),越界为空;
+- 报告42 解绑/重绑均返回 200 且净状态不变。
+- 
+
+
+## v1.0.1526 (2026-10-06)
+
+### 新功能
+- beautify action-detail (行) page — enhanced visual design with scores, gradients & hierarchy
+
+### 其他
+- - Gradient accent bar on all sections (brand green #10B981)
+- - Unified section-title-wrap with green left border
+- - Score explanation: ≥80 good, ≥60 warn, <60 bad
+- - Header subtext merged into title-wrap for consistent layout
+- - Visual hierarchy improvements throughout
+- 
+
+
 ## v1.0.1525 (2026-10-06)
 
 ### 重构

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1525
+> 当前版本: v1.0.1533
 
 ## 历史版本
 
@@ -8,6 +8,120 @@
 
 ---
 
+## v1.0.1533 (2026-10-07)
+
+### Bug 修复
+- 后台会员等级写入家庭维度事实源,修复健康方案生成误报未升级
+
+### 其他
+- 
+
+
+## v1.0.1532 (2026-10-07)
+
+### Bug 修复
+- 修复远程 b3e18f84e 引入的 ProductOrderService 编译错误
+
+### 其他
+- - getEligibleMemberPrice 方法被插入到类中错误位置, 提前用 } 闭合类,
+-   导致原"7.组装返回"代码段孤立在类外 (class/interface/enum expected)
+- - 方法内引用未定义的 userId, 应为参数 buyerId
+- 修复: 恢复"6.更新订单时间→7.组装返回"方法完整结构,
+- getEligibleMemberPrice 移到类内末尾, userId 改为 buyerId
+- 
+- 会员可见级别模型: 增加所有用户档(含未登录)+BASIC独立档位
+-   FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6,
+-   BASIC 从与 FREE 同级调整为独立档位(FREE 与 FAMILY 之间)
+- - 前端: ProductEdit/CategoryManage 可见级别下拉增加"所有用户(含未登录)"
+-   选项(值空串,提交转null), 显示全部级别含FREE; ProductManage
+-   levelLabel NULL 显示"所有用户"
+- - schema.sql: membership_levels.level_order 注释同步
+- 
+
+
+## v1.0.1531 (2026-10-07)
+
+### Bug 修复
+- 会员判定取个人与家庭身份中更高级别,防止家庭同步覆盖更高个人会员
+
+### 其他
+-   max 比较取更高者,覆盖全部用户侧调用方(折扣/可见等级/权限/赠礼前置校验等)
+- - resolveEffectiveLevel: level_order 比较,平级时优先非 FREE(当前 FREE=BASIC=1 防同级覆盖)
+- - syncMembershipToFamilyMembers/syncMembershipForNewMember: 家庭同步只升不降,
+-   成员已有更高级别个人身份时保留,避免覆盖
+- - downgradeFamilyMembers: 按家庭历史最高 level_order 守卫,仅降级不高于该级别的成员,
+-   保留 LIFETIME/PREMIUM 等纯个人身份
+- - 新增 MembershipServiceTest 17 用例(纯 Mockito)
+- 
+
+
+## v1.0.1530 (2026-10-07)
+
+### Bug 修复
+- min_visible_level 空串归一化为 NULL,避免管理端清空可见级别导致商品对所有用户不可见
+
+
+## v1.0.1529 (2026-10-07)
+
+### 样式
+- 商品管理页企业级视觉重构
+
+### 其他
+- - 筛选区改为「标签+控件」结构,去除 el-card 内套 filter-bar 的卡中卡阴影
+- - 数值列右对齐+等宽数字+售价千分位;状态标签标准化为浅底深字+同色点
+- - 操作列由 280px 缩减为 176px 并降为文本按钮,主色覆盖率收敛至 5% 内
+- - 新增批量选中提示条;弹窗区块标题/圆角/分割线统一;表格高度改为响应式
+- Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
+- 
+
+
+## v1.0.1528 (2026-10-06)
+
+### Bug 修复
+- team 页面 onLoad/onShow 首次加载守卫,消除审计 LIFECYCLE_DOUBLE 报错
+- 编辑报告时同步更新 report_date 字段
+
+### 新功能
+- 跨家庭报告转移申请,同MD5报告需原家庭同意
+
+
+## v1.0.1527 (2026-10-06)
+
+### Bug 修复
+- 报告列表补家庭隔离,修复解绑时"报告不属于该家庭"
+
+### 其他
+- 因此会把其他家庭的报告带进成员列表。实测家庭72成员返回7条中有5条来自
+- 家庭67/70;小程序照单显示"取消归属",点击后在 reassignReport 权限校验
+- (报告必须属于该家庭)处抛异常。列表侧与权限侧必须遵守同一条家庭边界。
+- - getMemberReports 在归一化 id 集合时同步解析成员所在家庭,
+-   查询追加 family_id = ? 约束;无法解析家庭时返回空列表,
+-   与 /report/list "无家庭信息返回空列表" 的既有语义一致
+- - 该方法的另两个调用方(FamilyMemberNutritionProfileService 营养画像)
+-   同步受益,避免营养画像跨家庭取报告
+- - 同步修复 HealthReportServiceTest 中 2 条被上次提交破坏的断言
+-   (旧成员重算已由 refreshFromGutReport 改为 rebuildFromMemberReports;
+-   pom 全局 skipTests=true 导致当时未被发现)
+- - 新增 getMemberReports_constrainsToMemberFamily 回归测试
+- 实测 member 21 列表由 7 条收敛为 2 条(均 family_id=72),越界为空;
+- 报告42 解绑/重绑均返回 200 且净状态不变。
+- 
+
+
+## v1.0.1526 (2026-10-06)
+
+### 新功能
+- beautify action-detail (行) page — enhanced visual design with scores, gradients & hierarchy
+
+### 其他
+- - Gradient accent bar on all sections (brand green #10B981)
+- - Unified section-title-wrap with green left border
+- - Score explanation: ≥80 good, ≥60 warn, <60 bad
+- - Header subtext merged into title-wrap for consistent layout
+- - Visual hierarchy improvements throughout
+- 
+
+
 ## v1.0.1525 (2026-10-06)
 
 ### 重构

+ 16 - 6
cfc-web/src/views/admin/CategoryManage.vue

@@ -80,15 +80,15 @@
             </el-form-item>
 
             <el-form-item label="最低可见级别">
-              <el-select v-model="form.minVisibleLevel" placeholder="全部可见" clearable style="width: 100%;">
+              <el-select v-model="form.minVisibleLevel" placeholder="所有用户(含未登录)" clearable style="width: 100%;">
                 <el-option
-                  v-for="lv in membershipLevels"
-                  :key="lv.levelCode"
-                  :label="lv.levelName + ' (' + lv.levelCode + ')'"
-                  :value="lv.levelCode"
+                  v-for="opt in visibleLevelOptions"
+                  :key="opt.value"
+                  :label="opt.label"
+                  :value="opt.value"
                 />
               </el-select>
-              <div style="font-size: 12px; color: #999; line-height: 1.5; margin-top: 4px;">留空=全部会员可见;选择后仅该级别及更高级别会员可见</div>
+              <div style="font-size: 12px; color: #999; line-height: 1.5; margin-top: 4px;">所有用户(含未登录)=游客及全部会员可见;选择级别后仅该级别及更高级别会员可见</div>
             </el-form-item>
 
             <el-form-item>
@@ -126,6 +126,7 @@ export default {
         minVisibleLevel: ''
       },
       membershipLevels: [],
+      allMembershipLevels: [],
       selectedDimensions: [],
       selectedProductTypes: [],
       rules: {
@@ -151,6 +152,13 @@ export default {
         return result
       }
       return filterNodes(this.treeData)
+    },
+    visibleLevelOptions() {
+      const options = [{ label: '所有用户(含未登录)', value: '' }]
+      ;(this.allMembershipLevels || []).forEach(lv => {
+        options.push({ label: lv.levelName + ' (' + lv.levelCode + ')', value: lv.levelCode })
+      })
+      return options
     }
   },
   mounted() {
@@ -162,6 +170,7 @@ export default {
       try {
         const res = await listMembershipLevels()
         if (res.code === 200 && Array.isArray(res.data)) {
+          this.allMembershipLevels = res.data
           this.membershipLevels = res.data.filter(l => l.levelCode !== 'FREE')
         }
       } catch (e) {
@@ -228,6 +237,7 @@ export default {
         try {
           const payload = {
             ...this.form,
+            minVisibleLevel: this.form.minVisibleLevel || null,
             dimensionCodes: this.selectedDimensions.join(','),
             productTypes: this.selectedProductTypes.join(',')
           }

+ 17 - 6
cfc-web/src/views/admin/ProductEdit.vue

@@ -260,15 +260,15 @@
         </el-form-item>
 
         <el-form-item label="最低可见级别">
-          <el-select v-model="form.minVisibleLevel" placeholder="全部可见" clearable style="width: 200px;">
+          <el-select v-model="form.minVisibleLevel" placeholder="所有用户(含未登录)" clearable style="width: 220px;">
             <el-option
-              v-for="lv in membershipLevels"
-              :key="lv.levelCode"
-              :label="lv.levelName + ' (' + lv.levelCode + ')'"
-              :value="lv.levelCode"
+              v-for="opt in visibleLevelOptions"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
             />
           </el-select>
-          <span style="margin-left: 10px; font-size: 12px; color: #999;">留空=全部会员可见;选择后仅该级别及更高级别会员可见</span>
+          <span style="margin-left: 10px; font-size: 12px; color: #999;">所有用户(含未登录)=游客及全部会员可见;选择级别后仅该级别及更高级别会员可见</span>
         </el-form-item>
 
         <el-form-item label="商品维度">
@@ -412,6 +412,7 @@ export default {
       giftRuleEnabled: false,
       giftRuleLevel: '',
       membershipLevels: [],
+      allMembershipLevels: [],
       // 可选赠品配置:{ [levelCode]: [{giftType,giftProductId,cfValue,sortOrder}] }
       giftConfigsByLevel: {},
       onShelfProducts: [],
@@ -575,6 +576,13 @@ export default {
       const roles = rolesStr ? JSON.parse(rolesStr) : []
       return roles.includes('supplier') || roles.includes('supplier_admin')
     },
+  visibleLevelOptions() {
+      const options = [{ label: '所有用户(含未登录)', value: '' }]
+      ;(this.allMembershipLevels || []).forEach(lv => {
+        options.push({ label: lv.levelName + ' (' + lv.levelCode + ')', value: lv.levelCode })
+      })
+      return options
+    },
   uploadActionUrl() {
     const baseURL = process.env.VUE_APP_BASE_API || ''
     return baseURL + '/api/admin/articles/upload/image'
@@ -765,6 +773,7 @@ async loadGiftRule(productId) {
       try {
         const lvRes = await listMembershipLevels()
         if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
+          this.allMembershipLevels = lvRes.data
           this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
         }
         const ruleRes = await listProductGiftRules(productId)
@@ -802,6 +811,7 @@ async loadGiftRule(productId) {
         try {
           const lvRes = await listMembershipLevels()
           if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
+            this.allMembershipLevels = lvRes.data
             this.membershipLevels = lvRes.data.filter(l => l.levelCode !== 'FREE')
           }
         } catch (e) {
@@ -1046,6 +1056,7 @@ async loadGiftRule(productId) {
         try {
           const productData = {
             ...this.form,
+            minVisibleLevel: this.form.minVisibleLevel || null,
             price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
             memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
             images: JSON.stringify(this.imageList)

+ 565 - 238
cfc-web/src/views/admin/ProductManage.vue

@@ -1,235 +1,313 @@
 <template>
-  <div class="product-manage admin-page">
-    <el-card>
-      <div slot="header" class="admin-page-header">
-        <span class="admin-page-title">商品管理</span>
-        <el-button type="success" size="mini" icon="el-icon-plus" @click="$router.push('/product-edit')">新增商品</el-button>
-        <el-dropdown trigger="hover" @command="handleBatchCmd">
-          <el-button size="mini" type="primary">
-            批量<i class="el-icon-arrow-down el-icon--right"></i>
+  <div class="product-manage">
+    <el-card class="pm-card" shadow="never">
+      <!-- 页面标题区:唯一主操作「新增商品」,其余降为次按钮 -->
+      <div class="pm-header">
+        <div class="pm-header__left">
+          <h1 class="pm-title">商品管理</h1>
+          <span class="pm-subtitle">共 {{ pagination.total }} 个商品</span>
+        </div>
+        <div class="pm-header__right">
+          <el-dropdown trigger="click" @command="handleBatchCmd">
+            <el-button size="small">
+              批量操作<i class="el-icon-arrow-down el-icon--right"></i>
+            </el-button>
+            <el-dropdown-menu slot="dropdown">
+              <el-dropdown-item command="batchOnShelf" icon="el-icon-upload2">批量上架</el-dropdown-item>
+              <el-dropdown-item command="batchOffShelf" icon="el-icon-download">批量下架</el-dropdown-item>
+            </el-dropdown-menu>
+          </el-dropdown>
+          <el-button size="small" type="primary" icon="el-icon-plus" @click="$router.push('/product-edit')">
+            新增商品
           </el-button>
-          <el-dropdown-menu slot="dropdown">
-            <el-dropdown-item command="batchOnShelf">批量上架</el-dropdown-item>
-            <el-dropdown-item command="batchOffShelf">批量下架</el-dropdown-item>
-          </el-dropdown-menu>
-        </el-dropdown>
+        </div>
       </div>
 
-      <div class="filter-bar" style="margin-bottom:12px;">
-        <el-input
-          v-model="filters.keyword"
-          placeholder="搜索商品名称"
-          prefix-icon="el-icon-search"
-          clearable
-          style="width:200px;"
-          @clear="loadList"
-          @keyup.enter.native="loadList"
-        />
-        <el-select v-model="filters.status" placeholder="商品状态" @change="loadList" clearable style="width:130px;">
-          <el-option label="全部状态" value="" />
-          <el-option label="待审核" value="pending" />
-          <el-option label="待上架" value="approved" />
-          <el-option label="已上架" value="on_shelf" />
-          <el-option label="已下架" value="off_shelf" />
-          <el-option label="已拒绝" value="rejected" />
-        </el-select>
-        <el-select v-model="filters.productType" placeholder="商品类型" @change="loadList" clearable style="width:140px;">
-          <el-option label="全部类型" value="" />
-          <el-option label="实物商品" value="physical" />
-          <el-option label="虚拟商品" value="virtual" />
-          <el-option label="优惠券" value="coupon" />
-          <el-option label="测评商品" value="assessment" />
-        </el-select>
-        <el-button type="primary" size="mini" @click="loadList">查询</el-button>
-        <el-button size="mini" @click="resetFilters">重置</el-button>
+      <!-- 筛选区:标签 + 控件结构,操作按钮居右 -->
+      <div class="pm-filter">
+        <div class="pm-filter__fields">
+          <div class="pm-field">
+            <label class="pm-field__label">关键词</label>
+            <el-input
+              v-model="filters.keyword"
+              placeholder="搜索商品名称"
+              prefix-icon="el-icon-search"
+              size="small"
+              clearable
+              class="pm-field__control pm-field__control--search"
+              @clear="loadList"
+              @keyup.enter.native="loadList"
+            />
+          </div>
+          <div class="pm-field">
+            <label class="pm-field__label">商品状态</label>
+            <el-select v-model="filters.status" placeholder="全部状态" size="small" clearable class="pm-field__control" @change="loadList">
+              <el-option label="全部状态" value="" />
+              <el-option label="待审核" value="pending" />
+              <el-option label="待上架" value="approved" />
+              <el-option label="已上架" value="on_shelf" />
+              <el-option label="已下架" value="off_shelf" />
+              <el-option label="已拒绝" value="rejected" />
+            </el-select>
+          </div>
+          <div class="pm-field">
+            <label class="pm-field__label">商品类型</label>
+            <el-select v-model="filters.productType" placeholder="全部类型" size="small" clearable class="pm-field__control" @change="loadList">
+              <el-option label="全部类型" value="" />
+              <el-option label="实物商品" value="physical" />
+              <el-option label="虚拟商品" value="virtual" />
+              <el-option label="优惠券" value="coupon" />
+              <el-option label="测评商品" value="assessment" />
+            </el-select>
+          </div>
+        </div>
+        <div class="pm-filter__actions">
+          <el-button size="small" @click="resetFilters">重置</el-button>
+          <el-button size="small" type="primary" @click="loadList">查询</el-button>
+        </div>
       </div>
 
-      <div class="table-scroll-wrap-sm">
-        <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe @selection-change="handleSelectionChange">
-      <el-table-column type="selection" width="60" />
-      <el-table-column prop="id" label="ID" width="70" />
-      <el-table-column label="商品图片" width="80">
-        <template slot-scope="{ row }">
-          <img
-            v-if="row.coverImage"
-            :src="row.coverImage"
-            class="product-thumb"
-            @click="previewImage(row.coverImage)"
-          />
-          <span v-else style="color:#999">-</span>
-        </template>
-      </el-table-column>
-      <el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip>
-        <template slot="header">
-          <span class="sort-header" @click.stop="onSortClick('name', $event)" @dblclick.stop="onSortToggle('name')">商品名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
-        </template>
-      </el-table-column>
-
-      <el-table-column label="类型" width="90">
-        <template slot-scope="{ row }">
-          {{ typeLabel(row.productType) }}
-        </template>
-      </el-table-column>
-
-      <el-table-column label="售价(元)" width="90">
-        <template slot-scope="{ row }">
-          {{ formatPriceWithSymbol(row.price || 0) }}
-        </template>
-      </el-table-column>
-      <el-table-column prop="stock" label="库存" width="80">
-        <template slot-scope="{ row }">
-          <span :class="{ 'stock-warning': row.stock !== null && row.stock < 10 }">
-            {{ row.stock }}
-          </span>
-        </template>
-        <template slot="header">
-          <span class="sort-header" @click.stop="onSortClick('stock', $event)" @dblclick.stop="onSortToggle('stock')">库存<span v-if="sortIndex('stock') !== null" class="sort-index" :class="sortClass('stock') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('stock') }}</span>{{ sortIcon('stock') }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column prop="salesCount" label="销量" width="70">
-        <template slot="header">
-          <span class="sort-header" @click.stop="onSortClick('salesCount', $event)" @dblclick.stop="onSortToggle('salesCount')">销量<span v-if="sortIndex('salesCount') !== null" class="sort-index" :class="sortClass('salesCount') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('salesCount') }}</span>{{ sortIcon('salesCount') }}</span>
-        </template>
-      </el-table-column>
-
-      <el-table-column label="供应商" width="120" show-overflow-tooltip>
-        <template slot-scope="{ row }">
-          {{ row.vendorName || '-' }}
-        </template>
-      </el-table-column>
-
-      <el-table-column label="状态" width="90">
-        <template slot-scope="{ row }">
-          <el-tag :type="statusType(row.status)" size="mini">{{ statusLabel(row.status) }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column prop="sortOrder" label="排序" width="80">
-        <template slot-scope="{ row }">
-          {{ row.sortOrder != null ? row.sortOrder : '-' }}
-        </template>
-        <template slot="header">
-          <span class="sort-header" @click.stop="onSortClick('sortOrder', $event)" @dblclick.stop="onSortToggle('sortOrder')">排序<span v-if="sortIndex('sortOrder') !== null" class="sort-index" :class="sortClass('sortOrder') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('sortOrder') }}</span>{{ sortIcon('sortOrder') }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column label="最低可见级别" width="140">
-        <template slot-scope="{ row }">
-          <span v-if="row.minVisibleLevel">{{ levelLabel(row.minVisibleLevel) }}</span>
-          <span v-else style="color:#999">全部</span>
-        </template>
-      </el-table-column>
-      <el-table-column prop="createdAt" label="创建时间" width="160">
-        <template slot-scope="{ row }">
-          {{ formatTime(row.createdAt) }}
-        </template>
-          <template slot="header">
-            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
-
-          </template></el-table-column>
-      <el-table-column label="操作" width="280" fixed="right">
-        <template slot-scope="{ row }">
-          <el-button size="mini" type="primary" @click="$router.push('/product-edit?id=' + row.id)">编辑</el-button>
-          <el-button size="mini" type="info" @click="showInventoryDialog(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="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
-              <el-dropdown-item v-if="row.status === 'pending'" command="approve" icon="el-icon-circle-check">通过</el-dropdown-item>
-              <el-dropdown-item v-if="row.status === 'pending'" command="reject" icon="el-icon-circle-close">拒绝</el-dropdown-item>
-              <el-dropdown-item v-if="row.status === 'on_shelf'" command="offShelf" icon="el-icon-download">下架</el-dropdown-item>
-              <el-dropdown-item v-if="row.status === 'off_shelf'" command="onShelf" icon="el-icon-upload2">上架</el-dropdown-item>
-            </el-dropdown-menu>
-          </el-dropdown>
-        </template>
-      </el-table-column>
+      <!-- 批量选择提示条:仅在选择后出现,避免常态占位 -->
+      <div v-if="selectedIds.length" class="pm-batch-bar">
+        <span class="pm-batch-bar__text">已选 <b>{{ selectedIds.length }}</b> 个商品</span>
+        <div class="pm-batch-bar__actions">
+          <el-button type="text" size="small" @click="handleBatchShelve(true)">批量上架</el-button>
+          <el-divider direction="vertical"></el-divider>
+          <el-button type="text" size="small" @click="handleBatchShelve(false)">批量下架</el-button>
+          <el-divider direction="vertical"></el-divider>
+          <el-button type="text" size="small" class="pm-text-muted" @click="clearSelection">清空选择</el-button>
+        </div>
+      </div>
+
+      <!-- 表格区:数值右对齐等宽、状态标准化标签、操作列降为文本按钮 -->
+      <div class="pm-table-wrap">
+        <el-table
+          ref="table"
+          :max-height="tableHeight"
+          :data="list"
+          v-loading="loading"
+          border
+          size="small"
+          @selection-change="handleSelectionChange"
+        >
+          <el-table-column type="selection" width="48" align="center" />
+          <el-table-column prop="id" label="ID" width="72" align="right" class-name="pm-num" />
+          <el-table-column label="图片" width="72" align="center">
+            <template slot-scope="{ row }">
+              <img
+                v-if="row.coverImage"
+                :src="row.coverImage"
+                class="pm-thumb"
+                @click="previewImage(row.coverImage)"
+              />
+              <span v-else class="pm-placeholder">—</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip>
+            <template slot="header">
+              <span
+                class="pm-sort-header"
+                title="单击切换排序,双击取消"
+                @click.stop="onSortClick('name', $event)"
+                @dblclick.stop="onSortToggle('name')"
+              >
+                商品名称<span v-if="sortIndex('name') !== null" class="pm-sort-index">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="类型" width="96">
+            <template slot-scope="{ row }">{{ typeLabel(row.productType) }}</template>
+          </el-table-column>
+          <el-table-column label="售价(元)" width="112" align="right" header-align="right" class-name="pm-num">
+            <template slot-scope="{ row }">{{ formatPriceWithSymbol(row.price || 0) }}</template>
+          </el-table-column>
+          <el-table-column prop="stock" label="库存" width="88" align="right" header-align="right" class-name="pm-num">
+            <template slot-scope="{ row }">
+              <span :class="{ 'pm-stock-warning': row.stock !== null && row.stock < 10 }">{{ row.stock }}</span>
+            </template>
+            <template slot="header">
+              <span
+                class="pm-sort-header"
+                title="单击切换排序,双击取消"
+                @click.stop="onSortClick('stock', $event)"
+                @dblclick.stop="onSortToggle('stock')"
+              >
+                库存<span v-if="sortIndex('stock') !== null" class="pm-sort-index">{{ sortIndex('stock') }}</span>{{ sortIcon('stock') }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="salesCount" label="销量" width="88" align="right" header-align="right" class-name="pm-num">
+            <template slot="header">
+              <span
+                class="pm-sort-header"
+                title="单击切换排序,双击取消"
+                @click.stop="onSortClick('salesCount', $event)"
+                @dblclick.stop="onSortToggle('salesCount')"
+              >
+                销量<span v-if="sortIndex('salesCount') !== null" class="pm-sort-index">{{ sortIndex('salesCount') }}</span>{{ sortIcon('salesCount') }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="供应商" width="140" show-overflow-tooltip>
+            <template slot-scope="{ row }">
+              <span :class="{ 'pm-placeholder': !row.vendorName }">{{ row.vendorName || '—' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="96">
+            <template slot-scope="{ row }">
+              <span class="pm-status" :class="'pm-status--' + row.status">
+                <i class="pm-status__dot"></i>{{ statusLabel(row.status) }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="sortOrder" label="排序" width="88" align="right" header-align="right" class-name="pm-num">
+            <template slot-scope="{ row }">{{ row.sortOrder != null ? row.sortOrder : '—' }}</template>
+            <template slot="header">
+              <span
+                class="pm-sort-header"
+                title="单击切换排序,双击取消"
+                @click.stop="onSortClick('sortOrder', $event)"
+                @dblclick.stop="onSortToggle('sortOrder')"
+              >
+                排序<span v-if="sortIndex('sortOrder') !== null" class="pm-sort-index">{{ sortIndex('sortOrder') }}</span>{{ sortIcon('sortOrder') }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="最低可见级别" width="128">
+            <template slot-scope="{ row }">
+              <span :class="{ 'pm-placeholder': !row.minVisibleLevel }">{{ levelLabel(row.minVisibleLevel) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="createdAt" label="创建时间" width="160">
+            <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+            <template slot="header">
+              <span
+                class="pm-sort-header"
+                title="单击切换排序,双击取消"
+                @click.stop="onSortClick('createdAt', $event)"
+                @dblclick.stop="onSortToggle('createdAt')"
+              >
+                创建时间<span v-if="sortIndex('createdAt') !== null" class="pm-sort-index">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="176" fixed="right" align="left">
+            <template slot-scope="{ row }">
+              <el-button type="text" size="small" @click="$router.push('/product-edit?id=' + row.id)">编辑</el-button>
+              <el-button type="text" size="small" @click="showInventoryDialog(row)">库存</el-button>
+              <el-dropdown trigger="click" @command="(cmd) => handleActionCmd(row, cmd)">
+                <el-button type="text" size="small">
+                  更多<i class="el-icon-arrow-down el-icon--right"></i>
+                </el-button>
+                <el-dropdown-menu slot="dropdown">
+                  <el-dropdown-item command="copy" icon="el-icon-document-copy">复制</el-dropdown-item>
+                  <el-dropdown-item v-if="row.status === 'pending'" command="approve" icon="el-icon-circle-check">通过</el-dropdown-item>
+                  <el-dropdown-item v-if="row.status === 'pending'" command="reject" icon="el-icon-circle-close" divided>拒绝</el-dropdown-item>
+                  <el-dropdown-item v-if="row.status === 'on_shelf'" command="offShelf" icon="el-icon-download">下架</el-dropdown-item>
+                  <el-dropdown-item v-if="row.status === 'off_shelf'" command="onShelf" icon="el-icon-upload2">上架</el-dropdown-item>
+                </el-dropdown-menu>
+              </el-dropdown>
+            </template>
+          </el-table-column>
         </el-table>
-    </div>
-
-    <el-pagination
-      @size-change="handleSizeChange"
-      @current-change="handlePageChange"
-      :current-page="pagination.page"
-      :page-sizes="[10, 20, 50]"
-      :page-size="pagination.size"
-      :total="pagination.total"
-      layout="total, sizes, prev, pager, next, jumper"
-      class="pagination-wrap"
-    />
-
-    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="420px">
-      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
+      </div>
+
+      <!-- 分页:右侧对齐,总数前置 -->
+      <div class="pm-pagination">
+        <el-pagination
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+          :current-page="pagination.page"
+          :page-sizes="[10, 20, 50]"
+          :page-size="pagination.size"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          background
+        />
+      </div>
+    </el-card>
+
+    <!-- 拒绝原因弹窗 -->
+    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="460px" class="pm-dialog">
+      <el-input v-model="rejectReason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请输入拒绝原因(将同步给商品提交方)" />
       <span slot="footer">
-        <el-button @click="rejectDialogVisible = false">取消</el-button>
-        <el-button type="danger" @click="confirmReject">确认拒绝</el-button>
+        <el-button size="small" @click="rejectDialogVisible = false">取消</el-button>
+        <el-button size="small" type="danger" @click="confirmReject">确认拒绝</el-button>
       </span>
     </el-dialog>
 
-    <el-dialog title="商品详情" :visible.sync="detailDialogVisible" width="600px">
-      <el-descriptions :column="2" border v-if="detail">
+    <!-- 商品详情弹窗 -->
+    <el-dialog title="商品详情" :visible.sync="detailDialogVisible" width="720px" class="pm-dialog">
+      <el-descriptions :column="2" border size="small" v-if="detail">
         <el-descriptions-item label="商品名称">{{ detail.name }}</el-descriptions-item>
         <el-descriptions-item label="商品类型">{{ typeLabel(detail.productType) }}</el-descriptions-item>
         <el-descriptions-item label="售价">{{ formatPriceWithSymbol(detail.price || 0) }}</el-descriptions-item>
         <el-descriptions-item label="库存">{{ detail.stock }}</el-descriptions-item>
         <el-descriptions-item label="销量">{{ detail.salesCount || 0 }}</el-descriptions-item>
-        <el-descriptions-item label="供应商">{{ detail.vendorName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="供应商">{{ detail.vendorName || '—' }}</el-descriptions-item>
         <el-descriptions-item label="状态">
-          <el-tag :type="statusType(detail.status)" size="mini">{{ statusLabel(detail.status) }}</el-tag>
-        </el-descriptions-item>
-        <el-descriptions-item label="最低可见级别">
-          {{ detail.minVisibleLevel ? levelLabel(detail.minVisibleLevel) : '全部' }}
+          <span class="pm-status" :class="'pm-status--' + detail.status">
+            <i class="pm-status__dot"></i>{{ statusLabel(detail.status) }}
+          </span>
         </el-descriptions-item>
-        <el-descriptions-item label="拒绝原因" v-if="detail.rejectReason">{{ detail.rejectReason }}</el-descriptions-item>
-        <el-descriptions-item label="商品描述" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="最低可见级别">{{ detail.minVisibleLevel ? levelLabel(detail.minVisibleLevel) : '所有用户' }}</el-descriptions-item>
+        <el-descriptions-item label="拒绝原因" v-if="detail.rejectReason" :span="2">{{ detail.rejectReason }}</el-descriptions-item>
+        <el-descriptions-item label="商品描述" :span="2">{{ detail.description || '—' }}</el-descriptions-item>
         <el-descriptions-item label="创建时间" :span="2">{{ formatTime(detail.createdAt) }}</el-descriptions-item>
       </el-descriptions>
-      <div v-else style="text-align:center;color:#999;">加载中...</div>
+      <div v-else class="pm-empty">加载中…</div>
     </el-dialog>
-  <!-- 库存弹窗 -->
-    <el-dialog title="库存管理" :visible.sync="inventoryDialogVisible" width="800px">
+
+    <!-- 库存弹窗 -->
+    <el-dialog title="库存管理" :visible.sync="inventoryDialogVisible" width="860px" class="pm-dialog">
       <div v-loading="inventoryLoading">
-        <el-descriptions :column="2" border v-if="inventoryData">
+        <el-descriptions :column="3" border size="small" v-if="inventoryData">
           <el-descriptions-item label="商品名称">{{ inventoryData.productName }}</el-descriptions-item>
           <el-descriptions-item label="当前库存">{{ inventoryData.stock }}</el-descriptions-item>
           <el-descriptions-item label="预警下限">{{ inventoryData.minStockAlert != null ? inventoryData.minStockAlert : 10 }}</el-descriptions-item>
         </el-descriptions>
 
-        <h4 style="margin:16px 0 8px;">SKU 库存</h4>
-        <el-table :data="inventoryData.skus" border stripe v-if="inventoryData && inventoryData.skus && inventoryData.skus.length">
-          <el-table-column prop="id" label="SKU ID" width="70" />
-          <el-table-column prop="specs" label="规格" min-width="150" />
-          <el-table-column prop="stock" label="库存" width="70" />
-          <el-table-column prop="minStockAlert" label="预警下限" width="70" />
-        </el-table>
-        <div v-else-if="inventoryData" style="color:#999;padding:8px;">无SKU数据</div>
+        <div class="pm-section">
+          <div class="pm-section__title">SKU 库存</div>
+          <el-table :data="inventoryData && inventoryData.skus || []" border size="small" empty-text="暂无 SKU 数据">
+            <el-table-column prop="id" label="SKU ID" width="88" align="right" class-name="pm-num" />
+            <el-table-column prop="specs" label="规格" min-width="180" show-overflow-tooltip />
+            <el-table-column prop="stock" label="库存" width="88" align="right" class-name="pm-num" />
+            <el-table-column prop="minStockAlert" label="预警下限" width="96" align="right" class-name="pm-num" />
+          </el-table>
+        </div>
 
-        <h4 style="margin:16px 0 8px;">最近 10 条流水</h4>
-        <el-table :data="inventoryData.recentTransactions" border stripe v-if="inventoryData && inventoryData.recentTransactions && inventoryData.recentTransactions.length">
-          <el-table-column label="类型" width="80">
-            <template slot-scope="{ row }">
-              <el-tag :type="row.type === 'inbound' ? 'success' : row.type === 'outbound' ? 'danger' : 'warning'" size="mini">
-                {{ {'inbound':'入库','outbound':'出库','adjustment':'调账','counting':'盘点','refund':'退款入库'}[row.type] || row.type }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column label="数量" width="70">
-            <template slot-scope="{ row }">
-              <span :style="{ color: row.direction === 1 ? '#67c23a' : '#f56c6c' }">{{ row.direction === 1 ? '+' : '-' }}{{ row.quantity }}</span>
-            </template>
-          </el-table-column>
-          <el-table-column prop="beforeStock" label="变动前" width="70" />
-          <el-table-column prop="afterStock" label="变动后" width="70" />
-          <el-table-column prop="remark" label="备注" min-width="150" />
-          <el-table-column prop="createdAt" label="时间" width="160" />
-        </el-table>
-        <div v-else style="color:#999;padding:8px;">暂无流水记录</div>
+        <div class="pm-section">
+          <div class="pm-section__title">最近 10 条流水</div>
+          <el-table :data="inventoryData && inventoryData.recentTransactions || []" border size="small" empty-text="暂无流水记录">
+            <el-table-column label="类型" width="96">
+              <template slot-scope="{ row }">
+                <span class="pm-status" :class="'pm-status--' + (row.type === 'inbound' || row.type === 'refund' ? 'on_shelf' : row.type === 'outbound' ? 'rejected' : 'pending')">
+                  <i class="pm-status__dot"></i>{{ txTypeLabel(row.type) }}
+                </span>
+              </template>
+            </el-table-column>
+            <el-table-column label="数量" width="96" align="right" class-name="pm-num">
+              <template slot-scope="{ row }">
+                <span :class="row.direction === 1 ? 'pm-qty-in' : 'pm-qty-out'">
+                  {{ row.direction === 1 ? '+' : '−' }}{{ row.quantity }}
+                </span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="beforeStock" label="变动前" width="88" align="right" class-name="pm-num" />
+            <el-table-column prop="afterStock" label="变动后" width="88" align="right" class-name="pm-num" />
+            <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
+            <el-table-column label="时间" width="160">
+              <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+            </el-table-column>
+          </el-table>
+        </div>
       </div>
       <span slot="footer">
-        <el-button @click="inventoryDialogVisible = false">关闭</el-button>
-        <el-button type="primary" @click="$router.push('/inventory')">进入库存管理</el-button>
+        <el-button size="small" @click="inventoryDialogVisible = false">关闭</el-button>
+        <el-button size="small" type="primary" @click="$router.push('/inventory')">进入库存管理</el-button>
       </span>
     </el-dialog>
-  </el-card>
   </div>
 </template>
 
@@ -240,15 +318,11 @@ import { getProductList, reviewProduct, shelveProduct, batchShelveProduct, copyP
 export default {
   mixins: [SortMixin],
   name: 'ProductManage',
-  computed: {
-    tableHeight() {
-      return window.innerHeight - 300
-    }
-  },
   data() {
     return {
       list: [],
       loading: false,
+      tableHeight: 480,
       filters: {
         keyword: '',
         status: '',
@@ -269,13 +343,23 @@ export default {
       inventoryDialogVisible: false,
       inventoryLoading: false,
       inventoryData: null,
-      selectedIds: []  // 批量操作选中的商品ID集合
+      selectedIds: [] // 批量操作选中的商品ID集合
     }
   },
   mounted() {
+    this.calcTableHeight()
+    window.addEventListener('resize', this.calcTableHeight)
     this.loadList()
   },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.calcTableHeight)
+  },
   methods: {
+    calcTableHeight() {
+      // 页面留白 24*2 + 卡片内边距 24*2 + 头部 32 + 筛选区 56 + 分页 56 ≈ 基准 300
+      const offset = this.selectedIds.length ? 336 : 300
+      this.tableHeight = Math.max(320, window.innerHeight - offset)
+    },
     async loadList() {
       this.loading = true
       try {
@@ -306,16 +390,6 @@ export default {
       this.pagination.page = page
       this.loadList()
     },
-    statusType(status) {
-      const map = {
-        pending: 'warning',
-        approved: 'success',
-        on_shelf: 'success',
-        off_shelf: 'info',
-        rejected: 'danger'
-      }
-      return map[status] || 'info'
-    },
     statusLabel(status) {
       const map = {
         pending: '待审核',
@@ -326,22 +400,27 @@ export default {
       }
       return map[status] || status
     },
+    txTypeLabel(type) {
+      const map = { inbound: '入库', outbound: '出库', adjustment: '调账', counting: '盘点', refund: '退款入库' }
+      return map[type] || type
+    },
     typeLabel(type) {
       const map = { physical: '实物商品', virtual: '虚拟商品', coupon: '优惠券', assessment: '测评商品' }
       return map[type] || type
     },
     levelLabel(code) {
-      if (!code) return '全部'
-      const map = { FREE: '免费版', FAMILY: '家庭版', PROVIDER: '服务商版', PREMIUM: '高级版', LIFETIME: '终身版' }
+      if (!code) return '所有用户'
+      const map = { FREE: '免费版', BASIC: '体验版', FAMILY: '家庭版', PROVIDER: '服务商版', PREMIUM: '高级版', LIFETIME: '终身版' }
       return map[code] || code
     },
     formatTime(t) {
-      if (!t) return '-'
+      if (!t) return '—'
       return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     },
     formatPriceWithSymbol(price) {
-      if (price === 0) return '免费'
-      return '¥' + (price / 100).toFixed(2)
+      if (!price) return '免费'
+      const v = (price / 100).toFixed(2)
+      return '¥' + v.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
     },
     async handleReview(row, action) {
       try {
@@ -435,7 +514,7 @@ export default {
         await this.$confirm('确认' + (shelve ? '批量上架' : '批量下架') + '选中的 ' + this.selectedIds.length + ' 个商品?', '提示')
         await batchShelveProduct(this.selectedIds, shelve)
         this.$message.success(shelve ? '批量上架成功' : '批量下架成功')
-        this.selectedIds = []
+        this.clearSelection()
         this.loadList()
       } catch (e) {
         if (e !== 'cancel') this.$message.error('操作失败')
@@ -443,42 +522,290 @@ export default {
     },
     handleSelectionChange(rows) {
       this.selectedIds = rows.map(r => r.id)
+      this.calcTableHeight()
+    },
+    clearSelection() {
+      this.selectedIds = []
+      if (this.$refs.table) this.$refs.table.clearSelection()
+      this.calcTableHeight()
     }
   }
 }
 </script>
 
 <style scoped>
+/* ==========================================================
+   商品管理页 · 企业级视觉规范
+   间距 8 基准 / 主色仅 1 处主操作 / 状态色仅承载语义
+   ========================================================== */
 .product-manage {
-  padding: 20px;
+  padding: 24px;
+}
+
+.pm-card {
+  border: 1px solid #f0f0f0;
+  border-radius: 8px;
+  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
+}
+
+.pm-card /deep/ .el-card__body {
+  padding: 24px;
+}
+
+/* ---------- 页面标题区 ---------- */
+.pm-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24px;
+}
+
+.pm-header__left {
+  display: flex;
+  align-items: baseline;
+  gap: 12px;
+}
+
+.pm-title {
+  margin: 0;
+  font-size: 20px;
+  line-height: 1.4;
+  font-weight: 600;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.pm-subtitle {
+  font-size: 12px;
+  color: rgba(0, 0, 0, 0.45);
+}
+
+.pm-header__right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+/* ---------- 筛选区:白底 + 下分割线,不用阴影 ---------- */
+.pm-filter {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 16px;
+  padding-bottom: 16px;
+  margin-bottom: 16px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.pm-filter__fields {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.pm-field {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.pm-field__label {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.65);
+  white-space: nowrap;
+}
+
+.pm-field__control {
+  width: 150px;
+}
+
+.pm-field__control--search {
+  width: 220px;
+}
+
+.pm-filter__actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+/* ---------- 批量选择提示条 ---------- */
+.pm-batch-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 40px;
+  padding: 0 16px;
+  margin-bottom: 12px;
+  background: #e6f4ff;
+  border: 1px solid #91caff;
+  border-radius: 6px;
+}
+
+.pm-batch-bar__text {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.65);
+}
+
+.pm-batch-bar__text b {
+  color: #0958d9;
+  font-weight: 600;
+}
+
+.pm-batch-bar__actions {
+  display: flex;
+  align-items: center;
+}
+
+/* ---------- 表格 ---------- */
+.pm-table-wrap /deep/ .el-table {
+  font-size: 14px;
+  border-radius: 6px;
+}
+
+.pm-table-wrap /deep/ .el-table th {
+  height: 46px;
+  padding: 0;
+  background: #fafafa;
+  color: rgba(0, 0, 0, 0.88);
+  font-weight: 500;
+}
+
+.pm-table-wrap /deep/ .el-table td {
+  padding: 12px 0;
+}
+
+/* 数值列:右对齐 + 等宽数字 */
+.pm-table-wrap /deep/ .pm-num {
+  font-variant-numeric: tabular-nums;
+  font-feature-settings: 'tnum';
 }
-.product-thumb {
-  width: 50px;
-  height: 50px;
+
+.pm-thumb {
+  width: 40px;
+  height: 40px;
   object-fit: cover;
   border-radius: 4px;
+  border: 1px solid #f0f0f0;
   cursor: pointer;
+  display: block;
 }
-.stock-warning {
-  color: #f56c6c;
-  font-weight: bold;
+
+.pm-placeholder {
+  color: rgba(0, 0, 0, 0.25);
 }
-.header-search {
-  width: 180px;
+
+.pm-stock-warning {
+  color: #cf1322;
+  font-weight: 600;
 }
 
-.sort-header {
+/* 排序表头 */
+.pm-sort-header {
   cursor: pointer;
   user-select: none;
+  font-size: 14px;
   font-weight: 500;
-  font-size: 12px;
 }
-.sort-header:hover {
-  color: #409eff;
+
+.pm-sort-header:hover {
+  color: #1677ff;
 }
-.sort-tag {
-  margin-left: 8px;
-  cursor: pointer;
+
+.pm-sort-index {
+  display: inline-block;
+  min-width: 14px;
+  height: 14px;
+  line-height: 14px;
+  margin-left: 4px;
+  padding: 0 3px;
+  font-size: 11px;
+  font-weight: 400;
+  text-align: center;
+  color: #1677ff;
+  background: #e6f4ff;
+  border-radius: 2px;
+}
+
+/* ---------- 标准化状态标签(浅底 + 深字 + 同色点) ---------- */
+.pm-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 2px 8px;
+  font-size: 12px;
+  line-height: 20px;
+  border-radius: 2px;
   white-space: nowrap;
 }
+
+.pm-status__dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+}
+
+.pm-status--pending { background: #fffbe6; color: #d48806; }
+.pm-status--approved { background: #e6f4ff; color: #0958d9; }
+.pm-status--on_shelf { background: #f6ffed; color: #389e0d; }
+.pm-status--off_shelf { background: #f5f5f5; color: rgba(0, 0, 0, 0.65); }
+.pm-status--rejected { background: #fff1f0; color: #cf1322; }
+
+/* ---------- 分页 ---------- */
+.pm-pagination {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+}
+
+/* ---------- 弹窗内区块 ---------- */
+.pm-dialog /deep/ .el-dialog__header {
+  padding: 16px 24px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.pm-dialog /deep/ .el-dialog__title {
+  font-size: 16px;
+  font-weight: 500;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.pm-dialog /deep/ .el-dialog__body {
+  padding: 24px;
+}
+
+.pm-dialog /deep/ .el-dialog__footer {
+  padding: 12px 24px;
+  border-top: 1px solid #f0f0f0;
+}
+
+.pm-section {
+  margin-top: 24px;
+}
+
+.pm-section__title {
+  font-size: 14px;
+  font-weight: 500;
+  color: rgba(0, 0, 0, 0.88);
+  padding-left: 8px;
+  border-left: 3px solid #1677ff;
+  margin-bottom: 12px;
+  line-height: 1.2;
+}
+
+.pm-qty-in { color: #389e0d; font-variant-numeric: tabular-nums; }
+.pm-qty-out { color: #cf1322; font-variant-numeric: tabular-nums; }
+
+.pm-empty {
+  padding: 32px 0;
+  text-align: center;
+  color: rgba(0, 0, 0, 0.45);
+  font-size: 14px;
+}
+
+.pm-text-muted {
+  color: rgba(0, 0, 0, 0.45);
+}
 </style>