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

feat: 修复素材选择组件

doi 2 месяцев назад
Родитель
Сommit
b66ca00b50

+ 170 - 59
src/components/scMaterialPicker/index.vue

@@ -48,7 +48,7 @@
                                 @click="selectCategory('')"
                             >
                                 <el-icon><el-icon-folder /></el-icon>
-                                <span>{{ fileType === '1' ? '全部图片' : '全部视频' }}</span>
+                                <span>{{ allCategoryLabel }}</span>
                             </li>
                             <li
                                 v-for="item in filteredCategories"
@@ -81,7 +81,7 @@
                                 :accept="uploadAccept"
                                 :http-request="uploadRequest"
                             >
-                                <el-button>{{ fileType === '1' ? '上传图片' : '上传视频' }}</el-button>
+                                <el-button>{{ uploadBtnText }}</el-button>
                             </el-upload>
                             <el-button
                                 type="danger"
@@ -89,13 +89,13 @@
                                 :disabled="!selectedCount"
                                 @click="removeSelected"
                             >
-                                {{ fileType === '1' ? '删除图片' : '删除视频' }}
+                                {{ deleteBtnText }}
                             </el-button>
                         </div>
                         <el-input
                             v-model="keyword"
                             clearable
-                            :placeholder="fileType === '1' ? '搜索图片名称' : '搜索视频名称'"
+                            :placeholder="searchPlaceholder"
                             style="width: 240px"
                             @keyup.enter="searchFiles"
                             @clear="searchFiles"
@@ -114,13 +114,13 @@
                                     v-for="item in fileList"
                                     :key="item.id"
                                     class="picker-item"
-                                    :class="{ selected: isSelected(item.id) }"
+                                    :class="{ selected: isSelectedItem(item) }"
                                 >
                                     <div class="picker-thumb" @click="toggleSelect(item)">
                                         <el-image
-                                            v-if="fileType === '1'"
+                                            v-if="!isVideoItem(item)"
                                             :src="item.url"
-                                            fit="contain"
+                                            fit="cover"
                                             lazy
                                         />
                                         <div v-else class="video-thumb">
@@ -128,7 +128,7 @@
                                             <el-icon v-else class="video-icon"><el-icon-video-play /></el-icon>
                                         </div>
                                         <div class="picker-check">
-                                            <el-icon v-if="isSelected(item.id)"><el-icon-check /></el-icon>
+                                            <el-icon v-if="isSelectedItem(item)"><el-icon-check /></el-icon>
                                         </div>
                                     </div>
                                     <p class="picker-name" :title="item.name">{{ item.name }}</p>
@@ -150,7 +150,7 @@
                             >
                                 全选
                             </el-checkbox>
-                            <span class="selected-tip">已选 {{ selectedCount }}</span>
+                            <span class="selected-tip">{{ selectedCountTip }}</span>
                         </div>
                         <el-pagination
                             v-if="total > 0"
@@ -207,6 +207,8 @@ export default {
         buttonText: { type: String, default: "选择素材" },
         /** 锁定类型:'1' 仅图片,'2' 仅视频,空则 Tab 切换 */
         lockFileType: { type: String, default: "" },
+        /** 是否允许同时选中图片与视频(切换 Tab 不清空已选) */
+        acceptBoth: { type: Boolean, default: true },
         /** 返回值类型 object | url */
         valueType: { type: String, default: "object" },
     },
@@ -233,16 +235,47 @@ export default {
         };
     },
     computed: {
+        /** 有 Tab 且未锁定单一类型时,可混选图片+视频 */
+        isMixedMode() {
+            return this.acceptBoth && !this.lockFileType;
+        },
         dialogTitle() {
             if (this.title) return this.title;
+            if (this.isMixedMode) return "选择素材";
             return this.fileType === "2" ? "上传视频" : "上传商品图";
         },
         confirmText() {
+            if (this.isMixedMode) return "使用选中素材";
             return this.fileType === "2" ? "使用选中视频" : "使用选中图片";
         },
+        allCategoryLabel() {
+            return this.fileType === "1" ? "全部图片" : "全部视频";
+        },
+        uploadBtnText() {
+            if (this.isMixedMode) return "上传素材";
+            return this.fileType === "1" ? "上传图片" : "上传视频";
+        },
+        deleteBtnText() {
+            if (this.isMixedMode) return "删除素材";
+            return this.fileType === "1" ? "删除图片" : "删除视频";
+        },
+        searchPlaceholder() {
+            if (this.isMixedMode) return "搜索素材名称";
+            return this.fileType === "1" ? "搜索图片名称" : "搜索视频名称";
+        },
         uploadAccept() {
+            if (this.isMixedMode) return "image/*,video/*";
             return this.fileType === "1" ? "image/*" : "video/*";
         },
+        selectedCountTip() {
+            const items = this.getUniqueSelectedItems();
+            const n = items.length;
+            if (!n) return "已选 0 个";
+            if (!this.isMixedMode) return `已选 ${n} 个`;
+            const video = items.filter((i) => this.isVideoItem(i)).length;
+            const image = n - video;
+            return `已选 ${n} 个(图片 ${image} / 视频 ${video})`;
+        },
         filteredCategories() {
             const kw = (this.categoryKeyword || "").trim().toLowerCase();
             if (!kw) return this.flatCategories;
@@ -258,19 +291,24 @@ export default {
             }
             return matched;
         },
-        selectedIds() {
-            return Object.keys(this.selectedMap);
-        },
         selectedCount() {
-            return this.selectedIds.length;
+            return this.getUniqueSelectedItems().length;
         },
         allSelected() {
             if (!this.fileList.length) return false;
-            return this.fileList.every((f) => this.isSelected(f.id));
+            return this.fileList.every((f) => this.isSelectedItem(f));
         },
         indeterminate() {
             if (!this.fileList.length || this.allSelected) return false;
-            return this.fileList.some((f) => this.isSelected(f.id));
+            return this.fileList.some((f) => this.isSelectedItem(f));
+        },
+    },
+    watch: {
+        modelValue: {
+            deep: true,
+            handler() {
+                if (this.visible) this.initSelectionFromModel();
+            },
         },
     },
     methods: {
@@ -294,26 +332,70 @@ export default {
         onDialogClosed() {
             this.$emit("close");
         },
+        urlSelectKey(url) {
+            return url ? `url:${url}` : "";
+        },
+        getItemSelectKey(item) {
+            if (!item) return "";
+            if (item.id != null && item.id !== "") return String(item.id);
+            if (item.url) return this.urlSelectKey(item.url);
+            return "";
+        },
+        putSelectedItem(n) {
+            const key = this.getItemSelectKey(n);
+            if (!key) return;
+            this.selectedMap = { ...this.selectedMap, [key]: n };
+        },
+        removeSelectedByItem(item) {
+            const key = this.findSelectedKeyForItem(item);
+            if (!key) return;
+            const next = { ...this.selectedMap };
+            delete next[key];
+            this.selectedMap = next;
+        },
+        findSelectedKeyForItem(item) {
+            if (!item) return "";
+            if (item.id != null && item.id !== "" && this.selectedMap[String(item.id)]) {
+                return String(item.id);
+            }
+            if (item.url) {
+                const uk = this.urlSelectKey(item.url);
+                if (this.selectedMap[uk]) return uk;
+                const hit = Object.entries(this.selectedMap).find(
+                    ([, s]) => s.url && s.url === item.url
+                );
+                if (hit) return hit[0];
+            }
+            return "";
+        },
         initSelectionFromModel() {
             const raw = this.modelValue;
             const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
             this.selectedMap = {};
             list.forEach((item) => {
                 if (typeof item === "string") {
-                    const row = { att_id: item, att_dir: item, real_name: item };
-                    const n = this.normalizeFileRow(row);
-                    this.selectedMap[String(n.id)] = n;
-                } else if (item && typeof item === "object") {
+                    const url = item.trim();
+                    if (!url) return;
+                    const row = { att_dir: url, real_name: url.split(/[/\\]/).pop() || url };
+                    this.putSelectedItem(this.normalizeFileRow(row));
+                    return;
+                }
+                if (item && typeof item === "object") {
                     const n = this.normalizeFileRow({
                         att_id: item.id ?? item.att_id,
                         att_dir: item.url ?? item.att_dir,
                         real_name: item.name ?? item.real_name,
+                        file_type: item.file_type,
                         ...item,
                     });
-                    if (n.id != null) this.selectedMap[String(n.id)] = n;
+                    this.putSelectedItem(n);
                 }
             });
         },
+        /** 编辑回显:按 att_id 或 url 与已选记录匹配 */
+        isSelectedItem(item) {
+            return !!this.findSelectedKeyForItem(item);
+        },
         async bootstrap() {
             await this.loadCategories();
             await this.loadFiles();
@@ -323,12 +405,15 @@ export default {
             this.keyword = "";
             this.categoryKeyword = "";
             this.query.page = 1;
-            if (this.multiple) {
+            if (!this.isMixedMode && this.multiple) {
                 this.selectedMap = {};
             }
             this.loadCategories();
             this.loadFiles();
         },
+        isVideoItem(item) {
+            return String(item?.file_type ?? "") === "2";
+        },
         onCategorySearchClear() {
             this.categoryKeyword = "";
         },
@@ -380,16 +465,17 @@ export default {
             const total = Number(d.count ?? d.total ?? list.length ?? 0);
             return { list, total };
         },
-        normalizeFileRow(row) {
+        normalizeFileRow(row, defaultType) {
             const rawId = row.att_id ?? row.id;
             const id = rawId != null && rawId !== "" ? String(rawId) : "";
             const url = row.att_dir || row.satt_dir || row.url || row.src || "";
             const name = row.real_name || row.name || row.file_name || `素材#${id || ""}`;
-            return { id, name, url, raw: row };
-        },
-        isSelected(id) {
-            if (id == null || id === "") return false;
-            return !!this.selectedMap[String(id)];
+            const ft =
+                row.file_type != null && row.file_type !== ""
+                    ? String(row.file_type)
+                    : String(defaultType ?? this.fileType);
+            const file_type = ft === "2" ? "2" : "1";
+            return { id, name, url, file_type, raw: row };
         },
         async loadCategories() {
             this.categoryLoading = true;
@@ -429,7 +515,9 @@ export default {
                     return;
                 }
                 const { list, total } = this.parseFileRows(res.data);
-                this.fileList = list.map((row) => this.normalizeFileRow(row)).filter((x) => x.id != null);
+                this.fileList = list
+                    .map((row) => this.normalizeFileRow(row, this.fileType))
+                    .filter((x) => x.id != null);
                 this.total = total;
             } finally {
                 this.listLoading = false;
@@ -443,58 +531,66 @@ export default {
             this.query.page = page;
             this.loadFiles();
         },
+        getUniqueSelectedItems() {
+            const items = [];
+            const seen = new Set();
+            Object.values(this.selectedMap).forEach((item) => {
+                const dedupeKey = item.id ? `id:${item.id}` : item.url ? `url:${item.url}` : "";
+                if (!dedupeKey || seen.has(dedupeKey)) return;
+                seen.add(dedupeKey);
+                items.push(item);
+            });
+            return items;
+        },
         toggleSelect(item) {
-            const key = String(item.id);
-            if (this.isSelected(item.id)) {
-                const next = { ...this.selectedMap };
-                delete next[key];
-                this.selectedMap = next;
+            if (this.isSelectedItem(item)) {
+                this.removeSelectedByItem(item);
                 return;
             }
             if (!this.multiple) {
-                this.selectedMap = { [key]: item };
+                this.selectedMap = {};
+                this.putSelectedItem(item);
                 return;
             }
-            if (this.max > 0 && this.selectedCount >= this.max) {
+            if (this.max > 0 && this.getUniqueSelectedItems().length >= this.max) {
                 this.$message.warning(`最多选择 ${this.max} 个`);
                 return;
             }
-            this.selectedMap = { ...this.selectedMap, [key]: item };
+            this.putSelectedItem(item);
         },
         onSelectAllChange(checked) {
             if (!checked) {
                 const next = { ...this.selectedMap };
                 this.fileList.forEach((f) => {
-                    delete next[String(f.id)];
+                    const k = this.findSelectedKeyForItem(f);
+                    if (k) delete next[k];
                 });
                 this.selectedMap = next;
                 return;
             }
             if (!this.multiple) {
                 if (this.fileList[0]) {
-                    const f = this.fileList[0];
-                    this.selectedMap = { [String(f.id)]: f };
+                    this.selectedMap = {};
+                    this.putSelectedItem(this.fileList[0]);
                 }
                 return;
             }
-            const next = { ...this.selectedMap };
             let hitMax = false;
             for (const f of this.fileList) {
-                if (this.max > 0 && Object.keys(next).length >= this.max) {
+                if (this.max > 0 && this.getUniqueSelectedItems().length >= this.max) {
                     hitMax = true;
                     break;
                 }
-                if (!this.isSelected(f.id)) {
-                    next[String(f.id)] = f;
+                if (!this.isSelectedItem(f)) {
+                    this.putSelectedItem(f);
                 }
             }
-            this.selectedMap = next;
             if (hitMax) {
                 this.$message.warning(`最多选择 ${this.max} 个`);
             }
         },
         confirmUse() {
-            const items = Object.values(this.selectedMap);
+            const items = this.getUniqueSelectedItems();
             if (!items.length) {
                 this.$message.warning("请先选择素材");
                 return;
@@ -517,7 +613,7 @@ export default {
             }
             this.previewUrl = item.url;
             this.previewTitle = item.name || "预览";
-            this.previewType = this.fileType === "2" ? "video" : "image";
+            this.previewType = this.isVideoItem(item) ? "video" : "image";
             this.previewVisible = true;
         },
         onPreviewClosed() {
@@ -534,9 +630,7 @@ export default {
                 this.$message.error(res.msg || res.message || "删除失败");
                 return;
             }
-            const next = { ...this.selectedMap };
-            delete next[String(item.id)];
-            this.selectedMap = next;
+            this.removeSelectedByItem(item);
             this.$message.success(res.msg || res.message || "删除成功");
             this.loadFiles();
         },
@@ -548,19 +642,22 @@ export default {
                 { type: "warning" }
             ).catch(() => {});
             if (confirm !== "confirm") return;
-            const ids = this.selectedIds;
+            const items = this.getUniqueSelectedItems();
+            const ids = items.map((i) => i.id).filter((id) => id != null && id !== "");
             const res = await this.$API.material.fileDelete.delete(ids);
             if (!this.apiOk(res)) {
                 this.$message.error(res.msg || res.message || "删除失败");
                 return;
             }
-            const next = { ...this.selectedMap };
-            ids.forEach((id) => delete next[String(id)]);
-            this.selectedMap = next;
+            this.selectedMap = {};
             this.$message.success(res.msg || res.message || "删除成功");
             this.loadFiles();
         },
         uploadRequest(param) {
+            const uploadType =
+                this.isMixedMode && param.file?.type?.startsWith("video/")
+                    ? "2"
+                    : this.fileType;
             const data = new FormData();
             data.append(uploadConfig.filename || "file", param.file);
             if (this.activeCategoryId !== "") {
@@ -568,7 +665,7 @@ export default {
                 data.append("category_id", String(this.activeCategoryId));
             }
             this.$API.material.upload
-                .post(this.fileType, data, {
+                .post(uploadType, data, {
                     onUploadProgress: (e) => {
                         if (e.total) {
                             const percent = Math.round((e.loaded / e.total) * 100);
@@ -697,31 +794,41 @@ export default {
     height: 100%;
 }
 .picker-grid {
+    --picker-thumb-size: 120px;
     display: grid;
-    grid-template-columns: repeat(6, 1fr);
+    grid-template-columns: repeat(auto-fill, var(--picker-thumb-size));
     gap: 12px;
     padding: 4px 4px 12px;
+    justify-content: start;
 }
 .picker-item {
+    width: var(--picker-thumb-size);
     user-select: none;
 }
 .picker-thumb {
     position: relative;
-    width: 100%;
-    aspect-ratio: 1;
+    width: var(--picker-thumb-size);
+    height: var(--picker-thumb-size);
+    flex-shrink: 0;
     border: 1px solid var(--el-border-color-lighter);
     border-radius: 4px;
     overflow: hidden;
     background: var(--el-fill-color-lighter);
     cursor: pointer;
 }
-.picker-thumb .el-image {
+.picker-thumb :deep(.el-image) {
+    display: block;
     width: 100%;
     height: 100%;
 }
-.video-thumb {
+.picker-thumb :deep(.el-image__inner) {
     width: 100%;
     height: 100%;
+    object-fit: cover;
+}
+.video-thumb {
+    position: absolute;
+    inset: 0;
     display: flex;
     align-items: center;
     justify-content: center;
@@ -730,7 +837,10 @@ export default {
 .video-thumb video {
     width: 100%;
     height: 100%;
+    max-width: 100%;
+    max-height: 100%;
     object-fit: cover;
+    display: block;
 }
 .video-icon {
     font-size: 32px;
@@ -767,6 +877,7 @@ export default {
     display: none;
 }
 .picker-name {
+    width: var(--picker-thumb-size);
     margin: 6px 0 0;
     font-size: 12px;
     text-align: center;

+ 19 - 19
src/config/route.js

@@ -27,25 +27,25 @@
 // ]
 
 const routes = [
-	{
-		name: "material",
-		path: "/material",
-		meta: {
-			icon: "el-icon-picture",
-			title: "素材管理",
-		},
-		children: [
-			{
-				name: "materialIndex",
-				path: "/material/index",
-				component: "material/index",
-				meta: {
-					icon: "el-icon-folder",
-					title: "素材库",
-				},
-			},
-		],
-	},
+	// {
+	// 	name: "material",
+	// 	path: "/material",
+	// 	meta: {
+	// 		icon: "el-icon-picture",
+	// 		title: "素材管理",
+	// 	},
+	// 	children: [
+	// 		{
+	// 			name: "materialIndex",
+	// 			path: "/material/index",
+	// 			component: "material/index",
+	// 			meta: {
+	// 				icon: "el-icon-folder",
+	// 				title: "素材库",
+	// 			},
+	// 		},
+	// 	],
+	// },
 	// {
 	// 	name: "permission",
 	// 	path: "/permission",

+ 20 - 6
src/views/material/index.vue

@@ -97,7 +97,7 @@
                                         <el-image
                                             v-if="fileType === '1'"
                                             :src="item.url"
-                                            fit="contain"
+                                            fit="cover"
                                             lazy
                                         />
                                         <div v-else class="video-thumb">
@@ -717,12 +717,15 @@ export default {
     height: calc(100vh - 320px);
 }
 .file-grid {
+    --material-thumb-size: 120px;
     display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+    grid-template-columns: repeat(auto-fill, var(--material-thumb-size));
     gap: 16px;
     padding: 4px 4px 16px;
+    justify-content: start;
 }
 .file-item {
+    width: var(--material-thumb-size);
     user-select: none;
 }
 .file-item .file-thumb {
@@ -730,20 +733,27 @@ export default {
 }
 .file-thumb {
     position: relative;
-    width: 100%;
-    aspect-ratio: 1;
+    width: var(--material-thumb-size);
+    height: var(--material-thumb-size);
+    flex-shrink: 0;
     border: 1px solid var(--el-border-color-lighter);
     border-radius: 4px;
     overflow: hidden;
     background: var(--el-fill-color-lighter);
 }
-.file-thumb .el-image {
+.file-thumb :deep(.el-image) {
+    display: block;
     width: 100%;
     height: 100%;
 }
-.video-thumb {
+.file-thumb :deep(.el-image__inner) {
     width: 100%;
     height: 100%;
+    object-fit: cover;
+}
+.video-thumb {
+    position: absolute;
+    inset: 0;
     display: flex;
     align-items: center;
     justify-content: center;
@@ -752,7 +762,10 @@ export default {
 .video-thumb video {
     width: 100%;
     height: 100%;
+    max-width: 100%;
+    max-height: 100%;
     object-fit: cover;
+    display: block;
 }
 .video-icon {
     font-size: 36px;
@@ -789,6 +802,7 @@ export default {
     display: none;
 }
 .file-name {
+    width: var(--material-thumb-size);
     margin: 8px 0 0;
     font-size: 12px;
     line-height: 1.4;

+ 21 - 5
src/views/task/manage/list/index.vue

@@ -160,6 +160,7 @@
                             ref="materialPickerRef"
                             :multiple="true"
                             :max="20"
+                            :accept-both="true"
                             title="选择素材"
                             button-text="选择素材"
                             :model-value="materialPickerValue"
@@ -171,9 +172,10 @@
                                 :key="`${file.url}-${index}`"
                                 closable
                                 class="material-selected-tag"
+                                :type="String(file.file_type) === '2' ? 'warning' : 'info'"
                                 @close="removeMaterial(index)"
                             >
-                                {{ file.name }}
+                                {{ String(file.file_type) === '2' ? '[视频]' : '[图片]' }} {{ file.name }}
                             </el-tag>
                         </div>
                         <p v-else class="material-pick-tip">请从素材库选择图片或视频,最多 20 个</p>
@@ -252,13 +254,21 @@ function normalizeMaterialFiles(material) {
                 const url = m.trim();
                 if (!url) return null;
                 const parts = url.split(/[/\\]/);
-                return { name: parts[parts.length - 1] || url, url };
+                return { id: "", name: parts[parts.length - 1] || url, url, file_type: "1" };
             }
-            const src = String(m.src || m.url || "").trim();
+            const src = String(m.src || m.url || m.att_dir || "").trim();
             if (!src) return null;
-            const fileName = String(m.fileName || m.name || "").trim();
+            const fileName = String(m.fileName || m.name || m.real_name || "").trim();
             const name = fileName || src.split(/[/\\]/).pop() || src;
-            return { name, url: src };
+            const rawId = m.att_id ?? m.id;
+            const id = rawId != null && rawId !== "" ? String(rawId) : "";
+            const ft = m.file_type != null && m.file_type !== "" ? String(m.file_type) : "1";
+            return {
+                id,
+                name,
+                url: src,
+                file_type: ft === "2" ? "2" : "1",
+            };
         })
         .filter(Boolean);
 }
@@ -360,6 +370,7 @@ export default {
                 id: f.id,
                 name: f.name,
                 url: f.url,
+                file_type: f.file_type,
             }));
         },
     },
@@ -503,10 +514,15 @@ export default {
                         String(item.name || item.real_name || "").trim() ||
                         url.split(/[/\\]/).pop() ||
                         url;
+                    const file_type =
+                        item.file_type != null && item.file_type !== ""
+                            ? String(item.file_type)
+                            : "1";
                     return {
                         id: item.id ?? item.att_id,
                         name,
                         url,
+                        file_type: file_type === "2" ? "2" : "1",
                     };
                 })
                 .filter(Boolean);