7 Commits f81365fcb9 ... cfa58a2de9

Autor SHA1 Mensagem Data
  doi cfa58a2de9 feat: 素材管理 2 meses atrás
  doi a8fd858dd5 feat: 修复权限页面 2 meses atrás
  doi c591ecac73 feat: 视频标签 2 meses atrás
  doi f606620569 feat: 修复登录超时问题 2 meses atrás
  doi 11151037d3 feat: 用户等级列表 2 meses atrás
  doi 81a4041282 feat: 用户等级列表 2 meses atrás
  doi fb907035bc feat: 标签页面 2 meses atrás

+ 1 - 1
.env.development

@@ -5,7 +5,7 @@ NODE_ENV = development
 VUE_APP_TITLE = 蓝微帧工场
 
 # 接口地址
-VUE_APP_API_BASEURL = https://cut.bluevchat.cn/adminapi/
+VUE_APP_API_BASEURL = http://192.168.2.64:5890/adminapi
 
 # 本地端口
 VUE_APP_PORT = 2808

BIN
public/favicon.ico


+ 28 - 0
src/api/model/cutter.js

@@ -64,5 +64,33 @@ export default {
         post: async function (id, data = {}) {
             return await http.post(`${this.url}/${id}`, data);
         }
+    },
+    labelList: {
+        url: `${config.API_URL}/cutter/label/list`,
+        name: "标签列表",
+        get: async function (type) {
+            return await http.get(`${this.url}/${type}`);
+        }
+    },
+    labelSave: {
+        url: `${config.API_URL}/cutter/label/save`,
+        name: "标签保存/编辑",
+        post: async function (data = {}) {
+            return await http.post(this.url, data);
+        }
+    },
+    labelDel: {
+        url: `${config.API_URL}/cutter/label/del`,
+        name: "标签删除",
+        delete: async function (id) {
+            return await http.delete(`${this.url}/${id}`);
+        }
+    },
+    labelInfo: {
+        url: `${config.API_URL}/cutter/label/info`,
+        name: "标签详情",
+        get: async function (id) {
+            return await http.get(`${this.url}/${id}`);
+        }
     }
 };

+ 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);
+        },
+    },
+};

+ 47 - 0
src/api/model/role.js

@@ -0,0 +1,47 @@
+import config from "@/config";
+import http from "@/utils/request";
+
+export default {
+    list: {
+        url: `${config.API_URL}/role`,
+        name: "身份列表",
+        get: async function (params = {}) {
+            return await http.get(this.url, params);
+        },
+    },
+    create: {
+        url: `${config.API_URL}/role/create`,
+        name: "身份添加菜单树",
+        get: async function () {
+            return await http.get(this.url);
+        },
+    },
+    save: {
+        url: `${config.API_URL}/role/save`,
+        name: "身份保存",
+        post: async function (data = {}) {
+            return await http.post(this.url, data);
+        },
+    },
+    edit: {
+        url: `${config.API_URL}/role/edit`,
+        name: "身份编辑数据",
+        get: async function (id) {
+            return await http.get(`${this.url}/${id}`);
+        },
+    },
+    del: {
+        url: `${config.API_URL}/role/del`,
+        name: "身份删除",
+        delete: async function (id) {
+            return await http.delete(`${this.url}/${id}`);
+        },
+    },
+    setStatus: {
+        url: `${config.API_URL}/role/set_status`,
+        name: "身份状态",
+        get: async function (id, status) {
+            return await http.get(`${this.url}/${id}/${status}`);
+        },
+    },
+};

+ 65 - 1
src/api/model/user.js

@@ -10,6 +10,14 @@ export default {
             return await http.get(this.url, params);
         },
     },
+    /** 后台管理员列表 adminapi/user/list(权限模块) */
+    adminList: {
+        url: `${config.API_URL}/user/list`,
+        name: "管理员列表",
+        get: async function (params = {}) {
+            return await http.get(this.url, params);
+        },
+    },
     setStatus: {
         url: `${config.API_URL}/users/set_status`,
         name: "用户状态",
@@ -17,9 +25,10 @@ export default {
             return await http.get(`${this.url}/${id}/${status}`);
         },
     },
+    /** POST adminapi/user/passwd:管理员编辑 id、roles(必填);password 非空才传则改密,否则不传 */
     pwd: {
         url: `${config.API_URL}/user/passwd`,
-        name: "-",
+        name: "管理员更新",
         post: async function (params) {
             return await http.post(this.url, params);
         },
@@ -52,4 +61,59 @@ export default {
             return await http.post(this.url, params);
         },
     },
+    /** 用户等级列表 adminapi/user/level/list */
+    levelList: {
+        url: `${config.API_URL}/user/level/list`,
+        name: "用户等级列表",
+        get: async function (params = {}) {
+            return await http.get(this.url, params);
+        },
+    },
+    /** 用户等级详情 adminapi/user/level/info/:id */
+    levelInfo: {
+        url: `${config.API_URL}/user/level/info`,
+        name: "用户等级详情",
+        get: async function (id) {
+            return await http.get(`${this.url}/${id}`);
+        },
+    },
+    /** 用户等级新增/编辑:新增 POST .../save;编辑 POST .../save/:id */
+    levelSave: {
+        url: `${config.API_URL}/user/level/save`,
+        name: "用户等级保存",
+        post: async function (data = {}) {
+            const id = data.id;
+            const colorRaw = data.color;
+            const color = Array.isArray(colorRaw)
+                ? colorRaw.map((x) => String(x ?? "").trim()).filter(Boolean)
+                : [];
+            const payload = {
+                name: String(data.name ?? ""),
+                grade: String(data.grade ?? ""),
+                icon: String(data.icon ?? ""),
+                explain: String(data.explain ?? ""),
+                exp_num: String(data.exp_num ?? ""),
+                background: String(data.background ?? ""),
+                color,
+            };
+            const url = id != null && String(id) !== "" ? `${this.url}/${id}` : this.url;
+            return await http.post(url, payload);
+        },
+    },
+    /** 用户等级删除 adminapi/user/level/del/:id */
+    levelDel: {
+        url: `${config.API_URL}/user/level/del`,
+        name: "用户等级删除",
+        delete: async function (id) {
+            return await http.delete(`${this.url}/${id}`);
+        },
+    },
+    /** 显示开关 adminapi/user/level/set_show/:id/:show */
+    levelSetShow: {
+        url: `${config.API_URL}/user/level/set_show`,
+        name: "用户等级显示设置",
+        get: async function (id, show) {
+            return await http.get(`${this.url}/${id}/${show}`);
+        },
+    },
 }

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

+ 49 - 1
src/config/route.js

@@ -26,6 +26,54 @@
 // 	}
 // ]
 
-const routes = []
+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",
+	// 	meta: {
+	// 		icon: "el-icon-lock",
+	// 		title: "权限管理",
+	// 	},
+	// 	children: [
+	// 		{
+	// 			name: "permissionRoleList",
+	// 			path: "/permission/role",
+	// 			component: "permission/role/list/index",
+	// 			meta: {
+	// 				icon: "el-icon-user-filled",
+	// 				title: "身份列表",
+	// 			},
+	// 		},
+	// 		{
+	// 			name: "permissionAdminList",
+	// 			path: "/permission/admin",
+	// 			component: "permission/admin/list/index",
+	// 			meta: {
+	// 				icon: "el-icon-monitor",
+	// 				title: "管理员列表",
+	// 			},
+	// 		},
+	// 	],
+	// },
+]
 
 export default routes;

+ 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);
 

+ 32 - 0
src/style/app.scss

@@ -616,6 +616,38 @@ fieldset legend {
 	background-color: var(--el-fill-color-light) !important;
 }
 
+/* 表格「操作」列:实心按钮、左对齐、不换行 */
+.table-row-actions {
+	display: flex;
+	flex-wrap: nowrap;
+	justify-content: flex-start;
+	align-items: center;
+	gap: 8px;
+	width: 100%;
+	box-sizing: border-box;
+	min-height: 32px;
+}
+
+.table-row-actions .el-button {
+	margin-left: 0 !important;
+	flex-shrink: 0;
+	text-align: center;
+}
+
+.table-row-actions > .muted {
+	flex-shrink: 0;
+	white-space: nowrap;
+	padding: 4px 2px;
+	color: var(--el-text-color-placeholder);
+	font-size: 12px;
+	font-weight: normal;
+	text-align: left;
+}
+
+.el-table .el-table__cell .cell:has(> .table-row-actions) {
+	white-space: nowrap;
+}
+
 .hide-text {
 	white-space: nowrap;
 	text-overflow: ellipsis;

+ 65 - 0
src/utils/extractBaiduPan.js

@@ -0,0 +1,65 @@
+/**
+ * 从混合格式文案(含文件名、说明、提取码等)中解析百度网盘标准分享链接。
+ */
+
+const PAN_S_URL_RE =
+    /https?:\/\/pan\.baidu\.com\/s\/[A-Za-z0-9_-]+(?:\?(?:[A-Za-z0-9_=&%.-]*))?/i;
+
+const PWD_LABEL_RE = /(?:提取码|密码|提取\s*码)[::\s]*([A-Za-z0-9]{4,8})\b/i;
+
+const FIRST_HTTP_RE =
+    /https?:\/\/[^\s\u4e00-\u9fff\u3000-\u303f]+/i;
+
+/**
+ * @param {string|null|undefined} text
+ * @returns {{ url: string|null, pwd: string|null }}
+ */
+export function parseBaiduPanShare(text) {
+    if (text == null || text === "") {
+        return { url: null, pwd: null };
+    }
+    const s = String(text);
+    const urlMatch = s.match(PAN_S_URL_RE);
+    let url = urlMatch ? urlMatch[0] : null;
+
+    let pwd = null;
+    if (url) {
+        try {
+            const u = new URL(url);
+            pwd = u.searchParams.get("pwd") || u.searchParams.get("PWD");
+        } catch {
+            /* ignore */
+        }
+    }
+
+    const pwdLabel = s.match(PWD_LABEL_RE);
+    if (!pwd && pwdLabel) {
+        pwd = pwdLabel[1];
+    }
+
+    if (url && pwd && !/pwd=/i.test(url)) {
+        url += url.includes("?") ? `&pwd=${encodeURIComponent(pwd)}` : `?pwd=${encodeURIComponent(pwd)}`;
+    }
+
+    return { url, pwd };
+}
+
+/**
+ * 表格/外链打开用:优先解析百度分享;否则取文案中首个 http(s) URL(去掉末尾中英文标点粘连)。
+ * @param {string|null|undefined} raw
+ * @returns {string|null}
+ */
+export function resolveExternalShareUrl(raw) {
+    const { url } = parseBaiduPanShare(raw);
+    if (url) return url;
+    if (raw == null || raw === "") return null;
+    const s = String(raw).trim();
+    const any = s.match(FIRST_HTTP_RE);
+    if (any) {
+        return any[0].replace(/[,.;,。、;]+$/, "");
+    }
+    if (/^https?:\/\//i.test(s)) {
+        return s.replace(/[,.;,。、;]+$/, "");
+    }
+    return null;
+}

+ 18 - 2
src/utils/request.js

@@ -1,9 +1,20 @@
 import axios from 'axios';
-import { ElNotification, ElMessageBox } from 'element-plus';
+import { ElNotification, ElMessageBox, ElMessage } from 'element-plus';
 import sysConfig from "@/config";
 import tool from '@/utils/tool';
 import router from '@/router';
-
+import store from '@/store';
+function clearLoginSession() {
+	tool.cookie.remove('TOKEN');
+	tool.data.remove('USER_INFO');
+	tool.data.remove('MENU');
+	tool.data.remove('PERMISSIONS');
+	tool.data.remove('DASHBOARDGRID');
+	tool.data.remove('grid');
+	store.commit('clearViewTags');
+	store.commit('clearKeepLive');
+	store.commit('clearIframeList');
+}
 axios.defaults.baseURL = ''
 
 axios.defaults.timeout = sysConfig.TIMEOUT
@@ -39,6 +50,11 @@ let MessageBox_401_show = false
 // HTTP response 拦截器
 axios.interceptors.response.use(
 	(response) => {
+		if (response.data.code == 401) {
+			clearLoginSession();
+			ElMessage.warning('登录已失效,请重新登录');
+			router.replace({ path: '/login' });
+		}
 		return response;
 	},
 	(error) => {

+ 61 - 19
src/views/cutter/apply/index.vue

@@ -8,6 +8,16 @@
             <el-table v-loading="loading" :data="list">
                 <el-table-column prop="id" label="ID" width="80" />
                 <el-table-column prop="real_name" label="真实姓名" min-width="100" show-overflow-tooltip />
+                <el-table-column label="行业" min-width="140" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ idsToLabelText(row.trade, tradeLabelMap) }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="风格" min-width="140" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ idsToLabelText(row.style, styleLabelMap) }}
+                    </template>
+                </el-table-column>
                 <el-table-column prop="card" label="身份证" min-width="160" show-overflow-tooltip />
                 <el-table-column prop="phone" label="手机号" width="130" />
                 <el-table-column prop="wechat" label="微信号" min-width="120" show-overflow-tooltip />
@@ -37,24 +47,13 @@
                 </el-table-column>
                 <el-table-column label="操作" width="200" fixed="right">
                     <template #default="{ row }">
-                        <el-button
-                            v-if="isPending(row)"
-                            type="success"
-                            size="small"
-                            @click="openRemark(row, 'agree')"
-                        >
-                            同意
-                        </el-button>
-                        <el-button
-                            v-if="isPending(row)"
-                            type="danger"
-                            size="small"
-                            plain
-                            @click="openRemark(row, 'refuse')"
-                        >
-                            拒绝
-                        </el-button>
-                        <span v-if="!isPending(row)" class="muted">已处理</span>
+                        <div class="table-row-actions">
+                            <template v-if="isPending(row)">
+                                <el-button type="success" size="small" @click="openRemark(row, 'agree')">同意</el-button>
+                                <el-button type="danger" size="small" plain @click="openRemark(row, 'refuse')">拒绝</el-button>
+                            </template>
+                            <span v-else class="muted">已处理</span>
+                        </div>
                     </template>
                 </el-table-column>
             </el-table>
@@ -97,13 +96,56 @@ export default {
             remarkTitle: "",
             remark: "",
             remarkAction: "",
-            currentRow: null
+            currentRow: null,
+            /** 行业标签 id -> 名称(type=1) */
+            tradeLabelMap: {},
+            /** 风格标签 id -> 名称(type=2) */
+            styleLabelMap: {}
         };
     },
     created() {
+        this.loadLabelMaps();
         this.getList();
     },
     methods: {
+        buildLabelMap(raw) {
+            const arr = Array.isArray(raw) ? raw : raw?.list || raw?.data || [];
+            const map = {};
+            for (const item of arr) {
+                const id = item?.id;
+                if (id == null) continue;
+                map[Number(id)] = item.label_name ?? item.name ?? String(id);
+            }
+            return map;
+        },
+        async loadLabelMaps() {
+            try {
+                const [tradeRes, styleRes] = await Promise.all([
+                    this.$API.cutter.labelList.get(1),
+                    this.$API.cutter.labelList.get(2)
+                ]);
+                if (tradeRes.code === 1) {
+                    this.tradeLabelMap = this.buildLabelMap(tradeRes.data);
+                }
+                if (styleRes.code === 1) {
+                    this.styleLabelMap = this.buildLabelMap(styleRes.data);
+                }
+            } catch (e) {
+                console.error(e);
+            }
+        },
+        idsToLabelText(ids, map) {
+            if (ids == null) return "-";
+            const list = Array.isArray(ids) ? ids : [ids];
+            if (!list.length) return "-";
+            return list
+                .map((id) => {
+                    const n = Number(id);
+                    const name = map[n];
+                    return name != null && name !== "" ? name : String(id);
+                })
+                .join("、");
+        },
         /** 后端为日期字符串 "2026-04-29 12:17:09" 或时间戳 */
         formatDispTime(val) {
             if (val === undefined || val === null || val === "") return "-";

+ 252 - 0
src/views/cutter/label/list/index.vue

@@ -0,0 +1,252 @@
+<template>
+    <el-main>
+        <el-card shadow="never">
+            <template #header>
+                <div class="header-row">
+                    <span class="card-title">标签管理</span>
+                    <div class="header-actions">
+                        <el-radio-group v-model="labelType" size="default" @change="onTypeChange">
+                            <el-radio-button :label="1">行业标签</el-radio-button>
+                            <el-radio-button :label="2">风格标签</el-radio-button>
+                            <el-radio-button :label="3">视频标签</el-radio-button>
+                        </el-radio-group>
+                        <el-button type="primary" round @click="openCreate">
+                            新增标签
+                        </el-button>
+                    </div>
+                </div>
+            </template>
+
+            <el-table v-loading="loading" :data="list" stripe class="label-table">
+                <el-table-column prop="id" label="ID" width="90" />
+                <el-table-column label="标签名称" min-width="160" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        <span class="label-name">{{ row.label_name ?? row.name ?? "-" }}</span>
+                    </template>
+                </el-table-column>
+                <!-- <el-table-column label="类型" width="110">
+                    <template #default="{ row }">
+                        {{ typeText(row.type ?? labelType) }}
+                    </template>
+                </el-table-column> -->
+                <el-table-column label="创建时间" min-width="170" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ formatTime(row.created_at || row.add_time || row.create_time) }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" width="248" fixed="right">
+                    <template #default="{ row }">
+                        <div class="table-row-actions">
+                            <el-button type="info" size="small" @click="showDetail(row)">详情</el-button>
+                            <el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+                            <el-button type="danger" size="small" @click="removeRow(row)">删除</el-button>
+                        </div>
+                    </template>
+                </el-table-column>
+            </el-table>
+        </el-card>
+
+        <el-dialog v-model="detailDialog" title="标签详情" width="520px" destroy-on-close>
+            <el-descriptions :column="1" border>
+                <el-descriptions-item label="ID">{{ detail.id ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="标签名称">{{ detail.label_name ?? detail.name ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="类型">{{ typeText(detail.type) }}</el-descriptions-item>
+                <el-descriptions-item label="创建时间">{{ formatTime(detail.created_at || detail.add_time || detail.create_time) }}</el-descriptions-item>
+                <el-descriptions-item label="更新时间">{{ formatTime(detail.updated_at || detail.update_time) }}</el-descriptions-item>
+            </el-descriptions>
+        </el-dialog>
+
+        <el-dialog v-model="editDialog" :title="editTitle" width="440px" destroy-on-close @closed="resetEditForm">
+            <el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
+                <el-form-item label="标签名称" prop="label_name">
+                    <el-input v-model="form.label_name" clearable maxlength="64" placeholder="请输入标签名称" />
+                </el-form-item>
+                <el-form-item label="类型">
+                    <el-tag size="small">{{ typeText(labelType) }}</el-tag>
+                    <span class="hint">与当前列表分类一致</span>
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button @click="editDialog = false">取消</el-button>
+                <el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
+            </template>
+        </el-dialog>
+    </el-main>
+</template>
+
+<script>
+const TYPE_MAP = {
+    1: "行业标签",
+    2: "风格标签",
+    3: "视频标签"
+};
+
+export default {
+    name: "LabelList",
+    data() {
+        return {
+            loading: false,
+            saving: false,
+            labelType: 1,
+            list: [],
+            detailDialog: false,
+            detail: {},
+            editDialog: false,
+            editTitle: "新增标签",
+            form: {
+                id: "",
+                label_name: ""
+            },
+            rules: {
+                label_name: [{ required: true, message: "请输入标签名称", trigger: "blur" }]
+            }
+        };
+    },
+    created() {
+        this.getList();
+    },
+    methods: {
+        typeText(type) {
+            if (type == null || type === "") return "-";
+            const n = Number(type);
+            return TYPE_MAP[n] ?? String(type);
+        },
+        formatTime(val) {
+            if (val === undefined || val === null || val === "") return "-";
+            if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}/.test(val.trim())) return val.trim();
+            const num = Number(val);
+            if (!Number.isFinite(num) || num <= 0) return String(val);
+            const sec = num > 1e12 ? Math.floor(num / 1000) : num;
+            return this.$TOOL.getTime(sec, "YYYY-MM-DD HH:mm:ss");
+        },
+        onTypeChange() {
+            this.getList();
+        },
+        async getList() {
+            this.loading = true;
+            try {
+                const res = await this.$API.cutter.labelList.get(this.labelType);
+                if (res.code !== 1) {
+                    this.$message.error(res.msg || "获取标签列表失败");
+                    return;
+                }
+                const raw = res.data;
+                this.list = Array.isArray(raw) ? raw : raw?.list || raw?.data || [];
+            } finally {
+                this.loading = false;
+            }
+        },
+        async showDetail(row) {
+            const id = row?.id;
+            if (id == null) return;
+            const res = await this.$API.cutter.labelInfo.get(id);
+            if (res.code !== 1) {
+                this.$message.error(res.msg || "获取详情失败");
+                return;
+            }
+            this.detail = res.data || {};
+            this.detailDialog = true;
+        },
+        openCreate() {
+            this.editTitle = "新增标签";
+            this.form = { id: "", label_name: "" };
+            this.editDialog = true;
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        openEdit(row) {
+            this.editTitle = "编辑标签";
+            this.form = {
+                id: row.id != null ? String(row.id) : "",
+                label_name: row.label_name ?? row.name ?? ""
+            };
+            this.editDialog = true;
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        resetEditForm() {
+            this.form = { id: "", label_name: "" };
+        },
+        submitForm() {
+            this.$refs.formRef.validate(async (valid) => {
+                if (!valid) return false;
+                this.saving = true;
+                try {
+                    const payload = {
+                        label_name: (this.form.label_name || "").trim(),
+                        type: String(this.labelType)
+                    };
+                    if (this.form.id) payload.id = String(this.form.id);
+                    const res = await this.$API.cutter.labelSave.post(payload);
+                    if (res.code !== 1) {
+                        this.$message.error(res.msg || "保存失败");
+                        return false;
+                    }
+                    this.$message.success(res.msg || "保存成功");
+                    this.editDialog = false;
+                    this.getList();
+                } finally {
+                    this.saving = false;
+                }
+            });
+        },
+        async removeRow(row) {
+            const id = row?.id;
+            if (id == null) return;
+            try {
+                await this.$confirm("确认删除该标签吗?", "提示", {
+                    type: "warning",
+                    confirmButtonText: "删除",
+                    cancelButtonText: "取消"
+                });
+            } catch (e) {
+                return;
+            }
+            const res = await this.$API.cutter.labelDel.delete(id);
+            if (res.code !== 1) {
+                this.$message.error(res.msg || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || "删除成功");
+            this.getList();
+        }
+    }
+};
+</script>
+
+<style scoped>
+.card-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: var(--el-text-color-primary);
+    letter-spacing: 0.02em;
+}
+.header-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 12px;
+}
+.header-actions {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+.label-table :deep(.el-table__header th.el-table__cell) {
+    font-weight: 600;
+    color: var(--el-text-color-regular);
+}
+.label-name {
+    font-weight: 500;
+    color: var(--el-text-color-primary);
+}
+.hint {
+    margin-left: 8px;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+}
+.pager {
+    margin-top: 16px;
+    display: flex;
+    justify-content: flex-end;
+}
+</style>

+ 35 - 42
src/views/cutter/order/index.vue

@@ -29,7 +29,7 @@
                 </el-table-column>
                 <el-table-column label="审核地址" min-width="180" show-overflow-tooltip>
                     <template #default="{ row }">
-                        <el-link v-if="row.audit_url" :href="row.audit_url" type="primary" target="_blank">
+                        <el-link v-if="shareHref(row.audit_url)" :href="shareHref(row.audit_url)" type="primary" target="_blank">
                             打开
                         </el-link>
                         <span v-else>-</span>
@@ -37,7 +37,7 @@
                 </el-table-column>
                 <el-table-column label="成品地址" min-width="180" show-overflow-tooltip>
                     <template #default="{ row }">
-                        <el-link v-if="row.finish_url" :href="row.finish_url" type="primary" target="_blank">
+                        <el-link v-if="shareHref(row.finish_url)" :href="shareHref(row.finish_url)" type="primary" target="_blank">
                             打开
                         </el-link>
                         <span v-else>-</span>
@@ -65,13 +65,14 @@
                         {{ formatTime(row.updated_at || row.update_time) }}
                     </template>
                 </el-table-column>
-                <el-table-column label="操作" width="190" fixed="right">
+                <el-table-column label="操作" width="288" fixed="right">
                     <template #default="{ row }">
-                        <el-button type="primary" link @click="showDetail(row)">详情</el-button>
-                        <el-button v-if="canAudit(row)" type="success" link @click="affirmOrder(row)">通过</el-button>
-                        <el-button v-if="canAudit(row)" type="danger" link @click="openRemark(row, 'reject')">拒绝</el-button>
-                        <el-button v-if="canSettle(row)" type="warning" link @click="settleOrder(row)">结算</el-button>
-                        <!-- <span v-if="!canAudit(row) && !canSettle(row)" class="muted">已处理</span> -->
+                        <div class="table-row-actions">
+                            <el-button type="info" size="small" @click="showDetail(row)">详情</el-button>
+                            <el-button v-if="canAudit(row)" type="success" size="small" @click="affirmOrder(row)">通过</el-button>
+                            <el-button v-if="canAudit(row)" type="danger" size="small" @click="openRemark(row, 'reject')">拒绝</el-button>
+                            <el-button v-if="canSettle(row)" type="warning" size="small" @click="openRemark(row, 'settle')">结算</el-button>
+                        </div>
                     </template>
                 </el-table-column>
             </el-table>
@@ -101,16 +102,16 @@
                 <el-descriptions-item label="更新时间">{{ formatTime(detail.updated_at || detail.update_time) }}</el-descriptions-item>
                 <el-descriptions-item label="备注">{{ detail.remark || detail.reject_remark || "-" }}</el-descriptions-item>
                 <el-descriptions-item label="审核地址" :span="2">
-                    <el-link v-if="detail.audit_url" :href="detail.audit_url" type="primary" target="_blank">
-                        {{ detail.audit_url }}
+                    <el-link v-if="shareHref(detail.audit_url)" :href="shareHref(detail.audit_url)" type="primary" target="_blank">
+                        {{ shareHref(detail.audit_url) }}
                     </el-link>
-                    <span v-else>-</span>
+                    <span v-else>{{ detail.audit_url || "-" }}</span>
                 </el-descriptions-item>
                 <el-descriptions-item label="成品地址" :span="2">
-                    <el-link v-if="detail.finish_url" :href="detail.finish_url" type="primary" target="_blank">
-                        {{ detail.finish_url }}
+                    <el-link v-if="shareHref(detail.finish_url)" :href="shareHref(detail.finish_url)" type="primary" target="_blank">
+                        {{ shareHref(detail.finish_url) }}
                     </el-link>
-                    <span v-else>-</span>
+                    <span v-else>{{ detail.finish_url || "-" }}</span>
                 </el-descriptions-item>
             </el-descriptions>
         </el-dialog>
@@ -126,6 +127,8 @@
 </template>
 
 <script>
+import { resolveExternalShareUrl } from "@/utils/extractBaiduPan";
+
 const STATUS_MAP = {
     "-1": "未通过",
     0: "待制作",
@@ -158,6 +161,9 @@ export default {
         this.getList();
     },
     methods: {
+        shareHref(raw) {
+            return resolveExternalShareUrl(raw);
+        },
         statusText(status) {
             if (status == null || status === "") return "-";
             const key = String(status);
@@ -241,35 +247,17 @@ export default {
             this.$message.success(res.msg || "操作成功");
             this.getList();
         },
-        async settleOrder(row) {
-            const id = row?.id;
-            if (id == null) {
-                this.$message.error("缺少订单ID");
-                return;
-            }
-            try {
-                await this.$confirm("确认将该订单结算吗?", "提示", {
-                    type: "warning",
-                    confirmButtonText: "确认",
-                    cancelButtonText: "取消"
-                });
-            } catch (e) {
-                return;
-            }
-            const res = await this.$API.cutter.orderSettle.post(id);
-            if (res.code !== 1) {
-                this.$message.error(res.msg || "操作失败");
-                return;
-            }
-            this.$message.success(res.msg || "结算成功");
-            this.getList();
-        },
         openRemark(row, action) {
             this.currentRow = row;
             this.remarkAction = action;
             this.remark = "";
-            this.remarkTitle = "订单拒绝";
-            this.remarkPlaceholder = "请输入拒绝理由(必填)";
+            if (action === "settle") {
+                this.remarkTitle = "订单结算";
+                this.remarkPlaceholder = "请输入结算备注(必填)";
+            } else {
+                this.remarkTitle = "订单拒绝";
+                this.remarkPlaceholder = "请输入拒绝理由(必填)";
+            }
             this.remarkDialog = true;
         },
         resetRemark() {
@@ -288,17 +276,22 @@ export default {
             }
             const remark = (this.remark || "").trim();
             if (!remark) {
-                this.$message.warning("请填写拒绝理由");
+                this.$message.warning(this.remarkAction === "settle" ? "请填写结算备注" : "请填写拒绝理由");
                 return;
             }
             this.submitting = true;
             try {
-                const res = await this.$API.cutter.orderReject.post(id, { remark });
+                let res;
+                if (this.remarkAction === "settle") {
+                    res = await this.$API.cutter.orderSettle.post(id, { remark });
+                } else {
+                    res = await this.$API.cutter.orderReject.post(id, { remark });
+                }
                 if (res.code !== 1) {
                     this.$message.error(res.msg || "操作失败");
                     return;
                 }
-                this.$message.success(res.msg || "操作成功");
+                this.$message.success(res.msg || (this.remarkAction === "settle" ? "结算成功" : "操作成功"));
                 this.remarkDialog = false;
                 this.getList();
             } finally {

+ 8 - 8
src/views/finance/extract/list/index.vue

@@ -34,15 +34,15 @@
                         {{ formatTime(row.add_time || row.create_time || row.created_at) }}
                     </template>
                 </el-table-column>
-                <el-table-column label="操作" width="220" fixed="right">
+                <el-table-column label="操作" width="200" fixed="right">
                     <template #default="{ row }">
-                        <el-button v-if="isPending(row)" type="success" size="small" @click="openRemark(row, 'agree')">
-                            同意
-                        </el-button>
-                        <el-button v-if="isPending(row)" type="danger" size="small" plain @click="openRemark(row, 'refuse')">
-                            拒绝
-                        </el-button>
-                        <span v-if="!isPending(row)" class="muted">已处理</span>
+                        <div class="table-row-actions">
+                            <template v-if="isPending(row)">
+                                <el-button type="success" size="small" @click="openRemark(row, 'agree')">同意</el-button>
+                                <el-button type="danger" size="small" plain @click="openRemark(row, 'refuse')">拒绝</el-button>
+                            </template>
+                            <span v-else class="muted">已处理</span>
+                        </div>
                     </template>
                 </el-table-column>
             </el-table>

+ 12 - 17
src/views/kefu/list/index.vue

@@ -41,10 +41,12 @@
                         />
                     </template>
                 </el-table-column>
-                <el-table-column label="操作" width="160" fixed="right">
+                <el-table-column label="操作" width="200" fixed="right">
                     <template #default="{ row }">
-                        <el-button size="small" @click="openEdit(row)">编辑</el-button>
-                        <el-button size="small" type="danger" @click="removeRow(row)">删除</el-button>
+                        <div class="table-row-actions">
+                            <el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+                            <el-button type="danger" size="small" plain @click="removeRow(row)">删除</el-button>
+                        </div>
                     </template>
                 </el-table-column>
             </el-table>
@@ -75,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" />
@@ -109,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" }]
             }
         };
     },
@@ -229,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>

+ 378 - 0
src/views/permission/admin/index.vue

@@ -0,0 +1,378 @@
+<template>
+    <el-main>
+        <el-card shadow="never">
+            <template #header>
+                <div class="header-row">
+                    <span>管理员列表</span>
+                    <el-button type="primary" @click="openCreate">添加管理员</el-button>
+                </div>
+            </template>
+
+            <el-table v-loading="loading" :data="list">
+                <el-table-column prop="id" label="ID" width="80" />
+                <el-table-column prop="username" label="账号" min-width="120" show-overflow-tooltip />
+                <el-table-column prop="truename" label="姓名" min-width="100" show-overflow-tooltip />
+                <el-table-column label="身份" min-width="120" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ formatRoles(row) }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="状态" width="100" align="center">
+                    <template #default="{ row }">
+                        <el-tag :type="Number(row.status) === 1 ? 'success' : 'info'" size="small">
+                            {{ Number(row.status) === 1 ? "正常" : "停用" }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column prop="login_at" label="最后登录" min-width="170" show-overflow-tooltip />
+                <el-table-column label="创建时间" min-width="170" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ row.create_at || row.created_at || "-" }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" width="200" fixed="right">
+                    <template #default="{ row }">
+                        <div class="table-row-actions">
+                            <el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+                            <el-button type="danger" size="small" plain @click="removeRow(row)">删除</el-button>
+                        </div>
+                    </template>
+                </el-table-column>
+            </el-table>
+
+            <div class="pager">
+                <el-pagination
+                    background
+                    layout="total, prev, pager, next, sizes"
+                    :total="total"
+                    :current-page="query.page"
+                    :page-size="query.limit"
+                    :page-sizes="[5, 10, 20, 50]"
+                    @current-change="handlePageChange"
+                    @size-change="handleSizeChange"
+                />
+            </div>
+        </el-card>
+
+        <el-dialog
+            v-model="dialogVisible"
+            :title="dialogMode === 'create' ? '添加管理员' : '编辑管理员'"
+            width="440px"
+            destroy-on-close
+            @closed="onDialogClosed"
+        >
+            <el-form ref="formRef" :model="form" :rules="activeRules" label-width="90px">
+                <template v-if="dialogMode === 'create'">
+                    <el-form-item label="账号" prop="username">
+                        <el-input v-model="form.username" clearable maxlength="40" autocomplete="off" />
+                    </el-form-item>
+                    <el-form-item label="姓名" prop="truename">
+                        <el-input v-model="form.truename" clearable maxlength="40" />
+                    </el-form-item>
+                    <el-form-item label="密码" prop="password">
+                        <el-input v-model="form.password" type="password" show-password maxlength="64" autocomplete="new-password" />
+                    </el-form-item>
+                </template>
+                <template v-else>
+                    <el-form-item label="账号">
+                        <el-input :model-value="form.username" disabled />
+                    </el-form-item>
+                    <el-form-item label="姓名">
+                        <el-input :model-value="form.truename || '-'" disabled />
+                    </el-form-item>
+                    <el-form-item label="新密码" prop="password">
+                        <el-input
+                            v-model="form.password"
+                            type="password"
+                            show-password
+                            maxlength="64"
+                            autocomplete="new-password"
+                            placeholder="留空则不修改密码"
+                        />
+                    </el-form-item>
+                </template>
+                <el-form-item label="身份" prop="roles">
+                    <el-select
+                        v-model="form.roles"
+                        placeholder="请选择身份"
+                        clearable
+                        filterable
+                        :loading="roleLoading"
+                        style="width: 100%"
+                    >
+                        <el-option v-for="r in roleOptions" :key="r.id" :label="roleOptionLabel(r)" :value="String(r.id)" />
+                    </el-select>
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button @click="dialogVisible = false">取消</el-button>
+                <el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
+            </template>
+        </el-dialog>
+    </el-main>
+</template>
+
+<script>
+export default {
+    name: "PermissionAdminList",
+    data() {
+        return {
+            loading: false,
+            saving: false,
+            roleLoading: false,
+            list: [],
+            total: 0,
+            query: { page: 1, limit: 10 },
+            dialogVisible: false,
+            dialogMode: "create",
+            roleOptions: [],
+            /** 身份 id -> 名称,用于列表解析 roles 数字字段 */
+            roleNameById: {},
+            form: this.getDefaultForm(),
+            rulesCreate: {
+                username: [{ required: true, message: "请输入账号", trigger: "blur" }],
+                truename: [{ required: true, message: "请输入姓名", trigger: "blur" }],
+                password: [{ required: true, message: "请输入密码", trigger: "blur" }],
+                roles: [{ required: true, message: "请选择身份", trigger: "change" }],
+            },
+            rulesEdit: {
+                roles: [{ required: true, message: "请选择身份", trigger: "change" }],
+            },
+        };
+    },
+    computed: {
+        activeRules() {
+            return this.dialogMode === "create" ? this.rulesCreate : this.rulesEdit;
+        },
+    },
+    created() {
+        this.getList();
+    },
+    methods: {
+        /** 后端部分接口 code 为 1,权限相关为 200 */
+        apiOk(res) {
+            return res && (res.code === 1 || res.code === 200);
+        },
+        getDefaultForm() {
+            return { id: "", username: "", truename: "", password: "", roles: "" };
+        },
+        parseRoleRows(data) {
+            const d = data ?? {};
+            return Array.isArray(d) ? d : d.list || d.data || d.roles || [];
+        },
+        /** 身份 id → 展示名写入 roleNameById;下拉与表格均以 role_name 为主 */
+        roleOptionLabel(row) {
+            const n = row.role_name;
+            if (n != null && String(n).trim() !== "") return String(n);
+            return row.name ?? `身份#${row.id ?? ""}`;
+        },
+        normalizeAdminRoleValue(rolesField) {
+            if (rolesField == null || rolesField === "") return "";
+            if (typeof rolesField === "object" && !Array.isArray(rolesField)) {
+                const id = rolesField.id ?? rolesField.role_id;
+                return id != null ? String(id) : "";
+            }
+            return String(rolesField);
+        },
+        applyRoleRows(rows, onlyEnabledForSelect = false) {
+            const map = { ...this.roleNameById };
+            const options = [];
+            rows.forEach((r) => {
+                const id = r.id;
+                if (id == null) return;
+                const sid = String(id);
+                map[sid] = this.roleOptionLabel({ ...r }) || map[sid] || "";
+                if (!onlyEnabledForSelect || Number(r.status) === 1) {
+                    options.push(r);
+                }
+            });
+            this.roleNameById = map;
+            return options;
+        },
+        async refreshRoleLookup() {
+            const res = await this.$API.role.list.get({});
+            if (!this.apiOk(res)) return;
+            const rows = this.parseRoleRows(res.data);
+            this.applyRoleRows(rows, false);
+        },
+        formatRoles(row) {
+            const topName = row.role_name ?? row.roles_name;
+            if (topName != null && String(topName).trim() !== "") return String(topName);
+            if (row.roles != null && typeof row.roles === "object" && !Array.isArray(row.roles)) {
+                const o = row.roles;
+                if (o.role_name != null && String(o.role_name).trim() !== "") return String(o.role_name);
+                const kid = o.id != null ? String(o.id) : "";
+                if (kid) return this.roleNameById[kid] || `身份 #${kid}`;
+            }
+            if (Array.isArray(row.roles)) {
+                return row.roles
+                    .map((x) =>
+                        typeof x === "object" && x
+                            ? (x.role_name != null && String(x.role_name).trim() !== "" ? String(x.role_name) : this.roleOptionLabel(x))
+                            : this.roleNameById[String(x)] || `#${x}`
+                    )
+                    .filter(Boolean)
+                    .join("、");
+            }
+            if (row.roles != null && row.roles !== "") {
+                const key = String(row.roles);
+                const name = this.roleNameById[key];
+                return name ? name : `身份 #${key}`;
+            }
+            return "-";
+        },
+        /**
+         * 加载身份下拉。新增仅展示启用身份(status=1);编辑需全集,否则会因当前绑定身份被过滤而下拉只显示 id。
+         * @param {boolean} onlyEnabled  true:仅启用;false:全集
+         */
+        async loadRoleOptions(onlyEnabled = true) {
+            this.roleLoading = true;
+            try {
+                const params = onlyEnabled ? { status: 1 } : {};
+                const res = await this.$API.role.list.get(params);
+                if (!this.apiOk(res)) return;
+                const rows = this.parseRoleRows(res.data);
+                const list = this.applyRoleRows(rows, onlyEnabled);
+                this.roleOptions = list.length ? list : rows;
+            } finally {
+                this.roleLoading = false;
+            }
+        },
+        /** 当前管理员绑定的身份若仍不在下拉里,补一条(优先用行内 role_name) */
+        ensureRoleOptionForRow(row) {
+            const rid = this.normalizeAdminRoleValue(row.roles);
+            if (!rid) return;
+            if (this.roleOptions.some((r) => String(r.id) === rid)) return;
+            let roleName = row.role_name;
+            if (roleName != null && String(roleName).trim() === "") roleName = undefined;
+            if (!roleName && row.roles && typeof row.roles === "object" && !Array.isArray(row.roles) && row.roles.role_name) {
+                roleName = row.roles.role_name;
+            }
+            if (!roleName) roleName = this.roleNameById[rid];
+            this.roleOptions = [...this.roleOptions, { id: rid, role_name: roleName || `身份 #${rid}` }];
+        },
+        async getList() {
+            this.loading = true;
+            try {
+                await this.refreshRoleLookup();
+                const res = await this.$API.user.adminList.get(this.query);
+                if (!this.apiOk(res)) {
+                    this.$message.error(res.msg || "获取管理员列表失败");
+                    return;
+                }
+                const data = res.data || {};
+                this.list = data.list || data.data || [];
+                this.total = Number(data.count ?? data.total ?? this.list.length ?? 0);
+            } finally {
+                this.loading = false;
+            }
+        },
+        handlePageChange(page) {
+            this.query.page = page;
+            this.getList();
+        },
+        handleSizeChange(limit) {
+            this.query.limit = limit;
+            this.query.page = 1;
+            this.getList();
+        },
+        async openCreate() {
+            this.dialogMode = "create";
+            this.form = this.getDefaultForm();
+            await this.loadRoleOptions();
+            this.dialogVisible = true;
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        async openEdit(row) {
+            this.dialogMode = "edit";
+            await this.loadRoleOptions(false);
+            this.ensureRoleOptionForRow(row);
+            this.form = {
+                id: String(row.id ?? ""),
+                username: row.username ?? "",
+                truename: row.truename ?? "",
+                password: "",
+                roles: this.normalizeAdminRoleValue(row.roles),
+            };
+            this.dialogVisible = true;
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        onDialogClosed() {
+            this.dialogMode = "create";
+            this.form = this.getDefaultForm();
+        },
+        submitForm() {
+            this.$refs.formRef.validate(async (valid) => {
+                if (!valid) return false;
+                this.saving = true;
+                try {
+                    if (this.dialogMode === "create") {
+                        const res = await this.$API.user.save.post({
+                            username: this.form.username,
+                            truename: this.form.truename,
+                            password: this.form.password,
+                            roles: String(this.form.roles),
+                        });
+                        if (!this.apiOk(res)) {
+                            this.$message.error(res.msg || "保存失败");
+                            return false;
+                        }
+                        this.$message.success(res.msg || "保存成功");
+                    } else {
+                        const payload = {
+                            id: String(this.form.id),
+                            roles: String(this.form.roles),
+                        };
+                        const pwd = String(this.form.password ?? "").trim();
+                        if (pwd) payload.password = pwd;
+                        const res = await this.$API.user.pwd.post(payload);
+                        if (!this.apiOk(res)) {
+                            this.$message.error(res.msg || "保存失败");
+                            return false;
+                        }
+                        this.$message.success(res.msg || "保存成功");
+                    }
+                    this.dialogVisible = false;
+                    this.getList();
+                } finally {
+                    this.saving = false;
+                }
+            });
+        },
+        async removeRow(row) {
+            const confirm = await this.$confirm("确认删除该管理员吗?", "提示", {
+                type: "warning",
+                confirmButtonText: "删除",
+                confirmButtonClass: "el-button--danger",
+            }).catch(() => {});
+            if (confirm !== "confirm") return;
+            const res = await this.$API.user.del.post({ id: String(row.id) });
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || "删除成功");
+            this.getList();
+        },
+    },
+};
+</script>
+
+<style scoped>
+.header-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}
+.table-row-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    align-items: center;
+}
+.pager {
+    margin-top: 16px;
+    display: flex;
+    justify-content: flex-end;
+}
+</style>

+ 347 - 0
src/views/permission/role/index.vue

@@ -0,0 +1,347 @@
+<template>
+    <el-main>
+        <el-card shadow="never">
+            <template #header>
+                <div class="header-row">
+                    <span>身份列表</span>
+                    <el-button type="primary" @click="openCreate">添加身份</el-button>
+                </div>
+            </template>
+
+            <el-table v-loading="loading" :data="list">
+                <el-table-column prop="id" label="ID" width="80" />
+                <el-table-column prop="role_name" label="身份名称" min-width="160" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ row.role_name ?? row.name ?? "-" }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="状态" width="120">
+                    <template #default="{ row }">
+                        <el-switch
+                            :model-value="Number(row.status)"
+                            :active-value="1"
+                            :inactive-value="0"
+                            inline-prompt
+                            active-text="启用"
+                            inactive-text="停用"
+                            active-color="#13ce66"
+                            inactive-color="#ff4949"
+                            @change="(val) => changeStatus(row, val)"
+                        />
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" width="200" fixed="right">
+                    <template #default="{ row }">
+                        <div class="table-row-actions">
+                            <el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+                            <el-button type="danger" size="small" plain @click="removeRow(row)">删除</el-button>
+                        </div>
+                    </template>
+                </el-table-column>
+            </el-table>
+        </el-card>
+
+        <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close @closed="onDialogClosed">
+            <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
+                <el-form-item label="名称" prop="role_name">
+                    <el-input v-model="form.role_name" clearable maxlength="60" placeholder="身份名称" />
+                </el-form-item>
+                <el-form-item label="状态">
+                    <el-radio-group v-model="form.status">
+                        <el-radio :label="1">启用</el-radio>
+                        <el-radio :label="0">停用</el-radio>
+                    </el-radio-group>
+                </el-form-item>
+                <el-form-item label="菜单权限">
+                    <el-scrollbar max-height="360px">
+                        <el-tree
+                            v-if="dialogVisible"
+                            :key="treeKey"
+                            ref="treeRef"
+                            :data="menuTree"
+                            :props="treeProps"
+                            show-checkbox
+                            check-strictly
+                            node-key="id"
+                            default-expand-all
+                            class="perm-tree"
+                        />
+                        <div v-if="!menuTree.length" class="tree-empty">暂无菜单数据</div>
+                    </el-scrollbar>
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button @click="dialogVisible = false">取消</el-button>
+                <el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
+            </template>
+        </el-dialog>
+    </el-main>
+</template>
+
+<script>
+/** 必选且不可取消勾选的菜单权限 id */
+const LOCKED_RULE_IDS = new Set(["1", "3"]);
+
+export default {
+    name: "PermissionRoleList",
+    data() {
+        return {
+            loading: false,
+            saving: false,
+            list: [],
+            dialogVisible: false,
+            dialogTitle: "添加身份",
+            treeKey: 0,
+            menuTree: [],
+            form: this.getDefaultForm(),
+            rules: {
+                role_name: [{ required: true, message: "请输入身份名称", trigger: "blur" }],
+            },
+            treeProps: {
+                children: "children",
+                label: "permLabel",
+                disabled: "disabled",
+            },
+        };
+    },
+    created() {
+        this.getList();
+    },
+    methods: {
+        getDefaultForm() {
+            return { id: "", role_name: "", status: 1 };
+        },
+        normalizeMenuTree(nodes) {
+            if (!Array.isArray(nodes)) return [];
+            return nodes.map((node) => {
+                const meta = node.meta && typeof node.meta === "object" ? node.meta : null;
+                const rawId = node.id ?? node.menu_id ?? node.value ?? meta?.id;
+                const id = rawId != null && rawId !== "" ? String(rawId) : rawId;
+                const label =
+                    (meta && (meta.title || meta.name)) ||
+                    node.title ||
+                    node.name ||
+                    node.menu_name ||
+                    node.label ||
+                    node.pathname ||
+                    node.path_name ||
+                    (id != null ? `节点 ${id}` : "未命名");
+                const raw = node.children ?? node.child ?? node.subs ?? node.son ?? node.nodes;
+                const children = Array.isArray(raw) && raw.length ? this.normalizeMenuTree(raw) : [];
+                const locked = id != null && LOCKED_RULE_IDS.has(String(id));
+                return {
+                    ...node,
+                    id,
+                    permLabel: label,
+                    disabled: locked,
+                    children: children.length ? children : undefined,
+                };
+            });
+        },
+        /** 后端可能直接返回数组(与 menu/list 一致),或包在 menus / authList / data 等字段中 */
+        pickMenuTree(payload) {
+            if (Array.isArray(payload)) return payload;
+            if (!payload || typeof payload !== "object") return [];
+            const d = payload;
+            const buckets = [
+                d.menus,
+                d.menu,
+                d.menuList,
+                d.menu_list,
+                d.authList,
+                d.authorize,
+                Array.isArray(d.data) ? d.data : null,
+                d.ruleList,
+                d.tree,
+                d.nodes,
+                d.routers,
+                d.routes,
+                d.list,
+            ];
+            for (let i = 0; i < buckets.length; i++) {
+                const b = buckets[i];
+                if (Array.isArray(b)) return b;
+            }
+            return [];
+        },
+        parseRulesKeys(rules) {
+            if (Array.isArray(rules)) return rules.map((x) => String(x));
+            if (typeof rules === "string" && rules.trim()) {
+                return rules
+                    .split(/[,|]/)
+                    .map((s) => s.trim())
+                    .filter(Boolean);
+            }
+            return [];
+        },
+        mergeRoleInfo(data) {
+            const d = data || {};
+            const role = d.role || d.info || {};
+            return {
+                id: role.id != null ? role.id : d.id,
+                role_name: role.role_name ?? role.name ?? d.role_name ?? "",
+                status: role.status != null ? Number(role.status) : d.status != null ? Number(d.status) : 1,
+                rules: role.rules ?? d.rules ?? d.rule ?? "",
+            };
+        },
+        apiOk(res) {
+            return res && (res.code === 1 || res.code === 200);
+        },
+        async getList() {
+            this.loading = true;
+            try {
+                const res = await this.$API.role.list.get({});
+                if (!this.apiOk(res)) {
+                    this.$message.error(res.msg || "获取身份列表失败");
+                    return;
+                }
+                const data = res.data ?? {};
+                const raw = Array.isArray(data) ? data : data.list || data.data || data.roles || [];
+                this.list = raw;
+            } finally {
+                this.loading = false;
+            }
+        },
+        collectRules() {
+            const tree = this.$refs.treeRef;
+            if (!tree) return [...LOCKED_RULE_IDS];
+            const checked = tree.getCheckedKeys(false);
+            const half = tree.getHalfCheckedKeys();
+            const picked = [...new Set([...checked, ...half].map(String))];
+            return this.ensureLockedRules(picked);
+        },
+        /** 提交与编辑回填时都必须包含必选 id */
+        ensureLockedRules(keys) {
+            const set = new Set(keys.map(String));
+            LOCKED_RULE_IDS.forEach((id) => set.add(id));
+            return [...set];
+        },
+        async openCreate() {
+            this.dialogTitle = "添加身份";
+            this.form = this.getDefaultForm();
+            this.treeKey += 1;
+            this.dialogVisible = true;
+            try {
+                const res = await this.$API.role.create.get();
+                if (!this.apiOk(res)) {
+                    this.$message.error(res.msg || "获取菜单失败");
+                    this.menuTree = [];
+                    return;
+                }
+                const d = res.data || {};
+                this.menuTree = this.normalizeMenuTree(this.pickMenuTree(d));
+                await this.$nextTick();
+                /** 新建:默认仅勾选 1、3,且这两项禁止取消 */
+                this.$refs.treeRef?.setCheckedKeys([...LOCKED_RULE_IDS]);
+            } catch (e) {
+                this.menuTree = [];
+            }
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        async openEdit(row) {
+            const id = row.id;
+            if (id == null) {
+                this.$message.error("缺少身份 ID");
+                return;
+            }
+            this.dialogTitle = "编辑身份";
+            this.treeKey += 1;
+            this.dialogVisible = true;
+            try {
+                const res = await this.$API.role.edit.get(id);
+                if (!this.apiOk(res)) {
+                    this.$message.error(res.msg || "获取身份详情失败");
+                    this.menuTree = [];
+                    return;
+                }
+                const d = res.data || {};
+                this.menuTree = this.normalizeMenuTree(this.pickMenuTree(d));
+                const merged = this.mergeRoleInfo(d);
+                this.form = {
+                    id: merged.id != null ? String(merged.id) : "",
+                    role_name: merged.role_name,
+                    status: merged.status,
+                };
+                await this.$nextTick();
+                const keys = this.ensureLockedRules(this.parseRulesKeys(merged.rules));
+                this.$refs.treeRef?.setCheckedKeys(keys);
+            } catch (e) {
+                this.menuTree = [];
+            }
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        onDialogClosed() {
+            this.menuTree = [];
+            this.form = this.getDefaultForm();
+        },
+        submitForm() {
+            this.$refs.formRef.validate(async (valid) => {
+                if (!valid) return false;
+                this.saving = true;
+                try {
+                    const payload = {
+                        role_name: this.form.role_name,
+                        status: String(this.form.status ?? 1),
+                        rules: this.collectRules(),
+                    };
+                    if (this.form.id != null && String(this.form.id) !== "") {
+                        payload.id = String(this.form.id);
+                    }
+                    const res = await this.$API.role.save.post(payload);
+                    if (!this.apiOk(res)) {
+                        this.$message.error(res.msg || "保存失败");
+                        return false;
+                    }
+                    this.$message.success(res.msg || "保存成功");
+                    this.dialogVisible = false;
+                    this.getList();
+                } finally {
+                    this.saving = false;
+                }
+            });
+        },
+        async removeRow(row) {
+            const confirm = await this.$confirm("确认删除该身份吗?", "提示", {
+                type: "warning",
+                confirmButtonText: "删除",
+                confirmButtonClass: "el-button--danger",
+            }).catch(() => {});
+            if (confirm !== "confirm") return;
+            const res = await this.$API.role.del.delete(row.id);
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || "删除成功");
+            this.getList();
+        },
+        async changeStatus(row, status) {
+            const res = await this.$API.role.setStatus.get(row.id, status);
+            if (!this.apiOk(res)) {
+                this.$message.error(res.msg || "状态更新失败");
+                this.getList();
+                return;
+            }
+            this.$message.success(res.msg || "状态更新成功");
+            row.status = status;
+        },
+    },
+};
+</script>
+
+<style scoped>
+.header-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}
+.perm-tree {
+    width: 100%;
+    padding: 6px 0;
+}
+.tree-empty {
+    color: var(--el-text-color-secondary);
+    font-size: 13px;
+    padding: 12px 0;
+}
+</style>

+ 287 - 14
src/views/task/manage/list/index.vue

@@ -12,6 +12,26 @@
                 <el-table-column prop="id" label="ID" width="80" />
                 <el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
                 <el-table-column prop="explain" label="说明" min-width="220" show-overflow-tooltip />
+                <el-table-column label="类型" width="110" align="center">
+                    <template #default="{ row }">
+                        {{ taskTypeLabel(row.type) }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="标签" width="130" align="center">
+                    <template #default="{ row }">
+                        <el-tag v-if="taskLabelName(row)" size="small" type="info">{{ taskLabelName(row) }}</el-tag>
+                        <span v-else>-</span>
+                    </template>
+                </el-table-column>
+                <el-table-column prop="exp" label="经验值" width="100" align="center" />
+                <el-table-column prop="total" label="总量" width="100" align="center" />
+                <el-table-column label="等级" min-width="120" show-overflow-tooltip align="center">
+                    <template #default="{ row }">
+                        {{ taskGradeNameLabel(row.grade) }}
+                    </template>
+                </el-table-column>
+                <el-table-column prop="kefu_id" label="客服ID" width="100" align="center" />
+                <el-table-column prop="price" label="价格" width="110" />
                 <el-table-column label="素材" min-width="180">
                     <template #default="{ row }">
                         <div v-if="row.materialDisplay.length">
@@ -27,8 +47,6 @@
                         <span v-else>-</span>
                     </template>
                 </el-table-column>
-                <el-table-column prop="kefu_id" label="客服ID" width="100" />
-                <el-table-column prop="price" label="价格" width="110" />
                 <el-table-column label="状态" width="120">
                     <template #default="{ row }">
                         <el-switch
@@ -49,10 +67,12 @@
                         {{ formatTime(row.end_time) }}
                     </template>
                 </el-table-column>
-                <el-table-column label="操作" width="140" fixed="right">
+                <el-table-column label="操作" width="200" fixed="right">
                     <template #default="{ row }">
-                        <el-button size="small" @click="openEdit(row)">编辑</el-button>
-                        <el-button size="small" type="danger" @click="removeTask(row)">删除</el-button>
+                        <div class="table-row-actions">
+                            <el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+                            <el-button type="danger" size="small" plain @click="removeTask(row)">删除</el-button>
+                        </div>
                     </template>
                 </el-table-column>
             </el-table>
@@ -73,22 +93,92 @@
 
         <el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close>
             <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
+                <el-form-item label="类型" prop="type">
+                    <el-radio-group v-model="form.type">
+                        <el-radio label="0">普通剪辑</el-radio>
+                        <el-radio label="1">剪同款</el-radio>
+                    </el-radio-group>
+                </el-form-item>
                 <el-form-item label="标题" prop="title">
                     <el-input v-model="form.title" clearable maxlength="80" />
                 </el-form-item>
                 <el-form-item label="说明" prop="explain">
                     <el-input v-model="form.explain" type="textarea" :rows="4" />
                 </el-form-item>
-                <el-form-item label="素材" prop="materialFiles">
-                    <scUploadFile
-                        v-model="form.materialFiles"
-                        :apiObj="$API.common.uploadFile"
-                        :multiple="true"
-                        :limit="20"
-                        tip="请上传素材文件,支持多文件,上传后自动写入素材链接"
-                        btnTxt="上传素材"
+                <el-form-item label="标签" prop="label_id">
+                    <el-select
+                        v-model="form.label_id"
+                        placeholder="请选择视频标签"
+                        clearable
+                        filterable
+                        :loading="taskLabelLoading"
+                        style="width: 100%;"
+                    >
+                        <el-option
+                            v-for="item in taskLabelOptions"
+                            :key="item.id"
+                            :label="item.label"
+                            :value="item.value"
+                        />
+                    </el-select>
+                </el-form-item>
+                <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="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">
+                    <el-input v-model="form.taskTotal" placeholder="任务可领取或完成的总名额" clearable />
+                </el-form-item>
+                <el-form-item label="等级" prop="grade">
+                    <el-select
+                        v-model="form.grade"
+                        placeholder="请选择用户等级"
+                        clearable
+                        filterable
+                        :loading="levelLoading"
+                        style="width: 100%;"
+                    >
+                        <el-option
+                            v-for="item in levelOptions"
+                            :key="item.id"
+                            :label="item.label"
+                            :value="item.value"
+                        />
+                    </el-select>
+                </el-form-item>
+                <el-form-item label="素材" prop="materialFiles">
+                    <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" />
                 </el-form-item>
@@ -140,6 +230,9 @@
 </template>
 
 <script>
+/** 与标签管理「视频标签」一致,对应接口 GET /cutter/label/list/3 */
+const TASK_LABEL_LIST_TYPE = 3;
+
 function formatMaterialLabels(material) {
     if (!Array.isArray(material) || !material.length) return [];
     return material
@@ -170,6 +263,40 @@ function normalizeMaterialFiles(material) {
         .filter(Boolean);
 }
 
+/** 列表接口 grade 为 null 或 { name, grade, icon, color } */
+function taskGradeNameLabel(grade) {
+    if (grade != null && typeof grade === "object") {
+        const name = grade.name != null ? String(grade.name).trim() : "";
+        return name || "-";
+    }
+    return "-";
+}
+
+/** 详情/保存表单:接口可能返回嵌套对象或纯等级数字 */
+function normalizeTaskGradeForForm(grade) {
+    if (grade == null || grade === "") return "";
+    if (typeof grade === "object") {
+        if (grade.grade != null && grade.grade !== "") return String(grade.grade);
+        return "";
+    }
+    return String(grade);
+}
+
+function taskTypeLabel(type) {
+    const val = String(type ?? "0");
+    return val === "1" ? "剪同款" : "普通剪辑";
+}
+
+/** 列表接口 label 可能为 { id, label_name } */
+function taskLabelName(row) {
+    const label = row?.label;
+    if (label != null && typeof label === "object") {
+        const name = label.label_name != null ? String(label.label_name).trim() : "";
+        return name || "";
+    }
+    return "";
+}
+
 export default {
     name: "taskList",
     data() {
@@ -180,6 +307,10 @@ export default {
             total: 0,
             kefuLoading: false,
             kefuOptions: [],
+            levelLoading: false,
+            levelOptions: [],
+            taskLabelLoading: false,
+            taskLabelOptions: [],
             query: {
                 page: 1,
                 limit: 15
@@ -190,23 +321,52 @@ export default {
             rules: {
                 title: [{ required: true, message: "请输入标题", trigger: "blur" }],
                 explain: [{ required: true, message: "请输入说明", trigger: "blur" }],
+                type: [{ required: true, message: "请选择类型", trigger: "change" }],
                 materialFiles: [
                     {
                         validator: (rule, value, callback) => {
                             if (Array.isArray(value) && value.length) callback();
-                            else callback(new Error("请上传素材文件"));
+                            else callback(new Error("请选择素材"));
+                        },
+                        trigger: "change"
+                    }
+                ],
+                cover_video: [
+                    {
+                        validator: (rule, value, callback) => {
+                            if (String(this.form.type) !== "1") {
+                                callback();
+                                return;
+                            }
+                            if (value && String(value).trim()) callback();
+                            else callback(new Error("请选择封面视频"));
                         },
                         trigger: "change"
                     }
                 ],
                 price: [{ required: true, message: "请输入价格", trigger: "blur" }],
+                exp: [{ required: true, message: "请输入经验值", trigger: "blur" }],
+                taskTotal: [{ required: true, message: "请输入总量", trigger: "blur" }],
+                grade: [{ required: true, message: "请选择等级", trigger: "change" }],
+                label_id: [{ required: true, message: "请选择标签", trigger: "change" }],
                 kefu_id: [{ required: true, message: "请选择客服", trigger: "change" }],
                 endDate: [{ required: true, message: "请选择截止时间", trigger: "change" }]
             }
         };
     },
+    computed: {
+        materialPickerValue() {
+            return (this.form.materialFiles || []).map((f) => ({
+                id: f.id,
+                name: f.name,
+                url: f.url,
+            }));
+        },
+    },
     created() {
         this.getKefuOptions();
+        this.getLevelOptions();
+        this.getTaskLabelOptions();
         this.getList();
     },
     methods: {
@@ -215,9 +375,15 @@ export default {
                 id: "",
                 title: "",
                 explain: "",
+                type: "0",
+                cover_video: "",
                 materialFiles: [],
                 price: "",
+                exp: "",
+                taskTotal: "",
+                grade: "",
                 kefu_id: "",
+                label_id: "",
                 endDate: "",
                 end_time: "",
                 status: 1
@@ -229,6 +395,9 @@ export default {
             if (!num) return ts;
             return this.$TOOL.getTime(Math.floor(num / 1000), "YYYY-MM-DD HH:mm:ss");
         },
+        taskGradeNameLabel,
+        taskTypeLabel,
+        taskLabelName,
         async getList() {
             this.loading = true;
             try {
@@ -269,6 +438,47 @@ export default {
                 this.kefuLoading = false;
             }
         },
+        async getLevelOptions() {
+            this.levelLoading = true;
+            try {
+                const res = await this.$API.user.levelList.get({ is_show: 1 });
+                if (res.code !== 1) {
+                    this.$message.error(res.msg || "获取用户等级列表失败");
+                    return;
+                }
+                const raw = res.data || {};
+                const rawList = Array.isArray(raw) ? raw : raw.list || raw.data || [];
+                const sorted = rawList.slice().sort((a, b) => Number(a.grade) - Number(b.grade));
+                this.levelOptions = sorted.map((item) => ({
+                    id: item.id,
+                    value: String(item.grade ?? ""),
+                    label: item.name
+                        ? `${item.name}(等级 ${item.grade})`
+                        : `等级 ${item.grade}`
+                }));
+            } finally {
+                this.levelLoading = false;
+            }
+        },
+        async getTaskLabelOptions() {
+            this.taskLabelLoading = true;
+            try {
+                const res = await this.$API.cutter.labelList.get(TASK_LABEL_LIST_TYPE);
+                if (res.code !== 1) {
+                    this.$message.error(res.msg || "获取标签列表失败");
+                    return;
+                }
+                const raw = res.data;
+                const rawList = Array.isArray(raw) ? raw : raw?.list || raw?.data || [];
+                this.taskLabelOptions = rawList.map((item) => ({
+                    id: item.id,
+                    value: String(item.id),
+                    label: item.label_name ?? item.name ?? String(item.id)
+                }));
+            } finally {
+                this.taskLabelLoading = false;
+            }
+        },
         handlePageChange(page) {
             this.query.page = page;
             this.getList();
@@ -278,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();
@@ -301,9 +539,18 @@ export default {
                 id: info.id || row.id,
                 title: info.title || "",
                 explain: info.explain || "",
+                type: String(info.type ?? "0"),
+                cover_video: String(info.cover_video || ""),
                 materialFiles,
                 price: info.price || info.pirce || "",
+                exp: info.exp != null && info.exp !== "" ? String(info.exp) : "",
+                taskTotal: info.total != null && info.total !== "" ? String(info.total) : "",
+                grade: normalizeTaskGradeForForm(info.grade),
                 kefu_id: String(info.kefu_id || ""),
+                label_id:
+                    info.label_id != null && info.label_id !== ""
+                        ? String(info.label_id)
+                        : "",
                 endDate: endTime ? String(endTime) : "",
                 end_time: String(info.end_time || ""),
                 status: Number(info.status ?? 1)
@@ -318,12 +565,19 @@ export default {
                         fileName: f.name || "",
                         src: f.url || ""
                     })).filter((m) => m.src);
+                    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,
                         explain: this.form.explain,
+                        type: String(this.form.type),
+                        cover_video: coverVideo,
                         material,
                         price: this.form.price,
+                        exp: this.form.exp,
+                        total: this.form.taskTotal,
+                        grade: this.form.grade,
+                        label_id: this.form.label_id,
                         end_time: String(endTimeSec),
                         kefu_id: this.form.kefu_id,
                         status: String(this.form.status)
@@ -389,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>

+ 481 - 0
src/views/user/level/list/index.vue

@@ -0,0 +1,481 @@
+<template>
+    <el-main>
+        <el-card shadow="never">
+            <template #header>
+                <div class="header-row">
+                    <span class="card-title">用户等级</span>
+                    <div class="header-actions">
+                        <el-button type="primary"  @click="openCreate">添加等级</el-button>
+                    </div>
+                </div>
+            </template>
+
+            <el-table v-loading="loading" :data="list" stripe class="level-table">
+                <el-table-column prop="id" label="ID" width="88" />
+                <el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
+                <el-table-column prop="grade" label="等级" width="88" align="center" />
+                <el-table-column prop="exp_num" label="所需经验" width="110" align="right" />
+                <el-table-column label="图标" width="88" align="center">
+                    <template #default="{ row }">
+                        <el-image
+                            v-if="row.icon"
+                            class="level-icon"
+                            :src="row.icon"
+                            fit="contain"
+                            :preview-src-list="[row.icon]"
+                            preview-teleported
+                        />
+                        <span v-else>-</span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="背景图" width="88" align="center">
+                    <template #default="{ row }">
+                        <el-image
+                            v-if="row.background"
+                            class="level-icon"
+                            :src="row.background"
+                            fit="contain"
+                            :preview-src-list="[row.background]"
+                            preview-teleported
+                        />
+                        <span v-else>-</span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="颜色" min-width="140" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ rowColorSummary(row) }}
+                    </template>
+                </el-table-column>
+                <el-table-column prop="explain" label="说明" min-width="200" show-overflow-tooltip />
+                <el-table-column label="显示" width="120" align="center">
+                    <template #default="{ row }">
+                        <el-switch
+                            :model-value="Number(row.is_show)"
+                            :active-value="1"
+                            :inactive-value="0"
+                            inline-prompt
+                            active-text="显示"
+                            inactive-text="隐藏"
+                            active-color="#13ce66"
+                            inactive-color="#909399"
+                            @change="(val) => changeShow(row, val)"
+                        />
+                    </template>
+                </el-table-column>
+                <el-table-column label="创建时间" min-width="170" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ formatTime(row.created_at) }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="更新时间" min-width="170" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        {{ formatTime(row.updated_at) }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" width="248" fixed="right">
+                    <template #default="{ row }">
+                        <div class="table-row-actions">
+                            <el-button type="info" size="small" @click="showDetail(row)">详情</el-button>
+                            <el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+                            <el-button type="danger" size="small" @click="removeRow(row)">删除</el-button>
+                        </div>
+                    </template>
+                </el-table-column>
+            </el-table>
+        </el-card>
+
+        <el-dialog v-model="detailDialog" title="等级详情" width="560px" destroy-on-close>
+            <el-descriptions :column="1" border>
+                <el-descriptions-item label="ID">{{ detail.id ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="名称">{{ detail.name ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="等级">{{ detail.grade ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="所需经验">{{ detail.exp_num ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="说明">{{ detail.explain ?? "-" }}</el-descriptions-item>
+                <el-descriptions-item label="显示">
+                    <el-tag :type="Number(detail.is_show) === 1 ? 'success' : 'info'" size="small">
+                        {{ Number(detail.is_show) === 1 ? "显示" : "隐藏" }}
+                    </el-tag>
+                </el-descriptions-item>
+                <el-descriptions-item label="图标">
+                    <el-image
+                        v-if="detail.icon"
+                        class="detail-icon"
+                        :src="detail.icon"
+                        fit="cover"
+                        :preview-src-list="[detail.icon]"
+                        preview-teleported
+                    />
+                    <span v-else>-</span>
+                </el-descriptions-item>
+                <el-descriptions-item label="背景图">
+                    <el-image
+                        v-if="detail.background"
+                        class="detail-bg"
+                        :src="detail.background"
+                        fit="cover"
+                        :preview-src-list="[detail.background]"
+                        preview-teleported
+                    />
+                    <span v-else>-</span>
+                </el-descriptions-item>
+                <el-descriptions-item label="颜色">
+                    <template v-if="detailColorTags.length">
+                        <el-tag v-for="(c, i) in detailColorTags" :key="i" size="small" class="color-tag">{{ c }}</el-tag>
+                    </template>
+                    <span v-else>-</span>
+                </el-descriptions-item>
+                <el-descriptions-item label="创建时间">{{ formatTime(detail.created_at) }}</el-descriptions-item>
+                <el-descriptions-item label="更新时间">{{ formatTime(detail.updated_at) }}</el-descriptions-item>
+            </el-descriptions>
+        </el-dialog>
+
+        <el-dialog v-model="editDialog" :title="editTitle" width="580px" destroy-on-close @closed="resetEditForm">
+            <el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
+                <el-form-item label="名称" prop="name">
+                    <el-input v-model="form.name" clearable maxlength="64" placeholder="如 Lv1" />
+                </el-form-item>
+                <el-form-item label="等级" prop="grade">
+                    <el-input-number v-model="form.grade" :min="0" :max="9999" :step="1" controls-position="right" class="w-full-num" />
+                </el-form-item>
+                <el-form-item label="所需经验" prop="exp_num">
+                    <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">
+                    <sc-material-image
+                        v-model="form.icon"
+                        title="选择图标"
+                        button-text="选择图片"
+                        input-placeholder="或填写图片地址"
+                    />
+                </el-form-item>
+                <el-form-item label="背景图" prop="background">
+                    <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">
+                        <p v-if="!form.colorList.length" class="palette-hint">点击「添加颜色」,用取色器选择</p>
+                        <div v-for="(_, index) in form.colorList" :key="'palette-row-' + index" class="palette-row">
+                            <el-color-picker
+                                :model-value="form.colorList[index]"
+                                color-format="hex"
+                                :show-alpha="false"
+                                @update:model-value="(v) => updatePaletteColor(index, v)"
+                            />
+                            <span class="palette-hex">{{ form.colorList[index] }}</span>
+                            <el-button type="danger" link size="small" @click="removePaletteColor(index)">移除</el-button>
+                        </div>
+                        <el-button size="small" type="primary" plain @click="addPaletteColor">添加颜色</el-button>
+                    </div>
+                </el-form-item>
+                <el-form-item label="说明" prop="explain">
+                    <el-input v-model="form.explain" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="等级说明" />
+                </el-form-item>
+            </el-form>
+            <template #footer>
+                <el-button @click="editDialog = false">取消</el-button>
+                <el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
+            </template>
+        </el-dialog>
+    </el-main>
+</template>
+
+<script>
+function defaultLevelForm() {
+    return {
+        id: "",
+        name: "",
+        grade: 1,
+        exp_num: 0,
+        icon: "",
+        background: "",
+        colorList: [],
+        explain: ""
+    };
+}
+
+/** 规范为六位小写 #rrggbb,供接口 ["#000000"] 格式 */
+function normalizeHex(val) {
+    if (val == null || val === "") return "#000000";
+    let s = String(val).trim();
+    if (!s.startsWith("#")) s = `#${s}`;
+    if (s.length === 9 && /^#[0-9a-fA-F]{8}$/.test(s)) s = s.slice(0, 7);
+    if (s.length === 4 && /^#[0-9a-fA-F]{3}$/.test(s)) {
+        s = `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
+    }
+    if (!/^#[0-9a-fA-F]{6}$/.test(s)) return "#000000";
+    return s.toLowerCase();
+}
+
+/** 将接口里的 color(一维字符串数组;兼容历史 JSON 字符串或逗号分隔)解析为字符串数组 */
+function parseColorList(val) {
+    if (val == null || val === "") return [];
+    if (Array.isArray(val)) return val.map(String).filter(Boolean);
+    if (typeof val === "string") {
+        const s = val.trim();
+        if (!s) return [];
+        try {
+            const parsed = JSON.parse(s);
+            if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
+        } catch (_) {
+            /* 非 JSON 时按分隔符拆分 */
+        }
+        return s.split(/[,,]/).map((x) => x.trim()).filter(Boolean);
+    }
+    return [];
+}
+
+export default {
+    name: "UserLevelList",
+    computed: {
+        detailColorTags() {
+            return parseColorList(this.detail?.color);
+        }
+    },
+    data() {
+        return {
+            loading: false,
+            saving: false,
+            list: [],
+            detailDialog: false,
+            detail: {},
+            editDialog: false,
+            editTitle: "添加等级",
+            form: defaultLevelForm(),
+            rules: {
+                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" }],
+                explain: [{ required: true, message: "请输入说明", trigger: "blur" }]
+            }
+        };
+    },
+    created() {
+        this.getList();
+    },
+    methods: {
+        rowColorSummary(row) {
+            const arr = parseColorList(row?.color);
+            if (!arr.length) return "-";
+            return arr.join("、");
+        },
+        addPaletteColor() {
+            this.form.colorList.push("#409eff");
+        },
+        removePaletteColor(index) {
+            this.form.colorList.splice(index, 1);
+        },
+        updatePaletteColor(index, val) {
+            if (val == null || val === "") {
+                this.form.colorList.splice(index, 1);
+                return;
+            }
+            const hex = normalizeHex(val);
+            this.form.colorList.splice(index, 1, hex);
+        },
+        formatTime(val) {
+            if (val === undefined || val === null || val === "") return "-";
+            if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}/.test(val.trim())) return val.trim();
+            const num = Number(val);
+            if (!Number.isFinite(num) || num <= 0) return String(val);
+            const sec = num > 1e12 ? Math.floor(num / 1000) : num;
+            return this.$TOOL.getTime(sec, "YYYY-MM-DD HH:mm:ss");
+        },
+        async getList() {
+            this.loading = true;
+            try {
+                const res = await this.$API.user.levelList.get();
+                if (res.code !== 1) {
+                    this.$message.error(res.msg || "获取用户等级列表失败");
+                    return;
+                }
+                const raw = res.data || {};
+                this.list = Array.isArray(raw) ? raw : raw.list || raw.data || [];
+            } finally {
+                this.loading = false;
+            }
+        },
+        async showDetail(row) {
+            const id = row?.id;
+            if (id == null) return;
+            const res = await this.$API.user.levelInfo.get(id);
+            if (res.code !== 1) {
+                this.$message.error(res.msg || "获取详情失败");
+                return;
+            }
+            this.detail = res.data || {};
+            this.detailDialog = true;
+        },
+        openCreate() {
+            this.editTitle = "添加等级";
+            this.form = defaultLevelForm();
+            this.editDialog = true;
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        openEdit(row) {
+            this.editTitle = "编辑等级";
+            this.form = {
+                id: row.id != null ? String(row.id) : "",
+                name: row.name || "",
+                grade: row.grade != null ? Number(row.grade) : 1,
+                exp_num: row.exp_num != null ? Number(row.exp_num) : 0,
+                icon: row.icon || "",
+                background: row.background || "",
+                colorList: parseColorList(row.color).map((c) => normalizeHex(c)),
+                explain: row.explain || ""
+            };
+            this.editDialog = true;
+            this.$nextTick(() => this.$refs.formRef?.clearValidate());
+        },
+        resetEditForm() {
+            this.form = defaultLevelForm();
+        },
+        submitForm() {
+            this.$refs.formRef.validate(async (valid) => {
+                if (!valid) return false;
+                this.saving = true;
+                try {
+                    /** 接口约定:color 为一维数组,元素为颜色字符串,如 ["#000000","#ffffff"] */
+                    const color = (this.form.colorList || []).map((x) => normalizeHex(x));
+                    const payload = {
+                        name: (this.form.name || "").trim(),
+                        grade: this.form.grade,
+                        exp_num: this.form.exp_num,
+                        icon: (this.form.icon || "").trim(),
+                        background: (this.form.background || "").trim(),
+                        color,
+                        explain: (this.form.explain || "").trim()
+                    };
+                    if (this.form.id) payload.id = String(this.form.id);
+                    const res = await this.$API.user.levelSave.post(payload);
+                    if (res.code !== 1) {
+                        this.$message.error(res.msg || "保存失败");
+                        return false;
+                    }
+                    this.$message.success(res.msg || "保存成功");
+                    this.editDialog = false;
+                    this.getList();
+                } finally {
+                    this.saving = false;
+                }
+            });
+        },
+        async removeRow(row) {
+            const id = row?.id;
+            if (id == null) return;
+            try {
+                await this.$confirm("确认删除该等级吗?", "提示", {
+                    type: "warning",
+                    confirmButtonText: "删除",
+                    cancelButtonText: "取消"
+                });
+            } catch (e) {
+                return;
+            }
+            const res = await this.$API.user.levelDel.delete(id);
+            if (res.code !== 1) {
+                this.$message.error(res.msg || "删除失败");
+                return;
+            }
+            this.$message.success(res.msg || "删除成功");
+            this.getList();
+        },
+        async changeShow(row, show) {
+            const id = row?.id;
+            if (id == null) {
+                this.$message.error("缺少等级 ID");
+                return;
+            }
+            const res = await this.$API.user.levelSetShow.get(id, show);
+            if (res.code !== 1) {
+                this.$message.error(res.msg || "显示状态更新失败");
+                this.getList();
+                return;
+            }
+            this.$message.success(res.msg || "已更新");
+            row.is_show = show;
+        }
+    }
+};
+</script>
+
+<style scoped>
+.card-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: var(--el-text-color-primary);
+    letter-spacing: 0.02em;
+}
+.header-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 12px;
+}
+.header-actions {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+.level-table :deep(.el-table__header th.el-table__cell) {
+    font-weight: 600;
+    color: var(--el-text-color-regular);
+}
+.level-icon {
+    width: 40px;
+    height: 40px;
+    border-radius: 6px;
+}
+.detail-icon {
+    width: 72px;
+    height: 72px;
+    border-radius: 8px;
+}
+.icon-field {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    width: 100%;
+}
+.w-full-num {
+    width: 100%;
+}
+.w-full-num :deep(.el-input__inner) {
+    text-align: left;
+}
+.color-palette-field {
+    width: 100%;
+}
+.palette-hint {
+    margin: 0 0 10px;
+    font-size: 12px;
+    color: var(--el-text-color-placeholder);
+}
+.palette-row {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 10px;
+}
+.palette-hex {
+    font-family: ui-monospace, monospace;
+    font-size: 13px;
+    color: var(--el-text-color-regular);
+    min-width: 7.5em;
+}
+.detail-bg {
+    max-width: 240px;
+    max-height: 120px;
+    border-radius: 8px;
+}
+.color-tag {
+    margin-right: 6px;
+    margin-bottom: 4px;
+}
+</style>

+ 5 - 0
src/views/user/list/index.vue

@@ -14,6 +14,11 @@
                         {{ row.phone ?? "-" }}
                     </template>
                 </el-table-column>
+                <el-table-column prop="nickname" label="等级" min-width="140" ><template #default="{ row }">
+                        {{ row.level?.name ?? "-" }}
+                    </template>
+                </el-table-column>
+
                 <el-table-column prop="brokerage_price" label="佣金余额" width="100" align="right" />
                 <el-table-column label="剪辑师" width="88" align="center">
                     <template #default="{ row }">