Просмотр исходного кода

feat(shop): 商品类目分级与会员级别可见性(min_visible_level/level_order/shop_categories),管理端同步支持

liaoxg 6 дней назад
Родитель
Сommit
9545d6273c

+ 23 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1554,6 +1554,7 @@ public class DatabaseInitializer implements CommandLineRunner {
                     "enabled TINYINT(1) DEFAULT 1 COMMENT '启用'," +
                     "dimension_codes VARCHAR(128) COMMENT '维度编码(逗号分隔: body,mind,heart)'," +
                     "product_types VARCHAR(128) COMMENT '商品类型(逗号分隔: PHYSICAL,COURSE,ASSESSMENT)'," +
+                    "min_visible_level VARCHAR(50) COMMENT '最低可见会员级别编码(NULL=全部可见)'," +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
                     "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
                     "INDEX idx_parent (parent_id)," +
@@ -6342,6 +6343,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                     "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 '级别数字(越小级别越低)', " +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
                     "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
                     "UNIQUE KEY uk_level_code (level_code)" +
@@ -10902,7 +10904,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
             log.warn("迁移313: 修复 meal_configs participant_member_ids 失败: {}", e.getMessage());
         }
 
-        // 迁移314: meal_configs 增加 config_date 字段(当天临时共餐配置)+ 修改 UNIQUE KEY
+// 迁移314: meal_configs 增加 config_date 字段(当天临时共餐配置)+ 修改 UNIQUE KEY
         // 支持「今天」模式:config_date 有值=当天临时配置(优先级最高),config_date=NULL+config_date_type=工作日/周末默认配置
         try {
             jdbcTemplate.execute("ALTER TABLE meal_configs ADD COLUMN config_date DATE DEFAULT NULL COMMENT '当天临时配置日期(NULL=默认工作日/周末配置)'");
@@ -10922,6 +10924,26 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         } catch (Exception e) {
             log.warn("迁移314: 添加新 UNIQUE KEY 失败: {}", e.getMessage());
         }
+
+        // 迁移315: 商品/分类可见性由「角色」改为「按会员级别」控制
+        // - 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=全部可见)
+        ensureColumn("membership_levels", "level_order",
+                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5'");
+        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");
+            log.info("迁移315: 已回填 membership_levels.level_order");
+        } catch (Exception e) {
+            log.warn("迁移315: 回填 membership_levels.level_order 失败: {}", e.getMessage());
+        }
+        ensureColumn("products", "min_visible_level",
+                "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'");
+        ensureColumn("shop_categories", "min_visible_level",
+                "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'");
+        log.info("迁移315: 已完成商品/分类按会员级别可见性改造");
     }
 
     private void seedGutFloraFingerprints() {

+ 6 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/shop/ProductCategoryController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller.shop;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.ProductCategoryService;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -20,13 +21,14 @@ public class ProductCategoryController {
     private ProductCategoryService productCategoryService;
 
     @PostMapping("/tree")
-    public Result<List<Map<String, Object>>> tree() {
-        return productCategoryService.tree(false);
+    public Result<List<Map<String, Object>>> tree(@RequestAttribute(value = "userId", required = false) Long userId) {
+        return productCategoryService.tree(false, userId);
     }
 
     @PostMapping("/products")
-    public Result<List<Map<String, Object>>> products(@RequestBody Map<String, Object> body) {
+    public Result<List<Map<String, Object>>> products(@RequestBody Map<String, Object> body,
+            @RequestAttribute(value = "userId", required = false) Long userId) {
         Long categoryId = ParamUtils.getLong(body.get("categoryId"));
-        return productCategoryService.products(categoryId);
+        return productCategoryService.products(categoryId, userId);
     }
 }

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

@@ -21,4 +21,5 @@ public class MembershipLevelDTO {
     private Integer aiReviewEnabled;
     private Integer prioritySupport;
     private Integer teacherConsultation;
+    private Integer levelOrder;
 }

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

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

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

@@ -45,6 +45,8 @@ public class MembershipLevel implements Serializable {
 
     private Integer teacherConsultation;  // 老师咨询
 
+    private Integer levelOrder;  // 级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5
+
     private Date createdAt;
 
     private Date updatedAt;

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

@@ -52,6 +52,9 @@ public class Product implements Serializable {
     /** 可见角色JSON数组,NULL表示全部角色可见 */
     private String visibleRoles;
 
+    /** 最低可见会员级别编码(NULL=全部可见): FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME */
+    private String minVisibleLevel;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 
@@ -95,6 +98,7 @@ public class Product implements Serializable {
     public Integer getMinQuantity() { return minQuantity; }
     public Integer getSortOrder() { return sortOrder; }
     public String getVisibleRoles() { return visibleRoles; }
+    public String getMinVisibleLevel() { return minVisibleLevel; }
     public Date getCreatedAt() { return createdAt; }
     public Date getUpdatedAt() { return updatedAt; }
 }

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

@@ -21,6 +21,8 @@ public class ProductCategory implements Serializable {
     private Boolean enabled;
     private String dimensionCodes;
     private String productTypes;
+    /** 最低可见会员级别编码(NULL=全部可见): FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME */
+    private String minVisibleLevel;
     private Date createdAt;
     private Date updatedAt;
 }

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -1148,6 +1149,48 @@ public class MembershipService implements MembershipServiceInterface {
         return level;
     }
 
+    /**
+     * 获取等级编码对应的级别数字(数字越小级别越低),未知等级回退 1(最低)
+     */
+    public int getLevelOrder(String levelCode) {
+        if (levelCode == null || levelCode.isEmpty()) {
+            return 1;
+        }
+        try {
+            MembershipLevel level = levelMapper.selectOne(
+                    new LambdaQueryWrapper<MembershipLevel>()
+                            .eq(MembershipLevel::getLevelCode, levelCode)
+                            .last("LIMIT 1"));
+            return (level != null && level.getLevelOrder() != null) ? level.getLevelOrder() : 1;
+        } catch (Exception e) {
+            return 1;
+        }
+    }
+
+    /**
+     * 获取用户可见的会员等级编码集合:级别数字 &lt;= 用户级别数字 的所有等级编码(低级别可见则高级别也可见)。
+     * 游客/匿名(userId 为空或&lt;=0)返回空集合,仅可见 min_visible_level IS NULL(全部可见)的商品/分类。
+     */
+    public List<String> getVisibleMemberLevelCodes(Long userId) {
+        if (userId == null || userId <= 0) {
+            return Collections.emptyList();
+        }
+        int userOrder = getLevelOrder(getMemberLevel(userId));
+        List<MembershipLevel> allLevels;
+        try {
+            allLevels = levelMapper.selectList(null);
+        } catch (Exception e) {
+            allLevels = Collections.emptyList();
+        }
+        List<String> codes = new ArrayList<>();
+        for (MembershipLevel lv : allLevels) {
+            if (lv.getLevelOrder() != null && lv.getLevelOrder() <= userOrder) {
+                codes.add(lv.getLevelCode());
+            }
+        }
+        return codes;
+    }
+
     /**
      * 查询指定会员等级当前有效的家庭创建者用户ID列表(PERIODIC发券用)
      */
@@ -1432,6 +1475,7 @@ public class MembershipService implements MembershipServiceInterface {
         dto.setAiReviewEnabled(level.getAiReviewEnabled());
         dto.setPrioritySupport(level.getPrioritySupport());
         dto.setTeacherConsultation(level.getTeacherConsultation());
+        dto.setLevelOrder(level.getLevelOrder());
         return dto;
     }
 

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductCategoryService.java

@@ -21,13 +21,28 @@ public class ProductCategoryService {
     @Resource
     private ProductMapper productMapper;
 
+    @Resource
+    private MembershipService membershipService;
+
     public Result<List<Map<String, Object>>> tree(Boolean includeDisabled) {
+        return tree(includeDisabled, null);
+    }
+
+    /** 按会员级别过滤分类树:userId 为空时不过滤(管理端/无限制),非空时仅返回用户级别可见的分类 */
+    public Result<List<Map<String, Object>>> tree(Boolean includeDisabled, Long userId) {
         List<ProductCategory> all = productCategoryMapper.selectList(
             new LambdaQueryWrapper<ProductCategory>()
                 .eq(!Boolean.TRUE.equals(includeDisabled), ProductCategory::getEnabled, true)
                 .orderByAsc(ProductCategory::getSort)
                 .orderByAsc(ProductCategory::getId)
         );
+        if (userId != null) {
+            List<String> visibleLevelCodes = membershipService.getVisibleMemberLevelCodes(userId);
+            all = all.stream()
+                .filter(c -> c.getMinVisibleLevel() == null || c.getMinVisibleLevel().isEmpty()
+                    || visibleLevelCodes.contains(c.getMinVisibleLevel()))
+                .collect(Collectors.toList());
+        }
         return Result.success(buildTree(all, 0L));
     }
 
@@ -90,6 +105,11 @@ public class ProductCategoryService {
     }
 
     public Result<List<Map<String, Object>>> products(Long categoryId) {
+        return products(categoryId, null);
+    }
+
+    /** 按会员级别过滤分类下商品:userId 为空时不过滤(管理端/无限制),非空时仅返回用户级别可见的商品 */
+    public Result<List<Map<String, Object>>> products(Long categoryId, Long userId) {
         List<Product> products = productMapper.selectList(
             new LambdaQueryWrapper<Product>()
                 .eq(Product::getCategoryId, categoryId)
@@ -97,6 +117,13 @@ public class ProductCategoryService {
                 .orderByAsc(Product::getSortOrder)
                 .orderByDesc(Product::getCreatedAt)
         );
+        if (userId != null) {
+            List<String> visibleLevelCodes = membershipService.getVisibleMemberLevelCodes(userId);
+            products = products.stream()
+                .filter(p -> p.getMinVisibleLevel() == null || p.getMinVisibleLevel().isEmpty()
+                    || visibleLevelCodes.contains(p.getMinVisibleLevel()))
+                .collect(Collectors.toList());
+        }
         List<Map<String, Object>> result = products.stream().map(p -> {
             Map<String, Object> m = new LinkedHashMap<>();
             m.put("id", p.getId());
@@ -136,6 +163,7 @@ public class ProductCategoryService {
         m.put("enabled", cat.getEnabled());
         m.put("dimensionCodes", cat.getDimensionCodes());
         m.put("productTypes", cat.getProductTypes());
+        m.put("minVisibleLevel", cat.getMinVisibleLevel());
         return m;
     }
 }

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

@@ -97,12 +97,17 @@ wrapper.in(Product::getId, productIds)
         if (userId == null) {
             wrapper.ne(Product::getMemberEligible, "yes");
         }
-        // 可见角色过滤:visible_roles 为 NULL 时全部可见,否则仅匹配用户角色
+        // 最低可见级别过滤:min_visible_level 为 NULL 时全部可见,否则仅当用户级别数字 >= 该等级数字时可见
         if (userId == null) {
-            wrapper.isNull(Product::getVisibleRoles);
-        } else if (role != null && !role.isEmpty()) {
-            wrapper.and(w -> w.isNull(Product::getVisibleRoles)
-                .or().apply("JSON_CONTAINS(visible_roles, JSON_QUOTE({0}))", role));
+            wrapper.isNull(Product::getMinVisibleLevel);
+        } else {
+            List<String> visibleLevelCodes = membershipService.getVisibleMemberLevelCodes(userId);
+            wrapper.and(w -> {
+                w.isNull(Product::getMinVisibleLevel);
+                if (!visibleLevelCodes.isEmpty()) {
+                    w.or().in(Product::getMinVisibleLevel, visibleLevelCodes);
+                }
+            });
         }
         if (query.getProductType() != null && !query.getProductType().isEmpty()) {
             wrapper.eq(Product::getProductType, query.getProductType());
@@ -490,6 +495,7 @@ wrapper.in(Product::getId, productIds)
         copy.setMinQuantity(original.getMinQuantity());
         copy.setSortOrder(original.getSortOrder());
         copy.setVisibleRoles(original.getVisibleRoles());
+        copy.setMinVisibleLevel(original.getMinVisibleLevel());
         copy.setCreatedAt(new Date());
         copy.setUpdatedAt(new Date());
         productMapper.insert(copy);

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

@@ -1159,10 +1159,29 @@ CREATE TABLE IF NOT EXISTS products (
     external_data TEXT COMMENT '外部来源数据JSON',
     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=全部可见)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
 
+-- 商品类目表(迁移创建于 DatabaseInitializer,同步到 schema.sql 保持完整快照)
+CREATE TABLE IF NOT EXISTS shop_categories (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(64) NOT NULL COMMENT '分类名称',
+    parent_id BIGINT DEFAULT 0 COMMENT '父分类ID(0=根节点)',
+    level INT DEFAULT 0 COMMENT '层级(0/1/2/3)',
+    sort INT DEFAULT 0 COMMENT '排序',
+    image VARCHAR(512) COMMENT '分类图标',
+    enabled TINYINT(1) DEFAULT 1 COMMENT '启用',
+    dimension_codes VARCHAR(128) COMMENT '维度编码(逗号分隔: body,mind,heart)',
+    product_types VARCHAR(128) COMMENT '商品类型(逗号分隔: PHYSICAL,COURSE,ASSESSMENT)',
+    min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_parent (parent_id),
+    INDEX idx_sort (sort)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类目';
+
 -- 库存交易流水表
 CREATE TABLE IF NOT EXISTS inventory_transactions (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -3875,6 +3894,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',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_level_code (level_code)

+ 31 - 4
cfc-web/src/views/admin/CategoryManage.vue

@@ -79,6 +79,18 @@
               <el-switch v-model="form.enabled" :active-value="true" :inactive-value="false" />
             </el-form-item>
 
+            <el-form-item label="最低可见级别">
+              <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"
+                />
+              </el-select>
+              <div style="font-size: 12px; color: #999; line-height: 1.5; margin-top: 4px;">留空=全部会员可见;选择后仅该级别及更高级别会员可见</div>
+            </el-form-item>
+
             <el-form-item>
               <el-button type="primary" :loading="saving" @click="handleSave">
                 {{ form.id ? '保存修改' : '创建分类' }}
@@ -94,7 +106,7 @@
 </template>
 
 <script>
-import { getCategoryTree, createCategory, updateCategory, deleteCategory, toggleCategory } from '@/api/admin'
+import { getCategoryTree, createCategory, updateCategory, deleteCategory, toggleCategory, listMembershipLevels } from '@/api/admin'
 
 export default {
   name: 'CategoryManage',
@@ -110,8 +122,10 @@ export default {
         sort: 0,
         enabled: true,
         dimensionCodes: '',
-        productTypes: ''
+        productTypes: '',
+        minVisibleLevel: ''
       },
+      membershipLevels: [],
       selectedDimensions: [],
       selectedProductTypes: [],
       rules: {
@@ -141,8 +155,19 @@ export default {
   },
   mounted() {
     this.loadTree()
+    this.loadMembershipLevels()
   },
   methods: {
+    async loadMembershipLevels() {
+      try {
+        const res = await listMembershipLevels()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.membershipLevels = res.data.filter(l => l.levelCode !== 'FREE')
+        }
+      } catch (e) {
+        console.warn('加载会员等级失败', e)
+      }
+    },
     async loadTree() {
       try {
         const res = await getCategoryTree(true)
@@ -172,7 +197,8 @@ export default {
         sort: data.sort || 0,
         enabled: data.enabled !== false,
         dimensionCodes: data.dimensionCodes || '',
-        productTypes: data.productTypes || ''
+        productTypes: data.productTypes || '',
+        minVisibleLevel: data.minVisibleLevel || ''
       }
       this.selectedDimensions = data.dimensionCodes ? data.dimensionCodes.split(',').filter(Boolean) : []
       this.selectedProductTypes = data.productTypes ? data.productTypes.split(',').filter(Boolean) : []
@@ -185,7 +211,8 @@ export default {
         sort: 0,
         enabled: true,
         dimensionCodes: '',
-        productTypes: ''
+        productTypes: '',
+        minVisibleLevel: ''
       }
       this.selectedDimensions = []
       this.selectedProductTypes = []

+ 6 - 0
cfc-web/src/views/admin/MembershipCenter.vue

@@ -231,6 +231,10 @@
         <el-form-item label="等级描述">
           <el-input v-model="editForm.levelDesc" type="textarea" :rows="2" maxlength="200" show-word-limit style="width:320px;" />
         </el-form-item>
+        <el-form-item label="级别排序">
+          <el-input-number v-model="editForm.levelOrder" :min="1" :max="999" :step="1" style="width:160px;" />
+          <span style="color:#999;margin-left:8px;font-size:12px;">数字越小级别越低,低级别可见则更高级别也可见</span>
+        </el-form-item>
         <el-form-item label="月费(元)">
           <el-input-number v-model="editForm.priceMonthly" :min="0" :step="100" :precision="0" style="width:160px;" />
         </el-form-item>
@@ -319,6 +323,7 @@ export default {
         levelCode: '',
         levelName: '',
         levelDesc: '',
+        levelOrder: 1,
         priceMonthly: 0,
         priceQuarterly: 0,
         priceYearly: 0,
@@ -437,6 +442,7 @@ export default {
         levelCode: row.levelCode,
         levelName: row.levelName || '',
         levelDesc: row.levelDesc || '',
+        levelOrder: row.levelOrder || 1,
         priceMonthly: row.priceMonthly,
         priceQuarterly: row.priceQuarterly,
         priceYearly: row.priceYearly,

+ 11 - 9
cfc-web/src/views/admin/ProductEdit.vue

@@ -250,14 +250,16 @@
           </el-radio-group>
         </el-form-item>
 
-        <el-form-item label="可见角色">
-          <el-select v-model="form.visibleRoles" multiple placeholder="全部可见" style="width: 200px;">
-            <el-option label="家长" value="parent" />
-            <el-option label="孩子" value="child" />
-            <el-option label="规划师" value="teacher" />
-            <el-option label="管理员" value="admin" />
+        <el-form-item label="最低可见级别">
+          <el-select v-model="form.minVisibleLevel" placeholder="全部可见" clearable style="width: 200px;">
+            <el-option
+              v-for="lv in membershipLevels"
+              :key="lv.levelCode"
+              :label="lv.levelName + ' (' + lv.levelCode + ')'"
+              :value="lv.levelCode"
+            />
           </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="商品维度">
@@ -376,7 +378,7 @@ export default {
         distributionSystemId: null,
         deliveryMethod: 1,
         memberEligible: 'no',
-        visibleRoles: [],
+        minVisibleLevel: '',
         dimensionWeights: [],
         // 测评商品扩展
         assessmentType: 'single',
@@ -636,7 +638,7 @@ export default {
             distributionSystemId: p.distributionSystemId || null,
             deliveryMethod: p.deliveryMethod || 1,
             memberEligible: p.memberEligible || 'no',
-            visibleRoles: p.visibleRoles || [],
+            minVisibleLevel: p.minVisibleLevel || '',
             dimensionWeights: [],
             assessmentType: p.assessmentType || 'single',
             totalSessions: p.totalSessions || 1,

+ 9 - 5
cfc-web/src/views/admin/ProductManage.vue

@@ -95,9 +95,9 @@
           {{ row.sortOrder != null ? row.sortOrder : '-' }}
         </template>
       </el-table-column>
-      <el-table-column label="可见角色" width="140">
+      <el-table-column label="最低可见级别" width="140">
         <template slot-scope="{ row }">
-          <span v-if="row.visibleRoles && row.visibleRoles.length">{{ row.visibleRoles.join(', ') }}</span>
+          <span v-if="row.minVisibleLevel">{{ levelLabel(row.minVisibleLevel) }}</span>
           <span v-else style="color:#999">全部</span>
         </template>
       </el-table-column>
@@ -160,10 +160,9 @@
         <el-descriptions-item label="状态">
           <el-tag :type="statusType(detail.status)" size="mini">{{ statusLabel(detail.status) }}</el-tag>
         </el-descriptions-item>
-        <el-descriptions-item label="可见角色" v-if="detail.visibleRoles && detail.visibleRoles.length">
-          {{ detail.visibleRoles.join(', ') }}
+        <el-descriptions-item label="最低可见级别">
+          {{ detail.minVisibleLevel ? levelLabel(detail.minVisibleLevel) : '全部' }}
         </el-descriptions-item>
-        <el-descriptions-item label="可见角色" v-else>全部</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="创建时间" :span="2">{{ formatTime(detail.createdAt) }}</el-descriptions-item>
@@ -314,6 +313,11 @@ export default {
       const map = { physical: '实物商品', virtual: '虚拟商品', coupon: '优惠券', assessment: '测评商品' }
       return map[type] || type
     },
+    levelLabel(code) {
+      if (!code) return '全部'
+      const map = { FREE: '免费版', FAMILY: '家庭版', PROVIDER: '服务商版', PREMIUM: '高级版', LIFETIME: '终身版' }
+      return map[code] || code
+    },
     formatTime(t) {
       if (!t) return '-'
       return t.replace ? t.replace('T', ' ').substring(0, 19) : t