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

fix(asset-library): 修复素材库组件未注册/标签筛选/拖拽上传/分类硬过滤,编辑器加素材库按钮,上传按分类自动打标签

前端:
- 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 等异常值) + 按分类补标签
Xiaogang Liao 2 недель назад
Родитель
Сommit
8e92fd0a35

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

@@ -7579,6 +7579,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() {

+ 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";
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-5ddc2a9a14de88561bb2d7c83ac8e4cd1b230af6
+5b9dfd0f72f2516932adbc81da671e2ba8ffb33e

+ 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 = {