Browse Source

Merge remote-tracking branch 'origin/cfclub' into chore/att-s2-nonblocking-into-cfclub

Agnes Agent 2 weeks ago
parent
commit
dd92dd6781

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

@@ -7594,6 +7594,81 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
 
         // 迁移: products表添加min_quantity列(商品最小购买数限制)
         ensureColumn("products", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
+
+        // 迁移: 素材库历史数据修复(file_type 回填 + 按分类补标签,幂等)
+        backfillAssetLibrary();
+    }
+
+    /**
+     * 素材库历史数据修复(幂等):
+     * <ol>
+     *   <li>回填 file_record.file_type(历史记录可能为 NULL,导致素材库类型筛选失效)</li>
+     *   <li>按分类名给记录补自动标签(规则与上传时一致;文件类型不参与标签)</li>
+     * </ol>
+     */
+    private void backfillAssetLibrary() {
+        try {
+            int n = jdbcTemplate.update(
+                    "UPDATE file_record SET file_type = CASE " +
+                    "WHEN content_type LIKE 'image/%' THEN 'image' " +
+                    "WHEN content_type LIKE 'video/%' THEN 'video' " +
+                    "WHEN content_type LIKE 'audio/%' THEN 'audio' " +
+                    "WHEN content_type LIKE 'text/%' THEN 'document' " +
+                    "WHEN content_type IN ('application/pdf','application/msword'," +
+                    "'application/vnd.openxmlformats-officedocument.wordprocessingml.document'," +
+                    "'application/vnd.ms-excel'," +
+                    "'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'," +
+                    "'application/vnd.ms-powerpoint'," +
+                    "'application/vnd.openxmlformats-officedocument.presentationml.presentation') THEN 'document' " +
+                    "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
+                    "IN ('jpg','jpeg','png','gif','webp','bmp','svg','ico','heic') THEN 'image' " +
+                    "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
+                    "IN ('mp4','webm','ogg','mov','m4v','avi','mkv') THEN 'video' " +
+                    "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
+                    "IN ('mp3','wav','m4a','aac','flac','amr') THEN 'audio' " +
+                    "WHEN SUBSTRING_INDEX(LOWER(original_filename), '.', -1) " +
+                    "IN ('pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','csv','rtf','odt','ods') THEN 'document' " +
+                    "ELSE 'other' END " +
+                    "WHERE file_type IS NULL OR file_type = '' " +
+                    "OR file_type NOT IN ('image','video','audio','document','other')");
+            if (n > 0) {
+                log.info("素材库回填 file_type: {} 条", n);
+            }
+        } catch (Exception e) {
+            log.warn("素材库回填 file_type 失败: {}", e.getMessage());
+        }
+
+        try {
+            List<Map<String, Object>> cats = jdbcTemplate.queryForList(
+                    "SELECT category FROM file_record WHERE category IS NOT NULL AND category <> '' GROUP BY category");
+            int tagged = 0;
+            for (Map<String, Object> row : cats) {
+                String category = String.valueOf(row.get("category"));
+                String tagName = com.etotem.cfc.util.AssetCategoryTag.label(category);
+                String color = com.etotem.cfc.util.AssetCategoryTag.color(category);
+                List<Map<String, Object>> found = jdbcTemplate.queryForList(
+                        "SELECT id FROM file_tag WHERE name = ? LIMIT 1", tagName);
+                Long tagId;
+                if (found.isEmpty()) {
+                    jdbcTemplate.update(
+                            "INSERT INTO file_tag (name, color, sort_order, created_at) VALUES (?, ?, 0, NOW())",
+                            tagName, color);
+                    tagId = jdbcTemplate.queryForObject(
+                            "SELECT id FROM file_tag WHERE name = ? LIMIT 1", Long.class, tagName);
+                } else {
+                    tagId = ((Number) found.get(0).get("id")).longValue();
+                }
+                tagged += jdbcTemplate.update(
+                        "INSERT IGNORE INTO file_tag_rel (file_id, tag_id) " +
+                        "SELECT id, ? FROM file_record WHERE category = ?",
+                        tagId, category);
+            }
+            if (tagged > 0) {
+                log.info("素材库按分类补标签: {} 条", tagged);
+            }
+        } catch (Exception e) {
+            log.warn("素材库按分类补标签失败: {}", e.getMessage());
+        }
     }
 
     private void runMigration99() {

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

@@ -88,6 +88,7 @@ public class CouponService {
         item.put("usedAt", fc.getUsedAt());
         item.put("orderId", fc.getOrderId());
         item.put("productId", coupon.getProductId());
+        item.put("grantType", coupon.getGrantType());
         return item;
     }
 

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

@@ -651,6 +651,8 @@ public class MembershipService implements MembershipServiceInterface {
                 resp.put("env", dto.getEnv());
                 resp.put("signType", dto.getSignType());
                 resp.put("currentPeriod", dto.getCurrentPeriod());
+                // 虚拟支付兜底:道具价格/风控校验失败(如 GOODS_PRICE_INVALID)时,前端自动改走普通微信支付(JSAPI)
+                resp.put("wechatPayParams", buildWechatFallbackParams(mainOrder, level, openid));
                 return resp;
             }
             // 虚拟道具未配置或停用,降级为普通微信支付
@@ -667,6 +669,34 @@ public class MembershipService implements MembershipServiceInterface {
         return resp;
     }
 
+    /**
+     * 虚拟支付失败兜底:生成普通微信支付(v3 JSAPI)参数。
+     *
+     * <p>微信虚拟支付对道具价格做严格校验(goodsPrice 需与后台道具注册价一致),
+     * 价格不匹配时前端会收到 GOODS_PRICE_INVALID(-15013) 而无法完成支付。
+     * 此处预先生成 JSAPI 支付参数,前端在虚拟支付失败时自动改走普通微信支付完成下单。
+     *
+     * @return 微信 JSAPI 支付参数;openid 缺失或微信统一下单失败时返回 null
+     */
+    private Map<String, Object> buildWechatFallbackParams(PaymentOrder order, MembershipLevel level, String openid) {
+        if (order == null || order.getAmount() == null || order.getAmount() <= 0) {
+            return null;
+        }
+        if (openid == null || openid.isEmpty()) {
+            log.warn("会员订单普通支付兜底跳过(openid缺失): orderNo={}", order.getOrderNo());
+            return null;
+        }
+        try {
+            String levelName = level != null && level.getLevelName() != null
+                    ? level.getLevelName() : order.getLevelCode();
+            String description = "\u6d6e\u827e\u798f-" + levelName + "\u4f1a\u5458";
+            return paymentService.createWechatPrepay(order.getOrderNo(), description, order.getAmount(), openid);
+        } catch (Exception e) {
+            log.warn("会员订单普通支付兜底参数生成失败: orderNo={}", order.getOrderNo(), e);
+            return null;
+        }
+    }
+
     /**
      * 处理支付回调
      */

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -9,8 +9,13 @@ import com.amazonaws.services.s3.model.PutObjectRequest;
 import com.amazonaws.services.s3.model.S3Object;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.FileRecord;
+import com.etotem.cfc.entity.FileTag;
+import com.etotem.cfc.entity.FileTagRel;
 import com.etotem.cfc.mapper.FileRecordMapper;
+import com.etotem.cfc.mapper.FileTagMapper;
+import com.etotem.cfc.mapper.FileTagRelMapper;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.util.AssetCategoryTag;
 import com.etotem.cfc.util.FileTypeResolver;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -74,6 +79,12 @@ public class JdCloudStorageService implements StorageService {
     @Resource
     private FileRecordMapper fileRecordMapper;
 
+    @Resource
+    private FileTagMapper fileTagMapper;
+
+    @Resource
+    private FileTagRelMapper fileTagRelMapper;
+
     private AmazonS3 s3Client;
 
     @PostConstruct
@@ -139,6 +150,7 @@ public class JdCloudStorageService implements StorageService {
                     fileRecordMapper.updateById(existing);
                     log.info("去重命中并补齐 file_type: hash={}, fileType={}", hash, fileType);
                 }
+                autoTagByCategory(existing);
                 log.info("文件已存在(去重命中): hash={}, url={}, category={}",
                         hash, existing.getUrl(), existing.getCategory());
                 return existing;
@@ -177,6 +189,7 @@ public class JdCloudStorageService implements StorageService {
             record.setCreatedAt(new Date());
             record.setUpdatedAt(new Date());
             fileRecordMapper.insert(record);
+            autoTagByCategory(record);
 
             return record;
         } catch (Exception e) {
@@ -184,6 +197,45 @@ public class JdCloudStorageService implements StorageService {
         }
     }
 
+    /**
+     * 按素材分类自动打标签(“未从素材库上传”的业务文件也统一纳入)。
+     *
+     * <p>规则:标签名 = 分类中文名({@link AssetCategoryTag#label}),文件类型
+     * (image/audio/document...)作为独立维度,不参与标签。已有任意标签的文件跳过。</p>
+     */
+    private void autoTagByCategory(FileRecord record) {
+        if (record == null || record.getId() == null) return;
+        try {
+
+            String category = isBlank(record.getCategory()) ? "general" : record.getCategory().trim();
+            String tagName = AssetCategoryTag.label(category);
+            FileTag tag = fileTagMapper.selectOne(
+                    new LambdaQueryWrapper<FileTag>().eq(FileTag::getName, tagName).last("LIMIT 1"));
+            if (tag == null) {
+                tag = new FileTag();
+                tag.setName(tagName);
+                tag.setColor(AssetCategoryTag.color(category));
+                tag.setSortOrder(0);
+                tag.setCreatedAt(new Date());
+                fileTagMapper.insert(tag);
+                log.info("素材库自动创建分类标签: {}", tagName);
+            }
+            Long exists = fileTagRelMapper.selectCount(
+                    new LambdaQueryWrapper<FileTagRel>()
+                            .eq(FileTagRel::getFileId, record.getId())
+                            .eq(FileTagRel::getTagId, tag.getId()));
+            if (exists != null && exists > 0) return;
+            FileTagRel rel = new FileTagRel();
+            rel.setFileId(record.getId());
+            rel.setTagId(tag.getId());
+            fileTagRelMapper.insert(rel);
+        } catch (Exception e) {
+            // 自动打标签失败不影响上传主流程
+            log.warn("素材库自动打标签失败: fileId={}, category={}, err={}",
+                    record.getId(), record.getCategory(), e.getMessage());
+        }
+    }
+
     private static boolean isBlank(String s) {
         return s == null || s.trim().isEmpty();
     }

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

@@ -2,8 +2,13 @@ package com.etotem.cfc.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.FileRecord;
+import com.etotem.cfc.entity.FileTag;
+import com.etotem.cfc.entity.FileTagRel;
 import com.etotem.cfc.mapper.FileRecordMapper;
+import com.etotem.cfc.mapper.FileTagMapper;
+import com.etotem.cfc.mapper.FileTagRelMapper;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.util.AssetCategoryTag;
 import com.etotem.cfc.util.FileTypeResolver;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -32,6 +37,12 @@ public class LocalDiskStorageService implements StorageService {
     @Resource
     private FileRecordMapper fileRecordMapper;
 
+    @Resource
+    private FileTagMapper fileTagMapper;
+
+    @Resource
+    private FileTagRelMapper fileTagRelMapper;
+
     @Override
     public String storeFile(MultipartFile file) {
         return storeFile(file, null);
@@ -119,6 +130,7 @@ public class LocalDiskStorageService implements StorageService {
                     existing.setUpdatedAt(new Date());
                     fileRecordMapper.updateById(existing);
                 }
+                autoTagByCategory(existing);
                 return existing;
             }
 
@@ -135,12 +147,44 @@ public class LocalDiskStorageService implements StorageService {
             record.setCreatedAt(new Date());
             record.setUpdatedAt(new Date());
             fileRecordMapper.insert(record);
+            autoTagByCategory(record);
             return record;
         } catch (IOException e) {
             throw new RuntimeException("文件存储失败", e);
         }
     }
 
+    /** 按分类自动打标签(与京东云实现保持一致,文件类型不参与标签)。 */
+    private void autoTagByCategory(FileRecord record) {
+        if (record == null || record.getId() == null) return;
+        try {
+            String category = (record.getCategory() == null || record.getCategory().trim().isEmpty())
+                    ? "general" : record.getCategory().trim();
+            String tagName = AssetCategoryTag.label(category);
+            FileTag tag = fileTagMapper.selectOne(
+                    new LambdaQueryWrapper<FileTag>().eq(FileTag::getName, tagName).last("LIMIT 1"));
+            if (tag == null) {
+                tag = new FileTag();
+                tag.setName(tagName);
+                tag.setColor(AssetCategoryTag.color(category));
+                tag.setSortOrder(0);
+                tag.setCreatedAt(new Date());
+                fileTagMapper.insert(tag);
+            }
+            Long exists = fileTagRelMapper.selectCount(
+                    new LambdaQueryWrapper<FileTagRel>()
+                            .eq(FileTagRel::getFileId, record.getId())
+                            .eq(FileTagRel::getTagId, tag.getId()));
+            if (exists != null && exists > 0) return;
+            FileTagRel rel = new FileTagRel();
+            rel.setFileId(record.getId());
+            rel.setTagId(tag.getId());
+            fileTagRelMapper.insert(rel);
+        } catch (Exception e) {
+            System.err.println("素材库自动打标签失败: " + e.getMessage());
+        }
+    }
+
     @Override
     public void deleteFile(String fileUrl) {
         if (fileUrl == null || fileUrl.isEmpty()) {

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/AssetCategoryTag.java

@@ -0,0 +1,71 @@
+package com.etotem.cfc.util;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 素材分类 → 自动标签名 / 标签颜色。
+ *
+ * <p>素材库按“分类名”自动生成标签(未从素材库入口上传的业务文件同样适用);
+ * 文件类型(image / video / audio / document / other)是独立维度,不参与标签。</p>
+ */
+public final class AssetCategoryTag {
+
+    private static final Map<String, String> LABELS;
+    private static final Map<String, String> COLORS;
+
+    static {
+        Map<String, String> labels = new HashMap<>();
+        labels.put("general", "通用素材");
+        labels.put("products", "商品图片");
+        labels.put("product-skus", "商品SKU图");
+        labels.put("articles", "文章配图");
+        labels.put("articles-audio", "文章音频");
+        labels.put("activities", "活动图片");
+        labels.put("banners", "轮播图");
+        labels.put("guide-packages", "攻略包");
+        labels.put("kb-docs", "知识库文档");
+        labels.put("kb-images", "知识库图片");
+        labels.put("health-reports", "健康报告");
+        labels.put("uploads", "其它上传");
+        LABELS = Collections.unmodifiableMap(labels);
+
+        Map<String, String> colors = new HashMap<>();
+        colors.put("general", "#909399");
+        colors.put("products", "#409EFF");
+        colors.put("product-skus", "#409EFF");
+        colors.put("articles", "#E6A23C");
+        colors.put("articles-audio", "#67C23A");
+        colors.put("activities", "#F56C6C");
+        colors.put("banners", "#FF8C42");
+        colors.put("guide-packages", "#9B59B6");
+        colors.put("kb-docs", "#1ABC9C");
+        colors.put("kb-images", "#1ABC9C");
+        colors.put("health-reports", "#67C23A");
+        colors.put("uploads", "#909399");
+        COLORS = Collections.unmodifiableMap(colors);
+    }
+
+    private AssetCategoryTag() {
+    }
+
+    /** 分类对应的标签名;未知分类回退为分类 key 本身。 */
+    public static String label(String category) {
+        if (category == null || category.trim().isEmpty()) {
+            return LABELS.get("general");
+        }
+        String key = category.trim();
+        String label = LABELS.get(key);
+        return label != null ? label : key;
+    }
+
+    /** 分类对应的标签颜色;未知分类回退为默认蓝。 */
+    public static String color(String category) {
+        if (category == null || category.trim().isEmpty()) {
+            return COLORS.get("general");
+        }
+        String color = COLORS.get(category.trim());
+        return color != null ? color : "#409EFF";
+    }
+}

+ 14 - 3
cfc-frontend/pages/membership/pay.vue

@@ -153,7 +153,8 @@ export default {
         if (c.status && c.status !== 'AVAILABLE') return false
         if (c.applicableTo && c.applicableTo !== 'ALL' && c.applicableTo !== 'MEMBERSHIP') return false
         // 会员专属券:非会员不显示在可选列表中
-        if (c.applicableTo === 'MEMBERSHIP' && !self.isMember) return false
+        // 会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用;手动发放券(grantType=MANUAL)例外,允许非会员购买时使用
+        if (c.applicableTo === 'MEMBERSHIP' && !self.isMember && c.grantType !== 'MANUAL') return false
         return true
       })
     },
@@ -239,7 +240,8 @@ export default {
     },
     getCouponCondition: function(coupon) {
       if (!coupon) return '无门槛'
-      if (coupon.applicableTo === 'MEMBERSHIP' && !this.isMember) {
+      // 会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用;手动发放券(grantType=MANUAL)例外,允许非会员购买时使用
+      if (coupon.applicableTo === 'MEMBERSHIP' && !this.isMember && coupon.grantType !== 'MANUAL') {
         return '开通会员后可用'
       }
       if (!coupon.minSpend || coupon.minSpend <= 0) return '无门槛'
@@ -346,8 +348,17 @@ export default {
           }
         },
         fail: function(err) {
-          self.paying = false
           var errCode = err && err.errCode
+          // 虚拟支付失败(道具价格不匹配/道具未发布/环境不支持等)→ 自动降级为普通微信支付(订单支付)
+          // 用户主动取消(-2)与登录态失效(-15007/-15005)不降级,直接提示
+          if (dto && dto.wechatPayParams && errCode !== -2 && errCode !== -15007 && errCode !== -15005) {
+            uni.showToast({ title: '虚拟支付不可用,转微信支付', icon: 'none' })
+            setTimeout(function() {
+              self.payWechat(dto.wechatPayParams)
+            }, 800)
+            return
+          }
+          self.paying = false
           var msg = ''
           if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录后支付'
           else if (errCode === -15010) msg = '商品未发布,请联系客服'

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-fa53b3758da7dd051b121bae65df18bc7a673c74
+2cbda56e4fc5a907a74956f945f53a79856bedc7

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

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

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

@@ -4,6 +4,262 @@
 
 ---
 
+## v1.0.1824 (2026-10-29)
+
+### 新功能
+- 虚拟支付失败自动转微信JSAPI + 会员券非会员可用 + 素材库回填幂等修复
+- 饮食模块整合-去食材查询合并食材推荐
+- 商品停留改按累计排序 + 新增停留时段热力图
+- 后台Dashboard重设计 + 真实行为数据口径
+- B2 能量沙盘保留与适配(C1/C2/C3 三冲突处理)
+
+### 其他
+- - CouponService: toCouponVO 暴露 grantType,前端据此允许非会员使用手动发放(MANUAL)的会员券
+- - pay.vue: 虚拟支付失败(非取消/非登录态失效)自动降级 payWechat; 会员券筛选放行 MANUAL
+- - 素材库 backfillAssetLibrary 幂等回填(手动补跑 file_type 1422 + 按分类补标签 7867)
+- 
+- - main.js 补注册 Image/RadioButton/CheckboxButton/Popconfirm/OptionGroup/Backtop/PageHeader(el-image、类型筛选此前完全失效)
+- - AssetPicker: 上传区改为 el-upload drag 整块可点击可拖拽;el-check-tag(Element Plus 组件) 换为可点击 el-tag;category 不再作为硬过滤条件,改为可选分类筛选(默认全部)
+- - editorAssetUpload: 注册 wangEditor 自定义素材库菜单 + 工具栏配置
+- - ArticleEdit/ActivityEdit/ProductEdit: 工具栏加入素材库按钮,点击打开素材库弹窗
+- 后端:
+- - 新增 AssetCategoryTag:分类 -> 中文标签名/颜色(文件类型为独立维度,不参与标签)
+- - JdCloud/LocalDisk StorageService 上传(含去重命中)时按分类自动打标签
+- - DatabaseInitializer 新增幂等回填:file_type 归一化(含 NULL/pdf 等异常值) + 按分类补标签
+- 
+-   interface_test_metrics.env,供 bash 提交信息使用
+- - bash 提交前读取该文件并初始化默认值,消除 set -u 下 $fixed/$regressed
+-   unbound variable 导致整条 git commit 命令展开失败(曾使测试结果无法提交)
+- - 提交信息由假 '?%' 改为真实通过率,例如 '通过率 45% (fix=0 reg=0)'
+- 
+- 
+- - 删除 pages/diet/food-query.vue 及 pages.json 路由,recommendation 标题改「食材推荐」
+- - 饮食首页:移除「食材查询」入口,「食谱推荐」改名「食材推荐」指向 recommendation
+- - 成长页:仅保留「饮食推荐」入口(指向 recommendation),移除「食材戒备匹配」卡片及相关数据/方法/接口导入
+- - 肠道菌群报告详情:移除「食材戒备」卡片、goToCaution、cautionCount 及 getFoodCautionList 导入
+- 任务:饮食模块整合-去食材查询合并食材推荐
+- 验证:verify-vue-compile 全站 PASS(389/390,mp-html 跳过);audit-mp-antipatterns ERROR 0;audit-duplicate-api-calls 0 error;npm run build:mp-weixin 构建成功
+- 
+- 扩展核验发现 1 项真实 a11y 缺陷(星图 Canvas 缺 ARIA/文字替代,建议合入前修复)
+- 及 2 项门禁 FAIL 为误报/既有约束(lint_hardcodes、slop_tells)。
+- 
+- 微信小程序运行时该参数解析为 undefined,触发 Cannot read properties of undefined (reading 'id'),
+- 报告列表无法进入详情。H5 正常,仅 MP 端复现。
+- 修复:模板内联事件改传基本类型 id / reportType / subjectId,同步调整 7 个处理方法签名
+- (onRowClick / onTouchStart / adjustBinding / confirmDelete / doDelete / approveTransfer /
+- denyTransfer / selectMemberAdjust)。模板 :class/:style/:key 的 item.x 绑定不受影响,保持原样。
+- 验证:
+- - scripts/verify-vue-compile.js report-list.vue → PASS(模板/样式/脚本)
+- - scripts/audit-mp-antipatterns.js --error-only → ERROR 0(含全站回归扫描)
+- - npm run build:mp-weixin → DONE exit 0;编译后 wxml 确认各 handler 改为从列表数据提取基本字段
+-   (onRowClick: item.m10,'id' + item.m11,'reportType' 等),不再传递整个对象
+- 
+- - getItemName 原按 province||city||district||street 顺序取值,北京/天津区级节点 province 也填北京市,
+-   导致区县级列表全部显示北京市;改为按 item.level 取对应层级字段(1省/2市/3区/4街道)
+- 
+-   家庭内任一成员完成五维自检也视为通过,修复完成健康自测后沙盘仍被锁定的问题
+- - isGateUnlocked(SELF_CHECK): count同时累加health_status记录数,与hasFamilySelfCheck口径一致
+- 
+- a11y(能量沙盘): canvas 补 role=img+动态 aria-label,粒子层 aria-hidden(修复 WCAG 1.1.1)
+- - 禁用态契约从 .btn 收口到全部交互控件:.svc/.tab/.icon-btn/.task-check
+-   一律 opacity:1 + pointer-events:none,带底控件用 warm-200+text-2,纯文字/图标控件用 muted text-3
+-   (原 opacity:0.45 淡出合成 2.51/2.31:1 不可读 → 实测 6.04/6.05:1)
+- - 16 个内联装饰图标 SVG 补 aria-hidden="true"
+- - 契约说明条与实现一致化
+- - 删除孤儿令牌 --opacity-disabled(零引用)
+- - 计划文档 §4.3 同步:禁用控件分类 + 令牌仅保留定义不再被引用
+- 
+-   且涉及 cfc-backend 改动时,自动跑 mvn -DskipTests compile,红构建阻止 push
+- - 新增 docs/2026-10-29-CI编译闸门-远端CI草案.md:方案 B 远端 CI 配置草案(Gitea/GitLab/webhook)
+- - AGENTS.md 补充启用说明(git config core.hooksPath githooks)
+- - 约束:编译闸门仅在 iwintrue(远端CI)与 local(本机)执行
+- - 验证:bash -n 通过;非受保护分支跳过 exit0;受保护分支触发 mvn compile 绿灯 exit0
+- 
+- - 补记主理人裁定:§4.3 禁用态淡出已移除+pointer-events;§10 富/智按周口径落地
+- 
+- 2. 新增「内容吸引力 · 停留时段热力」:复用已预取的 articleReads/productViews,按 0-23 点聚合近30天真实停留时长,返回 dwellByHour/dwellByHourTotal/dwellByHourPeak。
+- 3. Dashboard 渲染 24 小时热力图(颜色深度=相对峰值停留),含时段轴、图例、合计,帮助运营把握内容推送黄金时段;商品卡片副文案同步改「按累计停留排序」。
+- Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
+- 
+- 2. 统计口径修正:文章热度排名由 view_count(每次打开+1的虚高指标)改为 article_reading_records 真实阅读记录聚合;商品销量本就来自真实 product_orders,统一加"真实"标识。
+- 3. 商品停留埋点:新增 product_view_records 表 + POST /api/stats/product-view 接口 + 小程序 dwellTracker 埋点(onLoad/onUnload 上报浏览时长),补齐此前缺失的商品停留时长数据。
+- Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
+- 
+- - Canvas 双图层(starCanvas + flowCanvas)+ 点击感应层
+- - live / preview 双模式、右上切换与徽章逻辑
+- - GateLock 沙盘锁链路(sandboxLocked / sandboxLockGate)未触碰
+- - §1.3 全量 props 契约(mode/scores/overallScore/members/personalData/
+-   badgeText/showDimensionBar/dimensions/totalEnergy/totalHealthIndex/
+-   starHeight/familyUnlocked/memberCount/currentView)原样保留
+- - 环形顶点顺序 [心,身,智,富,行] = sortedDimensions 序,未改
+- C1 冲突:顶点与维度条 emoji(火/叶/水滴/闪电/地球)全部替换为内联 SVG
+- - 24×24 viewBox、stroke-width 1.8、round cap/join,mp-weixin 用
+-   data:image/svg+xml 作为 image src(仓库既有模式)
+- - 五行语义改由形状承载:心=火焰 身=山 智=闪电 富=水滴 行=叶
+- - 组件忽略接口返回的 icon 字段,始终按 code 取内置图形
+- C2 冲突:沙盘"身"色统一到 $dim-body 家族
+- - 移除组件内紫蓝渐变切换器与 $brown-warm 身色,一稿内不再出现两个"身"色
+- C3 冲突:.sandbox-star 明确高度
+- - CSS 兜底 height/min-height + flex-shrink:0 + 父级 starHeight 内联覆盖
+- 配色统一全站 $dim-*
+- - 新增 $dim-data-* 五个数据笔触深档令牌(uni.scss),Canvas 与图标同源
+- - 五行主色仅作装饰(tint 底 / 光晕 / 填充),数据笔触用 deep 档
+- - 对填充 $dim-sandbox-fill 实测对比度 4.56:1 ~ 6.57:1,均 >= 3:1
+- 顺带修正既有对齐 bug
+- - render/hitdetect 由旧 ts 序改为环形序 [mind,body,wisdom,wealth,action],
+-   臂尖=outer[i],消除画布臂色与 DOM 顶点错位 3~4/5 的问题
+- - flowCanvas 外接半径系数 0.50 -> 0.46,与 render/hitdetect 对齐
+- preview 模式改轻量引导条
+- - 标题 + 「登录查看完整报告」徽章 + 弱化五边形轮廓,不初始化/不画完整星图
+- - 仍 emit point-click,保持父级登录引导链路
+- 其他
+- - 维度条线性序改为 身 -> 智 -> 富 -> 行 -> 心(规格 §4.2)
+- - 交互目标 >= 44px(88rpx);尊重 prefers-reduced-motion(CSS 降级 + Canvas JS 降级)
+- - 移除全部 emoji 与符号字形;:class/:key 不含表达式与带参方法调用
+- index-home/index.vue 未改动(B1 正在修改,本批零侵入)
+- 
+- T29-B2: 报告代传功能修复·第二批——权限越权与安全策略收敛 (B2-1~B2-6)
+-      否则 code=403);删除 ensureUserFamily 兜底建家庭,无家庭改为返回空列表
+- B2-2 proxyParsePreview 增加 familyId 绑定校验:非平台角色且 familyId 不在有效家庭集内 → 拒绝,
+-      校验发生在建草稿/写文件之前,越权不产生副作用
+- B2-3 auditConfirm 增加草稿归属校验(非 admin 仅能确认自己创建的草稿,admin 不受限)+
+-      subjectId 归属家庭一致性校验(跨家庭篡改 → 明确错误,不再静默跨家庭绑定)
+- B2-4 FamilyAccessInterceptor 收紧:有效家庭集为空且请求携带 familyId → 403(此前直接放行);
+-      平台角色(admin/operator/finance)直通,与 /api/admin 语义一致
+-      getEffectiveFamilyIds 口径补全:并入 family_members 反查 + guide_families(status=binding) 绑定,
+-      使「空集」严格等价于「无任何访问渠道」,避免误伤成长规划师等合法用户
+- B2-5 proxy-parse/proxy-confirm 移入 JwtInterceptor.LOGIN_REQUIRED_PATHS:无 token 直接 401
+-      (此前降级为匿名放行到 controller,仅返回业务 500)
+- B2-6 proxyParsePreview 补齐三层校验编排注释:角色校验 / familyId 绑定 / 拦截器兜底及各自返回码
+- 测试:新增/更新 6 个测试类共 37 项全部通过(拦截器 11、有效家庭集 5、成员列表 4、
+- 代传绑定 6、审核确认 8、登录必需 3);另有 5 项失败 + 2 项错误经基线 worktree 复跑确认为
+- 改动前既已存在(DanReportParseServiceTest / FoodRecommendServiceTest / ServiceRoleApproveTest)。
+- 不改前端、不新增 DB 迁移、不改 schema。
+- 
+- - run_interface_tests_cron.sh: 报告生成同步 AUTH_FAIL 分类
+- - 新增 INTERFACE_CONTRACT_REVIEW.md(柯砚接口契约评审)、INTERFACE_TEST_BUSINESS_REPORT.md(业务级重测报告)
+- - 业务级重测结果 interface_test_biz_retest.tsv: 23 条用例, BIZ_OK 7, 404 4(疑似部署版本与源码分支错位, 待开发团队确认)
+- - 聚焦用例集 interface_test_focus_biz.tsv
+- 
+- 
+- - 报告写入 run 目录而非仓库根 INTERFACE_TEST_REPORT.md,git add 永远抓不到
+- - git add 含可能不存在的 EXTRA_CASES 路径,整体失败使结果永不提交
+- 
+- - 纳入 Playwright 测试运行产物(.last-run.json),便于 CI 比对测试结果
+- 
+-   冒烟回归对比 → 为新增接口自动补全用例并跑新测试 → 提交结果 → push 与开发团队闭环)
+- - scan_frontend_endpoints.py: 扫描 cfc-frontend 提取所有 request() 的 method+path,用于需求变更检测
+- - run_interface_tests_cron.bat: Windows 任务计划程序启动器(定位 git-bash 调用 .sh)
+- - ops_interface_smoke.py: 新增 --extra 用例文件支持,便于新需求自动补全测试覆盖
+- 
+- - tests/test-cases: 按角色结构化用例 + 测评预约/方案激活拆任务幂等模块深挖
+- - tests/defects: 测评预约 F1-F8 缺陷单(含五要素与修复建议)
+- - tests/e2e: 测评预约自包含功能规格(direct-register 写种子数据, 实测 5 passed/2 skipped)
+- - tests/playwright.config.func.js: 去 globalSetup 独立配置, 规避测试库 Access denied
+- - tests/README.md: 登记新测试资产与运行说明
+- 
+-   * 支持 IP+Host 头绕过 DNS、空体 wechat-login 自动获取父角色 JWT、按业务 code 分类(BIZ_OK/BIZ_FAIL/NOT_FOUND/SERVER_ERR/NON_JSON/CONN_ERR)
+- - INTERFACE_TEST_MATRIX.md: 267 个页面路由 → 接口映射(方法/路径/鉴权/参数/来源函数)
+- - INTERFACE_TEST_REPORT.md: 首次冒烟结论(884 真实页面用例:481 BIZ_OK / 339 500 / 15 个 404 路径 / 8 个 403 / 32 个 400)
+-   * 主要问题:角色切换类接口 404、健康模块 500 缺乏统一入参校验、articles/detail 403
+- - interface_test_report.tsv: 千行原始结果,便于回归对比
+- 回归命令:
+- python ops_interface_smoke.py --base http://192.168.16.251 --host cfc.iwintrue.com --auto-login --timeout 3 --report
+- 
+
+### Bug 修复
+- 修复素材库组件未注册/标签筛选/拖拽上传/分类硬过滤,编辑器加素材库按钮,上传按分类自动打标签
+- 暴露接口冒烟真实通过率并修复 set -u 下 commit 失败
+- 闭环默认测试目标切到 cfc.iwintrue.com(原 192.168.16.251 已失效)并放宽超时至 8s
+- 修复小程序健康报告列表点详情崩溃(MP 内联事件传对象导致 undefined)
+- 修复省市区选择器显示[object Object]及区县全显示北京市
+- 统一SELF_CHECK门禁判定口径,五维自检或健康档案任一存在即解锁能量沙盘
+- 修正 D12§7 Q2 SQL 列名与成员级口径 + periods 接口 listPeriods NPE 空安全
+- 修复 StatsController 4 处 lambda 捕获非 effectively-final 局部变量的编译错误(subList 重赋值)——原 ee71f7716 导致 origin/cfclub 编译不过
+- 修复接口闭环脚本三处缺陷
+
+### 测试
+- 接口冒烟闭环 2026-10-29 22:56 — 通过率 44% (fix=243 reg=0)
+- 接口冒烟闭环 2026-10-29 22:52 — 通过率 0% (fix=0 reg=243)
+- 接口冒烟闭环 2026-10-29 22:47 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 22:42 — 通过率 3% (fix=0 reg=224)
+- 接口冒烟闭环 2026-10-29 22:35 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 22:26 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 21:45 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 21:39 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 15:56 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 15:24 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 15:13 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 14:50 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 13:51 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 13:49 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 12:04 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 11:04 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 10:46 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 09:41 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 08:21 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 08:17 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 06:29 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 05:26 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 05:20 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 04:12 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 03:07 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 03:02 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 01:58 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 01:53 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 00:50 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 00:43 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 23:44 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 23:33 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 22:35 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 22:17 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 21:06 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 19:57 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 18:46 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 18:11 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 17:37 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 16:27 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 15:19 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 15:12 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 13:35 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 12:23 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 11:10 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 10:33 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 09:59 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 09:21 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 08:06 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 06:56 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 05:48 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 05:41 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 04:38 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 03:30 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 02:20 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 01:07 — 通过率 ?% (fix=0 reg=0)
+- 业务级重测 — 契约感知断言 + direct-register多角色token + 错误路径修正
+- 接口冒烟闭环 2026-10-27 23:49 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-27 21:06 — 通过率 44% (fix=0 reg=0)
+- 闭环脚本增加防重入锁,避免 30 分钟间隔下长耗时测试重叠
+- 新增接口测试闭环定时任务(pull→检测新需求→回归+补全用例→提交→push)
+- 新增用户使用场景、结构化测试用例、缺陷单与可跑规格
+- 新增 CFC 小程序端接口冒烟测试与页面-接口映射矩阵
+
+### 文档
+- B2 能量沙盘独立核验报告(wuxing-sandbox.vue)
+- 设计稿 FAIL 修复收口 + 计划文档裁定同步
+- 新增 CI 编译闸门待办(方案 B 远端 CI + A 本地 hook,仅 iwintrue/local 编译)+ 任务池 OPS-CI-GATE
+- 设计稿入库 + 计划文档补齐裁定
+
+
+## v1.0.1823 (2026-10-29)
+
+### 测试
+- 接口冒烟闭环 2026-10-29 22:56 — 通过率 44% (fix=243 reg=0)
+- 接口冒烟闭环 2026-10-29 22:52 — 通过率 0% (fix=0 reg=243)
+- 接口冒烟闭环 2026-10-29 22:47 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 22:42 — 通过率 3% (fix=0 reg=224)
+- 接口冒烟闭环 2026-10-29 22:35 — 通过率 44% (fix=0 reg=0)
+
+
 ## v1.0.1822 (2026-10-29)
 
 ### 测试

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1822
+> 当前版本: v1.0.1824
 
 ## 历史版本
 
@@ -8,6 +8,262 @@
 
 ---
 
+## v1.0.1824 (2026-10-29)
+
+### 新功能
+- 虚拟支付失败自动转微信JSAPI + 会员券非会员可用 + 素材库回填幂等修复
+- 饮食模块整合-去食材查询合并食材推荐
+- 商品停留改按累计排序 + 新增停留时段热力图
+- 后台Dashboard重设计 + 真实行为数据口径
+- B2 能量沙盘保留与适配(C1/C2/C3 三冲突处理)
+
+### 其他
+- - CouponService: toCouponVO 暴露 grantType,前端据此允许非会员使用手动发放(MANUAL)的会员券
+- - pay.vue: 虚拟支付失败(非取消/非登录态失效)自动降级 payWechat; 会员券筛选放行 MANUAL
+- - 素材库 backfillAssetLibrary 幂等回填(手动补跑 file_type 1422 + 按分类补标签 7867)
+- 
+- - main.js 补注册 Image/RadioButton/CheckboxButton/Popconfirm/OptionGroup/Backtop/PageHeader(el-image、类型筛选此前完全失效)
+- - AssetPicker: 上传区改为 el-upload drag 整块可点击可拖拽;el-check-tag(Element Plus 组件) 换为可点击 el-tag;category 不再作为硬过滤条件,改为可选分类筛选(默认全部)
+- - editorAssetUpload: 注册 wangEditor 自定义素材库菜单 + 工具栏配置
+- - ArticleEdit/ActivityEdit/ProductEdit: 工具栏加入素材库按钮,点击打开素材库弹窗
+- 后端:
+- - 新增 AssetCategoryTag:分类 -> 中文标签名/颜色(文件类型为独立维度,不参与标签)
+- - JdCloud/LocalDisk StorageService 上传(含去重命中)时按分类自动打标签
+- - DatabaseInitializer 新增幂等回填:file_type 归一化(含 NULL/pdf 等异常值) + 按分类补标签
+- 
+-   interface_test_metrics.env,供 bash 提交信息使用
+- - bash 提交前读取该文件并初始化默认值,消除 set -u 下 $fixed/$regressed
+-   unbound variable 导致整条 git commit 命令展开失败(曾使测试结果无法提交)
+- - 提交信息由假 '?%' 改为真实通过率,例如 '通过率 45% (fix=0 reg=0)'
+- 
+- 
+- - 删除 pages/diet/food-query.vue 及 pages.json 路由,recommendation 标题改「食材推荐」
+- - 饮食首页:移除「食材查询」入口,「食谱推荐」改名「食材推荐」指向 recommendation
+- - 成长页:仅保留「饮食推荐」入口(指向 recommendation),移除「食材戒备匹配」卡片及相关数据/方法/接口导入
+- - 肠道菌群报告详情:移除「食材戒备」卡片、goToCaution、cautionCount 及 getFoodCautionList 导入
+- 任务:饮食模块整合-去食材查询合并食材推荐
+- 验证:verify-vue-compile 全站 PASS(389/390,mp-html 跳过);audit-mp-antipatterns ERROR 0;audit-duplicate-api-calls 0 error;npm run build:mp-weixin 构建成功
+- 
+- 扩展核验发现 1 项真实 a11y 缺陷(星图 Canvas 缺 ARIA/文字替代,建议合入前修复)
+- 及 2 项门禁 FAIL 为误报/既有约束(lint_hardcodes、slop_tells)。
+- 
+- 微信小程序运行时该参数解析为 undefined,触发 Cannot read properties of undefined (reading 'id'),
+- 报告列表无法进入详情。H5 正常,仅 MP 端复现。
+- 修复:模板内联事件改传基本类型 id / reportType / subjectId,同步调整 7 个处理方法签名
+- (onRowClick / onTouchStart / adjustBinding / confirmDelete / doDelete / approveTransfer /
+- denyTransfer / selectMemberAdjust)。模板 :class/:style/:key 的 item.x 绑定不受影响,保持原样。
+- 验证:
+- - scripts/verify-vue-compile.js report-list.vue → PASS(模板/样式/脚本)
+- - scripts/audit-mp-antipatterns.js --error-only → ERROR 0(含全站回归扫描)
+- - npm run build:mp-weixin → DONE exit 0;编译后 wxml 确认各 handler 改为从列表数据提取基本字段
+-   (onRowClick: item.m10,'id' + item.m11,'reportType' 等),不再传递整个对象
+- 
+- - getItemName 原按 province||city||district||street 顺序取值,北京/天津区级节点 province 也填北京市,
+-   导致区县级列表全部显示北京市;改为按 item.level 取对应层级字段(1省/2市/3区/4街道)
+- 
+-   家庭内任一成员完成五维自检也视为通过,修复完成健康自测后沙盘仍被锁定的问题
+- - isGateUnlocked(SELF_CHECK): count同时累加health_status记录数,与hasFamilySelfCheck口径一致
+- 
+- a11y(能量沙盘): canvas 补 role=img+动态 aria-label,粒子层 aria-hidden(修复 WCAG 1.1.1)
+- - 禁用态契约从 .btn 收口到全部交互控件:.svc/.tab/.icon-btn/.task-check
+-   一律 opacity:1 + pointer-events:none,带底控件用 warm-200+text-2,纯文字/图标控件用 muted text-3
+-   (原 opacity:0.45 淡出合成 2.51/2.31:1 不可读 → 实测 6.04/6.05:1)
+- - 16 个内联装饰图标 SVG 补 aria-hidden="true"
+- - 契约说明条与实现一致化
+- - 删除孤儿令牌 --opacity-disabled(零引用)
+- - 计划文档 §4.3 同步:禁用控件分类 + 令牌仅保留定义不再被引用
+- 
+-   且涉及 cfc-backend 改动时,自动跑 mvn -DskipTests compile,红构建阻止 push
+- - 新增 docs/2026-10-29-CI编译闸门-远端CI草案.md:方案 B 远端 CI 配置草案(Gitea/GitLab/webhook)
+- - AGENTS.md 补充启用说明(git config core.hooksPath githooks)
+- - 约束:编译闸门仅在 iwintrue(远端CI)与 local(本机)执行
+- - 验证:bash -n 通过;非受保护分支跳过 exit0;受保护分支触发 mvn compile 绿灯 exit0
+- 
+- - 补记主理人裁定:§4.3 禁用态淡出已移除+pointer-events;§10 富/智按周口径落地
+- 
+- 2. 新增「内容吸引力 · 停留时段热力」:复用已预取的 articleReads/productViews,按 0-23 点聚合近30天真实停留时长,返回 dwellByHour/dwellByHourTotal/dwellByHourPeak。
+- 3. Dashboard 渲染 24 小时热力图(颜色深度=相对峰值停留),含时段轴、图例、合计,帮助运营把握内容推送黄金时段;商品卡片副文案同步改「按累计停留排序」。
+- Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
+- 
+- 2. 统计口径修正:文章热度排名由 view_count(每次打开+1的虚高指标)改为 article_reading_records 真实阅读记录聚合;商品销量本就来自真实 product_orders,统一加"真实"标识。
+- 3. 商品停留埋点:新增 product_view_records 表 + POST /api/stats/product-view 接口 + 小程序 dwellTracker 埋点(onLoad/onUnload 上报浏览时长),补齐此前缺失的商品停留时长数据。
+- Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
+- 
+- - Canvas 双图层(starCanvas + flowCanvas)+ 点击感应层
+- - live / preview 双模式、右上切换与徽章逻辑
+- - GateLock 沙盘锁链路(sandboxLocked / sandboxLockGate)未触碰
+- - §1.3 全量 props 契约(mode/scores/overallScore/members/personalData/
+-   badgeText/showDimensionBar/dimensions/totalEnergy/totalHealthIndex/
+-   starHeight/familyUnlocked/memberCount/currentView)原样保留
+- - 环形顶点顺序 [心,身,智,富,行] = sortedDimensions 序,未改
+- C1 冲突:顶点与维度条 emoji(火/叶/水滴/闪电/地球)全部替换为内联 SVG
+- - 24×24 viewBox、stroke-width 1.8、round cap/join,mp-weixin 用
+-   data:image/svg+xml 作为 image src(仓库既有模式)
+- - 五行语义改由形状承载:心=火焰 身=山 智=闪电 富=水滴 行=叶
+- - 组件忽略接口返回的 icon 字段,始终按 code 取内置图形
+- C2 冲突:沙盘"身"色统一到 $dim-body 家族
+- - 移除组件内紫蓝渐变切换器与 $brown-warm 身色,一稿内不再出现两个"身"色
+- C3 冲突:.sandbox-star 明确高度
+- - CSS 兜底 height/min-height + flex-shrink:0 + 父级 starHeight 内联覆盖
+- 配色统一全站 $dim-*
+- - 新增 $dim-data-* 五个数据笔触深档令牌(uni.scss),Canvas 与图标同源
+- - 五行主色仅作装饰(tint 底 / 光晕 / 填充),数据笔触用 deep 档
+- - 对填充 $dim-sandbox-fill 实测对比度 4.56:1 ~ 6.57:1,均 >= 3:1
+- 顺带修正既有对齐 bug
+- - render/hitdetect 由旧 ts 序改为环形序 [mind,body,wisdom,wealth,action],
+-   臂尖=outer[i],消除画布臂色与 DOM 顶点错位 3~4/5 的问题
+- - flowCanvas 外接半径系数 0.50 -> 0.46,与 render/hitdetect 对齐
+- preview 模式改轻量引导条
+- - 标题 + 「登录查看完整报告」徽章 + 弱化五边形轮廓,不初始化/不画完整星图
+- - 仍 emit point-click,保持父级登录引导链路
+- 其他
+- - 维度条线性序改为 身 -> 智 -> 富 -> 行 -> 心(规格 §4.2)
+- - 交互目标 >= 44px(88rpx);尊重 prefers-reduced-motion(CSS 降级 + Canvas JS 降级)
+- - 移除全部 emoji 与符号字形;:class/:key 不含表达式与带参方法调用
+- index-home/index.vue 未改动(B1 正在修改,本批零侵入)
+- 
+- T29-B2: 报告代传功能修复·第二批——权限越权与安全策略收敛 (B2-1~B2-6)
+-      否则 code=403);删除 ensureUserFamily 兜底建家庭,无家庭改为返回空列表
+- B2-2 proxyParsePreview 增加 familyId 绑定校验:非平台角色且 familyId 不在有效家庭集内 → 拒绝,
+-      校验发生在建草稿/写文件之前,越权不产生副作用
+- B2-3 auditConfirm 增加草稿归属校验(非 admin 仅能确认自己创建的草稿,admin 不受限)+
+-      subjectId 归属家庭一致性校验(跨家庭篡改 → 明确错误,不再静默跨家庭绑定)
+- B2-4 FamilyAccessInterceptor 收紧:有效家庭集为空且请求携带 familyId → 403(此前直接放行);
+-      平台角色(admin/operator/finance)直通,与 /api/admin 语义一致
+-      getEffectiveFamilyIds 口径补全:并入 family_members 反查 + guide_families(status=binding) 绑定,
+-      使「空集」严格等价于「无任何访问渠道」,避免误伤成长规划师等合法用户
+- B2-5 proxy-parse/proxy-confirm 移入 JwtInterceptor.LOGIN_REQUIRED_PATHS:无 token 直接 401
+-      (此前降级为匿名放行到 controller,仅返回业务 500)
+- B2-6 proxyParsePreview 补齐三层校验编排注释:角色校验 / familyId 绑定 / 拦截器兜底及各自返回码
+- 测试:新增/更新 6 个测试类共 37 项全部通过(拦截器 11、有效家庭集 5、成员列表 4、
+- 代传绑定 6、审核确认 8、登录必需 3);另有 5 项失败 + 2 项错误经基线 worktree 复跑确认为
+- 改动前既已存在(DanReportParseServiceTest / FoodRecommendServiceTest / ServiceRoleApproveTest)。
+- 不改前端、不新增 DB 迁移、不改 schema。
+- 
+- - run_interface_tests_cron.sh: 报告生成同步 AUTH_FAIL 分类
+- - 新增 INTERFACE_CONTRACT_REVIEW.md(柯砚接口契约评审)、INTERFACE_TEST_BUSINESS_REPORT.md(业务级重测报告)
+- - 业务级重测结果 interface_test_biz_retest.tsv: 23 条用例, BIZ_OK 7, 404 4(疑似部署版本与源码分支错位, 待开发团队确认)
+- - 聚焦用例集 interface_test_focus_biz.tsv
+- 
+- 
+- - 报告写入 run 目录而非仓库根 INTERFACE_TEST_REPORT.md,git add 永远抓不到
+- - git add 含可能不存在的 EXTRA_CASES 路径,整体失败使结果永不提交
+- 
+- - 纳入 Playwright 测试运行产物(.last-run.json),便于 CI 比对测试结果
+- 
+-   冒烟回归对比 → 为新增接口自动补全用例并跑新测试 → 提交结果 → push 与开发团队闭环)
+- - scan_frontend_endpoints.py: 扫描 cfc-frontend 提取所有 request() 的 method+path,用于需求变更检测
+- - run_interface_tests_cron.bat: Windows 任务计划程序启动器(定位 git-bash 调用 .sh)
+- - ops_interface_smoke.py: 新增 --extra 用例文件支持,便于新需求自动补全测试覆盖
+- 
+- - tests/test-cases: 按角色结构化用例 + 测评预约/方案激活拆任务幂等模块深挖
+- - tests/defects: 测评预约 F1-F8 缺陷单(含五要素与修复建议)
+- - tests/e2e: 测评预约自包含功能规格(direct-register 写种子数据, 实测 5 passed/2 skipped)
+- - tests/playwright.config.func.js: 去 globalSetup 独立配置, 规避测试库 Access denied
+- - tests/README.md: 登记新测试资产与运行说明
+- 
+-   * 支持 IP+Host 头绕过 DNS、空体 wechat-login 自动获取父角色 JWT、按业务 code 分类(BIZ_OK/BIZ_FAIL/NOT_FOUND/SERVER_ERR/NON_JSON/CONN_ERR)
+- - INTERFACE_TEST_MATRIX.md: 267 个页面路由 → 接口映射(方法/路径/鉴权/参数/来源函数)
+- - INTERFACE_TEST_REPORT.md: 首次冒烟结论(884 真实页面用例:481 BIZ_OK / 339 500 / 15 个 404 路径 / 8 个 403 / 32 个 400)
+-   * 主要问题:角色切换类接口 404、健康模块 500 缺乏统一入参校验、articles/detail 403
+- - interface_test_report.tsv: 千行原始结果,便于回归对比
+- 回归命令:
+- python ops_interface_smoke.py --base http://192.168.16.251 --host cfc.iwintrue.com --auto-login --timeout 3 --report
+- 
+
+### Bug 修复
+- 修复素材库组件未注册/标签筛选/拖拽上传/分类硬过滤,编辑器加素材库按钮,上传按分类自动打标签
+- 暴露接口冒烟真实通过率并修复 set -u 下 commit 失败
+- 闭环默认测试目标切到 cfc.iwintrue.com(原 192.168.16.251 已失效)并放宽超时至 8s
+- 修复小程序健康报告列表点详情崩溃(MP 内联事件传对象导致 undefined)
+- 修复省市区选择器显示[object Object]及区县全显示北京市
+- 统一SELF_CHECK门禁判定口径,五维自检或健康档案任一存在即解锁能量沙盘
+- 修正 D12§7 Q2 SQL 列名与成员级口径 + periods 接口 listPeriods NPE 空安全
+- 修复 StatsController 4 处 lambda 捕获非 effectively-final 局部变量的编译错误(subList 重赋值)——原 ee71f7716 导致 origin/cfclub 编译不过
+- 修复接口闭环脚本三处缺陷
+
+### 测试
+- 接口冒烟闭环 2026-10-29 22:56 — 通过率 44% (fix=243 reg=0)
+- 接口冒烟闭环 2026-10-29 22:52 — 通过率 0% (fix=0 reg=243)
+- 接口冒烟闭环 2026-10-29 22:47 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 22:42 — 通过率 3% (fix=0 reg=224)
+- 接口冒烟闭环 2026-10-29 22:35 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 22:26 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 21:45 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 21:39 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 15:56 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 15:24 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 15:13 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 14:50 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 13:51 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 13:49 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 12:04 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 11:04 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 10:46 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 09:41 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 08:21 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 08:17 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 06:29 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 05:26 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 05:20 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 04:12 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 03:07 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 03:02 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 01:58 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 01:53 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 00:50 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 00:43 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 23:44 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 23:33 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 22:35 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 22:17 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 21:06 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 19:57 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 18:46 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 18:11 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 17:37 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 16:27 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 15:19 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 15:12 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 13:35 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 12:23 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 11:10 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 10:33 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 09:59 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 09:21 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 08:06 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 06:56 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 05:48 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 05:41 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 04:38 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 03:30 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 02:20 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-28 01:07 — 通过率 ?% (fix=0 reg=0)
+- 业务级重测 — 契约感知断言 + direct-register多角色token + 错误路径修正
+- 接口冒烟闭环 2026-10-27 23:49 — 通过率 ?% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-27 21:06 — 通过率 44% (fix=0 reg=0)
+- 闭环脚本增加防重入锁,避免 30 分钟间隔下长耗时测试重叠
+- 新增接口测试闭环定时任务(pull→检测新需求→回归+补全用例→提交→push)
+- 新增用户使用场景、结构化测试用例、缺陷单与可跑规格
+- 新增 CFC 小程序端接口冒烟测试与页面-接口映射矩阵
+
+### 文档
+- B2 能量沙盘独立核验报告(wuxing-sandbox.vue)
+- 设计稿 FAIL 修复收口 + 计划文档裁定同步
+- 新增 CI 编译闸门待办(方案 B 远端 CI + A 本地 hook,仅 iwintrue/local 编译)+ 任务池 OPS-CI-GATE
+- 设计稿入库 + 计划文档补齐裁定
+
+
+## v1.0.1823 (2026-10-29)
+
+### 测试
+- 接口冒烟闭环 2026-10-29 22:56 — 通过率 44% (fix=243 reg=0)
+- 接口冒烟闭环 2026-10-29 22:52 — 通过率 0% (fix=0 reg=243)
+- 接口冒烟闭环 2026-10-29 22:47 — 通过率 44% (fix=0 reg=0)
+- 接口冒烟闭环 2026-10-29 22:42 — 通过率 3% (fix=0 reg=224)
+- 接口冒烟闭环 2026-10-29 22:35 — 通过率 44% (fix=0 reg=0)
+
+
 ## v1.0.1822 (2026-10-29)
 
 ### 测试

+ 164 - 76
cfc-web/src/components/AssetPicker.vue

@@ -8,16 +8,11 @@
     @closed="onClosed"
   >
     <div class="asset-picker">
-      <!-- 上传区 -->
-      <div
-        class="ap-upload-row"
-        :class="{ 'is-dragover': dragOver }"
-        @dragover.prevent="onDragOver"
-        @dragleave="onDragLeave"
-        @drop.prevent="onDrop"
-      >
+      <!-- 上传区:整块可点击 / 可拖拽 -->
+      <div class="ap-upload-row">
         <el-upload
-          class="ap-upload-trigger"
+          class="ap-upload-drop"
+          drag
           action=""
           :http-request="handleUploadRequest"
           :show-file-list="false"
@@ -25,32 +20,35 @@
           :before-upload="beforeUpload"
           :disabled="uploading"
         >
-          <el-button type="primary" size="small" :loading="uploading" icon="el-icon-upload2">
-            上传到素材库
-          </el-button>
+          <div class="ap-drop-inner" v-loading="uploading">
+            <i class="el-icon-upload"></i>
+            <div class="ap-drop-text">点击或拖拽文件到此处,上传到素材库</div>
+          </div>
         </el-upload>
-        <el-select
-          v-model="uploadTags"
-          multiple
-          collapse-tags
-          filterable
-          size="small"
-          placeholder="上传后自动打标签"
-          class="ap-upload-tags"
-        >
-          <el-option v-for="t in tags" :key="t.id" :label="t.name" :value="t.id">
-            <span :style="{ color: t.color }">{{ t.name }}</span>
-          </el-option>
-        </el-select>
-        <span class="ap-upload-hint">
-          分类:<el-tag size="mini" type="info">{{ uploadCategory }}</el-tag>
-          <template v-if="uploadTags.length > 0">
-            <span v-for="id in uploadTags" :key="id">
-              <el-tag size="mini" :style="{ color: colorOf(id), borderColor: colorOf(id) }">{{ nameOf(id) }}</el-tag>
-            </span>
-          </template>
-          <span class="ap-drop-hint">{{ dragOver ? '松开即上传' : '点击或拖拽文件到此处' }}</span>
-        </span>
+        <div class="ap-upload-side">
+          <el-select
+            v-model="uploadTags"
+            multiple
+            collapse-tags
+            filterable
+            size="small"
+            placeholder="上传后自动打标签(留空则按分类自动打)"
+            class="ap-upload-tags"
+          >
+            <el-option v-for="t in tags" :key="t.id" :label="t.name" :value="t.id">
+              <span :style="{ color: t.color }">{{ t.name }}</span>
+            </el-option>
+          </el-select>
+          <div class="ap-upload-hint">
+            入库分类:<el-tag size="mini" type="info">{{ uploadCategoryLabel }}</el-tag>
+            <el-tag
+              v-for="id in uploadTags"
+              :key="id"
+              size="mini"
+              :style="{ color: colorOf(id), borderColor: colorOf(id) }"
+            >{{ nameOf(id) }}</el-tag>
+          </div>
+        </div>
       </div>
 
       <!-- 筛选区 -->
@@ -61,6 +59,22 @@
           <el-radio-button label="video" v-if="showVideoFilter">视频</el-radio-button>
           <el-radio-button label="document">文档</el-radio-button>
         </el-radio-group>
+        <el-select
+          v-model="categoryFilter"
+          size="small"
+          clearable
+          placeholder="全部分类"
+          class="ap-category"
+          @change="onCategoryChange"
+        >
+          <el-option label="全部分类" value="" />
+          <el-option
+            v-for="c in categoryOptions"
+            :key="c.value"
+            :label="c.label + '(' + c.count + ')'"
+            :value="c.value"
+          />
+        </el-select>
         <el-input
           v-model="keyword"
           size="small"
@@ -79,15 +93,22 @@
       <!-- 标签筛选 -->
       <div v-if="tags.length > 0" class="ap-tag-row">
         <span class="ap-tag-label">标签:</span>
-        <el-check-tag
+        <el-tag
           v-for="t in tags"
           :key="t.id"
-          :checked="selectedTagIds.indexOf(t.id) !== -1"
-          @change="onTagFilterToggle(t.id)"
+          size="small"
           class="ap-tag-chip"
-        >
-          <span :style="{ color: t.color }">{{ t.name }}</span>
-        </el-check-tag>
+          :effect="isTagActive(t.id) ? 'dark' : 'plain'"
+          :style="tagChipStyle(t)"
+          @click="onTagFilterToggle(t.id)"
+        >{{ t.name }}</el-tag>
+        <el-tag
+          v-if="selectedTagIds.length > 0"
+          size="small"
+          type="info"
+          class="ap-tag-clear"
+          @click="clearTagFilter"
+        >清除标签筛选</el-tag>
       </div>
 
       <!-- 网格 -->
@@ -159,7 +180,23 @@
 </template>
 
 <script>
-import { listLibrary, uploadToLibrary, getTags, resolveAssetUrl } from '@/utils/asset'
+import { listLibrary, uploadToLibrary, getTags, getLibraryStats, resolveAssetUrl } from '@/utils/asset'
+
+/** 素材分类 → 中文展示名(标签也按这个规则自动生成) */
+const CATEGORY_LABELS = {
+  general: '通用素材',
+  products: '商品图片',
+  'product-skus': '商品SKU图',
+  articles: '文章配图',
+  'articles-audio': '文章音频',
+  activities: '活动图片',
+  banners: '轮播图',
+  'guide-packages': '攻略包',
+  'kb-docs': '知识库文档',
+  'kb-images': '知识库图片',
+  'health-reports': '健康报告',
+  uploads: '其它上传'
+}
 
 export default {
   name: 'AssetPicker',
@@ -190,10 +227,11 @@ export default {
       total: 0,
       keyword: '',
       typeFilter: this.initTypeFilter(),
+      categoryFilter: '',
+      categories: [],
       selectedTagIds: [],
       uploadTags: [],
-      selectedRecords: [],
-      dragOver: false
+      selectedRecords: []
     }
   },
   computed: {
@@ -211,6 +249,14 @@ export default {
     uploadCategory() {
       return this.category || 'general'
     },
+    uploadCategoryLabel() {
+      const key = this.uploadCategory
+      const hit = this.categories.find(function (c) { return c.value === key })
+      return (hit && hit.label) || CATEGORY_LABELS[key] || key
+    },
+    categoryOptions() {
+      return this.categories
+    },
     uploadAccept() {
       if (this.assetType === 'image') return 'image/*'
       if (this.assetType === 'video') return 'video/*'
@@ -243,11 +289,26 @@ export default {
       this.page = 1
       this.keyword = ''
       this.typeFilter = this.initTypeFilter()
+      this.categoryFilter = ''
       this.selectedTagIds = this.defaultTagIds.slice()
       this.uploadTags = this.defaultTagIds.slice()
       this.selectedRecords = (this.value || []).map(function (r) { return Object.assign({}, r) })
+      this.loadCategories()
       this.loadTags().then(this.loadData)
     },
+    /** 分类筛选项:来自素材库统计,避免把页面 category 当硬过滤条件导致空列表 */
+    loadCategories() {
+      return getLibraryStats().then(function (res) {
+        if (res.code === 200 && res.data && Array.isArray(res.data.byCategory)) {
+          this.categories = res.data.byCategory
+            .filter(function (c) { return c.category })
+            .map(function (c) {
+              return { value: c.category, label: CATEGORY_LABELS[c.category] || c.category, count: Number(c.cnt) || 0 }
+            })
+            .sort(function (a, b) { return b.count - a.count })
+        }
+      }.bind(this)).catch(function () {})
+    },
     loadTags() {
       return getTags().then(function (res) {
         if (res.code === 200 && Array.isArray(res.data)) {
@@ -261,7 +322,7 @@ export default {
         page: this.page,
         pageSize: this.pageSize,
         fileType: this.typeFilter || '',
-        category: this.category || '',
+        category: this.categoryFilter || '',
         search: this.keyword || '',
         tagIds: this.selectedTagIds.length > 0 ? this.selectedTagIds : []
       }).then(function (res) {
@@ -277,6 +338,24 @@ export default {
       this.page = 1
       this.loadData()
     },
+    onCategoryChange() {
+      this.page = 1
+      this.loadData()
+    },
+    isTagActive(tagId) {
+      return this.selectedTagIds.indexOf(tagId) !== -1
+    },
+    tagChipStyle(t) {
+      const color = (t && t.color) || '#409EFF'
+      return this.isTagActive(t.id)
+        ? { backgroundColor: color, borderColor: color, color: '#fff', cursor: 'pointer' }
+        : { color: color, borderColor: color, cursor: 'pointer' }
+    },
+    clearTagFilter() {
+      this.selectedTagIds = []
+      this.page = 1
+      this.loadData()
+    },
     onSearch() {
       this.page = 1
       this.loadData()
@@ -326,24 +405,6 @@ export default {
       if (!file) return
       await this.uploadAndSelect(file)
     },
-    onDragOver(e) {
-      this.dragOver = true
-      if (e && e.dataTransfer && e.dataTransfer.effectAllowed !== 'none') {
-        e.dataTransfer.dropEffect = 'copy'
-      }
-    },
-    onDragLeave() {
-      this.dragOver = false
-    },
-    async onDrop(e) {
-      this.dragOver = false
-      const transfer = e && e.dataTransfer
-      const files = transfer && (transfer.files || transfer.items)
-      if (!files || files.length === 0) return
-      const file = Array.isArray(files) ? files[0] : files[0]
-      if (!file || !file.name) return
-      await this.uploadAndSelect(file)
-    },
     async uploadAndSelect(file) {
       if (!file) return
       this.uploading = true
@@ -429,27 +490,48 @@ export default {
 }
 .ap-upload-row {
   display: flex;
-  align-items: center;
-  gap: 10px;
+  align-items: stretch;
+  gap: 12px;
   margin-bottom: 12px;
-  padding: 8px;
-  border: 1px dashed #dcdfe6;
-  border-radius: 4px;
-  transition: border-color 0.2s, background-color 0.2s;
 }
-.ap-upload-row.is-dragover {
-  border-color: #409EFF;
-  background-color: #ecf5ff;
+.ap-upload-drop {
+  flex: 1;
+  min-width: 0;
 }
-.ap-drop-hint {
-  font-size: 12px;
+.ap-upload-drop ::v-deep .el-upload {
+  display: block;
+  width: 100%;
+}
+.ap-upload-drop ::v-deep .el-upload-dragger {
+  width: 100%;
+  height: auto;
+  padding: 12px;
+  box-sizing: border-box;
+}
+.ap-drop-inner {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  color: #606266;
+}
+.ap-drop-inner i {
+  font-size: 26px;
   color: #c0c4cc;
 }
-.ap-upload-trigger {
-  display: inline-block;
+.ap-drop-text {
+  font-size: 13px;
+}
+.ap-upload-side {
+  width: 260px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 8px;
 }
 .ap-upload-tags {
-  width: 220px;
+  width: 100%;
 }
 .ap-upload-hint {
   font-size: 12px;
@@ -459,6 +541,12 @@ export default {
   gap: 6px;
   flex-wrap: wrap;
 }
+.ap-category {
+  width: 160px;
+}
+.ap-tag-clear {
+  cursor: pointer;
+}
 .ap-filter-row {
   display: flex;
   align-items: center;

+ 10 - 0
cfc-web/src/main.js

@@ -11,6 +11,8 @@ import {
   Progress, Container, Header, Aside, Main, Avatar, Scrollbar, Divider,
   Tab, Badge, Tree, ColorPicker, Tooltip, Popover,
   Slider, InputNumber, Link, Rate, Loading, Alert,
+  Image, RadioButton, CheckboxButton, Popconfirm, OptionGroup, Backtop,
+  PageHeader as ElPageHeader,
   MessageBox, Message
 } from 'element-ui'
 import 'element-ui/lib/theme-chalk/index.css'
@@ -79,6 +81,14 @@ Vue.use(Link)
 Vue.use(Rate)
 Vue.use(Loading)
 Vue.use(Alert)
+// 补充注册:素材库与表单页用到的组件(此前漏注册,导致 el-image/el-radio-button 等渲染成惰性标签)
+Vue.use(Image)
+Vue.use(RadioButton)
+Vue.use(CheckboxButton)
+Vue.use(Popconfirm)
+Vue.use(OptionGroup)
+Vue.use(Backtop)
+Vue.use(ElPageHeader)
 // MessageBox 没有 install 方法,Vue.use(MessageBox) ——这是错误的,不会注册 $confirm
 // 需要手动挂载到 prototype 上
 Vue.prototype.$confirm = MessageBox.confirm

+ 59 - 1
cfc-web/src/utils/editorAssetUpload.js

@@ -9,6 +9,7 @@
  * - insertAssetIntoEditor:把素材库选中的记录插入编辑器光标处(图片/视频节点)。
  */
 import { Message } from 'element-ui'
+import { Boot } from '@wangeditor/editor'
 import { resolveAssetUrl } from './asset'
 
 const BASE_URL = process.env.VUE_APP_BASE_API || ''
@@ -141,9 +142,66 @@ export function insertAssetIntoEditor(editor, record, type) {
   }
 }
 
+
+
+// ==================== 编辑器工具栏「素材库」菜单 ====================
+
+export const ASSET_MENU_KEY = 'assetLibrary'
+
+let assetMenuHandler = null
+let assetMenuRegistered = false
+
+/**
+ * 注册「素材库」按钮点击回调。页面在 created 时绑定,exec 时调用。
+ * @param {Function} fn
+ */
+export function setAssetMenuHandler(fn) {
+  assetMenuHandler = typeof fn === 'function' ? fn : null
+}
+
+/**
+ * 向 wangEditor 注册自定义「素材库」工具栏菜单(全局只注册一次)。
+ * 必须在编辑器渲染前调用(页面 created 即可)。
+ */
+export function registerAssetLibraryMenu() {
+  if (assetMenuRegistered) return
+  assetMenuRegistered = true
+  Boot.registerMenu({
+    key: ASSET_MENU_KEY,
+    factory() {
+      return {
+        tag: 'button',
+        title: '素材库',
+        iconSvg: '<svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M160 192h704a64 64 0 0 1 64 64v512a64 64 0 0 1-64 64H160a64 64 0 0 1-64-64V256a64 64 0 0 1 64-64zm0 64v512h704V256H160zm96 96h160a32 32 0 0 1 32 32v96a32 32 0 0 1-32 32H256a32 32 0 0 1-32-32v-96a32 32 0 0 1 32-32zm0 224h512v48H256v-48z"/></svg>',
+        getValue() { return '' },
+        isActive() { return false },
+        isDisabled() { return false },
+        exec() {
+          if (assetMenuHandler) assetMenuHandler()
+        }
+      }
+    }
+  })
+}
+
+/**
+ * 生成带「素材库」按钮的工具栏配置。
+ * @param {Object} extra 额外 toolbarConfig 字段
+ */
+export function buildAssetToolbarConfig(extra) {
+  return Object.assign(
+    { insertKeys: { index: 0, keys: [ASSET_MENU_KEY] } },
+    extra || {}
+  )
+}
+
 export default {
   buildEditorAssetMenuConf,
   insertAssetIntoEditor,
   getEditorCustomAlert,
-  getUploadHeaders
+  getUploadHeaders,
+  registerAssetLibraryMenu,
+  setAssetMenuHandler,
+  buildAssetToolbarConfig,
+  ASSET_MENU_KEY
 }

+ 8 - 2
cfc-web/src/views/admin/ActivityEdit.vue

@@ -286,7 +286,7 @@ import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weigh
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 import AssetPicker from '@/components/AssetPicker.vue'
-import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert } from '@/utils/editorAssetUpload'
+import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert, registerAssetLibraryMenu, setAssetMenuHandler, buildAssetToolbarConfig } from '@/utils/editorAssetUpload'
 
 export default {
   name: 'ActivityEdit',
@@ -347,7 +347,7 @@ export default {
         ]
       },
       editorConfig: null,
-      toolbarConfig: {},
+      toolbarConfig: buildAssetToolbarConfig(),
       editorInstance: null,
       assetPickerVisible: false,
       assetPickerType: 'image',
@@ -385,6 +385,12 @@ export default {
     }
   },
   created() {
+    registerAssetLibraryMenu()
+    setAssetMenuHandler(() => {
+      this.assetPickerTarget = 'content'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    })
     this.initEditorConfig()
     const id = this.$route.query.id
     this.readonly = this.$route.query.readonly === '1'

+ 8 - 2
cfc-web/src/views/admin/ArticleEdit.vue

@@ -178,7 +178,7 @@ import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import { DomEditor, SlateTransforms, SlatePath } from '@wangeditor/editor'
 import '@wangeditor/editor/dist/css/style.css'
 import AssetPicker from '@/components/AssetPicker.vue'
-import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert } from '@/utils/editorAssetUpload'
+import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert, registerAssetLibraryMenu, setAssetMenuHandler, buildAssetToolbarConfig } from '@/utils/editorAssetUpload'
 
 export default {
   name: 'ArticleEdit',
@@ -197,7 +197,7 @@ export default {
       rejectReason: '',
       categories: [],
       editorConfig: null,
-      toolbarConfig: {},
+      toolbarConfig: buildAssetToolbarConfig(),
       editorMode: 'default',
       editorInstance: null,
       assetPickerVisible: false,
@@ -256,6 +256,12 @@ export default {
     }
   },
   created() {
+    registerAssetLibraryMenu()
+    setAssetMenuHandler(() => {
+      this.assetPickerTarget = 'content'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    })
     this.initEditorConfig()
     this.loadCategories()
     const id = this.$route.query.id

+ 8 - 2
cfc-web/src/views/admin/ProductEdit.vue

@@ -386,7 +386,7 @@ import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 import AssetPicker from '@/components/AssetPicker.vue'
-import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert } from '@/utils/editorAssetUpload'
+import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert, registerAssetLibraryMenu, setAssetMenuHandler, buildAssetToolbarConfig } from '@/utils/editorAssetUpload'
 
 export default {
   name: 'ProductEdit',
@@ -452,7 +452,7 @@ export default {
       purchaseFieldsEnabled: false,
       predefinedFields: [],
       editorConfig: null,
-      toolbarConfig: {},
+      toolbarConfig: buildAssetToolbarConfig(),
       editorInstance: null,
       assetPickerVisible: false,
       assetPickerMode: 'single',
@@ -586,6 +586,12 @@ export default {
     }
   },
   created() {
+    registerAssetLibraryMenu()
+    setAssetMenuHandler(() => {
+      this.assetPickerTarget = 'content'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    })
     this.loadApprovedGuides()
     // 上传统一写入素材库(file_record)
     this.editorConfig = {