Browse Source

feat: 素材管理

doi 2 months ago
parent
commit
cfa58a2de9

+ 66 - 0
src/api/model/material.js

@@ -0,0 +1,66 @@
+import config from "@/config";
+import http from "@/utils/request";
+
+export default {
+    /** 分类列表 GET adminapi/file/category?file_type= */
+    categoryList: {
+        url: `${config.API_URL}/file/category`,
+        name: "素材分类列表",
+        get: async function (fileType, params = {}) {
+            return await http.get(this.url, { file_type: fileType, ...params });
+        },
+    },
+    /** 上级分类 GET adminapi/file/category/cate_list?file_type= */
+    categoryParentList: {
+        url: `${config.API_URL}/file/category/cate_list`,
+        name: "素材上级分类",
+        get: async function (fileType) {
+            return await http.get(this.url, { file_type: fileType });
+        },
+    },
+    /** 分类保存 POST adminapi/file/category/save/:id */
+    categorySave: {
+        url: `${config.API_URL}/file/category/save`,
+        name: "素材分类保存",
+        post: async function (id, data = {}) {
+            const cid = id == null || id === "" ? 0 : id;
+            return await http.post(`${this.url}/${cid}`, data);
+        },
+    },
+    /** 分类删除 DELETE adminapi/file/category/delete/:id */
+    categoryDelete: {
+        url: `${config.API_URL}/file/category/delete`,
+        name: "素材分类删除",
+        delete: async function (id) {
+            return await http.delete(`${this.url}/${id}`);
+        },
+    },
+    /** 素材上传 POST adminapi/upload/data/1 图片,/2 视频 */
+    upload: {
+        url: `${config.API_URL}/upload/data`,
+        name: "素材上传",
+        post: async function (fileType, data, reqConfig = {}) {
+            const type = String(fileType) === "2" ? "2" : "1";
+            return await http.post(`${this.url}/${type}`, data, reqConfig);
+        },
+    },
+    /** 文件列表 GET adminapi/file */
+    fileList: {
+        url: `${config.API_URL}/file`,
+        name: "素材文件列表",
+        get: async function (params = {}) {
+            return await http.get(this.url, params);
+        },
+    },
+    /** 文件删除 DELETE adminapi/file/delete body: ids 为逗号拼接字符串,如 "1,2,3" */
+    fileDelete: {
+        url: `${config.API_URL}/file/delete`,
+        name: "素材文件删除",
+        delete: async function (ids = []) {
+            const list = Array.isArray(ids) ? ids : [ids];
+            const data = new FormData();
+            data.append("ids", list.map((id) => String(id)).join(","));
+            return await http.delete(this.url, data);
+        },
+    },
+};

+ 95 - 0
src/components/scMaterialImage/index.vue

@@ -0,0 +1,95 @@
+<template>
+    <div class="sc-material-image">
+        <el-image
+            v-if="isImage && modelValue"
+            :src="modelValue"
+            :preview-src-list="[modelValue]"
+            fit="cover"
+            class="sc-material-image__preview"
+            preview-teleported
+        />
+        <video
+            v-else-if="!isImage && modelValue"
+            :src="modelValue"
+            class="sc-material-image__preview sc-material-image__video"
+            controls
+        />
+        <sc-material-picker
+            :model-value="modelValue"
+            :multiple="false"
+            :lock-file-type="fileType"
+            value-type="url"
+            :title="pickerTitle"
+            :button-text="buttonTextResolved"
+            @update:model-value="onPick"
+        />
+        <el-input
+            v-if="showInput"
+            :model-value="modelValue"
+            :placeholder="inputPlaceholder"
+            clearable
+            class="sc-material-image__input"
+            @update:model-value="onPick"
+        />
+    </div>
+</template>
+
+<script>
+export default {
+    name: "ScMaterialImage",
+    props: {
+        modelValue: { type: String, default: "" },
+        /** 1 图片 2 视频 */
+        fileType: { type: String, default: "1" },
+        title: { type: String, default: "" },
+        buttonText: { type: String, default: "" },
+        showInput: { type: Boolean, default: true },
+        inputPlaceholder: { type: String, default: "或填写图片地址" },
+    },
+    emits: ["update:modelValue", "change"],
+    computed: {
+        isImage() {
+            return String(this.fileType) !== "2";
+        },
+        pickerTitle() {
+            if (this.title) return this.title;
+            return this.isImage ? "选择图片" : "选择视频";
+        },
+        buttonTextResolved() {
+            if (this.buttonText) return this.buttonText;
+            return this.isImage ? "选择图片" : "选择视频";
+        },
+    },
+    methods: {
+        onPick(val) {
+            const url = val == null ? "" : String(val);
+            this.$emit("update:modelValue", url);
+            this.$emit("change", url);
+        },
+    },
+};
+</script>
+
+<style scoped>
+.sc-material-image {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 10px;
+    width: 100%;
+}
+.sc-material-image__preview {
+    width: 120px;
+    height: 120px;
+    border-radius: 4px;
+    border: 1px solid var(--el-border-color-lighter);
+}
+.sc-material-image__video {
+    object-fit: contain;
+    background: #000;
+}
+.sc-material-image__input {
+    max-width: 360px;
+    width: 100%;
+}
+</style>

+ 824 - 0
src/components/scMaterialPicker/index.vue

@@ -0,0 +1,824 @@
+<template>
+    <span class="sc-material-picker">
+        <span class="sc-material-picker__trigger" @click="open">
+            <slot>
+                <el-button type="primary">{{ buttonText }}</el-button>
+            </slot>
+        </span>
+
+        <el-dialog
+            v-model="visible"
+            :title="dialogTitle"
+            width="1120px"
+            class="sc-material-picker-dialog"
+            append-to-body
+            destroy-on-close
+            :close-on-click-modal="false"
+            @closed="onDialogClosed"
+        >
+            <el-tabs
+                v-if="!lockFileType"
+                v-model="fileType"
+                class="picker-tabs"
+                @tab-change="onFileTypeChange"
+            >
+                <el-tab-pane label="图片" name="1" />
+                <el-tab-pane label="视频" name="2" />
+            </el-tabs>
+
+            <div class="picker-body">
+                <aside class="picker-side" v-loading="categoryLoading">
+                    <el-input
+                        v-model="categoryKeyword"
+                        size="small"
+                        clearable
+                        placeholder="请输入分类名称"
+                        class="category-search"
+                        @keyup.enter="onCategorySearchEnter"
+                        @clear="onCategorySearchClear"
+                    >
+                        <template #prefix>
+                            <el-icon><el-icon-search /></el-icon>
+                        </template>
+                    </el-input>
+                    <el-scrollbar class="category-scroll">
+                        <ul class="category-list">
+                            <li
+                                :class="{ active: activeCategoryId === '' }"
+                                @click="selectCategory('')"
+                            >
+                                <el-icon><el-icon-folder /></el-icon>
+                                <span>{{ fileType === '1' ? '全部图片' : '全部视频' }}</span>
+                            </li>
+                            <li
+                                v-for="item in filteredCategories"
+                                :key="item.id"
+                                :class="{ active: String(activeCategoryId) === String(item.id) }"
+                                :style="{ paddingLeft: 12 + item.level * 14 + 'px' }"
+                                @click="selectCategory(item.id)"
+                            >
+                                <el-icon><el-icon-folder /></el-icon>
+                                <span class="category-name" :title="item.name">{{ item.name }}</span>
+                            </li>
+                            <li v-if="categoryKeyword && !filteredCategories.length" class="category-empty">
+                                无匹配分类
+                            </li>
+                        </ul>
+                    </el-scrollbar>
+                </aside>
+
+                <section class="picker-main">
+                    <div class="picker-toolbar">
+                        <div class="picker-toolbar-left">
+                            <el-button type="primary" :disabled="!selectedCount" @click="confirmUse">
+                                {{ confirmText }}
+                            </el-button>
+                            <el-upload
+                                class="picker-upload"
+                                action=""
+                                multiple
+                                :show-file-list="false"
+                                :accept="uploadAccept"
+                                :http-request="uploadRequest"
+                            >
+                                <el-button>{{ fileType === '1' ? '上传图片' : '上传视频' }}</el-button>
+                            </el-upload>
+                            <el-button
+                                type="danger"
+                                plain
+                                :disabled="!selectedCount"
+                                @click="removeSelected"
+                            >
+                                {{ fileType === '1' ? '删除图片' : '删除视频' }}
+                            </el-button>
+                        </div>
+                        <el-input
+                            v-model="keyword"
+                            clearable
+                            :placeholder="fileType === '1' ? '搜索图片名称' : '搜索视频名称'"
+                            style="width: 240px"
+                            @keyup.enter="searchFiles"
+                            @clear="searchFiles"
+                        >
+                            <template #append>
+                                <el-button :icon="Search" @click="searchFiles" />
+                            </template>
+                        </el-input>
+                    </div>
+
+                    <div v-loading="listLoading" class="picker-grid-wrap">
+                        <el-empty v-if="!fileList.length && !listLoading" description="暂无素材" />
+                        <el-scrollbar v-else class="picker-grid-scroll">
+                            <div class="picker-grid">
+                                <div
+                                    v-for="item in fileList"
+                                    :key="item.id"
+                                    class="picker-item"
+                                    :class="{ selected: isSelected(item.id) }"
+                                >
+                                    <div class="picker-thumb" @click="toggleSelect(item)">
+                                        <el-image
+                                            v-if="fileType === '1'"
+                                            :src="item.url"
+                                            fit="contain"
+                                            lazy
+                                        />
+                                        <div v-else class="video-thumb">
+                                            <video v-if="item.url" :src="item.url" preload="metadata" />
+                                            <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>
+                                        </div>
+                                    </div>
+                                    <p class="picker-name" :title="item.name">{{ item.name }}</p>
+                                    <div class="picker-actions">
+                                        <span @click.stop="previewItem(item)">查看</span>
+                                        <span class="danger" @click.stop="removeOne(item)">删除</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </el-scrollbar>
+                    </div>
+
+                    <div class="picker-footer">
+                        <div class="picker-footer-left">
+                            <el-checkbox
+                                :model-value="allSelected"
+                                :indeterminate="indeterminate"
+                                @change="onSelectAllChange"
+                            >
+                                全选
+                            </el-checkbox>
+                            <span class="selected-tip">已选 {{ selectedCount }} 个</span>
+                        </div>
+                        <el-pagination
+                            v-if="total > 0"
+                            background
+                            layout="total, prev, pager, next, jumper"
+                            :total="total"
+                            :current-page="query.page"
+                            :page-size="query.limit"
+                            @current-change="handlePageChange"
+                        />
+                    </div>
+                </section>
+            </div>
+        </el-dialog>
+
+        <el-dialog
+            v-model="previewVisible"
+            :title="previewTitle"
+            width="auto"
+            class="material-preview-dialog"
+            append-to-body
+            destroy-on-close
+            align-center
+            @closed="onPreviewClosed"
+        >
+            <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" alt="" />
+            <video
+                v-else-if="previewType === 'video' && previewUrl"
+                :src="previewUrl"
+                class="preview-media"
+                controls
+                autoplay
+            />
+        </el-dialog>
+    </span>
+</template>
+
+<script>
+import { Search } from "@element-plus/icons-vue";
+import uploadConfig from "@/config/upload";
+
+export default {
+    name: "ScMaterialPicker",
+    props: {
+        /** 选中值:对象数组 { id, url, name } 或 url 字符串/数组(由 valueType 决定) */
+        modelValue: { type: [Array, Object, String], default: () => [] },
+        /** 是否多选 */
+        multiple: { type: Boolean, default: true },
+        /** 最多可选数量,0 不限制 */
+        max: { type: Number, default: 0 },
+        /** 弹窗标题 */
+        title: { type: String, default: "" },
+        /** 默认触发按钮文案 */
+        buttonText: { type: String, default: "选择素材" },
+        /** 锁定类型:'1' 仅图片,'2' 仅视频,空则 Tab 切换 */
+        lockFileType: { type: String, default: "" },
+        /** 返回值类型 object | url */
+        valueType: { type: String, default: "object" },
+    },
+    emits: ["update:modelValue", "confirm", "open", "close"],
+    data() {
+        return {
+            Search,
+            visible: false,
+            fileType: "1",
+            keyword: "",
+            categoryKeyword: "",
+            activeCategoryId: "",
+            categoryLoading: false,
+            listLoading: false,
+            flatCategories: [],
+            fileList: [],
+            selectedMap: {},
+            total: 0,
+            query: { page: 1, limit: 18 },
+            previewVisible: false,
+            previewUrl: "",
+            previewTitle: "",
+            previewType: "image",
+        };
+    },
+    computed: {
+        dialogTitle() {
+            if (this.title) return this.title;
+            return this.fileType === "2" ? "上传视频" : "上传商品图";
+        },
+        confirmText() {
+            return this.fileType === "2" ? "使用选中视频" : "使用选中图片";
+        },
+        uploadAccept() {
+            return this.fileType === "1" ? "image/*" : "video/*";
+        },
+        filteredCategories() {
+            const kw = (this.categoryKeyword || "").trim().toLowerCase();
+            if (!kw) return this.flatCategories;
+            const matched = this.flatCategories.filter((c) =>
+                String(c.name).toLowerCase().includes(kw)
+            );
+            if (!this.activeCategoryId) return matched;
+            const active = this.flatCategories.find(
+                (c) => String(c.id) === String(this.activeCategoryId)
+            );
+            if (active && !matched.some((c) => String(c.id) === String(active.id))) {
+                return [active, ...matched];
+            }
+            return matched;
+        },
+        selectedIds() {
+            return Object.keys(this.selectedMap);
+        },
+        selectedCount() {
+            return this.selectedIds.length;
+        },
+        allSelected() {
+            if (!this.fileList.length) return false;
+            return this.fileList.every((f) => this.isSelected(f.id));
+        },
+        indeterminate() {
+            if (!this.fileList.length || this.allSelected) return false;
+            return this.fileList.some((f) => this.isSelected(f.id));
+        },
+    },
+    methods: {
+        apiOk(res) {
+            if (!res) return false;
+            const code = res.code ?? res.status;
+            return code === 1 || code === 200;
+        },
+        open() {
+            if (this.lockFileType) {
+                this.fileType = String(this.lockFileType);
+            }
+            this.visible = true;
+            this.initSelectionFromModel();
+            this.$emit("open");
+            this.bootstrap();
+        },
+        close() {
+            this.visible = false;
+        },
+        onDialogClosed() {
+            this.$emit("close");
+        },
+        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 n = this.normalizeFileRow({
+                        att_id: item.id ?? item.att_id,
+                        att_dir: item.url ?? item.att_dir,
+                        real_name: item.name ?? item.real_name,
+                        ...item,
+                    });
+                    if (n.id != null) this.selectedMap[String(n.id)] = n;
+                }
+            });
+        },
+        async bootstrap() {
+            await this.loadCategories();
+            await this.loadFiles();
+        },
+        onFileTypeChange() {
+            this.activeCategoryId = "";
+            this.keyword = "";
+            this.categoryKeyword = "";
+            this.query.page = 1;
+            if (this.multiple) {
+                this.selectedMap = {};
+            }
+            this.loadCategories();
+            this.loadFiles();
+        },
+        onCategorySearchClear() {
+            this.categoryKeyword = "";
+        },
+        onCategorySearchEnter() {
+            const kw = (this.categoryKeyword || "").trim();
+            if (!kw) {
+                this.selectCategory("");
+                return;
+            }
+            const list = this.filteredCategories;
+            if (!list.length) return;
+            const exact = list.find((c) => String(c.name).toLowerCase() === kw.toLowerCase());
+            this.selectCategory((exact || list[0]).id);
+        },
+        selectCategory(id) {
+            this.activeCategoryId = id === "" ? "" : String(id);
+            this.categoryKeyword = "";
+            this.query.page = 1;
+            this.loadFiles();
+        },
+        flattenCategories(nodes, level = 0, out = []) {
+            (nodes || []).forEach((node) => {
+                const id = node.id ?? node.cate_id ?? node.value;
+                const name = node.name ?? node.title ?? node.label ?? node.cate_name ?? `分类#${id}`;
+                if (id != null && id !== "") {
+                    out.push({
+                        id,
+                        name,
+                        level,
+                        raw: node,
+                    });
+                }
+                const children = node.children || node.child || [];
+                if (children.length) this.flattenCategories(children, level + 1, out);
+            });
+            return out;
+        },
+        parseCategoryRows(data) {
+            if (Array.isArray(data)) return data;
+            const d = data || {};
+            if (Array.isArray(d.list)) return d.list;
+            if (Array.isArray(d.data)) return d.data;
+            return d.children || [];
+        },
+        parseFileRows(data) {
+            if (Array.isArray(data)) return { list: data, total: data.length };
+            const d = data || {};
+            const list = d.list || d.data || d.rows || [];
+            const total = Number(d.count ?? d.total ?? list.length ?? 0);
+            return { list, total };
+        },
+        normalizeFileRow(row) {
+            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)];
+        },
+        async loadCategories() {
+            this.categoryLoading = true;
+            try {
+                const res = await this.$API.material.categoryList.get(this.fileType);
+                if (!this.apiOk(res)) {
+                    this.flatCategories = [];
+                    return;
+                }
+                this.flatCategories = this.flattenCategories(this.parseCategoryRows(res.data));
+            } finally {
+                this.categoryLoading = false;
+            }
+        },
+        buildFileQuery() {
+            const params = {
+                file_type: this.fileType,
+                real_name: this.keyword || "",
+                page: this.query.page,
+                limit: this.query.limit,
+            };
+            if (this.activeCategoryId !== "") {
+                params.pid = this.activeCategoryId;
+                params.category_id = this.activeCategoryId;
+                params.cate_id = this.activeCategoryId;
+                params.relation_id = this.activeCategoryId;
+            }
+            return params;
+        },
+        async loadFiles() {
+            this.listLoading = true;
+            try {
+                const res = await this.$API.material.fileList.get(this.buildFileQuery());
+                if (!this.apiOk(res)) {
+                    this.fileList = [];
+                    this.total = 0;
+                    return;
+                }
+                const { list, total } = this.parseFileRows(res.data);
+                this.fileList = list.map((row) => this.normalizeFileRow(row)).filter((x) => x.id != null);
+                this.total = total;
+            } finally {
+                this.listLoading = false;
+            }
+        },
+        searchFiles() {
+            this.query.page = 1;
+            this.loadFiles();
+        },
+        handlePageChange(page) {
+            this.query.page = page;
+            this.loadFiles();
+        },
+        toggleSelect(item) {
+            const key = String(item.id);
+            if (this.isSelected(item.id)) {
+                const next = { ...this.selectedMap };
+                delete next[key];
+                this.selectedMap = next;
+                return;
+            }
+            if (!this.multiple) {
+                this.selectedMap = { [key]: item };
+                return;
+            }
+            if (this.max > 0 && this.selectedCount >= this.max) {
+                this.$message.warning(`最多选择 ${this.max} 个`);
+                return;
+            }
+            this.selectedMap = { ...this.selectedMap, [key]: item };
+        },
+        onSelectAllChange(checked) {
+            if (!checked) {
+                const next = { ...this.selectedMap };
+                this.fileList.forEach((f) => {
+                    delete next[String(f.id)];
+                });
+                this.selectedMap = next;
+                return;
+            }
+            if (!this.multiple) {
+                if (this.fileList[0]) {
+                    const f = this.fileList[0];
+                    this.selectedMap = { [String(f.id)]: f };
+                }
+                return;
+            }
+            const next = { ...this.selectedMap };
+            let hitMax = false;
+            for (const f of this.fileList) {
+                if (this.max > 0 && Object.keys(next).length >= this.max) {
+                    hitMax = true;
+                    break;
+                }
+                if (!this.isSelected(f.id)) {
+                    next[String(f.id)] = f;
+                }
+            }
+            this.selectedMap = next;
+            if (hitMax) {
+                this.$message.warning(`最多选择 ${this.max} 个`);
+            }
+        },
+        confirmUse() {
+            const items = Object.values(this.selectedMap);
+            if (!items.length) {
+                this.$message.warning("请先选择素材");
+                return;
+            }
+            let value;
+            if (this.valueType === "url") {
+                const urls = items.map((i) => i.url);
+                value = this.multiple ? urls : urls[0];
+            } else {
+                value = this.multiple ? items : items[0];
+            }
+            this.$emit("update:modelValue", value);
+            this.$emit("confirm", value, items);
+            this.close();
+        },
+        previewItem(item) {
+            if (!item?.url) {
+                this.$message.warning("暂无预览地址");
+                return;
+            }
+            this.previewUrl = item.url;
+            this.previewTitle = item.name || "预览";
+            this.previewType = this.fileType === "2" ? "video" : "image";
+            this.previewVisible = true;
+        },
+        onPreviewClosed() {
+            this.previewUrl = "";
+            this.previewTitle = "";
+        },
+        async removeOne(item) {
+            const confirm = await this.$confirm(`确认删除「${item.name}」吗?`, "提示", {
+                type: "warning",
+            }).catch(() => {});
+            if (confirm !== "confirm") return;
+            const res = await this.$API.material.fileDelete.delete([item.id]);
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || res.message || "删除失败");
+                return;
+            }
+            const next = { ...this.selectedMap };
+            delete next[String(item.id)];
+            this.selectedMap = next;
+            this.$message.success(res.msg || res.message || "删除成功");
+            this.loadFiles();
+        },
+        async removeSelected() {
+            if (!this.selectedCount) return;
+            const confirm = await this.$confirm(
+                `确认删除选中的 ${this.selectedCount} 个素材吗?`,
+                "提示",
+                { type: "warning" }
+            ).catch(() => {});
+            if (confirm !== "confirm") return;
+            const ids = this.selectedIds;
+            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.$message.success(res.msg || res.message || "删除成功");
+            this.loadFiles();
+        },
+        uploadRequest(param) {
+            const data = new FormData();
+            data.append(uploadConfig.filename || "file", param.file);
+            if (this.activeCategoryId !== "") {
+                data.append("pid", String(this.activeCategoryId));
+                data.append("category_id", String(this.activeCategoryId));
+            }
+            this.$API.material.upload
+                .post(this.fileType, data, {
+                    onUploadProgress: (e) => {
+                        if (e.total) {
+                            const percent = Math.round((e.loaded / e.total) * 100);
+                            param.onProgress({ percent });
+                        }
+                    },
+                })
+                .then((res) => {
+                    const parsed = uploadConfig.parseData(res);
+                    const uploadCode = parsed.code ?? res.status ?? res.code;
+                    if (uploadCode == uploadConfig.successCode || this.apiOk(res)) {
+                        param.onSuccess(res);
+                        this.loadFiles();
+                    } else {
+                        param.onError(parsed.msg || res.msg || "上传失败");
+                    }
+                })
+                .catch((err) => {
+                    param.onError(err);
+                });
+        },
+    },
+};
+</script>
+
+<style scoped>
+.sc-material-picker {
+    display: inline-block;
+}
+.sc-material-picker__trigger {
+    display: inline-block;
+}
+.sc-material-picker-dialog :deep(.el-dialog__body) {
+    padding: 0 20px 16px;
+}
+.picker-tabs {
+    margin-bottom: 0;
+}
+.picker-body {
+    display: flex;
+    height: 560px;
+    border-top: 1px solid var(--el-border-color-lighter);
+    margin-top: 8px;
+}
+.picker-side {
+    width: 168px;
+    flex-shrink: 0;
+    border-right: 1px solid var(--el-border-color-lighter);
+    display: flex;
+    flex-direction: column;
+    padding: 10px 0;
+}
+.category-search {
+    margin: 0 8px 8px;
+    width: calc(100% - 16px);
+}
+.category-search :deep(.el-input__wrapper) {
+    padding-left: 8px;
+    padding-right: 8px;
+}
+.category-empty {
+    padding: 12px;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    text-align: center;
+    cursor: default;
+}
+.category-scroll {
+    flex: 1;
+    height: 0;
+}
+.category-list {
+    list-style: none;
+    margin: 0;
+    padding: 0 0 8px;
+}
+.category-list li {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 9px 12px;
+    cursor: pointer;
+    font-size: 13px;
+}
+.category-list li:hover {
+    background: var(--el-fill-color-light);
+}
+.category-list li.active {
+    background: var(--el-color-primary-light-9);
+    color: var(--el-color-primary);
+}
+.category-name {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.picker-main {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    padding: 12px 0 0 16px;
+}
+.picker-toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: 12px;
+    flex-wrap: wrap;
+}
+.picker-toolbar-left {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+}
+.picker-upload {
+    display: inline-block;
+}
+.picker-grid-wrap {
+    flex: 1;
+    min-height: 0;
+}
+.picker-grid-scroll {
+    height: 100%;
+}
+.picker-grid {
+    display: grid;
+    grid-template-columns: repeat(6, 1fr);
+    gap: 12px;
+    padding: 4px 4px 12px;
+}
+.picker-item {
+    user-select: none;
+}
+.picker-thumb {
+    position: relative;
+    width: 100%;
+    aspect-ratio: 1;
+    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 {
+    width: 100%;
+    height: 100%;
+}
+.video-thumb {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #1a1a1a;
+}
+.video-thumb video {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+.video-icon {
+    font-size: 32px;
+    color: #fff;
+}
+.picker-check {
+    position: absolute;
+    top: 6px;
+    right: 6px;
+    z-index: 2;
+    width: 18px;
+    height: 18px;
+    border: 1px solid #fff;
+    background: rgba(0, 0, 0, 0.35);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-size: 12px;
+    box-sizing: border-box;
+}
+.picker-item.selected .picker-thumb {
+    border-color: var(--el-color-primary);
+    box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
+}
+.picker-item.selected .picker-check {
+    background: var(--el-color-primary);
+    border-color: var(--el-color-primary);
+}
+.picker-item.selected .picker-check .el-icon {
+    display: flex;
+}
+.picker-check .el-icon {
+    display: none;
+}
+.picker-name {
+    margin: 6px 0 0;
+    font-size: 12px;
+    text-align: center;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    color: var(--el-text-color-regular);
+}
+.picker-actions {
+    display: none;
+    justify-content: center;
+    gap: 12px;
+    margin-top: 4px;
+    font-size: 12px;
+}
+.picker-item:hover .picker-actions {
+    display: flex;
+}
+.picker-actions span {
+    color: var(--el-color-primary);
+    cursor: pointer;
+}
+.picker-actions span.danger {
+    color: var(--el-color-danger);
+}
+.picker-footer {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding-top: 12px;
+    border-top: 1px solid var(--el-border-color-lighter);
+    flex-wrap: wrap;
+    gap: 8px;
+}
+.picker-footer-left {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+}
+.selected-tip {
+    font-size: 13px;
+    color: var(--el-text-color-secondary);
+}
+.material-preview-dialog :deep(.el-dialog__body) {
+    padding: 12px 20px 20px;
+    text-align: center;
+}
+.preview-media {
+    display: block;
+    max-width: min(90vw, 960px);
+    max-height: 75vh;
+    margin: 0 auto;
+    object-fit: contain;
+}
+</style>

+ 19 - 0
src/config/route.js

@@ -27,6 +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: "permission",
 	// 	path: "/permission",

+ 4 - 0
src/scui.js

@@ -19,6 +19,8 @@ import scForm from './components/scForm'
 import scTitle from './components/scTitle'
 import scWaterMark from './components/scWaterMark'
 import scQrCode from './components/scQrCode'
+import scMaterialPicker from './components/scMaterialPicker'
+import scMaterialImage from './components/scMaterialImage'
 
 import scStatusIndicator from './components/scMini/scStatusIndicator'
 import scTrend from './components/scMini/scTrend'
@@ -60,6 +62,8 @@ export default {
 		app.component('scTitle', scTitle);
 		app.component('scWaterMark', scWaterMark);
 		app.component('scQrCode', scQrCode);
+		app.component('scMaterialPicker', scMaterialPicker);
+		app.component('scMaterialImage', scMaterialImage);
 		app.component('scStatusIndicator', scStatusIndicator);
 		app.component('scTrend', scTrend);
 

+ 7 - 14
src/views/kefu/list/index.vue

@@ -77,10 +77,12 @@
                     <el-input v-model="form.phone" clearable maxlength="20" />
                 </el-form-item>
                 <el-form-item label="二维码" prop="qrcode">
-                    <div class="qrcode-row">
-                        <sc-upload v-model="form.qrcode" title="上传" :api-obj="$API.common.upload" />
-                        <el-input v-model="form.qrcode" placeholder="或填写图片地址" clearable class="qrcode-input" />
-                    </div>
+                    <sc-material-image
+                        v-model="form.qrcode"
+                        title="选择二维码"
+                        button-text="选择图片"
+                        input-placeholder="或填写图片地址"
+                    />
                 </el-form-item>
                 <el-form-item label="备注" prop="aa">
                     <el-input v-model="form.aa" placeholder="与接口字段 aa 对应" maxlength="120" />
@@ -111,7 +113,7 @@ export default {
                 name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
                 wechat: [{ required: true, message: "请输入微信号", trigger: "blur" }],
                 phone: [{ required: true, message: "请输入手机号", trigger: "blur" }],
-                qrcode: [{ required: true, message: "请上传或填写二维码", trigger: "blur" }]
+                qrcode: [{ required: true, message: "请选择或填写二维码", trigger: "blur" }]
             }
         };
     },
@@ -231,13 +233,4 @@ export default {
     display: flex;
     justify-content: flex-end;
 }
-.qrcode-row {
-    display: flex;
-    flex-direction: column;
-    gap: 10px;
-    width: 100%;
-}
-.qrcode-input {
-    max-width: 360px;
-}
 </style>

+ 12 - 2
src/views/manage/setting/basic/index.vue

@@ -14,7 +14,12 @@
                                 <div class="el-form-item-msg"></div>
                             </el-form-item>
                             <el-form-item label="系统Logo" prop="logo">
-                                <sc-upload v-model="sys.logo"></sc-upload>
+                                <sc-material-image
+                                    v-model="sys.logo"
+                                    title="选择系统Logo"
+                                    button-text="选择图片"
+                                    :show-input="false"
+                                />
                             </el-form-item>
                             <el-form-item label="备案号" prop="miitbeian" class="label-item">
                                 <el-input v-model="sys.miitbeian" placeholder="请输入" clearable />
@@ -95,7 +100,12 @@
                                             <div class="el-form-item-msg">小程序后台获取</div>
                                         </el-form-item>
                                         <el-form-item label="分享图片" prop="share">
-                                            <sc-upload v-model="sys.share"></sc-upload>
+                                            <sc-material-image
+                                                v-model="sys.share"
+                                                title="选择分享图片"
+                                                button-text="选择图片"
+                                                :show-input="false"
+                                            />
                                         </el-form-item>
                                     </fieldset>
                                 </el-col>

+ 839 - 0
src/views/material/index.vue

@@ -0,0 +1,839 @@
+<template>
+    <el-main class="material-main">
+        <el-card shadow="never" class="material-card">
+            <el-tabs v-model="fileType" class="material-tabs" @tab-change="onFileTypeChange">
+                <el-tab-pane label="图片" name="1" />
+                <el-tab-pane label="视频" name="2" />
+            </el-tabs>
+
+            <div class="material-body">
+                <aside class="material-side" v-loading="categoryLoading">
+                    <div class="category-head">
+                        <span>分类</span>
+                        <el-button type="primary" link @click="openCategoryDialog()">新建</el-button>
+                    </div>
+                    <el-scrollbar class="category-scroll">
+                        <ul class="category-list">
+                            <li
+                                :class="{ active: activeCategoryId === '' }"
+                                @click="selectCategory('')"
+                            >
+                                <el-icon><el-icon-folder /></el-icon>
+                                <span>{{ fileType === '1' ? '全部图片' : '全部视频' }}</span>
+                            </li>
+                            <li
+                                v-for="item in flatCategories"
+                                :key="item.id"
+                                :class="{ active: String(activeCategoryId) === String(item.id) }"
+                                :style="{ paddingLeft: 12 + item.level * 14 + 'px' }"
+                                @click="selectCategory(item.id)"
+                            >
+                                <el-icon><el-icon-folder /></el-icon>
+                                <span class="category-name" :title="item.name">{{ item.name }}</span>
+                                <div class="category-actions" @click.stop>
+                                    <el-button type="primary" link size="small" @click="openCategoryDialog(item)">编辑</el-button>
+                                    <el-button type="danger" link size="small" @click="removeCategory(item)">删除</el-button>
+                                </div>
+                            </li>
+                        </ul>
+                    </el-scrollbar>
+                </aside>
+
+                <section class="material-content">
+                    <div class="toolbar">
+                        <div class="toolbar-left">
+                            <el-upload
+                                ref="uploaderRef"
+                                class="material-upload"
+                                action=""
+                                multiple
+                                :show-file-list="false"
+                                :accept="uploadAccept"
+                                :http-request="uploadRequest"
+                            >
+                                <el-button type="primary">{{ fileType === '1' ? '上传图片' : '上传视频' }}</el-button>
+                            </el-upload>
+                            <el-button @click="toggleSelectAll">{{ allSelected ? '取消全选' : '全选' }}</el-button>
+                            <el-button type="danger" plain :disabled="!selectedIds.length" @click="removeSelected">
+                                {{ fileType === '1' ? '删除图片' : '删除视频' }}
+                            </el-button>
+                        </div>
+                        <div class="toolbar-right">
+                            <el-input
+                                v-model="keyword"
+                                clearable
+                                :placeholder="fileType === '1' ? '搜索图片名称' : '搜索视频名称'"
+                                style="width: 220px"
+                                @keyup.enter="searchFiles"
+                                @clear="searchFiles"
+                            >
+                                <template #append>
+                                    <el-button :icon="Search" @click="searchFiles" />
+                                </template>
+                            </el-input>
+                            <el-button-group class="view-toggle">
+                                <el-button :type="viewMode === 'grid' ? 'primary' : 'default'" @click="setViewMode('grid')">
+                                    <el-icon><GridIcon /></el-icon>
+                                </el-button>
+                                <el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="setViewMode('list')">
+                                    <el-icon><ListIcon /></el-icon>
+                                </el-button>
+                            </el-button-group>
+                        </div>
+                    </div>
+
+                    <div v-loading="listLoading" class="file-panel">
+                        <el-empty v-if="!fileList.length && !listLoading" description="暂无素材" />
+
+                        <el-scrollbar v-else-if="viewMode === 'grid'" class="file-grid-scroll">
+                            <div class="file-grid">
+                                <div
+                                    v-for="item in fileList"
+                                    :key="item.id"
+                                    class="file-item"
+                                    :class="{ selected: isSelected(item.id) }"
+                                >
+                                    <div class="file-thumb" @click="toggleSelect(item.id)">
+                                        <el-image
+                                            v-if="fileType === '1'"
+                                            :src="item.url"
+                                            fit="contain"
+                                            lazy
+                                        />
+                                        <div v-else class="video-thumb">
+                                            <video v-if="item.url" :src="item.url" preload="metadata" />
+                                            <el-icon v-else class="video-icon"><el-icon-video-play /></el-icon>
+                                        </div>
+                                        <div class="file-check">
+                                            <el-icon v-if="isSelected(item.id)"><el-icon-check /></el-icon>
+                                        </div>
+                                    </div>
+                                    <p class="file-name" :title="item.name">{{ item.name }}</p>
+                                    <div class="file-actions">
+                                        <span @click.stop="previewItem(item)">查看</span>
+                                        <span class="danger" @click.stop="removeOne(item)">删除</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </el-scrollbar>
+
+                        <el-table
+                            v-else
+                            ref="fileTableRef"
+                            :data="fileList"
+                            row-key="id"
+                            border
+                            stripe
+                            @selection-change="onTableSelectionChange"
+                        >
+                            <el-table-column type="selection" width="48" />
+                            <el-table-column label="预览" width="100">
+                                <template #default="{ row }">
+                                    <el-image
+                                        v-if="fileType === '1'"
+                                        :src="row.url"
+                                        fit="cover"
+                                        style="width: 56px; height: 56px; border-radius: 4px;"
+                                        :preview-src-list="[row.url]"
+                                        preview-teleported
+                                    />
+                                    <el-tag v-else size="small">视频</el-tag>
+                                </template>
+                            </el-table-column>
+                            <el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
+                            <el-table-column label="大小" width="90" show-overflow-tooltip>
+                                <template #default="{ row }">{{ row.raw?.att_size || "-" }}</template>
+                            </el-table-column>
+                            <el-table-column prop="id" label="ID" width="80" />
+                        </el-table>
+
+                        <div v-if="total > query.limit" class="pager">
+                            <el-pagination
+                                background
+                                layout="total, prev, pager, next, sizes"
+                                :total="total"
+                                :current-page="query.page"
+                                :page-size="query.limit"
+                                :page-sizes="[20, 40, 60, 100]"
+                                @current-change="handlePageChange"
+                                @size-change="handleSizeChange"
+                            />
+                        </div>
+                    </div>
+                </section>
+            </div>
+        </el-card>
+
+        <el-dialog
+            v-model="categoryDialogVisible"
+            :title="categoryForm.id ? '编辑分类' : '新建分类'"
+            width="440px"
+            destroy-on-close
+            @closed="resetCategoryForm"
+        >
+            <el-form ref="categoryFormRef" :model="categoryForm" :rules="categoryRules" label-width="90px">
+                <el-form-item label="分类名称" prop="name">
+                    <el-input v-model="categoryForm.name" clearable maxlength="40" />
+                </el-form-item>
+                <el-form-item label="上级分类" prop="pid">
+                    <el-select v-model="categoryForm.pid" placeholder="顶级分类" clearable style="width: 100%">
+                        <el-option label="顶级分类" value="0" />
+                        <el-option
+                            v-for="c in parentCategoryOptions"
+                            :key="c.id"
+                            :label="c.name"
+                            :value="String(c.id)"
+                        />
+                    </el-select>
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button @click="categoryDialogVisible = false">取消</el-button>
+                <el-button type="primary" :loading="categorySaving" @click="submitCategory">保存</el-button>
+            </template>
+        </el-dialog>
+
+        <el-dialog
+            v-model="previewVisible"
+            :title="previewTitle"
+            width="auto"
+            class="material-preview-dialog"
+            append-to-body
+            destroy-on-close
+            align-center
+            @closed="onPreviewClosed"
+        >
+            <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" alt="" />
+            <video
+                v-else-if="previewType === 'video' && previewUrl"
+                :src="previewUrl"
+                class="preview-media"
+                controls
+                autoplay
+            />
+        </el-dialog>
+    </el-main>
+</template>
+
+<script>
+import { Search, Grid as GridIcon, List as ListIcon } from "@element-plus/icons-vue";
+import uploadConfig from "@/config/upload";
+
+export default {
+    name: "MaterialManage",
+    components: { GridIcon, ListIcon },
+    data() {
+        return {
+            Search,
+            fileType: "1",
+            viewMode: "grid",
+            keyword: "",
+            activeCategoryId: "",
+            categoryLoading: false,
+            listLoading: false,
+            categorySaving: false,
+            categories: [],
+            flatCategories: [],
+            parentCategoryOptions: [],
+            fileList: [],
+            selectedIds: [],
+            total: 0,
+            query: { page: 1, limit: 40 },
+            categoryDialogVisible: false,
+            categoryForm: { id: "", name: "", pid: "0" },
+            categoryRules: {
+                name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
+            },
+            /** 程序同步表格勾选时,忽略 selection-change 避免清空 selectedIds */
+            syncingTableSelection: false,
+            previewVisible: false,
+            previewUrl: "",
+            previewTitle: "",
+            previewType: "image",
+        };
+    },
+    computed: {
+        uploadAccept() {
+            return this.fileType === "1" ? "image/*" : "video/*";
+        },
+        allSelected() {
+            if (!this.fileList.length) return false;
+            const set = new Set(this.selectedIds.map((id) => String(id)));
+            return this.fileList.every((f) => set.has(String(f.id)));
+        },
+    },
+    watch: {
+        fileList() {
+            if (this.viewMode === "list") {
+                this.$nextTick(() => this.syncTableSelection());
+            }
+        },
+    },
+    created() {
+        this.bootstrap();
+    },
+    methods: {
+        /** 兼容 code / status 两种成功标识 */
+        apiOk(res) {
+            if (!res) return false;
+            const code = res.code ?? res.status;
+            return code === 1 || code === 200;
+        },
+        async bootstrap() {
+            await this.loadCategories();
+            await this.loadFiles();
+        },
+        onFileTypeChange() {
+            this.activeCategoryId = "";
+            this.keyword = "";
+            this.query.page = 1;
+            this.selectedIds = [];
+            this.loadCategories();
+            this.loadFiles();
+        },
+        selectCategory(id) {
+            this.activeCategoryId = id === "" ? "" : String(id);
+            this.query.page = 1;
+            this.selectedIds = [];
+            this.loadFiles();
+        },
+        flattenCategories(nodes, level = 0, out = []) {
+            (nodes || []).forEach((node) => {
+                const id = node.id ?? node.value;
+                const name = node.name ?? node.title ?? node.label ?? node.cate_name ?? `分类#${id}`;
+                if (id != null && id !== "") {
+                    out.push({
+                        id,
+                        name,
+                        level,
+                        pid: node.pid ?? node.parent_id ?? 0,
+                        raw: node,
+                    });
+                }
+                const children = node.children || node.child || [];
+                if (children.length) this.flattenCategories(children, level + 1, out);
+            });
+            return out;
+        },
+        /** 分类:{ data: { list: [{ id, pid, name, title, children }] } } */
+        parseCategoryRows(data) {
+            if (Array.isArray(data)) return data;
+            const d = data || {};
+            if (Array.isArray(d.list)) return d.list;
+            if (Array.isArray(d.data)) return d.data;
+            return d.children || [];
+        },
+        parseFileRows(data) {
+            if (Array.isArray(data)) return { list: data, total: data.length };
+            const d = data || {};
+            const list = d.list || d.data || d.rows || [];
+            const total = Number(d.count ?? d.total ?? list.length ?? 0);
+            return { list, total };
+        },
+        /** 文件项:att_id、real_name、att_dir(缩略图可用 satt_dir) */
+        normalizeFileRow(row) {
+            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.selectedIds.some((x) => String(x) === String(id));
+        },
+        async loadCategories() {
+            this.categoryLoading = true;
+            try {
+                const res = await this.$API.material.categoryList.get(this.fileType);
+                if (!this.apiOk(res)) {
+                    this.$message.error(res.msg || res.message || "获取分类失败");
+                    this.categories = [];
+                    this.flatCategories = [];
+                    return;
+                }
+                this.categories = this.parseCategoryRows(res.data);
+                this.flatCategories = this.flattenCategories(this.categories);
+            } finally {
+                this.categoryLoading = false;
+            }
+        },
+        async loadParentCategories() {
+            const res = await this.$API.material.categoryParentList.get(this.fileType);
+            if (!this.apiOk(res)) {
+                this.parentCategoryOptions = [];
+                return;
+            }
+            const rows = this.parseCategoryRows(res.data);
+            this.parentCategoryOptions = this.flattenCategories(rows).map((x) => ({
+                id: x.id,
+                name: x.name,
+            }));
+        },
+        buildFileQuery() {
+            const params = {
+                file_type: this.fileType,
+                real_name: this.keyword || "",
+                page: this.query.page,
+                limit: this.query.limit,
+            };
+            if (this.activeCategoryId !== "") {
+                params.pid = this.activeCategoryId;
+                params.category_id = this.activeCategoryId;
+                params.cate_id = this.activeCategoryId;
+                params.relation_id = this.activeCategoryId;
+            }
+            return params;
+        },
+        async loadFiles() {
+            this.listLoading = true;
+            try {
+                const res = await this.$API.material.fileList.get(this.buildFileQuery());
+                if (!this.apiOk(res)) {
+                    this.$message.error(res.msg || res.message || "获取素材列表失败");
+                    this.fileList = [];
+                    this.total = 0;
+                    return;
+                }
+                const { list, total } = this.parseFileRows(res.data);
+                this.fileList = list.map((row) => this.normalizeFileRow(row)).filter((x) => x.id != null);
+                this.total = total;
+                this.selectedIds = this.selectedIds.filter((id) =>
+                    this.fileList.some((f) => String(f.id) === String(id))
+                );
+            } finally {
+                this.listLoading = false;
+            }
+        },
+        searchFiles() {
+            this.query.page = 1;
+            this.loadFiles();
+        },
+        handlePageChange(page) {
+            this.query.page = page;
+            this.loadFiles();
+        },
+        handleSizeChange(limit) {
+            this.query.limit = limit;
+            this.query.page = 1;
+            this.loadFiles();
+        },
+        toggleSelect(id) {
+            const sid = String(id);
+            if (this.isSelected(id)) {
+                this.selectedIds = this.selectedIds.filter((x) => String(x) !== sid);
+            } else {
+                this.selectedIds = [...this.selectedIds, sid];
+            }
+        },
+        setViewMode(mode) {
+            this.viewMode = mode;
+            if (mode === "list") {
+                this.$nextTick(() => this.syncTableSelection());
+            }
+        },
+        syncTableSelection() {
+            const table = this.$refs.fileTableRef;
+            if (!table || this.viewMode !== "list") return;
+            this.syncingTableSelection = true;
+            table.clearSelection();
+            this.fileList.forEach((row) => {
+                if (this.isSelected(row.id)) {
+                    table.toggleRowSelection(row, true);
+                }
+            });
+            this.$nextTick(() => {
+                this.syncingTableSelection = false;
+            });
+        },
+        toggleSelectAll() {
+            if (this.allSelected) {
+                this.selectedIds = [];
+                if (this.viewMode === "list") {
+                    this.$refs.fileTableRef?.clearSelection();
+                }
+                return;
+            }
+            this.selectedIds = this.fileList.map((f) => f.id);
+            if (this.viewMode === "list") {
+                this.$nextTick(() => this.syncTableSelection());
+            }
+        },
+        onTableSelectionChange(rows) {
+            if (this.syncingTableSelection) return;
+            this.selectedIds = rows.map((r) => r.id);
+        },
+        previewItem(item) {
+            if (!item?.url) {
+                this.$message.warning("暂无预览地址");
+                return;
+            }
+            this.previewUrl = item.url;
+            this.previewTitle = item.name || "预览";
+            this.previewType = this.fileType === "2" ? "video" : "image";
+            this.previewVisible = true;
+        },
+        onPreviewClosed() {
+            this.previewUrl = "";
+            this.previewTitle = "";
+        },
+        async removeOne(item) {
+            const confirm = await this.$confirm(`确认删除「${item.name}」吗?`, "提示", {
+                type: "warning",
+            }).catch(() => {});
+            if (confirm !== "confirm") return;
+            const res = await this.$API.material.fileDelete.delete([item.id]);
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || res.message || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || res.message || "删除成功");
+            this.selectedIds = this.selectedIds.filter((id) => String(id) !== String(item.id));
+            this.loadFiles();
+        },
+        async removeSelected() {
+            if (!this.selectedIds.length) return;
+            const confirm = await this.$confirm(
+                `确认删除选中的 ${this.selectedIds.length} 个素材吗?`,
+                "提示",
+                { type: "warning" }
+            ).catch(() => {});
+            if (confirm !== "confirm") return;
+            const res = await this.$API.material.fileDelete.delete(this.selectedIds);
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || res.message || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || res.message || "删除成功");
+            this.selectedIds = [];
+            this.loadFiles();
+        },
+        uploadRequest(param) {
+            const data = new FormData();
+            data.append(uploadConfig.filename || "file", param.file);
+            if (this.activeCategoryId !== "") {
+                data.append("pid", String(this.activeCategoryId));
+                data.append("category_id", String(this.activeCategoryId));
+            }
+            this.$API.material.upload
+                .post(this.fileType, data, {
+                    onUploadProgress: (e) => {
+                        if (e.total) {
+                            const percent = Math.round((e.loaded / e.total) * 100);
+                            param.onProgress({ percent });
+                        }
+                    },
+                })
+                .then((res) => {
+                    const parsed = uploadConfig.parseData(res);
+                    const uploadCode = parsed.code ?? res.status ?? res.code;
+                    if (uploadCode == uploadConfig.successCode || this.apiOk(res)) {
+                        param.onSuccess(res);
+                        this.loadFiles();
+                    } else {
+                        param.onError(parsed.msg || res.msg || "上传失败");
+                    }
+                })
+                .catch((err) => {
+                    param.onError(err);
+                });
+        },
+        resetCategoryForm() {
+            this.categoryForm = { id: "", name: "", pid: "0" };
+        },
+        async openCategoryDialog(row) {
+            await this.loadParentCategories();
+            if (row) {
+                const raw = row.raw || row;
+                this.categoryForm = {
+                    id: String(row.id),
+                    name: row.name,
+                    pid: String(raw.pid ?? "0"),
+                };
+            } else {
+                this.resetCategoryForm();
+                if (this.activeCategoryId !== "") {
+                    this.categoryForm.pid = String(this.activeCategoryId);
+                }
+            }
+            this.categoryDialogVisible = true;
+            this.$nextTick(() => this.$refs.categoryFormRef?.clearValidate());
+        },
+        submitCategory() {
+            this.$refs.categoryFormRef.validate(async (valid) => {
+                if (!valid) return false;
+                this.categorySaving = true;
+                try {
+                    const payload = {
+                        name: this.categoryForm.name,
+                        pid: String(this.categoryForm.pid || "0"),
+                        file_type: this.fileType,
+                    };
+                    const res = await this.$API.material.categorySave.post(
+                        this.categoryForm.id || 0,
+                        payload
+                    );
+                    if (!this.apiOk(res)) {
+                        this.$message.error(res.msg || res.message || "保存失败");
+                        return false;
+                    }
+                    this.$message.success(res.msg || res.message || "保存成功");
+                    this.categoryDialogVisible = false;
+                    await this.loadCategories();
+                } finally {
+                    this.categorySaving = false;
+                }
+            });
+        },
+        async removeCategory(row) {
+            const confirm = await this.$confirm(`确认删除分类「${row.name}」吗?`, "提示", {
+                type: "warning",
+            }).catch(() => {});
+            if (confirm !== "confirm") return;
+            const res = await this.$API.material.categoryDelete.delete(row.id);
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || res.message || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || res.message || "删除成功");
+            if (String(this.activeCategoryId) === String(row.id)) {
+                this.activeCategoryId = "";
+            }
+            await this.loadCategories();
+            this.loadFiles();
+        },
+    },
+};
+</script>
+
+<style scoped>
+.material-main {
+    padding: 0;
+}
+.material-card :deep(.el-card__body) {
+    padding-top: 8px;
+}
+.material-tabs {
+    margin-bottom: 0;
+}
+.material-body {
+    display: flex;
+    min-height: calc(100vh - 220px);
+    border-top: 1px solid var(--el-border-color-lighter);
+    margin-top: 8px;
+}
+.material-side {
+    width: 220px;
+    flex-shrink: 0;
+    border-right: 1px solid var(--el-border-color-lighter);
+    display: flex;
+    flex-direction: column;
+}
+.category-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 12px 8px;
+    font-size: 13px;
+    color: var(--el-text-color-secondary);
+}
+.category-scroll {
+    flex: 1;
+    height: 0;
+}
+.category-list {
+    list-style: none;
+    margin: 0;
+    padding: 0 0 12px;
+}
+.category-list li {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 10px 12px;
+    cursor: pointer;
+    font-size: 14px;
+    transition: background 0.15s;
+}
+.category-list li:hover {
+    background: var(--el-fill-color-light);
+}
+.category-list li.active {
+    background: var(--el-color-primary-light-9);
+    color: var(--el-color-primary);
+}
+.category-name {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.category-actions {
+    display: none;
+    flex-shrink: 0;
+}
+.category-list li:hover .category-actions {
+    display: flex;
+    gap: 2px;
+}
+.material-content {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    padding: 16px;
+}
+.toolbar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: 16px;
+}
+.toolbar-left,
+.toolbar-right {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 10px;
+}
+.material-upload {
+    display: inline-block;
+}
+.view-toggle {
+    margin-left: 4px;
+}
+.file-panel {
+    flex: 1;
+    min-height: 320px;
+}
+.file-grid-scroll {
+    height: calc(100vh - 320px);
+}
+.file-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+    gap: 16px;
+    padding: 4px 4px 16px;
+}
+.file-item {
+    user-select: none;
+}
+.file-item .file-thumb {
+    cursor: pointer;
+}
+.file-thumb {
+    position: relative;
+    width: 100%;
+    aspect-ratio: 1;
+    border: 1px solid var(--el-border-color-lighter);
+    border-radius: 4px;
+    overflow: hidden;
+    background: var(--el-fill-color-lighter);
+}
+.file-thumb .el-image {
+    width: 100%;
+    height: 100%;
+}
+.video-thumb {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #1a1a1a;
+}
+.video-thumb video {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+.video-icon {
+    font-size: 36px;
+    color: #fff;
+}
+.file-check {
+    position: absolute;
+    top: 6px;
+    right: 6px;
+    z-index: 2;
+    width: 20px;
+    height: 20px;
+    border: 1px solid #fff;
+    background: rgba(0, 0, 0, 0.35);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-size: 12px;
+    box-sizing: border-box;
+}
+.file-item.selected .file-thumb {
+    border-color: var(--el-color-primary);
+    box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
+}
+.file-item.selected .file-check {
+    background: var(--el-color-primary);
+    border-color: var(--el-color-primary);
+}
+.file-item.selected .file-check .el-icon {
+    display: flex;
+}
+.file-check .el-icon {
+    display: none;
+}
+.file-name {
+    margin: 8px 0 0;
+    font-size: 12px;
+    line-height: 1.4;
+    text-align: center;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    color: var(--el-text-color-regular);
+}
+.file-actions {
+    display: none;
+    align-items: center;
+    justify-content: center;
+    gap: 16px;
+    margin-top: 6px;
+    font-size: 12px;
+    line-height: 1.5;
+}
+.file-item:hover .file-actions {
+    display: flex;
+}
+.file-actions span {
+    color: var(--el-color-primary);
+    cursor: pointer;
+}
+.file-actions span:hover {
+    opacity: 0.85;
+}
+.file-actions span.danger {
+    color: var(--el-color-danger);
+}
+.material-preview-dialog :deep(.el-dialog__body) {
+    padding: 12px 20px 20px;
+    text-align: center;
+}
+.preview-media {
+    display: block;
+    max-width: min(90vw, 960px);
+    max-height: 75vh;
+    margin: 0 auto;
+    object-fit: contain;
+}
+.pager {
+    margin-top: 16px;
+    display: flex;
+    justify-content: flex-end;
+}
+</style>

+ 91 - 41
src/views/task/manage/list/index.vue

@@ -125,15 +125,13 @@
                 <el-form-item label="经验值" prop="exp">
                     <el-input v-model="form.exp" placeholder="完成任务可获得的经验值" clearable />
                 </el-form-item>
-                <el-form-item v-if="String(form.type) === '1'" label="封面视频" prop="coverVideoFiles">
-                    <scUploadFile
-                        v-model="form.coverVideoFiles"
-                        :apiObj="$API.common.upload"
-                        :multiple="false"
-                        :limit="1"
-                        accept="video/*"
-                        tip="请上传封面视频,限制1个文件"
-                        btnTxt="上传视频"
+                <el-form-item v-if="String(form.type) === '1'" label="封面视频" prop="cover_video">
+                    <sc-material-image
+                        v-model="form.cover_video"
+                        file-type="2"
+                        title="选择封面视频"
+                        button-text="选择视频"
+                        input-placeholder="或填写视频地址"
                     />
                 </el-form-item>
                 <el-form-item label="总量" prop="taskTotal">
@@ -157,14 +155,29 @@
                     </el-select>
                 </el-form-item>
                 <el-form-item label="素材" prop="materialFiles">
-                    <scUploadFile
-                        v-model="form.materialFiles"
-                        :apiObj="$API.common.upload"
-                        :multiple="true"
-                        :limit="20"
-                        tip="请上传素材文件,支持多文件,上传后自动写入素材链接"
-                        btnTxt="上传素材"
-                    />
+                    <div class="material-pick-block">
+                        <sc-material-picker
+                            ref="materialPickerRef"
+                            :multiple="true"
+                            :max="20"
+                            title="选择素材"
+                            button-text="选择素材"
+                            :model-value="materialPickerValue"
+                            @confirm="onMaterialConfirm"
+                        />
+                        <div v-if="form.materialFiles.length" class="material-selected-list">
+                            <el-tag
+                                v-for="(file, index) in form.materialFiles"
+                                :key="`${file.url}-${index}`"
+                                closable
+                                class="material-selected-tag"
+                                @close="removeMaterial(index)"
+                            >
+                                {{ file.name }}
+                            </el-tag>
+                        </div>
+                        <p v-else class="material-pick-tip">请从素材库选择图片或视频,最多 20 个</p>
+                    </div>
                 </el-form-item>
                 <el-form-item label="价格" prop="price">
                     <el-input v-model="form.price" />
@@ -269,23 +282,6 @@ function normalizeTaskGradeForForm(grade) {
     return String(grade);
 }
 
-function normalizeSingleUploadFile(fileValue) {
-    if (!fileValue) return [];
-    if (typeof fileValue === "string") {
-        const url = fileValue.trim();
-        if (!url) return [];
-        const name = url.split(/[/\\]/).pop() || url;
-        return [{ name, url }];
-    }
-    if (typeof fileValue === "object") {
-        const url = String(fileValue.src || fileValue.url || "").trim();
-        if (!url) return [];
-        const name = String(fileValue.fileName || fileValue.name || "").trim() || url.split(/[/\\]/).pop() || url;
-        return [{ name, url }];
-    }
-    return [];
-}
-
 function taskTypeLabel(type) {
     const val = String(type ?? "0");
     return val === "1" ? "剪同款" : "普通剪辑";
@@ -330,20 +326,20 @@ export default {
                     {
                         validator: (rule, value, callback) => {
                             if (Array.isArray(value) && value.length) callback();
-                            else callback(new Error("请上传素材文件"));
+                            else callback(new Error("请选择素材"));
                         },
                         trigger: "change"
                     }
                 ],
-                coverVideoFiles: [
+                cover_video: [
                     {
                         validator: (rule, value, callback) => {
                             if (String(this.form.type) !== "1") {
                                 callback();
                                 return;
                             }
-                            if (Array.isArray(value) && value.length && value[0]?.url) callback();
-                            else callback(new Error("请上传封面视频"));
+                            if (value && String(value).trim()) callback();
+                            else callback(new Error("请选择封面视频"));
                         },
                         trigger: "change"
                     }
@@ -358,6 +354,15 @@ export default {
             }
         };
     },
+    computed: {
+        materialPickerValue() {
+            return (this.form.materialFiles || []).map((f) => ({
+                id: f.id,
+                name: f.name,
+                url: f.url,
+            }));
+        },
+    },
     created() {
         this.getKefuOptions();
         this.getLevelOptions();
@@ -371,7 +376,6 @@ export default {
                 title: "",
                 explain: "",
                 type: "0",
-                coverVideoFiles: [],
                 cover_video: "",
                 materialFiles: [],
                 price: "",
@@ -484,6 +488,34 @@ export default {
             this.query.page = 1;
             this.getList();
         },
+        onMaterialConfirm(value) {
+            const list = Array.isArray(value) ? value : value ? [value] : [];
+            this.form.materialFiles = list
+                .map((item) => {
+                    if (typeof item === "string") {
+                        const url = item.trim();
+                        if (!url) return null;
+                        return { name: url.split(/[/\\]/).pop() || url, url };
+                    }
+                    const url = String(item.url || item.att_dir || "").trim();
+                    if (!url) return null;
+                    const name =
+                        String(item.name || item.real_name || "").trim() ||
+                        url.split(/[/\\]/).pop() ||
+                        url;
+                    return {
+                        id: item.id ?? item.att_id,
+                        name,
+                        url,
+                    };
+                })
+                .filter(Boolean);
+            this.$nextTick(() => this.$refs.formRef?.validateField("materialFiles"));
+        },
+        removeMaterial(index) {
+            this.form.materialFiles.splice(index, 1);
+            this.$nextTick(() => this.$refs.formRef?.validateField("materialFiles"));
+        },
         openCreate() {
             this.dialogTitle = "新增任务";
             this.form = this.getDefaultForm();
@@ -508,7 +540,6 @@ export default {
                 title: info.title || "",
                 explain: info.explain || "",
                 type: String(info.type ?? "0"),
-                coverVideoFiles: normalizeSingleUploadFile(info.cover_video),
                 cover_video: String(info.cover_video || ""),
                 materialFiles,
                 price: info.price || info.pirce || "",
@@ -534,7 +565,7 @@ export default {
                         fileName: f.name || "",
                         src: f.url || ""
                     })).filter((m) => m.src);
-                    const coverVideo = (this.form.coverVideoFiles || [])[0]?.url || "";
+                    const coverVideo = String(this.form.cover_video || "").trim();
                     const endTimeSec = this.form.endDate ? Math.floor(Number(this.form.endDate) / 1000) : "";
                     const payload = {
                         title: this.form.title,
@@ -612,4 +643,23 @@ export default {
     margin-bottom: 4px;
 }
 
+.material-pick-block {
+    width: 100%;
+}
+.material-selected-list {
+    margin-top: 10px;
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+}
+.material-selected-tag {
+    max-width: 100%;
+}
+.material-pick-tip {
+    margin: 8px 0 0;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    line-height: 1.5;
+}
+
 </style>

+ 13 - 9
src/views/user/level/list/index.vue

@@ -141,16 +141,20 @@
                     <el-input-number v-model="form.exp_num" :min="0" :max="999999999" :step="1" controls-position="right" class="w-full-num" />
                 </el-form-item>
                 <el-form-item label="图标" prop="icon">
-                    <div class="icon-field">
-                        <sc-upload v-model="form.icon" title="上传" :api-obj="$API.common.upload" />
-                        <el-input v-model="form.icon" placeholder="或填写图片地址" clearable />
-                    </div>
+                    <sc-material-image
+                        v-model="form.icon"
+                        title="选择图标"
+                        button-text="选择图片"
+                        input-placeholder="或填写图片地址"
+                    />
                 </el-form-item>
                 <el-form-item label="背景图" prop="background">
-                    <div class="icon-field">
-                        <sc-upload v-model="form.background" title="上传" :api-obj="$API.common.upload" />
-                        <el-input v-model="form.background" placeholder="或填写背景图地址" clearable />
-                    </div>
+                    <sc-material-image
+                        v-model="form.background"
+                        title="选择背景图"
+                        button-text="选择图片"
+                        input-placeholder="或填写背景图地址"
+                    />
                 </el-form-item>
                 <el-form-item label="颜色" prop="colorList">
                     <div class="color-palette-field">
@@ -246,7 +250,7 @@ export default {
                 name: [{ required: true, message: "请输入名称", trigger: "blur" }],
                 grade: [{ required: true, message: "请输入等级", trigger: "change" }],
                 exp_num: [{ required: true, message: "请输入所需经验", trigger: "change" }],
-                icon: [{ required: true, message: "请上传或填写图标地址", trigger: "blur" }],
+                icon: [{ required: true, message: "请选择或填写图标地址", trigger: "blur" }],
                 explain: [{ required: true, message: "请输入说明", trigger: "blur" }]
             }
         };