Przeglądaj źródła

feat(asset): 后台素材库前端 — AssetLibrary 页 + AssetPicker 组件 + 上传点改造

- 新增 src/utils/asset.js(素材库 API 封装 + resolveAssetUrl)、src/utils/editorAssetUpload.js(wangEditor 上传配置公共封装 + 图片/视频插入,修复旧 customInsert baseURL 双前缀 bug)
- 新增 components/AssetPicker.vue(图片/视频/文档三类筛选 + 上传入库 + 标签筛选 + 单选/多选)
- 新增 views/admin/AssetLibrary.vue(网格卡片 + 类型/分类/标签/关键词/日期筛选 + 批量打标签/删除 + 改描述 + 预览 + 标签管理 + 统计);router 注册 admin/asset-library,Layout 加菜单与缓存页
- ArticleEdit / ActivityEdit / ProductEdit / ProductSkuEditor / HomeBannerManage / KnowledgeDialog 接入「从素材库选择」与 /api/admin/file-record/upload
- HomeBannerManage 修复重复 beforeUpload 导致封面图永远无法上传的缺陷;FileManage 类型口径 pdf → document
Sisyphus 2 tygodni temu
rodzic
commit
e07dd7294a

+ 558 - 0
cfc-web/src/components/AssetPicker.vue

@@ -0,0 +1,558 @@
+<template>
+  <el-dialog
+    :title="dialogTitle"
+    :visible.sync="dialogVisible"
+    :width="pickerWidth"
+    :append-to-body="true"
+    :close-on-click-modal="false"
+    @closed="onClosed"
+  >
+    <div class="asset-picker">
+      <!-- 上传区 -->
+      <div class="ap-upload-row">
+        <el-upload
+          class="ap-upload-trigger"
+          action=""
+          :http-request="handleUploadRequest"
+          :show-file-list="false"
+          :accept="uploadAccept"
+          :before-upload="beforeUpload"
+          :disabled="uploading"
+        >
+          <el-button type="primary" size="small" :loading="uploading" icon="el-icon-upload2">
+            上传到素材库
+          </el-button>
+        </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>
+      </div>
+
+      <!-- 筛选区 -->
+      <div class="ap-filter-row">
+        <el-radio-group v-model="typeFilter" size="small" @change="onTypeChange">
+          <el-radio-button label="">全部</el-radio-button>
+          <el-radio-button label="image">图片</el-radio-button>
+          <el-radio-button label="video" v-if="showVideoFilter">视频</el-radio-button>
+          <el-radio-button label="document">文档</el-radio-button>
+        </el-radio-group>
+        <el-input
+          v-model="keyword"
+          size="small"
+          placeholder="搜索文件名 / 描述"
+          clearable
+          class="ap-search"
+          @keyup.enter.native="onSearch"
+          @clear="onSearch"
+        >
+          <i slot="prefix" class="el-icon-search"></i>
+        </el-input>
+        <el-button size="small" icon="el-icon-search" @click="onSearch">查询</el-button>
+        <el-button size="small" icon="el-icon-refresh-right" @click="onRefresh">刷新</el-button>
+      </div>
+
+      <!-- 标签筛选 -->
+      <div v-if="tags.length > 0" class="ap-tag-row">
+        <span class="ap-tag-label">标签:</span>
+        <el-check-tag
+          v-for="t in tags"
+          :key="t.id"
+          :checked="selectedTagIds.indexOf(t.id) !== -1"
+          @change="onTagFilterToggle(t.id)"
+          class="ap-tag-chip"
+        >
+          <span :style="{ color: t.color }">{{ t.name }}</span>
+        </el-check-tag>
+      </div>
+
+      <!-- 网格 -->
+      <div v-loading="loading" class="ap-grid-wrap">
+        <el-row v-if="records.length > 0" :gutter="10">
+          <el-col v-for="r in records" :key="r.id" :span="4" class="ap-grid-col">
+            <div
+              class="ap-item"
+              :class="{ 'is-selected': isSelected(r), 'is-disabled': !isSelected(r) && isMaxReached }"
+              @click="toggleSelect(r)"
+            >
+              <div class="ap-thumb">
+                <el-image
+                  v-if="r.fileType === 'image'"
+                  :src="thumbUrl(r)"
+                  fit="cover"
+                  class="ap-thumb-img"
+                  :preview-src-list="[thumbUrl(r)]"
+                />
+                <div v-else class="ap-thumb-doc">
+                  <i :class="docIcon(r)"></i>
+                  <span class="ap-thumb-type">{{ r.fileType || 'other' }}</span>
+                </div>
+                <div v-if="isSelected(r)" class="ap-check">
+                  <i class="el-icon-circle-check"></i>
+                </div>
+              </div>
+              <div class="ap-item-name" :title="r.originalFilename">{{ r.originalFilename }}</div>
+              <div class="ap-item-meta">
+                <span>{{ formatSize(r.fileSize) }}</span>
+                <span v-if="r.category">{{ r.category }}</span>
+              </div>
+              <div v-if="(r.tags || []).length > 0" class="ap-item-tags">
+                <el-tag
+                  v-for="t in r.tags"
+                  :key="t.id"
+                  size="mini"
+                  :style="{ color: t.color, borderColor: t.color, background: 'rgba(255,255,255,0.85)' }"
+                >{{ t.name }}</el-tag>
+              </div>
+            </div>
+          </el-col>
+        </el-row>
+        <div v-else-if="!loading" class="ap-empty">
+          <i class="el-icon-picture-outline"></i>
+          <p>没有匹配的素材,可上传新文件</p>
+        </div>
+      </div>
+
+      <!-- 分页 -->
+      <div class="ap-pagination">
+        <el-pagination
+          background
+          layout="total, prev, pager, next"
+          :current-page="page"
+          :page-size="pageSize"
+          :total="total"
+          @current-change="onPageChange"
+        ></el-pagination>
+      </div>
+    </div>
+
+    <div slot="footer">
+      <span class="ap-footer-info">已选 {{ selectedRecords.length }} 项</span>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" :disabled="selectedRecords.length === 0" @click="onConfirm">确定</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { listLibrary, uploadToLibrary, getTags, resolveAssetUrl } from '@/utils/asset'
+
+export default {
+  name: 'AssetPicker',
+  props: {
+    /** 弹窗显隐(配合 :visible.sync 使用) */
+    visible: { type: Boolean, default: false },
+    /** 单选 / 多选 */
+    mode: { type: String, default: 'single' },
+    /** 素材类型:image / document / all(决定默认筛选与上传类型) */
+    assetType: { type: String, default: 'image' },
+    /** 上传入库的素材分类 */
+    category: { type: String, default: '' },
+    /** 默认打上的标签 id(上传与筛选初始值) */
+    defaultTagIds: { type: Array, default: function () { return [] } },
+    /** 多选模式下的最大可选数量(0 表示不限) */
+    max: { type: Number, default: 0 },
+    /** 已选记录(外部回显) */
+    value: { type: Array, default: function () { return [] } }
+  },
+  data() {
+    return {
+      loading: false,
+      uploading: false,
+      records: [],
+      tags: [],
+      page: 1,
+      pageSize: 24,
+      total: 0,
+      keyword: '',
+      typeFilter: this.initTypeFilter(),
+      selectedTagIds: [],
+      uploadTags: [],
+      selectedRecords: []
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() { return this.visible },
+      set(v) { this.$emit('update:visible', v) }
+    },
+    dialogTitle() {
+      const tip = this.mode === 'multiple' ? '(多选)' : '(单选)'
+      return '从素材库选择' + tip
+    },
+    pickerWidth() {
+      return this.mode === 'multiple' ? '960px' : '860px'
+    },
+    uploadCategory() {
+      return this.category || 'general'
+    },
+    uploadAccept() {
+      if (this.assetType === 'image') return 'image/*'
+      if (this.assetType === 'video') return 'video/*'
+      if (this.assetType === 'document') return '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv'
+      return 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv'
+    },
+    showVideoFilter() {
+      return this.assetType === 'video' || this.assetType === 'all'
+    },
+    isMaxReached() {
+      return this.mode === 'multiple' && this.max > 0 && this.selectedRecords.length >= this.max
+    }
+  },
+  watch: {
+    visible: {
+      immediate: true,
+      handler(val) {
+        if (val) this.open()
+      }
+    }
+  },
+  methods: {
+    initTypeFilter() {
+      if (this.assetType === 'image') return 'image'
+      if (this.assetType === 'video') return 'video'
+      if (this.assetType === 'document') return 'document'
+      return ''
+    },
+    open() {
+      this.page = 1
+      this.keyword = ''
+      this.typeFilter = this.initTypeFilter()
+      this.selectedTagIds = this.defaultTagIds.slice()
+      this.uploadTags = this.defaultTagIds.slice()
+      this.selectedRecords = (this.value || []).map(function (r) { return Object.assign({}, r) })
+      this.loadTags().then(this.loadData)
+    },
+    loadTags() {
+      return getTags().then(function (res) {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.tags = res.data
+        }
+      }.bind(this)).catch(function () {})
+    },
+    loadData() {
+      this.loading = true
+      return listLibrary({
+        page: this.page,
+        pageSize: this.pageSize,
+        fileType: this.typeFilter || '',
+        category: this.category || '',
+        search: this.keyword || '',
+        tagIds: this.selectedTagIds.length > 0 ? this.selectedTagIds : []
+      }).then(function (res) {
+        if (res.code === 200 && res.data) {
+          this.records = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      }.bind(this)).catch(function () {}).finally(function () {
+        this.loading = false
+      }.bind(this))
+    },
+    onTypeChange() {
+      this.page = 1
+      this.loadData()
+    },
+    onSearch() {
+      this.page = 1
+      this.loadData()
+    },
+    onRefresh() {
+      this.page = 1
+      this.loadData()
+    },
+    onPageChange(page) {
+      this.page = page
+      this.loadData()
+    },
+    onTagFilterToggle(tagId) {
+      const idx = this.selectedTagIds.indexOf(tagId)
+      if (idx === -1) {
+        this.selectedTagIds.push(tagId)
+      } else {
+        this.selectedTagIds.splice(idx, 1)
+      }
+      this.page = 1
+      this.loadData()
+    },
+    isSelected(r) {
+      return this.selectedRecords.some(function (s) { return s.id === r.id })
+    },
+    toggleSelect(r) {
+      if (this.mode === 'single') {
+        this.selectedRecords = [Object.assign({}, r)]
+        return
+      }
+      const idx = this.selectedRecords.findIndex(function (s) { return s.id === r.id })
+      if (idx === -1) {
+        if (this.isMaxReached) {
+          this.$message.warning('最多选择 ' + this.max + ' 项')
+          return
+        }
+        this.selectedRecords.push(Object.assign({}, r))
+      } else {
+        this.selectedRecords.splice(idx, 1)
+      }
+    },
+    beforeUpload(file) {
+      return true
+    },
+    async handleUploadRequest(options) {
+      const file = options.file
+      if (!file) return
+      this.uploading = true
+      try {
+        const res = await uploadToLibrary(file, {
+          category: this.uploadCategory,
+          tagIds: this.uploadTags
+        })
+        if (res.code === 200 && res.data) {
+          this.$message.success('已上传到素材库')
+          const record = res.data
+          this.page = 1
+          await this.loadData()
+          // 入网格后自动选中
+          const merged = this.records.filter(function (r) { return r.id === record.id })
+          if (merged.length > 0) {
+            const target = merged[0]
+            if (this.mode === 'single') {
+              this.selectedRecords = [Object.assign({}, target)]
+            } else {
+              if (!this.isSelected(target) && !this.isMaxReached) {
+                this.selectedRecords.push(Object.assign({}, target))
+              }
+            }
+          } else if (!this.isSelected(record)) {
+            // 兜底:分页未命中时直接用返回值
+            if (this.mode === 'single') {
+              this.selectedRecords = [Object.assign({}, record)]
+            } else if (!this.isMaxReached) {
+              this.selectedRecords.push(Object.assign({}, record))
+            }
+          }
+        } else {
+          this.$message.error((res && res.message) || '上传失败')
+        }
+      } catch (e) {
+        // request 拦截器已提示
+      } finally {
+        this.uploading = false
+      }
+    },
+    onConfirm() {
+      this.$emit('select', this.selectedRecords.slice())
+      this.dialogVisible = false
+    },
+    onClosed() {
+      this.records = []
+      this.total = 0
+      this.page = 1
+    },
+    thumbUrl(r) {
+      return resolveAssetUrl(r && r.url)
+    },
+    docIcon(r) {
+      const type = (r && r.fileType) || 'other'
+      if (type === 'video') return 'el-icon-video-camera'
+      if (type === 'audio') return 'el-icon-headphone'
+      return 'el-icon-document'
+    },
+    formatSize(bytes) {
+      if (!bytes) return '-'
+      if (bytes < 1024) return bytes + ' B'
+      if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
+      return (bytes / 1024 / 1024).toFixed(2) + ' MB'
+    },
+    colorOf(id) {
+      const t = this.tags.find(function (x) { return x.id === id })
+      return t && t.color ? t.color : '#409EFF'
+    },
+    nameOf(id) {
+      const t = this.tags.find(function (x) { return x.id === id })
+      return t ? t.name : ''
+    }
+  }
+}
+</script>
+
+<style scoped>
+.asset-picker {
+  display: flex;
+  flex-direction: column;
+  min-height: 480px;
+}
+.ap-upload-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+.ap-upload-trigger {
+  display: inline-block;
+}
+.ap-upload-tags {
+  width: 220px;
+}
+.ap-upload-hint {
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+.ap-filter-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 10px;
+}
+.ap-search {
+  width: 240px;
+}
+.ap-tag-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+.ap-tag-label {
+  font-size: 12px;
+  color: #909399;
+}
+.ap-tag-chip {
+  padding: 4px 10px;
+  font-size: 12px;
+}
+.ap-grid-wrap {
+  height: 360px;
+  overflow-y: auto;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  padding: 10px;
+  background: #fafafa;
+}
+.ap-grid-col {
+  margin-bottom: 10px;
+}
+.ap-item {
+  border: 1px solid #ebeef5;
+  border-radius: 6px;
+  background: #fff;
+  padding: 6px;
+  cursor: pointer;
+  transition: box-shadow 0.2s, border-color 0.2s;
+  text-align: center;
+}
+.ap-item:hover {
+  border-color: #c0c4cc;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+.ap-item.is-selected {
+  border-color: #409EFF;
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
+}
+.ap-item.is-disabled {
+  cursor: not-allowed;
+  opacity: 0.7;
+}
+.ap-thumb {
+  position: relative;
+  height: 96px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f7fa;
+  border-radius: 4px;
+  overflow: hidden;
+}
+.ap-thumb-img {
+  width: 100%;
+  height: 100%;
+}
+.ap-thumb-doc {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  color: #909399;
+  font-size: 12px;
+  gap: 4px;
+}
+.ap-thumb-doc i {
+  font-size: 30px;
+}
+.ap-check {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  color: #409EFF;
+  font-size: 20px;
+  text-shadow: 0 0 4px rgba(255, 255, 255, 0.9);
+}
+.ap-item-name {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.ap-item-meta {
+  margin-top: 2px;
+  font-size: 11px;
+  color: #909399;
+  display: flex;
+  justify-content: center;
+  gap: 8px;
+}
+.ap-item-tags {
+  margin-top: 4px;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 2px;
+}
+.ap-pagination {
+  margin-top: 12px;
+  display: flex;
+  justify-content: flex-end;
+}
+.ap-empty {
+  height: 360px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+}
+.ap-empty i {
+  font-size: 42px;
+  margin-bottom: 8px;
+}
+.ap-empty p {
+  margin: 0;
+  font-size: 13px;
+}
+.ap-footer-info {
+  margin-right: 16px;
+  font-size: 13px;
+  color: #909399;
+}
+</style>

+ 23 - 1
cfc-web/src/components/KnowledgeDialog/index.vue

@@ -48,6 +48,10 @@
         <el-form-item v-if="form.fileName" label="已选文件">
           <span>{{ form.fileName }}</span>
         </el-form-item>
+        <el-form-item label="素材库">
+          <el-button size="small" plain icon="el-icon-folder-opened" @click="assetPickerVisible = true">从素材库选择文件</el-button>
+          <div class="upload-tip">已上传到素材库的文档可直接选用</div>
+        </el-form-item>
         <el-form-item label="标题" prop="title">
           <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
         </el-form-item>
@@ -87,17 +91,27 @@
       <el-button @click="handleClose">取消</el-button>
       <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
     </span>
+
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      mode="single"
+      asset-type="document"
+      category="kb-docs"
+      @select="onAssetPicked"
+    />
   </el-dialog>
 </template>
 
 <script>
 import DimensionSelector from '@/components/DimensionSelector'
 import TagSelector from '@/components/TagSelector'
+import AssetPicker from '@/components/AssetPicker.vue'
+import { resolveAssetUrl } from '@/utils/asset'
 import { uploadKnowledgeFile, fetchUrlContent } from '@/api/dimension'
 
 export default {
   name: 'KnowledgeDialog',
-  components: { DimensionSelector, TagSelector },
+  components: { DimensionSelector, TagSelector, AssetPicker },
   props: {
     visible: { type: Boolean, default: false },
     knowledge: { type: Object, default: () => null }
@@ -106,6 +120,7 @@ export default {
     return {
       submitting: false,
       fetching: false,
+      assetPickerVisible: false,
       fileList: [],
       form: this.initForm(),
       rules: {
@@ -174,6 +189,13 @@ export default {
       this.form.fileUrl = ''
       this.form.fileName = ''
     },
+    onAssetPicked(records) {
+      const record = records && records[0]
+      if (!record) return
+      this.form.fileUrl = resolveAssetUrl(record.url)
+      this.form.fileName = record.originalFilename || '素材库文件'
+      this.$message.success('已选择素材库文件')
+    },
     async handleUploadFile() {
       if (this.fileList.length === 0) {
         this.$message.warning('请先选择文件')

+ 6 - 0
cfc-web/src/router/index.js

@@ -925,6 +925,12 @@ const routes = [
         component: () => import('@/views/admin/FileManage'),
         meta: { title: '文件管理', perm: 'system:base' }
       },
+      {
+        path: 'admin/asset-library',
+        name: 'AssetLibrary',
+        component: () => import('@/views/admin/AssetLibrary.vue'),
+        meta: { title: '素材库', perm: 'system:base' }
+      },
       // ========== 机构管理 ==========
       {
         path: 'org',

+ 111 - 0
cfc-web/src/utils/asset.js

@@ -0,0 +1,111 @@
+/**
+ * 后台素材库(file_record)统一封装。
+ *
+ * - resolveAssetUrl:把素材库返回的 URL 规整为可直接用于 <img>/<video> 的地址
+ *   (绝对地址原样返回,相对地址拼 VUE_APP_BASE_API)
+ * - 其余为素材库 REST 封装,统一走 @/utils/request(已带 Bearer Token 与统一错误提示)
+ *   返回值为后端 Result 对象:{ code, message, data }
+ */
+import request from './request'
+
+/**
+ * 规整素材 URL:
+ * - http(s):// 或 // 开头 → 原样返回(京东云公开地址已是绝对地址)
+ * - data: / blob: → 原样返回
+ * - 相对路径(/uploads/xxx)→ 拼接 VUE_APP_BASE_API
+ */
+export function resolveAssetUrl(url) {
+  if (!url) return ''
+  const s = String(url)
+  if (/^(https?:)?\/\//i.test(s)) return s
+  if (/^(data:|blob:)/i.test(s)) return s
+  const baseURL = process.env.VUE_APP_BASE_API || ''
+  if (s.charAt(0) === '/') return baseURL + s
+  return baseURL + '/' + s
+}
+
+/** 素材库分页查询 */
+export function listLibrary(params) {
+  return request.post('/api/admin/file-record/list', params || {})
+}
+
+/**
+ * 上传到素材库(统一入库 + 打标签)
+ *
+ * @param {File} file 文件对象
+ * @param {Object} options { category, tagIds }
+ */
+export function uploadToLibrary(file, options = {}) {
+  const category = options.category || 'general'
+  const tagIds = options.tagIds || []
+  const formData = new FormData()
+  formData.append('file', file)
+  formData.append('category', category)
+  tagIds.forEach(id => {
+    if (id !== undefined && id !== null && id !== '') {
+      formData.append('tagIds', id)
+    }
+  })
+  // 不手动设置 Content-Type,由浏览器自动带 multipart boundary
+  return request.post('/api/admin/file-record/upload', formData)
+}
+
+/** 标签列表 */
+export function getTags() {
+  return request.post('/api/admin/file-record/tags')
+}
+
+/** 新建标签 */
+export function createTag(data) {
+  return request.post('/api/admin/file-record/tag/create', data)
+}
+
+/** 更新标签 */
+export function updateTag(data) {
+  return request.post('/api/admin/file-record/tag/update', data)
+}
+
+/** 删除标签 */
+export function deleteTag(data) {
+  return request.post('/api/admin/file-record/tag/delete', data)
+}
+
+/** 删除素材 */
+export function deleteFile(data) {
+  return request.post('/api/admin/file-record/delete', data)
+}
+
+/** 更新素材(描述 + 标签) */
+export function updateFile(data) {
+  return request.post('/api/admin/file-record/update', data)
+}
+
+/** 素材库统计 */
+export function getLibraryStats() {
+  return request.post('/api/admin/file-record/stats')
+}
+
+/**
+ * 从素材库上传响应中提取 url(兼容 { url } 对象与纯字符串)
+ */
+export function extractAssetUrl(res) {
+  if (!res) return ''
+  const data = res.data !== undefined ? res.data : res
+  if (!data) return ''
+  if (typeof data === 'string') return data
+  return data.url || ''
+}
+
+export default {
+  resolveAssetUrl,
+  listLibrary,
+  uploadToLibrary,
+  getTags,
+  createTag,
+  updateTag,
+  deleteTag,
+  deleteFile,
+  updateFile,
+  getLibraryStats,
+  extractAssetUrl
+}

+ 137 - 0
cfc-web/src/utils/editorAssetUpload.js

@@ -0,0 +1,137 @@
+/**
+ * 富文本编辑器(@wangeditor/editor)素材库上传与「从素材库选择」公共封装。
+ *
+ * - buildEditorAssetMenuConf:生成 MENU_CONF.uploadImage / uploadVideo,
+ *   上传端点统一指向 /api/admin/file-record/upload(fieldName=file,Bearer 认证),
+ *   使编辑器内粘贴/拖拽/按钮上传自动写入 file_record(素材库)。
+ *   customInsert 用 resolveAssetUrl 规整 URL,修复旧代码 baseURL+url 在京东云
+ *   绝对地址下产生的双前缀 bug。
+ * - insertAssetIntoEditor:把素材库选中的记录插入编辑器光标处(图片/视频节点)。
+ */
+import { Message } from 'element-ui'
+import { resolveAssetUrl } from './asset'
+
+const BASE_URL = process.env.VUE_APP_BASE_API || ''
+
+function getUploadHeaders() {
+  const token = localStorage.getItem('token')
+  return token ? { Authorization: 'Bearer ' + token } : {}
+}
+
+function makeCustomInsert() {
+  return function customInsert(res, insertFn) {
+    if (!res) return
+    if (res.code !== 200) {
+      Message.error((res.message || '上传失败') + '(素材未入库)')
+      return
+    }
+    const data = res.data
+    const url = data && data.url ? data.url : data
+    if (!url) return
+    insertFn(resolveAssetUrl(url))
+  }
+}
+
+function makeCustomAlert() {
+  // 注意:wangEditor 从编辑器顶层 config.customAlert 读取(不是 MENU_CONF.uploadImage.customAlert)
+  return function customAlert(msg, type) {
+    Message({ message: msg, type: type || 'error' })
+  }
+}
+
+function makeOnError(label, maxSizeMB) {
+  return function onError(file) {
+    const name = (file && file.name) || label
+    Message.error(name + ' 上传失败,' + label + '不能超过 ' + maxSizeMB + 'MB')
+  }
+}
+
+/**
+ * 生成 wangEditor MENU_CONF 的 uploadImage / uploadVideo 配置。
+ *
+ * @param {Object} opts
+ * @param {String} opts.category 上传入库的素材分类,默认 general
+ * @param {Number} opts.maxImageSize 图片上限(MB),默认 5
+ * @param {Number} opts.maxVideoSize 视频上限(MB),默认 50
+ * @returns {{uploadImage:Object, uploadVideo:Object}}
+ */
+export function buildEditorAssetMenuConf(opts = {}) {
+  const category = opts.category || 'general'
+  const maxImageSize = opts.maxImageSize || 5
+  const maxVideoSize = opts.maxVideoSize || 50
+  return {
+    uploadImage: {
+      server: BASE_URL + '/api/admin/file-record/upload',
+      headers: getUploadHeaders(),
+      meta: { category },
+      maxNumberOfFiles: 1,
+      maxFileSize: maxImageSize * 1024 * 1024,
+      fieldName: 'file',
+      customInsert: makeCustomInsert(),
+      onError: makeOnError('图片', maxImageSize)
+    },
+    uploadVideo: {
+      server: BASE_URL + '/api/admin/file-record/upload',
+      headers: getUploadHeaders(),
+      meta: { category },
+      maxNumberOfFiles: 1,
+      maxFileSize: maxVideoSize * 1024 * 1024,
+      fieldName: 'file',
+      timeout: 5 * 60 * 1000,
+      allowedFileTypes: ['video/*'],
+      customInsert: makeCustomInsert(),
+      onError: makeOnError('视频', maxVideoSize)
+    }
+  }
+}
+
+/**
+ * 把素材库选中的记录插入编辑器当前光标处。
+ *
+ * @param {Object} editor wangEditor 实例
+ * @param {Object} record 素材库记录(含 url/fileType)
+ * @param {String} type   image | video(不传时按 record.fileType 推断)
+ */
+export function insertAssetIntoEditor(editor, record, type) {
+  if (!editor || !record) return
+  const url = resolveAssetUrl(record.url)
+  if (!url) return
+  const resolvedType = type || (record.fileType === 'video' ? 'video' : 'image')
+  try {
+    if (resolvedType === 'video') {
+      editor.insertNode({
+        type: 'video',
+        src: url,
+        poster: '',
+        width: 'auto',
+        height: 'auto',
+        children: [{ text: '' }]
+      })
+    } else {
+      editor.insertNode({
+        type: 'image',
+        src: url,
+        alt: '',
+        href: '',
+        style: {},
+        children: [{ text: '' }]
+      })
+    }
+  } catch (e) {
+    console.error('编辑器插入素材失败', e)
+    try {
+      editor.dangerouslyInsertHtml(
+        resolvedType === 'video' ? '<video src="' + url + '"></video>' : '<img src="' + url + '"/>'
+      )
+    } catch (e2) {
+      console.error('编辑器 HTML 兜底插入失败', e2)
+    }
+  }
+}
+
+export default {
+  buildEditorAssetMenuConf,
+  insertAssetIntoEditor,
+  getEditorCustomAlert: makeCustomAlert,
+  getUploadHeaders
+}

+ 2 - 1
cfc-web/src/views/Layout.vue

@@ -314,6 +314,7 @@ export default {
                 { path: '/gates', label: '关卡配置', icon: 'el-icon-key', perm: 'system:base' },
                 { path: '/system-prompt', label: '系统提示词', icon: 'el-icon-chat-line-square', perm: 'system:base' },
                 { path: '/file-manage', label: '文件管理', icon: 'el-icon-files', perm: 'system:base' },
+                { path: '/admin/asset-library', label: '素材库', icon: 'el-icon-picture-outline', perm: 'system:base' },
               ]},
             // --- 配置中心(统一配置中心 service_config;W0.2 双配置中心合并后单一入口) ---
             { title: '配置中心', icon: 'el-icon-setting', perm: 'config:dimension',
@@ -364,7 +365,7 @@ export default {
       ],
       // 需要保持列表状态的页面组件 name(进入编辑/详情返回时保留筛选、页码、勾选)。
       // 接入方式:把组件 name 加进数组,并在该页面混入 ListStateMixin(见 src/mixins/ListStateMixin.js)
-      cachedPages: ['Activities', 'ActivityReview', 'ArticleAudit', 'ArticleManage', 'AssessmentAssign', 'AssessmentOrders', 'AssessmentProducts', 'ButlerAssignments', 'ButlerAudit', 'CfValueManage', 'Children', 'CommentReview', 'CouponGrantLog', 'CouponManagement', 'DanExecution', 'DanSettlement', 'DataSourceConfig', 'DiscountRuleManagement', 'EcomSupplierManage', 'EmotionAlertManage', 'EnergyRuleManagement', 'Families', 'FamilyEarningsWithdraw', 'FileManage', 'Foods', 'GatesManage', 'GrowthTaskManagement', 'GuideAudit', 'GuideFamilyTask', 'HealthPlanManage', 'HealthReports', 'HomeBannerManage', 'IndicatorCorrelations', 'IndicatorMappings', 'InventoryManage', 'KnowledgeManage', 'MembershipCenter', 'NutritionistAudit', 'NutritionProducts', 'OperationLogs', 'OrchestrationFlow', 'OrderManage', 'PackageAudit', 'PendingRefund', 'PendingRefundAudit', 'Points', 'PointsLog', 'ProductManage', 'ProductPpointManage', 'ProductProfitRate', 'ProfileManagement', 'PromotionManagement', 'ProxyReport', 'ProxyReportUpload', 'PurchaseManagement', 'RecommendationRules', 'ReportManagement', 'ReportParseStatus', 'Rewards', 'SubscriptionManagement', 'SupplierProductManage', 'SupplyManageList', 'SupplySystemList', 'SysConfig', 'SystemPromptManagement', 'Tasks', 'TaskTemplates', 'TeacherConsult', 'TeacherOrders', 'Users', 'VectorKnowledgeBase', 'VendorAudit', 'VirtualTeamList', 'Wishes', 'WithdrawAudit'],
+      cachedPages: ['Activities', 'ActivityReview', 'AssetLibrary', 'ArticleAudit', 'ArticleManage', 'AssessmentAssign', 'AssessmentOrders', 'AssessmentProducts', 'ButlerAssignments', 'ButlerAudit', 'CfValueManage', 'Children', 'CommentReview', 'CouponGrantLog', 'CouponManagement', 'DanExecution', 'DanSettlement', 'DataSourceConfig', 'DiscountRuleManagement', 'EcomSupplierManage', 'EmotionAlertManage', 'EnergyRuleManagement', 'Families', 'FamilyEarningsWithdraw', 'FileManage', 'Foods', 'GatesManage', 'GrowthTaskManagement', 'GuideAudit', 'GuideFamilyTask', 'HealthPlanManage', 'HealthReports', 'HomeBannerManage', 'IndicatorCorrelations', 'IndicatorMappings', 'InventoryManage', 'KnowledgeManage', 'MembershipCenter', 'NutritionistAudit', 'NutritionProducts', 'OperationLogs', 'OrchestrationFlow', 'OrderManage', 'PackageAudit', 'PendingRefund', 'PendingRefundAudit', 'Points', 'PointsLog', 'ProductManage', 'ProductPpointManage', 'ProductProfitRate', 'ProfileManagement', 'PromotionManagement', 'ProxyReport', 'ProxyReportUpload', 'PurchaseManagement', 'RecommendationRules', 'ReportManagement', 'ReportParseStatus', 'Rewards', 'SubscriptionManagement', 'SupplierProductManage', 'SupplyManageList', 'SupplySystemList', 'SysConfig', 'SystemPromptManagement', 'Tasks', 'TaskTemplates', 'TeacherConsult', 'TeacherOrders', 'Users', 'VectorKnowledgeBase', 'VendorAudit', 'VirtualTeamList', 'Wishes', 'WithdrawAudit'],
       menuLoadFailed: false,
       profileDialogVisible: false,
       passwordDialogVisible: false,

+ 62 - 28
cfc-web/src/views/admin/ActivityEdit.vue

@@ -95,6 +95,10 @@
 
         <el-form-item label="活动描述" prop="description">
           <div class="rich-editor" :class="{ 'is-disabled': readonly }">
+            <div v-if="!readonly" class="editor-extra-row">
+              <el-button size="mini" plain icon="el-icon-picture-outline" @click="openAssetPicker('image')">从素材库选择图片</el-button>
+              <el-button size="mini" plain icon="el-icon-video-camera" @click="openAssetPicker('video')">从素材库选择视频</el-button>
+            </div>
             <Toolbar :editor="editorInstance" :defaultConfig="toolbarConfig" />
             <Editor :defaultConfig="editorConfig" v-model="form.description" @onCreated="handleEditorCreated" :disabled="readonly" class="editor-body" />
           </div>
@@ -105,6 +109,7 @@
             <el-upload
               :action="uploadActionUrl"
               :headers="uploadHeaders"
+              :data="{ category: 'activities' }"
               :on-success="handleUploadSuccess"
               :on-error="handleUploadError"
               :before-upload="beforeUpload"
@@ -114,6 +119,7 @@
               <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
               <el-button v-else type="primary" plain>+ 上传封面</el-button>
             </el-upload>
+            <el-button size="mini" plain icon="el-icon-picture-outline" class="ml-10" @click="openCoverAssetPicker">从素材库选择</el-button>
             <el-button v-if="form.coverImage" size="mini" type="text" class="ml-10" @click="form.coverImage = ''">清除</el-button>
           </template>
           <img v-else-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
@@ -227,6 +233,15 @@
         <el-button type="primary" @click="handlePublishAndBack" :loading="publishing">直接发布</el-button>
       </div>
     </el-dialog>
+
+    <!-- 素材库选择器 -->
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      mode="single"
+      :asset-type="assetPickerType"
+      category="activities"
+      @select="onAssetPicked"
+    />
   </div>
 </template>
 
@@ -236,10 +251,12 @@ import { getActivityDetail, createActivity, updateActivity, publishActivity, sub
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
 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'
 
 export default {
   name: 'ActivityEdit',
-  components: { DimensionWeightPicker, Editor, Toolbar },
+  components: { DimensionWeightPicker, Editor, Toolbar, AssetPicker },
   data() {
     return {
       isEdit: false,
@@ -293,6 +310,9 @@ export default {
       editorConfig: null,
       toolbarConfig: {},
       editorInstance: null,
+      assetPickerVisible: false,
+      assetPickerType: 'image',
+      assetPickerTarget: 'content',
       // T03 报名名册
       roster: { approvedCount: 0, pendingCount: 0, remaining: null },
       rosterRegistrants: [],
@@ -318,7 +338,7 @@ export default {
     },
     uploadActionUrl() {
       const baseURL = process.env.VUE_APP_BASE_API || ''
-      return baseURL + '/api/admin/articles/upload/image'
+      return baseURL + '/api/admin/file-record/upload'
     },
     uploadHeaders() {
       const token = localStorage.getItem('token')
@@ -336,35 +356,41 @@ export default {
   },
   methods: {
     initEditorConfig() {
-      const baseURL = process.env.VUE_APP_BASE_API || ''
-      const token = localStorage.getItem('token')
+      // 上传统一写入素材库(file_record),并修复旧 customInsert 的 baseURL 双前缀问题
       this.editorConfig = {
-        MENU_CONF: {
-          uploadImage: {
-            server: baseURL + '/api/admin/articles/upload/image',
-            headers: token ? { Authorization: 'Bearer ' + token } : {},
-            maxNumberOfFiles: 1,
-            maxFileSize: 5 * 1024 * 1024,
-            fieldName: 'file',
-            customAlert: (msg, type) => {
-              this.$message && this.$message({ message: msg, type: type || 'error' })
-            },
-            onError: (file, err, res) => {
-              const msg = (file && file.name || '图片') + ' 上传失败,图片不能超过 5MB'
-              this.$message && this.$message({ message: msg, type: 'error' })
-            },
-            customInsert(res, insertFn) {
-              if (res.code === 200) {
-                const url = res.data && res.data.url ? res.data.url : res.data
-                insertFn(baseURL + url)
-              }
-            }
-          }
-        }
+        // wangEditor 从编辑器顶层 config.customAlert 读取(不是 MENU_CONF.uploadImage.customAlert)
+        customAlert: getEditorCustomAlert(),
+        MENU_CONF: buildEditorAssetMenuConf({ category: 'activities' })
       }
     },
     handleEditorCreated(editor) {
-      this.editorInstance = editor
+      // Vue 2 必须 seal,否则会把 editor 做成响应式,内部 WeakMap 取不到实例
+      this.editorInstance = Object.seal(editor)
+    },
+    // ===== 素材库 =====
+    openAssetPicker(type) {
+      this.assetPickerTarget = 'content'
+      this.assetPickerType = type || 'image'
+      this.assetPickerVisible = true
+    },
+    openCoverAssetPicker() {
+      this.assetPickerTarget = 'cover'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    },
+    onAssetPicked(records) {
+      const record = records && records[0]
+      if (!record) return
+      if (this.assetPickerTarget === 'cover') {
+        this.form.coverImage = record.url
+        this.$message.success('已选择封面')
+        return
+      }
+      if (!this.editorInstance) {
+        this.$message.warning('编辑器尚未初始化完成')
+        return
+      }
+      insertAssetIntoEditor(this.editorInstance, record, this.assetPickerType)
     },
     // 活动时间收敛到 5 分钟粒度:分钟向下取整到 5 的倍数、秒清零(14:07 → 14:05)。
     // Date 入参返回 Date(与 v-model 类型保持一致,提交时仍由 axios 序列化为带秒的 ISO 串);
@@ -500,7 +526,9 @@ export default {
     },
     handleUploadSuccess(resp) {
       if (resp.code === 0 || resp.code === 200) {
-        this.form.coverImage = resp.data || resp
+        // 存库使用后端返回的原始 URL,避免把代理前缀写入数据库
+        const url = resp.data && resp.data.url ? resp.data.url : (typeof resp.data === 'string' ? resp.data : '')
+        this.form.coverImage = url
         this.$message.success('封面上传成功')
       } else {
         this.$message.error((resp && resp.message) || '封面上传失败')
@@ -754,6 +782,12 @@ activityId
   border: 1px solid var(--adm-border-base);
   border-radius: 4px;
 }
+.editor-extra-row {
+  display: flex;
+  gap: 8px;
+  padding: 6px 8px;
+  border-bottom: 1px solid var(--adm-border-base);
+}
 .rich-editor /deep/ .w-e-toolbar {
   flex-wrap: wrap;
 }

+ 60 - 54
cfc-web/src/views/admin/ArticleEdit.vue

@@ -71,6 +71,7 @@
             <el-upload
               :action="uploadActionUrl"
               :headers="uploadHeaders"
+              :data="{ category: 'articles' }"
               :on-success="handleUploadSuccess"
               :on-error="handleUploadError"
               :before-upload="beforeUpload"
@@ -80,6 +81,7 @@
               <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
               <el-button v-else type="primary" plain>+ 上传封面</el-button>
             </el-upload>
+            <el-button size="mini" plain icon="el-icon-picture-outline" class="btn-ml10" @click="openCoverAssetPicker">从素材库选择</el-button>
             <el-button v-if="form.coverImage" size="mini" type="text" class="btn-ml10" @click="form.coverImage = ''">清除</el-button>
           </template>
           <img v-else-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
@@ -88,6 +90,10 @@
 
         <el-form-item label="内容">
           <div class="rich-editor" :class="{ 'is-disabled': readonly }">
+            <div v-if="!readonly" class="editor-extra-row">
+              <el-button size="mini" plain icon="el-icon-picture-outline" @click="openAssetPicker('image')">从素材库选择图片</el-button>
+              <el-button size="mini" plain icon="el-icon-video-camera" @click="openAssetPicker('video')">从素材库选择视频</el-button>
+            </div>
             <Toolbar
               class="toolbar-border"
               :editor="editorInstance"
@@ -144,6 +150,15 @@
       </el-form>
       </div>
     </el-card>
+
+    <!-- 素材库选择器 -->
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      mode="single"
+      :asset-type="assetPickerType"
+      :category="'articles'"
+      @select="onAssetPicked"
+    />
   </div>
 </template>
 
@@ -162,10 +177,12 @@ import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weigh
 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'
 
 export default {
   name: 'ArticleEdit',
-  components: { DimensionWeightPicker, Editor, Toolbar },
+  components: { DimensionWeightPicker, Editor, Toolbar, AssetPicker },
   data() {
     return {
       isEdit: false,
@@ -183,6 +200,9 @@ export default {
       toolbarConfig: {},
       editorMode: 'default',
       editorInstance: null,
+      assetPickerVisible: false,
+      assetPickerType: 'image',
+      assetPickerTarget: 'content',
       form: {
         id: null,
         categoryId: '',
@@ -228,7 +248,7 @@ export default {
     },
     uploadActionUrl() {
       const baseURL = process.env.VUE_APP_BASE_API || ''
-      return baseURL + '/api/admin/articles/upload/image'
+      return baseURL + '/api/admin/file-record/upload'
     },
     uploadHeaders() {
       const token = localStorage.getItem('token')
@@ -259,64 +279,17 @@ export default {
   },
   methods: {
       initEditorConfig() {
-        const baseURL = process.env.VUE_APP_BASE_API || ''
-        const token = localStorage.getItem('token')
         this.editorConfig = {
           autoFocus: false,
+          // wangEditor 从编辑器顶层 config.customAlert 读取(不是 MENU_CONF.uploadImage.customAlert)
+          customAlert: getEditorCustomAlert(),
           hoverbarKeys: {
             video: {
               menuKeys: []
             }
           },
-          MENU_CONF: {
-            uploadImage: {
-              server: baseURL + '/api/admin/articles/upload/image',
-              headers: token ? { Authorization: 'Bearer ' + token } : {},
-              maxNumberOfFiles: 1,
-              maxFileSize: 5 * 1024 * 1024,
-              fieldName: 'file',
-              customAlert: (msg, type) => {
-                this.$message && this.$message({ message: msg, type: type || 'error' })
-              },
-onError: (file, err, res) => {
-              const msg = (file && file.name || '图片') + ' 上传失败,图片不能超过 5MB'
-              this.$message && this.$message({ message: msg, type: 'error' })
-            },
-              customInsert(res, insertFn) {
-                if (res.code === 200) {
-                  const url = res.data && res.data.url ? res.data.url : res.data
-                  insertFn(baseURL + url)
-                }
-              }
-            },
-            uploadVideo: {
-              server: baseURL + '/api/admin/articles/upload/video',
-              headers: token ? { Authorization: 'Bearer ' + token } : {},
-              maxNumberOfFiles: 1,
-              maxFileSize: 50 * 1024 * 1024,
-              fieldName: 'file',
-              timeout: 5 * 60 * 1000,
-              allowedFileTypes: ['video/*'],
-              customAlert: (msg, type) => {
-                this.$message && this.$message({ message: msg, type: type || 'error' })
-              },
-              onError: (file, err, res) => {
-                const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
-                this.$message && this.$message({ message: msg, type: 'error' })
-              },
-              customInsert(res, insertFn) {
-                if (res.code === 200) {
-                  const url = res.data && res.data.url ? res.data.url : res.data
-                  if (!url) return
-                  if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0) {
-                    insertFn(url)
-                  } else {
-                    insertFn(baseURL + url)
-                  }
-                }
-              }
-            }
-          }
+          // 上传统一写入素材库(file_record),并修复旧 customInsert 的 baseURL 双前缀问题
+          MENU_CONF: buildEditorAssetMenuConf({ category: 'articles' })
         }
       },
     destroyEditor() {
@@ -336,6 +309,31 @@ onError: (file, err, res) => {
       this.editorInstance = Object.seal(editor)
       this.bindVideoInteractions(editor)
     },
+    // ===== 素材库 =====
+    openAssetPicker(type) {
+      this.assetPickerTarget = 'content'
+      this.assetPickerType = type || 'image'
+      this.assetPickerVisible = true
+    },
+    openCoverAssetPicker() {
+      this.assetPickerTarget = 'cover'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    },
+    onAssetPicked(records) {
+      const record = records && records[0]
+      if (!record) return
+      if (this.assetPickerTarget === 'cover') {
+        this.form.coverImage = record.url
+        this.$message.success('已选择封面')
+        return
+      }
+      if (!this.editorInstance) {
+        this.$message.warning('编辑器尚未初始化完成')
+        return
+      }
+      insertAssetIntoEditor(this.editorInstance, record, this.assetPickerType)
+    },
     bindVideoInteractions(editor) {
       const root = (editor && editor.getEditableContainer && editor.getEditableContainer())
         || (this.$el && this.$el.querySelector('.w-e-text-container'))
@@ -695,7 +693,9 @@ onError: (file, err, res) => {
     },
     handleUploadSuccess(res) {
       if (res.code === 200 && res.data) {
-        this.form.coverImage = res.data.url || res.data
+        // 存库使用后端返回的原始 URL(生产为京东云绝对地址),避免把代理前缀写入数据库
+        const url = res.data.url || (typeof res.data === 'string' ? res.data : '')
+        this.form.coverImage = url
         this.$message.success('封面上传成功')
       } else {
         this.$message.error(res.message || '封面上传失败')
@@ -857,6 +857,12 @@ onError: (file, err, res) => {
   border: 1px solid var(--adm-border-base);
   border-radius: 4px;
 }
+.editor-extra-row {
+  display: flex;
+  gap: 8px;
+  padding: 6px 8px;
+  border-bottom: 1px solid var(--adm-border-base);
+}
 .rich-editor /deep/ .w-e-toolbar {
   flex-wrap: wrap;
 }

+ 792 - 0
cfc-web/src/views/admin/AssetLibrary.vue

@@ -0,0 +1,792 @@
+<template>
+  <div class="asset-library admin-page">
+    <el-card>
+      <PageHeader slot="header" title="素材库">
+        <div class="admin-page-actions">
+          <el-button icon="el-icon-tag" @click="tagDialogVisible = true">标签管理</el-button>
+          <el-button icon="el-icon-refresh" @click="loadAll">刷新</el-button>
+        </div>
+      </PageHeader>
+
+      <!-- 统计 -->
+      <el-row :gutter="16" class="mb-16">
+        <el-col :span="6">
+          <StatCard label="素材总数" :value="stats.total || 0" />
+        </el-col>
+        <el-col :span="6">
+          <StatCard label="占用空间" :value="formatSize(stats.totalBytes || 0)" />
+        </el-col>
+        <el-col :span="12">
+          <el-card shadow="never" class="stat-card">
+            <div class="stat-chips">
+              <StatusTag
+                v-for="c in stats.byFileType || []"
+                :key="c.file_type"
+                type="primary"
+                :text="(c.file_type || 'unknown') + ': ' + c.cnt"
+                size="small"
+              />
+            </div>
+            <div class="stat-label">类型分布</div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <!-- 筛选 -->
+      <div class="filter-bar">
+        <el-select v-model="query.fileType" placeholder="类型" clearable class="w-120" @change="onFilterChange">
+          <el-option label="全部" value="" />
+          <el-option label="图片" value="image" />
+          <el-option label="文档" value="document" />
+          <el-option label="视频" value="video" />
+          <el-option label="音频" value="audio" />
+          <el-option label="其它" value="other" />
+        </el-select>
+        <el-select v-model="query.category" placeholder="分类" clearable class="w-140" @change="onFilterChange">
+          <el-option label="全部" value="" />
+          <el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
+        </el-select>
+        <el-select v-model="query.tagIds" placeholder="标签(任一命中)" multiple collapse-tags clearable class="w-220" @change="onFilterChange">
+          <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>
+        <el-input
+          v-model="query.search"
+          placeholder="搜索文件名 / 描述"
+          clearable
+          class="w-200"
+          @keyup.enter.native="onSearch"
+          @clear="onSearch"
+        />
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="yyyy-MM-dd"
+          class="w-240"
+          @change="onSearch"
+        />
+        <el-button type="primary" icon="el-icon-search" @click="onSearch">查询</el-button>
+        <el-button icon="el-icon-refresh-left" @click="onReset">重置</el-button>
+      </div>
+
+      <!-- 批量操作条 -->
+      <div class="batch-bar" :class="{ 'is-active': selectedIds.length > 0 }">
+        <span class="batch-info">已选 {{ selectedIds.length }} 项</span>
+        <el-button size="mini" type="primary" :disabled="selectedIds.length === 0" @click="openBatchTagDialog">
+          批量打标签
+        </el-button>
+        <el-button size="mini" type="danger" :disabled="selectedIds.length === 0" @click="batchDelete">
+          批量删除
+        </el-button>
+        <el-button size="mini" @click="clearSelection">取消选择</el-button>
+      </div>
+
+      <!-- 网格 -->
+      <div v-loading="loading" class="grid-wrap">
+        <el-row v-if="records.length > 0" :gutter="12">
+          <el-col v-for="r in records" :key="r.id" :span="6" class="grid-col">
+            <div class="asset-card" :class="{ 'is-selected': isSelected(r) }">
+              <div class="card-thumb" @click="toggleSelect(r)">
+                <el-image
+                  v-if="r.fileType === 'image'"
+                  :src="thumbUrl(r)"
+                  fit="cover"
+                  class="thumb-img"
+                />
+                <div v-else class="thumb-doc">
+                  <i :class="docIcon(r)"></i>
+                  <span>{{ r.fileType || 'other' }}</span>
+                </div>
+                <el-checkbox
+                  class="card-check"
+                  :value="isSelected(r)"
+                  @click.native.stop="toggleSelect(r)"
+                ></el-checkbox>
+              </div>
+              <div class="card-body">
+                <div class="card-name" :title="r.originalFilename">{{ r.originalFilename }}</div>
+                <div class="card-meta">
+                  <span>{{ formatSize(r.fileSize) }}</span>
+                  <span>{{ r.category }}</span>
+                  <span>{{ formatTime(r.createdAt) }}</span>
+                </div>
+                <div v-if="r.description" class="card-desc" :title="r.description">{{ r.description }}</div>
+                <div v-if="(r.tags || []).length > 0" class="card-tags">
+                  <el-tag
+                    v-for="t in r.tags"
+                    :key="t.id"
+                    size="mini"
+                    :style="{ color: t.color, borderColor: t.color }"
+                  >{{ t.name }}</el-tag>
+                </div>
+                <div class="card-actions">
+                  <el-button size="mini" type="text" @click="openPreview(r)">预览</el-button>
+                  <el-button size="mini" type="text" @click="openDescDialog(r)">改描述</el-button>
+                  <el-button size="mini" type="text" @click="openTagDialog(r)">打标签</el-button>
+                  <el-button size="mini" type="text" class="text-error" @click="removeOne(r)">删除</el-button>
+                </div>
+              </div>
+            </div>
+          </el-col>
+        </el-row>
+        <div v-else-if="!loading" class="empty-box">
+          <i class="el-icon-picture-outline"></i>
+          <p>暂无素材</p>
+        </div>
+      </div>
+
+      <el-pagination
+        class="mt-16"
+        background
+        layout="total, sizes, prev, pager, next, jumper"
+        :current-page="query.page"
+        :page-size="query.pageSize"
+        :page-sizes="[24, 48, 96]"
+        :total="total"
+        @size-change="onSizeChange"
+        @current-change="onPageChange"
+      />
+    </el-card>
+
+    <!-- 改描述 -->
+    <el-dialog title="修改描述" :visible.sync="descVisible" width="480px">
+      <el-form label-width="80px">
+        <el-form-item label="文件名">
+          <el-input :value="currentRecord ? currentRecord.originalFilename : ''" disabled />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="descText" type="textarea" :rows="4" maxlength="500" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="descVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveDesc">保存</el-button>
+      </span>
+    </el-dialog>
+
+    <!-- 打标签(单个 / 批量) -->
+    <el-dialog
+      :title="assignMode === 'batch' ? '批量打标签' : '打标签'"
+      :visible.sync="tagAssignVisible"
+      width="480px"
+    >
+      <el-form label-width="80px">
+        <el-form-item label="目标">
+          <span class="target-info">
+            <template v-if="assignMode === 'batch'">已选 {{ selectedIds.length }} 项素材</template>
+            <template v-else>{{ currentRecord ? currentRecord.originalFilename : '' }}</template>
+          </span>
+        </el-form-item>
+        <el-form-item label="标签">
+          <el-select v-model="assignTagIds" multiple filterable placeholder="选择要追加的标签" class="full-width">
+            <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="form-hint">追加模式:保留素材原有标签</span>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="tagAssignVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveAssignTags">保存</el-button>
+      </span>
+    </el-dialog>
+
+    <!-- 预览 -->
+    <el-dialog title="预览" :visible.sync="previewVisible" width="640px">
+      <div v-if="previewRecord">
+        <el-image
+          v-if="previewRecord.fileType === 'image'"
+          :src="thumbUrl(previewRecord)"
+          fit="contain"
+          class="preview-img"
+        />
+        <video
+          v-else-if="previewRecord.fileType === 'video'"
+          :src="thumbUrl(previewRecord)"
+          controls
+          class="preview-img"
+        ></video>
+        <audio
+          v-else-if="previewRecord.fileType === 'audio'"
+          :src="thumbUrl(previewRecord)"
+          controls
+        ></audio>
+        <p v-else class="preview-doc">
+          该类型暂不支持在线预览,请下载后查看:
+          <a :href="thumbUrl(previewRecord)" target="_blank" rel="noopener">{{ previewRecord.originalFilename }}</a>
+        </p>
+      </div>
+    </el-dialog>
+
+    <!-- 标签管理 -->
+    <el-dialog title="标签管理" :visible.sync="tagDialogVisible" width="640px">
+      <div class="tag-create">
+        <el-input v-model="tagForm.name" size="small" placeholder="标签名称" class="tag-name-input" />
+        <el-color-picker v-model="tagForm.color" size="small" />
+        <el-input-number v-model="tagForm.sortOrder" size="small" :min="0" :max="9999" controls-position="right" class="tag-sort-input" />
+        <el-button type="primary" size="small" @click="submitTag">{{ tagForm.id ? '保存修改' : '新建标签' }}</el-button>
+        <el-button v-if="tagForm.id" size="small" @click="resetTagForm">取消编辑</el-button>
+      </div>
+      <el-table :data="tags" border size="mini" class="mt-16" max-height="360">
+        <el-table-column prop="name" label="名称" min-width="140">
+          <template slot-scope="scope">
+            <span :style="{ color: scope.row.color }">{{ scope.row.name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="color" label="颜色" width="110">
+          <template slot-scope="scope">
+            <el-tag size="mini" :style="{ color: scope.row.color, borderColor: scope.row.color }">{{ scope.row.color }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" label="排序" width="80" />
+        <el-table-column label="操作" width="130">
+          <template slot-scope="scope">
+            <el-button size="mini" type="text" @click="editTag(scope.row)">编辑</el-button>
+            <el-button size="mini" type="text" class="text-error" @click="removeTag(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import PageHeader from '@/components/PageHeader.vue'
+import StatCard from '@/components/StatCard.vue'
+import StatusTag from '@/components/StatusTag.vue'
+import {
+  listLibrary,
+  getTags,
+  createTag,
+  updateTag,
+  deleteTag,
+  deleteFile,
+  updateFile,
+  getLibraryStats,
+  resolveAssetUrl
+} from '@/utils/asset'
+
+export default {
+  name: 'AssetLibrary',
+  components: { PageHeader, StatCard, StatusTag },
+  data() {
+    return {
+      loading: false,
+      saving: false,
+      records: [],
+      tags: [],
+      stats: {},
+      total: 0,
+      dateRange: null,
+      selectedIds: [],
+      currentRecord: null,
+      query: {
+        page: 1,
+        pageSize: 24,
+        fileType: '',
+        category: '',
+        tagIds: [],
+        search: ''
+      },
+      descVisible: false,
+      descText: '',
+      previewVisible: false,
+      previewRecord: null,
+      tagAssignVisible: false,
+      assignMode: 'single',
+      assignTagIds: [],
+      tagDialogVisible: false,
+      tagForm: { id: null, name: '', color: '#409EFF', sortOrder: 0 }
+    }
+  },
+  mounted() {
+    this.loadAll()
+  },
+  computed: {
+    // 分类下拉:已知分类 + 数据库中已出现的分类(自动兼容新增分类)
+    categoryOptions() {
+      const counts = {}
+      ;(this.stats.byCategory || []).forEach(r => {
+        const key = r.category || ''
+        counts[key] = r.cnt || 0
+      })
+      const keys = Object.keys(counts)
+      ;['general', 'articles', 'activities', 'products', 'banners', 'product-skus', 'guides',
+        'kb-images', 'kb-docs', 'dan-reports', 'assessment-reports', 'health-reports']
+        .forEach(k => { if (keys.indexOf(k) === -1) keys.push(k) })
+      return keys.map(k => ({
+        value: k,
+        label: counts[k] !== undefined ? k + ' (' + counts[k] + ')' : k
+      }))
+    }
+  },
+  methods: {
+    loadAll() {
+      this.loadTags()
+      this.loadStats()
+      this.loadData()
+    },
+    async loadTags() {
+      try {
+        const res = await getTags()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.tags = res.data
+        }
+      } catch (e) { /* 拦截器已提示 */ }
+    },
+    async loadStats() {
+      try {
+        const res = await getLibraryStats()
+        if (res.code === 200 && res.data) {
+          this.stats = res.data
+        }
+      } catch (e) { /* 拦截器已提示 */ }
+    },
+    async loadData() {
+      this.loading = true
+      const params = {
+        page: this.query.page,
+        pageSize: this.query.pageSize,
+        fileType: this.query.fileType || '',
+        category: this.query.category || '',
+        search: this.query.search || '',
+        tagIds: this.query.tagIds || []
+      }
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.dateFrom = this.dateRange[0]
+        params.dateTo = this.dateRange[1]
+      }
+      try {
+        const res = await listLibrary(params)
+        if (res.code === 200 && res.data) {
+          this.records = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) { /* 拦截器已提示 */ } finally {
+        this.loading = false
+      }
+    },
+    onSearch() {
+      this.query.page = 1
+      this.loadData()
+    },
+    onFilterChange() {
+      this.onSearch()
+    },
+    onReset() {
+      this.query.fileType = ''
+      this.query.category = ''
+      this.query.tagIds = []
+      this.query.search = ''
+      this.dateRange = null
+      this.selectedIds = []
+      this.onSearch()
+    },
+    onSizeChange(size) {
+      this.query.pageSize = size
+      this.query.page = 1
+      this.loadData()
+    },
+    onPageChange(page) {
+      this.query.page = page
+      this.loadData()
+    },
+    isSelected(r) {
+      return this.selectedIds.indexOf(r.id) !== -1
+    },
+    toggleSelect(r) {
+      const idx = this.selectedIds.indexOf(r.id)
+      if (idx === -1) {
+        this.selectedIds.push(r.id)
+      } else {
+        this.selectedIds.splice(idx, 1)
+      }
+    },
+    clearSelection() {
+      this.selectedIds = []
+    },
+    openDescDialog(r) {
+      this.currentRecord = r
+      this.descText = r.description || ''
+      this.descVisible = true
+    },
+    openPreview(r) {
+      this.previewRecord = r
+      this.previewVisible = true
+    },
+    async saveDesc() {
+      if (!this.currentRecord) return
+      this.saving = true
+      try {
+        const res = await updateFile({ id: this.currentRecord.id, description: this.descText })
+        if (res.code === 200) {
+          this.$message.success('已保存')
+          this.descVisible = false
+          this.loadData()
+        } else {
+          this.$message.error(res.message || '保存失败')
+        }
+      } catch (e) { /* 拦截器已提示 */ } finally {
+        this.saving = false
+      }
+    },
+    openTagDialog(r) {
+      this.currentRecord = r
+      this.assignMode = 'single'
+      this.assignTagIds = []
+      this.tagAssignVisible = true
+    },
+    openBatchTagDialog() {
+      if (this.selectedIds.length === 0) {
+        this.$message.warning('请先选择素材')
+        return
+      }
+      this.assignMode = 'batch'
+      this.assignTagIds = []
+      this.tagAssignVisible = true
+    },
+    async saveAssignTags() {
+      if (this.assignTagIds.length === 0) {
+        this.$message.warning('请选择标签')
+        return
+      }
+      this.saving = true
+      try {
+        if (this.assignMode === 'batch') {
+          const targets = this.records.filter(r => this.selectedIds.indexOf(r.id) !== -1)
+          for (const r of targets) {
+            const merged = this.mergeTagIds(r, this.assignTagIds)
+            await updateFile({ id: r.id, tagIds: merged })
+          }
+          this.$message.success('已为 ' + targets.length + ' 项素材追加标签')
+        } else {
+          if (!this.currentRecord) return
+          const merged = this.mergeTagIds(this.currentRecord, this.assignTagIds)
+          await updateFile({ id: this.currentRecord.id, tagIds: merged })
+          this.$message.success('已保存标签')
+        }
+        this.tagAssignVisible = false
+        this.loadData()
+        this.loadStats()
+      } catch (e) { /* 拦截器已提示 */ } finally {
+        this.saving = false
+      }
+    },
+    mergeTagIds(record, extraIds) {
+      const ids = ((record && record.tags) || []).map(t => t.id)
+      extraIds.forEach(id => {
+        if (ids.indexOf(id) === -1) ids.push(id)
+      })
+      return ids
+    },
+    async removeOne(r) {
+      await this.confirmDelete([r])
+    },
+    async batchDelete() {
+      const targets = this.records.filter(r => this.selectedIds.indexOf(r.id) !== -1)
+      await this.confirmDelete(targets)
+    },
+    async confirmDelete(targets) {
+      if (!targets || targets.length === 0) return
+      try {
+        await this.$confirm(
+          '确定删除选中的 ' + targets.length + ' 个素材?对象存储文件将一并删除,且不可恢复。',
+          '警告',
+          { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
+        )
+      } catch (e) {
+        return
+      }
+      try {
+        for (const r of targets) {
+          await deleteFile({ id: r.id })
+        }
+        this.$message.success('已删除 ' + targets.length + ' 项')
+        this.selectedIds = []
+        this.loadData()
+        this.loadStats()
+      } catch (e) { /* 拦截器已提示 */ }
+    },
+    // ===== 标签管理 =====
+    resetTagForm() {
+      this.tagForm = { id: null, name: '', color: '#409EFF', sortOrder: 0 }
+    },
+    editTag(row) {
+      this.tagForm = {
+        id: row.id,
+        name: row.name,
+        color: row.color || '#409EFF',
+        sortOrder: row.sortOrder || 0
+      }
+    },
+    async submitTag() {
+      const name = (this.tagForm.name || '').trim()
+      if (!name) {
+        this.$message.warning('请输入标签名称')
+        return
+      }
+      const payload = {
+        name,
+        color: this.tagForm.color || '#409EFF',
+        sortOrder: this.tagForm.sortOrder || 0
+      }
+      try {
+        let res
+        if (this.tagForm.id) {
+          payload.id = this.tagForm.id
+          res = await updateTag(payload)
+        } else {
+          res = await createTag(payload)
+        }
+        if (res.code === 200) {
+          this.$message.success(this.tagForm.id ? '标签已更新' : '标签已创建')
+          this.resetTagForm()
+          this.loadTags()
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) { /* 拦截器已提示 */ }
+    },
+    async removeTag(row) {
+      try {
+        await this.$confirm('确定删除标签「' + row.name + '」?素材本身不会被删除。', '提示', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        })
+      } catch (e) {
+        return
+      }
+      try {
+        const res = await deleteTag({ id: row.id })
+        if (res.code === 200) {
+          this.$message.success('标签已删除')
+          this.loadTags()
+          this.loadData()
+        } else {
+          this.$message.error(res.message || '删除失败')
+        }
+      } catch (e) { /* 拦截器已提示 */ }
+    },
+    // ===== 展示辅助 =====
+    thumbUrl(r) {
+      return resolveAssetUrl(r && r.url)
+    },
+    docIcon(r) {
+      const type = (r && r.fileType) || 'other'
+      if (type === 'video') return 'el-icon-video-camera'
+      if (type === 'audio') return 'el-icon-headphone'
+      return 'el-icon-document'
+    },
+    formatSize(bytes) {
+      if (!bytes) return '0B'
+      const units = ['B', 'KB', 'MB', 'GB']
+      let i = 0
+      let size = bytes
+      while (size >= 1024 && i < units.length - 1) {
+        size /= 1024
+        i++
+      }
+      return size.toFixed(i > 0 ? 1 : 0) + units[i]
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.asset-library {
+  padding: 20px;
+}
+.filter-bar {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+.batch-bar {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 12px;
+  margin-bottom: 12px;
+  border-radius: 4px;
+  background: #f4f4f5;
+  border: 1px solid #e9e9eb;
+}
+.batch-bar.is-active {
+  background: #ecf5ff;
+  border-color: #b3d8ff;
+}
+.batch-info {
+  font-size: 13px;
+  color: #606266;
+}
+.grid-wrap {
+  min-height: 320px;
+}
+.grid-col {
+  margin-bottom: 12px;
+}
+.asset-card {
+  border: 1px solid #ebeef5;
+  border-radius: 6px;
+  overflow: hidden;
+  background: #fff;
+  transition: box-shadow 0.2s, border-color 0.2s;
+}
+.asset-card:hover {
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
+}
+.asset-card.is-selected {
+  border-color: #409eff;
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
+}
+.card-thumb {
+  position: relative;
+  height: 140px;
+  background: #f5f7fa;
+  cursor: pointer;
+}
+.thumb-img {
+  width: 100%;
+  height: 100%;
+}
+.thumb-doc {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 12px;
+  gap: 6px;
+}
+.thumb-doc i {
+  font-size: 38px;
+}
+.card-check {
+  position: absolute;
+  top: 6px;
+  left: 6px;
+  background: rgba(255, 255, 255, 0.85);
+  border-radius: 3px;
+}
+.card-body {
+  padding: 8px 10px 10px;
+}
+.card-name {
+  font-size: 13px;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.card-meta {
+  margin-top: 4px;
+  font-size: 11px;
+  color: #909399;
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.card-desc {
+  margin-top: 4px;
+  font-size: 12px;
+  color: #606266;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.card-tags {
+  margin-top: 6px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+.card-actions {
+  margin-top: 6px;
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+}
+.preview-img {
+  width: 100%;
+  max-height: 460px;
+  object-fit: contain;
+}
+.preview-doc {
+  font-size: 13px;
+  color: #606266;
+  word-break: break-all;
+}
+.empty-box {
+  height: 320px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+}
+.empty-box i {
+  font-size: 46px;
+  margin-bottom: 8px;
+}
+.empty-box p {
+  margin: 0;
+}
+.tag-create {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.tag-name-input {
+  width: 220px;
+}
+.tag-sort-input {
+  width: 120px;
+}
+.target-info {
+  font-size: 13px;
+  color: #606266;
+}
+.form-hint {
+  font-size: 12px;
+  color: #909399;
+}
+.stat-card {
+  text-align: center;
+}
+.stat-label {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+}
+.stat-chips {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  justify-content: center;
+}
+.text-error {
+  color: #f56c6c;
+}
+/* == C 批内联清理工具类 == */
+.w-120 { width: 120px; }
+.w-140 { width: 140px; }
+.w-200 { width: 200px; }
+.w-220 { width: 220px; }
+.w-240 { width: 240px; }
+.full-width { width: 100%; }
+.mt-16 { margin-top: 16px; }
+.mb-16 { margin-bottom: 16px; }
+</style>

+ 3 - 3
cfc-web/src/views/admin/FileManage.vue

@@ -32,7 +32,7 @@
         <el-select v-model="query.fileType" placeholder="文件类型" clearable class="w-120">
           <el-option label="全部" value=""></el-option>
           <el-option label="image" value="image"></el-option>
-          <el-option label="pdf" value="pdf"></el-option>
+          <el-option label="document" value="document"></el-option>
           <el-option label="video" value="video"></el-option>
           <el-option label="audio" value="audio"></el-option>
           <el-option label="other" value="other"></el-option>
@@ -55,7 +55,7 @@
         <el-table-column label="预览" width="70">
           <template slot-scope="scope">
             <el-image v-if="scope.row.fileType === 'image'" :src="scope.row.url" class="file-thumb" fit="cover" :preview-src-list="[scope.row.url]"></el-image>
-            <i v-else-if="scope.row.fileType === 'pdf'" class="el-icon-document file-icon fi-error"></i>
+            <i v-else-if="scope.row.fileType === 'document' || scope.row.fileType === 'pdf'" class="el-icon-document file-icon fi-error"></i>
             <i v-else-if="scope.row.fileType === 'video'" class="el-icon-video-camera file-icon fi-primary"></i>
             <i v-else-if="scope.row.fileType === 'audio'" class="el-icon-headset file-icon fi-success"></i>
             <i v-else class="el-icon-document file-icon fi-muted"></i>
@@ -202,7 +202,7 @@ export default {
       while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
       return size.toFixed(i > 0 ? 1 : 0) + units[i]
     },
-    typeTag(type) { return { image: 'success', pdf: 'danger', video: 'primary', audio: 'warning' }[type] || 'info' }
+    typeTag(type) { return { image: 'success', document: 'danger', pdf: 'danger', video: 'primary', audio: 'warning' }[type] || 'info' }
   }
 }
 </script>

+ 33 - 16
cfc-web/src/views/admin/HomeBannerManage.vue

@@ -177,6 +177,7 @@
           <el-upload
             :action="uploadActionUrl"
             :headers="uploadHeaders"
+            :data="{ category: 'banners' }"
             :on-success="handleUploadSuccess"
             :on-error="handleUploadError"
             :before-upload="beforeUpload"
@@ -186,6 +187,7 @@
             <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
             <el-button v-else type="primary" plain>+ 上传封面</el-button>
           </el-upload>
+          <el-button size="mini" plain icon="el-icon-picture-outline" class="ml-10" @click="openAssetPicker">从素材库选择</el-button>
           <div v-if="form.coverImage" class="mt-8">
             <el-image :src="form.coverImage" class="cover-lg" fit="cover" />
           </div>
@@ -226,6 +228,15 @@
         <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
       </div>
     </el-dialog>
+
+    <!-- 素材库选择器 -->
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      mode="single"
+      asset-type="image"
+      category="banners"
+      @select="onAssetPicked"
+    />
   </div>
 </template>
 
@@ -235,6 +246,7 @@ import { getHomeBannerList, saveHomeBanner, deleteHomeBanner, batchDeleteHomeBan
 import { getActivityList } from '@/api/activity'
 import { getProductList } from '@/api/admin'
 import { adminArticleList } from '@/api/article'
+import AssetPicker from '@/components/AssetPicker.vue'
 
 // 小程序已注册页面(供链接类型选择)
 const MINI_PAGES = [
@@ -255,6 +267,7 @@ const MINI_PAGES = [
 export default {
   name: 'HomeBannerManage',
   mixins: [ListStateMixin],
+  components: { AssetPicker },
   data() {
     return {
       list: [],
@@ -266,6 +279,7 @@ export default {
       filterType: '',
       dialogVisible: false,
       submitLoading: false,
+      assetPickerVisible: false,
       editId: null,
       form: {
         id: null,
@@ -334,7 +348,7 @@ export default {
     },
     uploadActionUrl() {
       const baseURL = process.env.VUE_APP_BASE_API || ''
-      return baseURL + '/api/admin/articles/upload/image'
+      return baseURL + '/api/admin/file-record/upload'
     },
     uploadHeaders() {
       const token = localStorage.getItem('token')
@@ -554,17 +568,32 @@ export default {
     },
 
     handleUploadSuccess(res) {
-      if (res && res.code === 200 && res.data && res.data.url) {
-        this.form.coverImage = res.data.url
+      if (res && res.code === 200 && res.data) {
+        // 存库使用后端返回的原始 URL(生产为京东云绝对地址),避免把代理前缀写入数据库
+        const url = res.data.url || (typeof res.data === 'string' ? res.data : '')
+        if (!url) {
+          this.$message.error('上传失败')
+          return
+        }
+        this.form.coverImage = url
         this.$message.success('上传成功')
       } else {
-        this.$message.error('上传失败')
+        this.$message.error((res && res.message) || '上传失败')
       }
     },
     handleUploadError(err) {
       console.error('[HomeBanner] 上传失败', err)
       this.$message.error('图片上传失败,请重试')
     },
+    openAssetPicker() {
+      this.assetPickerVisible = true
+    },
+    onAssetPicked(records) {
+      if (records && records.length > 0) {
+        this.form.coverImage = records[0].url
+        this.$message.success('已选择封面')
+      }
+    },
     beforeUpload(file) {
       const isImage = file.type.startsWith('image/jpeg') || file.type.startsWith('image/png') || file.type.startsWith('image/gif') || file.type.startsWith('image/webp')
       if (!isImage) {
@@ -618,18 +647,6 @@ export default {
     // 选择小程序页面
     selectMiniPage() {
       this.$message.info('小程序页面选择功能待后端开发')
-    },
-    beforeUpload(file) {
-      const isJPG = file.type === 'image/jpeg'
-      const isPNG = file.type === 'image/png'
-      const isGIF = file.type === 'image/gif'
-      const isQQ = file.type === 'image/webp'
-      if (!isJPG && !isPNG && !isGIF && !isQQ) {
-        this.$message.error('只能上传 jpg/png/gif/webp 格式的图片!')
-        return false
-      }
-      this.$message.warning('上传图片受限,请使用后台上传接口上传封面图')
-      return false
     }
   }
 }

+ 92 - 68
cfc-web/src/views/admin/ProductEdit.vue

@@ -120,6 +120,10 @@
 
         <el-form-item label="商品描述" prop="description">
           <div class="rich-editor">
+            <div class="editor-extra-row">
+              <el-button size="mini" plain icon="el-icon-picture-outline" @click="openAssetPicker('image')">从素材库选择图片</el-button>
+              <el-button size="mini" plain icon="el-icon-video-camera" @click="openAssetPicker('video')">从素材库选择视频</el-button>
+            </div>
             <Toolbar :editor="editorInstance" :defaultConfig="toolbarConfig" />
             <Editor :defaultConfig="editorConfig" v-model="form.description" @onCreated="handleEditorCreated" class="editor-body" />
           </div>
@@ -130,6 +134,7 @@
             <el-upload
               :action="uploadActionUrl"
               :headers="uploadHeaders"
+              :data="{ category: 'products' }"
               :show-file-list="false"
               :on-success="handleCoverSuccess"
               :before-upload="beforeUpload"
@@ -138,6 +143,7 @@
               <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
               <i v-else class="el-icon-plus cover-icon"></i>
             </el-upload>
+            <el-button size="mini" plain icon="el-icon-picture-outline" @click="openCoverAssetPicker">从素材库选择</el-button>
             <span class="cover-tip">点击上传封面图,建议尺寸 800x800</span>
           </div>
         </el-form-item>
@@ -151,6 +157,7 @@
         <el-upload
           :action="uploadActionUrl"
           :headers="uploadHeaders"
+          :data="{ category: 'products' }"
           :show-file-list="false"
           :on-success="handleImageSuccess"
           :before-upload="beforeUpload"
@@ -159,6 +166,7 @@
             >
               <i class="el-icon-plus image-add-icon"></i>
             </el-upload>
+            <el-button size="mini" plain icon="el-icon-picture-outline" @click="openGalleryAssetPicker">从素材库选择</el-button>
           </div>
         </el-form-item>
 
@@ -358,6 +366,15 @@
       </el-form>
       </div>
     </el-card>
+
+    <!-- 素材库选择器 -->
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      :mode="assetPickerMode"
+      :asset-type="assetPickerType"
+      category="products"
+      @select="onAssetPicked"
+    />
   </div>
 </template>
 
@@ -368,17 +385,12 @@ import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
-
-function resolveUploadedUrl(res) {
-  const url = res && res.data && res.data.url ? res.data.url : (res && res.data)
-  if (!url) return ''
-  if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0) return url
-  return (process.env.VUE_APP_BASE_API || '') + url
-}
+import AssetPicker from '@/components/AssetPicker.vue'
+import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert } from '@/utils/editorAssetUpload'
 
 export default {
   name: 'ProductEdit',
-  components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar },
+  components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar, AssetPicker },
   data() {
     return {
       form: {
@@ -439,50 +451,13 @@ export default {
       pageLoading: false,
       purchaseFieldsEnabled: false,
       predefinedFields: [],
-      editorConfig: {
-        MENU_CONF: {
-          uploadImage: {
-            server: '',
-            headers: {},
-            maxNumberOfFiles: 1,
-            maxFileSize: 5 * 1024 * 1024,
-            fieldName: 'file',
-            customAlert: (msg, type) => {
-              this.$message && this.$message({ message: msg, type: type || 'error' })
-            },
-            onError: (file, err, res) => {
-              const msg = (file && file.name || '图片') + ' 上传失败,图片不能超过 5MB'
-              this.$message && this.$message({ message: msg, type: 'error' })
-            },
-            customInsert(res, insertFn) {
-              if (res.code === 200) {
-                insertFn(resolveUploadedUrl(res))
-              }
-            }
-          },
-          uploadVideo: {
-            server: '',
-            headers: {},
-            maxNumberOfFiles: 1,
-            maxFileSize: 50 * 1024 * 1024,
-            fieldName: 'file',
-            timeout: 5 * 60 * 1000,
-            allowedFileTypes: ['video/*'],
-            customAlert: (msg, type) => {
-              this.$message && this.$message({ message: msg, type: type || 'error' })
-            },
-            onError: (file, err, res) => {
-              const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
-              this.$message && this.$message({ message: msg, type: 'error' })
-            },
-            customInsert(res, insertFn) {
-              if (res.code === 200) {
-                insertFn(resolveUploadedUrl(res))
-              }
-            }
-          }
-        }
-      },
+      editorConfig: null,
+      toolbarConfig: {},
+      editorInstance: null,
+      assetPickerVisible: false,
+      assetPickerMode: 'single',
+      assetPickerType: 'image',
+      assetPickerTarget: 'content',
     addBundleItem() {
       this.bundleItems = this.bundleItems || []
       this.bundleItems.push({
@@ -548,9 +523,7 @@ export default {
           }, this)
         }
       } catch (e) { /* ignore */ }
-    },
-      toolbarConfig: {},
-      editorInstance: null
+    }
     }
   },
   computed: {
@@ -588,13 +561,9 @@ export default {
       const roles = rolesStr ? JSON.parse(rolesStr) : []
       return roles.includes('supplier') || roles.includes('supplier_admin')
     },
-  uploadActionUrl() {
-    const baseURL = process.env.VUE_APP_BASE_API || ''
-    return baseURL + '/api/admin/articles/upload/image'
-    },
-    uploadVideoActionUrl() {
+    uploadActionUrl() {
       const baseURL = process.env.VUE_APP_BASE_API || ''
-      return baseURL + '/api/admin/articles/upload/video'
+      return baseURL + '/api/admin/file-record/upload'
     },
     uploadHeaders() {
       const token = localStorage.getItem('token')
@@ -618,10 +587,12 @@ export default {
   },
   created() {
     this.loadApprovedGuides()
-    this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
-    this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
-    this.editorConfig.MENU_CONF.uploadVideo.server = this.uploadVideoActionUrl
-    this.editorConfig.MENU_CONF.uploadVideo.headers = this.uploadHeaders
+    // 上传统一写入素材库(file_record)
+    this.editorConfig = {
+      // wangEditor 从编辑器顶层 config.customAlert 读取(不是 MENU_CONF.uploadImage.customAlert)
+      customAlert: getEditorCustomAlert(),
+      MENU_CONF: buildEditorAssetMenuConf({ category: 'products' })
+    }
   },
   mounted() {
     this.loadSupplySystems()
@@ -635,7 +606,48 @@ export default {
   },
   methods: {
     handleEditorCreated(editor) {
-      this.editorInstance = editor
+      // Vue 2 必须 seal,否则会把 editor 做成响应式,内部 WeakMap 取不到实例
+      this.editorInstance = Object.seal(editor)
+    },
+    // ===== 素材库 =====
+    openAssetPicker(type) {
+      this.assetPickerTarget = 'content'
+      this.assetPickerMode = 'single'
+      this.assetPickerType = type || 'image'
+      this.assetPickerVisible = true
+    },
+    openCoverAssetPicker() {
+      this.assetPickerTarget = 'cover'
+      this.assetPickerMode = 'single'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    },
+    openGalleryAssetPicker() {
+      this.assetPickerTarget = 'gallery'
+      this.assetPickerMode = 'multiple'
+      this.assetPickerType = 'image'
+      this.assetPickerVisible = true
+    },
+    onAssetPicked(records) {
+      if (!records || records.length === 0) return
+      if (this.assetPickerTarget === 'cover') {
+        this.form.coverImage = records[0].url
+        this.$message.success('已选择封面')
+        return
+      }
+      if (this.assetPickerTarget === 'gallery') {
+        const urls = records.map(function (r) { return r.url }).filter(Boolean)
+        if (urls.length === 0) return
+        this.imageList = this.imageList.concat(urls)
+        this.form.images = JSON.stringify(this.imageList)
+        this.$message.success('已添加 ' + urls.length + ' 张图片')
+        return
+      }
+      if (!this.editorInstance) {
+        this.$message.warning('编辑器尚未初始化完成')
+        return
+      }
+      insertAssetIntoEditor(this.editorInstance, records[0], this.assetPickerType)
     },
     async loadSupplySystems() {
       if (this.isSupplierAdmin) {
@@ -1034,7 +1046,9 @@ async loadGiftRule(productId) {
     },
     handleCoverSuccess(res) {
       if (res.code === 200 && res.data) {
-        this.form.coverImage = res.data.url || res.data
+        // 存库使用后端返回的原始 URL(生产为京东云绝对地址),避免把代理前缀写入数据库
+        const url = res.data.url || (typeof res.data === 'string' ? res.data : '')
+        this.form.coverImage = url
         this.$message.success('封面上传成功')
       } else {
         this.$message.error(res.message || '上传失败')
@@ -1042,7 +1056,11 @@ async loadGiftRule(productId) {
     },
     handleImageSuccess(res) {
       if (res.code === 200 && res.data) {
-        const url = res.data.url || res.data
+        const url = res.data.url || (typeof res.data === 'string' ? res.data : '')
+        if (!url) {
+          this.$message.error('上传失败')
+          return
+        }
         this.imageList.push(url)
         this.form.images = JSON.stringify(this.imageList)
         this.$message.success('图片上传成功')
@@ -1357,6 +1375,12 @@ async loadGiftRule(productId) {
   border: 1px solid #dcdfe6;
   border-radius: 4px;
 }
+.editor-extra-row {
+  display: flex;
+  gap: 8px;
+  padding: 6px 8px;
+  border-bottom: 1px solid #dcdfe6;
+}
 .rich-editor /deep/ .w-e-toolbar {
   flex-wrap: wrap;
 }

+ 30 - 6
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -88,6 +88,7 @@
             <el-upload
               :action="uploadActionUrl"
               :headers="uploadHeaders"
+              :data="{ category: 'product-skus' }"
               :show-file-list="false"
               :on-success="handleImageSuccess"
               :before-upload="beforeUpload"
@@ -96,6 +97,7 @@
               <img v-if="skuForm.image" :src="skuForm.image" class="sku-img-preview" />
               <el-button v-else size="small" type="primary">上传图片</el-button>
             </el-upload>
+            <el-button size="small" plain icon="el-icon-picture-outline" @click="assetPickerVisible = true">从素材库选择</el-button>
           </div>
         </el-form-item>
         </template>
@@ -111,16 +113,26 @@
     </el-dialog>
 
     <ProductPicker ref="productPicker" :product-id="productId" @selected="onProductSelected" />
+
+    <!-- 素材库选择器 -->
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      mode="single"
+      asset-type="image"
+      category="product-skus"
+      @select="onAssetPicked"
+    />
   </div>
 </template>
 
 <script>
 import { getProductSkuList, createProductSku, updateProductSku, deleteProductSku } from '@/api/admin'
 import ProductPicker from './ProductPicker.vue'
+import AssetPicker from '@/components/AssetPicker.vue'
 
 export default {
   name: 'ProductSkuEditor',
-  components: { ProductPicker },
+  components: { ProductPicker, AssetPicker },
   props: {
     productId: {
       type: Number,
@@ -133,6 +145,7 @@ export default {
       pendingSkus: [],
       loading: false,
       dialogVisible: false,
+      assetPickerVisible: false,
       saving: false,
       editingId: null,
       isLinkedMode: false,
@@ -168,8 +181,8 @@ export default {
       return this.editingId ? '编辑SKU' : '添加SKU'
     },
     uploadActionUrl() {
-const baseURL = process.env.VUE_APP_BASE_API || ''
-    return baseURL + '/api/admin/articles/upload/image'
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      return baseURL + '/api/admin/file-record/upload'
     },
     uploadHeaders() {
       const token = localStorage.getItem('token')
@@ -272,11 +285,22 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
       return true
     },
     handleImageSuccess(res) {
-      if (res.code === 200) {
-        this.skuForm.image = res.data.url || res.data
+      if (res.code === 200 && res.data) {
+        const url = res.data.url || (typeof res.data === 'string' ? res.data : '')
+        if (!url) {
+          this.$message.error('上传失败')
+          return
+        }
+        this.skuForm.image = url
         this.$message.success('上传成功')
       } else {
-        this.$message.error(res.message || '上传失败')
+        this.$message.error((res && res.message) || '上传失败')
+      }
+    },
+    onAssetPicked(records) {
+      if (records && records.length > 0) {
+        this.skuForm.image = records[0].url
+        this.$message.success('已选择图片')
       }
     },
     parseSpecs(specs) {