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