Sfoglia il codice sorgente

feat(asset): 素材库补齐拖拽上传与任务模板封面入库

- AssetPicker: 「上传到素材库」区支持拖拽上传(dragover/dragleave/drop),
  抽出 uploadAndSelect 统一点击与拖拽两条路径的上传入库+自动选中逻辑,
  增加拖拽态高亮与「点击或拖拽文件到此处」提示
- GuidePackages: 封面图上传端点由失效的 /api/upload(无鉴权、后端无此路由)
  改为 /api/admin/file-record/upload(Bearer + category=guide-packages),
  修正 handleUploadSuccess 取 res.data.url,并接入「从素材库选择」AssetPicker
- 回填脚本: file_type 回填的 WHERE 条件与注释声明的意图对齐
  (排除与目标值相同的行,避免无意义行更新)
Sisyphus 2 settimane fa
parent
commit
84fe5102a3

+ 45 - 3
cfc-web/src/components/AssetPicker.vue

@@ -9,7 +9,13 @@
   >
     <div class="asset-picker">
       <!-- 上传区 -->
-      <div class="ap-upload-row">
+      <div
+        class="ap-upload-row"
+        :class="{ 'is-dragover': dragOver }"
+        @dragover.prevent="onDragOver"
+        @dragleave="onDragLeave"
+        @drop.prevent="onDrop"
+      >
         <el-upload
           class="ap-upload-trigger"
           action=""
@@ -43,6 +49,7 @@
               <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>
 
@@ -185,7 +192,8 @@ export default {
       typeFilter: this.initTypeFilter(),
       selectedTagIds: [],
       uploadTags: [],
-      selectedRecords: []
+      selectedRecords: [],
+      dragOver: false
     }
   },
   computed: {
@@ -314,7 +322,29 @@ export default {
       return true
     },
     async handleUploadRequest(options) {
-      const file = options.file
+      const file = options && options.file
+      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
       try {
@@ -402,6 +432,18 @@ export default {
   align-items: center;
   gap: 10px;
   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-drop-hint {
+  font-size: 12px;
+  color: #c0c4cc;
 }
 .ap-upload-trigger {
   display: inline-block;

+ 53 - 10
cfc-web/src/views/GuidePackages.vue

@@ -123,15 +123,20 @@
           <span class="suffix-hint">公开后将显示在任务模板市场</span>
         </el-form-item>
         <el-form-item label="封面图">
-          <el-upload
-            class="avatar-uploader"
-            action="/api/upload"
-            :show-file-list="false"
-            :on-success="handleUploadSuccess"
-          >
-            <img v-if="form.coverImage" :src="form.coverImage" class="avatar">
-            <i v-else class="el-icon-plus avatar-uploader-icon"></i>
-          </el-upload>
+          <div class="cover-uploader">
+            <el-upload
+              class="avatar-uploader"
+              :action="uploadActionUrl"
+              :headers="uploadHeaders"
+              :data="{ category: 'guide-packages' }"
+              :show-file-list="false"
+              :on-success="handleUploadSuccess"
+            >
+              <img v-if="form.coverImage" :src="form.coverImage" class="avatar">
+              <i v-else class="el-icon-plus avatar-uploader-icon"></i>
+            </el-upload>
+            <el-button size="small" plain icon="el-icon-picture-outline" @click="assetPickerVisible = true">从素材库选择</el-button>
+          </div>
         </el-form-item>
         <el-form-item label="详细介绍">
           <el-input type="textarea" v-model="form.description" :rows="4"></el-input>
@@ -212,25 +217,44 @@
     </el-dialog>
   
   </el-table>
+    <!-- 素材库选择器 -->
+    <AssetPicker
+      :visible.sync="assetPickerVisible"
+      mode="single"
+      asset-type="image"
+      category="guide-packages"
+      @select="onAssetPicked"
+    />
   </div>
 </template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import { getMyPackages, getGuidePackage, getGuidePackageItems, createPackage, updatePackage, submitPackageForReview, publishPackage, addPackageItem, updatePackageItem, deletePackageItem } from '@/api/guidePackage'
+import AssetPicker from '@/components/AssetPicker.vue'
 
 export default {
   name: 'GuidePackages',
   mixins: [SortMixin],
+  components: { AssetPicker },
   computed: {
     tableHeight() {
       return window.innerHeight - 340
+    },
+    uploadActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      return baseURL + '/api/admin/file-record/upload'
+    },
+    uploadHeaders() {
+      const token = localStorage.getItem('token')
+      return token ? { Authorization: 'Bearer ' + token } : {}
     }
   },
   data() {
     return {
       loading: false,
       saving: false,
+      assetPickerVisible: false,
       tableData: [],
       sortableColumns: { id: 'ID', name: '任务模板名称', cycleDays: '周期', price: '价格', category: '分类', status: '状态', shareCode: '分享码', createdAt: '创建时间' },
       searchForm: { status: '' },
@@ -360,7 +384,21 @@ export default {
       })
     },
     handleUploadSuccess(res) {
-      this.form.coverImage = res.data
+      // 素材库上传接口返回记录对象({ url, id, ... }),取原始 URL 入库
+      const url = res && res.data && res.data.url ? res.data.url : (res && res.data)
+      if (!url) {
+        this.$message.error('上传失败')
+        return
+      }
+      this.form.coverImage = url
+      this.$message.success('上传成功')
+    },
+    onAssetPicked(records) {
+      const record = records && records[0]
+      if (!record) return
+      // 存库使用后端返回的原始 URL(生产为京东云绝对地址),避免把代理前缀写入数据库
+      this.form.coverImage = record.url
+      this.$message.success('已选择封面')
     },
     async handleSubmit(row) {
       try {
@@ -456,6 +494,11 @@ export default {
 <style scoped>
 
 .code-text { font-family: monospace; color: var(--adm-primary); }
+.cover-uploader {
+  display: flex;
+  align-items: flex-end;
+  gap: 12px;
+}
 .avatar-uploader .el-upload {
   border: 1px dashed var(--adm-border-base);
   border-radius: 6px;

+ 4 - 4
docs/migrations/20261025_asset_library_file_type_backfill.sql

@@ -54,22 +54,22 @@ INSERT IGNORE INTO file_record_bak_20261025 SELECT * FROM file_record;
 
 UPDATE file_record
 SET file_type = 'image'
-WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'image')
+WHERE (file_type IS NULL OR file_type <> 'image')
   AND content_type LIKE 'image/%';
 
 UPDATE file_record
 SET file_type = 'video'
-WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'video')
+WHERE (file_type IS NULL OR file_type <> 'video')
   AND content_type LIKE 'video/%';
 
 UPDATE file_record
 SET file_type = 'audio'
-WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'audio')
+WHERE (file_type IS NULL OR file_type <> 'audio')
   AND content_type LIKE 'audio/%';
 
 UPDATE file_record
 SET file_type = 'document'
-WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'document')
+WHERE (file_type IS NULL OR file_type <> 'document')
   AND (content_type LIKE 'text/%'
        OR content_type IN (
             'application/pdf',