index.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  1. <template>
  2. <el-main class="material-main">
  3. <el-card shadow="never" class="material-card">
  4. <el-tabs v-model="fileType" class="material-tabs" @tab-change="onFileTypeChange">
  5. <el-tab-pane label="图片" name="1" />
  6. <el-tab-pane label="视频" name="2" />
  7. </el-tabs>
  8. <div class="material-body">
  9. <aside class="material-side" v-loading="categoryLoading">
  10. <div class="category-head">
  11. <span>分类</span>
  12. <el-button type="primary" link @click="openCategoryDialog()">新建</el-button>
  13. </div>
  14. <el-scrollbar class="category-scroll">
  15. <ul class="category-list">
  16. <li
  17. :class="{ active: activeCategoryId === '' }"
  18. @click="selectCategory('')"
  19. >
  20. <el-icon><el-icon-folder /></el-icon>
  21. <span>{{ fileType === '1' ? '全部图片' : '全部视频' }}</span>
  22. </li>
  23. <li
  24. v-for="item in flatCategories"
  25. :key="item.id"
  26. :class="{ active: String(activeCategoryId) === String(item.id) }"
  27. :style="{ paddingLeft: 12 + item.level * 14 + 'px' }"
  28. @click="selectCategory(item.id)"
  29. >
  30. <el-icon><el-icon-folder /></el-icon>
  31. <span class="category-name" :title="item.name">{{ item.name }}</span>
  32. <div class="category-actions" @click.stop>
  33. <el-button type="primary" link size="small" @click="openCategoryDialog(item)">编辑</el-button>
  34. <el-button type="danger" link size="small" @click="removeCategory(item)">删除</el-button>
  35. </div>
  36. </li>
  37. </ul>
  38. </el-scrollbar>
  39. </aside>
  40. <section class="material-content">
  41. <div class="toolbar">
  42. <div class="toolbar-left">
  43. <el-upload
  44. ref="uploaderRef"
  45. class="material-upload"
  46. action=""
  47. multiple
  48. :show-file-list="false"
  49. :accept="uploadAccept"
  50. :http-request="uploadRequest"
  51. >
  52. <el-button type="primary">{{ fileType === '1' ? '上传图片' : '上传视频' }}</el-button>
  53. </el-upload>
  54. <el-button @click="toggleSelectAll">{{ allSelected ? '取消全选' : '全选' }}</el-button>
  55. <el-button type="danger" plain :disabled="!selectedIds.length" @click="removeSelected">
  56. {{ fileType === '1' ? '删除图片' : '删除视频' }}
  57. </el-button>
  58. </div>
  59. <div class="toolbar-right">
  60. <el-input
  61. v-model="keyword"
  62. clearable
  63. :placeholder="fileType === '1' ? '搜索图片名称' : '搜索视频名称'"
  64. style="width: 220px"
  65. @keyup.enter="searchFiles"
  66. @clear="searchFiles"
  67. >
  68. <template #append>
  69. <el-button :icon="Search" @click="searchFiles" />
  70. </template>
  71. </el-input>
  72. <el-button-group class="view-toggle">
  73. <el-button :type="viewMode === 'grid' ? 'primary' : 'default'" @click="setViewMode('grid')">
  74. <el-icon><GridIcon /></el-icon>
  75. </el-button>
  76. <el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="setViewMode('list')">
  77. <el-icon><ListIcon /></el-icon>
  78. </el-button>
  79. </el-button-group>
  80. </div>
  81. </div>
  82. <div v-loading="listLoading" class="file-panel">
  83. <el-empty v-if="!fileList.length && !listLoading" description="暂无素材" />
  84. <el-scrollbar v-else-if="viewMode === 'grid'" class="file-grid-scroll">
  85. <div class="file-grid">
  86. <div
  87. v-for="item in fileList"
  88. :key="item.id"
  89. class="file-item"
  90. :class="{ selected: isSelected(item.id) }"
  91. >
  92. <div class="file-thumb" @click="toggleSelect(item.id)">
  93. <el-image
  94. v-if="fileType === '1'"
  95. :src="item.url"
  96. fit="cover"
  97. lazy
  98. />
  99. <div v-else class="video-thumb">
  100. <video v-if="item.url" :src="item.url" preload="metadata" />
  101. <el-icon v-else class="video-icon"><el-icon-video-play /></el-icon>
  102. </div>
  103. <div class="file-check">
  104. <el-icon v-if="isSelected(item.id)"><el-icon-check /></el-icon>
  105. </div>
  106. </div>
  107. <p class="file-name" :title="item.name">{{ item.name }}</p>
  108. <div class="file-actions">
  109. <span @click.stop="previewItem(item)">查看</span>
  110. <span class="danger" @click.stop="removeOne(item)">删除</span>
  111. </div>
  112. </div>
  113. </div>
  114. </el-scrollbar>
  115. <el-table
  116. v-else
  117. ref="fileTableRef"
  118. :data="fileList"
  119. row-key="id"
  120. border
  121. stripe
  122. @selection-change="onTableSelectionChange"
  123. >
  124. <el-table-column type="selection" width="48" />
  125. <el-table-column label="预览" width="100">
  126. <template #default="{ row }">
  127. <el-image
  128. v-if="fileType === '1'"
  129. :src="row.url"
  130. fit="cover"
  131. style="width: 56px; height: 56px; border-radius: 4px;"
  132. :preview-src-list="[row.url]"
  133. preview-teleported
  134. />
  135. <el-tag v-else size="small">视频</el-tag>
  136. </template>
  137. </el-table-column>
  138. <el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
  139. <el-table-column label="大小" width="90" show-overflow-tooltip>
  140. <template #default="{ row }">{{ row.raw?.att_size || "-" }}</template>
  141. </el-table-column>
  142. <el-table-column prop="id" label="ID" width="80" />
  143. </el-table>
  144. <div v-if="total > query.limit" class="pager">
  145. <el-pagination
  146. background
  147. layout="total, prev, pager, next, sizes"
  148. :total="total"
  149. :current-page="query.page"
  150. :page-size="query.limit"
  151. :page-sizes="[20, 40, 60, 100]"
  152. @current-change="handlePageChange"
  153. @size-change="handleSizeChange"
  154. />
  155. </div>
  156. </div>
  157. </section>
  158. </div>
  159. </el-card>
  160. <el-dialog
  161. v-model="categoryDialogVisible"
  162. :title="categoryForm.id ? '编辑分类' : '新建分类'"
  163. width="440px"
  164. destroy-on-close
  165. @closed="resetCategoryForm"
  166. >
  167. <el-form ref="categoryFormRef" :model="categoryForm" :rules="categoryRules" label-width="90px">
  168. <el-form-item label="分类名称" prop="name">
  169. <el-input v-model="categoryForm.name" clearable maxlength="40" />
  170. </el-form-item>
  171. <el-form-item label="上级分类" prop="pid">
  172. <el-select v-model="categoryForm.pid" placeholder="顶级分类" clearable style="width: 100%">
  173. <el-option label="顶级分类" value="0" />
  174. <el-option
  175. v-for="c in parentCategoryOptions"
  176. :key="c.id"
  177. :label="c.name"
  178. :value="String(c.id)"
  179. />
  180. </el-select>
  181. </el-form-item>
  182. </el-form>
  183. <template #footer>
  184. <el-button @click="categoryDialogVisible = false">取消</el-button>
  185. <el-button type="primary" :loading="categorySaving" @click="submitCategory">保存</el-button>
  186. </template>
  187. </el-dialog>
  188. <el-dialog
  189. v-model="previewVisible"
  190. :title="previewTitle"
  191. width="auto"
  192. class="material-preview-dialog"
  193. append-to-body
  194. destroy-on-close
  195. align-center
  196. @closed="onPreviewClosed"
  197. >
  198. <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" alt="" />
  199. <video
  200. v-else-if="previewType === 'video' && previewUrl"
  201. :src="previewUrl"
  202. class="preview-media"
  203. controls
  204. autoplay
  205. />
  206. </el-dialog>
  207. </el-main>
  208. </template>
  209. <script>
  210. import { Search, Grid as GridIcon, List as ListIcon } from "@element-plus/icons-vue";
  211. import uploadConfig from "@/config/upload";
  212. export default {
  213. name: "MaterialManage",
  214. components: { GridIcon, ListIcon },
  215. data() {
  216. return {
  217. Search,
  218. fileType: "1",
  219. viewMode: "grid",
  220. keyword: "",
  221. activeCategoryId: "",
  222. categoryLoading: false,
  223. listLoading: false,
  224. categorySaving: false,
  225. categories: [],
  226. flatCategories: [],
  227. parentCategoryOptions: [],
  228. fileList: [],
  229. selectedIds: [],
  230. total: 0,
  231. query: { page: 1, limit: 40 },
  232. categoryDialogVisible: false,
  233. categoryForm: { id: "", name: "", pid: "0" },
  234. categoryRules: {
  235. name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
  236. },
  237. /** 程序同步表格勾选时,忽略 selection-change 避免清空 selectedIds */
  238. syncingTableSelection: false,
  239. previewVisible: false,
  240. previewUrl: "",
  241. previewTitle: "",
  242. previewType: "image",
  243. };
  244. },
  245. computed: {
  246. uploadAccept() {
  247. return this.fileType === "1" ? "image/*" : "video/*";
  248. },
  249. allSelected() {
  250. if (!this.fileList.length) return false;
  251. const set = new Set(this.selectedIds.map((id) => String(id)));
  252. return this.fileList.every((f) => set.has(String(f.id)));
  253. },
  254. },
  255. watch: {
  256. fileList() {
  257. if (this.viewMode === "list") {
  258. this.$nextTick(() => this.syncTableSelection());
  259. }
  260. },
  261. },
  262. created() {
  263. this.bootstrap();
  264. },
  265. methods: {
  266. /** 兼容 code / status 两种成功标识 */
  267. apiOk(res) {
  268. if (!res) return false;
  269. const code = res.code ?? res.status;
  270. return code === 1 || code === 200;
  271. },
  272. async bootstrap() {
  273. await this.loadCategories();
  274. await this.loadFiles();
  275. },
  276. onFileTypeChange() {
  277. this.activeCategoryId = "";
  278. this.keyword = "";
  279. this.query.page = 1;
  280. this.selectedIds = [];
  281. this.loadCategories();
  282. this.loadFiles();
  283. },
  284. selectCategory(id) {
  285. this.activeCategoryId = id === "" ? "" : String(id);
  286. this.query.page = 1;
  287. this.selectedIds = [];
  288. this.loadFiles();
  289. },
  290. flattenCategories(nodes, level = 0, out = []) {
  291. (nodes || []).forEach((node) => {
  292. const id = node.id ?? node.value;
  293. const name = node.name ?? node.title ?? node.label ?? node.cate_name ?? `分类#${id}`;
  294. if (id != null && id !== "") {
  295. out.push({
  296. id,
  297. name,
  298. level,
  299. pid: node.pid ?? node.parent_id ?? 0,
  300. raw: node,
  301. });
  302. }
  303. const children = node.children || node.child || [];
  304. if (children.length) this.flattenCategories(children, level + 1, out);
  305. });
  306. return out;
  307. },
  308. /** 分类:{ data: { list: [{ id, pid, name, title, children }] } } */
  309. parseCategoryRows(data) {
  310. if (Array.isArray(data)) return data;
  311. const d = data || {};
  312. if (Array.isArray(d.list)) return d.list;
  313. if (Array.isArray(d.data)) return d.data;
  314. return d.children || [];
  315. },
  316. parseFileRows(data) {
  317. if (Array.isArray(data)) return { list: data, total: data.length };
  318. const d = data || {};
  319. const list = d.list || d.data || d.rows || [];
  320. const total = Number(d.count ?? d.total ?? list.length ?? 0);
  321. return { list, total };
  322. },
  323. /** 文件项:att_id、real_name、att_dir(缩略图可用 satt_dir) */
  324. normalizeFileRow(row) {
  325. const rawId = row.att_id ?? row.id;
  326. const id = rawId != null && rawId !== "" ? String(rawId) : "";
  327. const url = row.att_dir || row.satt_dir || row.url || row.src || "";
  328. const name = row.real_name || row.name || row.file_name || `素材#${id || ""}`;
  329. return {
  330. id,
  331. name,
  332. url,
  333. raw: row,
  334. };
  335. },
  336. isSelected(id) {
  337. if (id == null || id === "") return false;
  338. return this.selectedIds.some((x) => String(x) === String(id));
  339. },
  340. async loadCategories() {
  341. this.categoryLoading = true;
  342. try {
  343. const res = await this.$API.material.categoryList.get(this.fileType);
  344. if (!this.apiOk(res)) {
  345. this.$message.error(res.msg || res.message || "获取分类失败");
  346. this.categories = [];
  347. this.flatCategories = [];
  348. return;
  349. }
  350. this.categories = this.parseCategoryRows(res.data);
  351. this.flatCategories = this.flattenCategories(this.categories);
  352. } finally {
  353. this.categoryLoading = false;
  354. }
  355. },
  356. async loadParentCategories() {
  357. const res = await this.$API.material.categoryParentList.get(this.fileType);
  358. if (!this.apiOk(res)) {
  359. this.parentCategoryOptions = [];
  360. return;
  361. }
  362. const rows = this.parseCategoryRows(res.data);
  363. this.parentCategoryOptions = this.flattenCategories(rows).map((x) => ({
  364. id: x.id,
  365. name: x.name,
  366. }));
  367. },
  368. buildFileQuery() {
  369. const params = {
  370. file_type: this.fileType,
  371. real_name: this.keyword || "",
  372. page: this.query.page,
  373. limit: this.query.limit,
  374. };
  375. if (this.activeCategoryId !== "") {
  376. params.pid = this.activeCategoryId;
  377. params.category_id = this.activeCategoryId;
  378. params.cate_id = this.activeCategoryId;
  379. params.relation_id = this.activeCategoryId;
  380. }
  381. return params;
  382. },
  383. async loadFiles() {
  384. this.listLoading = true;
  385. try {
  386. const res = await this.$API.material.fileList.get(this.buildFileQuery());
  387. if (!this.apiOk(res)) {
  388. this.$message.error(res.msg || res.message || "获取素材列表失败");
  389. this.fileList = [];
  390. this.total = 0;
  391. return;
  392. }
  393. const { list, total } = this.parseFileRows(res.data);
  394. this.fileList = list.map((row) => this.normalizeFileRow(row)).filter((x) => x.id != null);
  395. this.total = total;
  396. this.selectedIds = this.selectedIds.filter((id) =>
  397. this.fileList.some((f) => String(f.id) === String(id))
  398. );
  399. } finally {
  400. this.listLoading = false;
  401. }
  402. },
  403. searchFiles() {
  404. this.query.page = 1;
  405. this.loadFiles();
  406. },
  407. handlePageChange(page) {
  408. this.query.page = page;
  409. this.loadFiles();
  410. },
  411. handleSizeChange(limit) {
  412. this.query.limit = limit;
  413. this.query.page = 1;
  414. this.loadFiles();
  415. },
  416. toggleSelect(id) {
  417. const sid = String(id);
  418. if (this.isSelected(id)) {
  419. this.selectedIds = this.selectedIds.filter((x) => String(x) !== sid);
  420. } else {
  421. this.selectedIds = [...this.selectedIds, sid];
  422. }
  423. },
  424. setViewMode(mode) {
  425. this.viewMode = mode;
  426. if (mode === "list") {
  427. this.$nextTick(() => this.syncTableSelection());
  428. }
  429. },
  430. syncTableSelection() {
  431. const table = this.$refs.fileTableRef;
  432. if (!table || this.viewMode !== "list") return;
  433. this.syncingTableSelection = true;
  434. table.clearSelection();
  435. this.fileList.forEach((row) => {
  436. if (this.isSelected(row.id)) {
  437. table.toggleRowSelection(row, true);
  438. }
  439. });
  440. this.$nextTick(() => {
  441. this.syncingTableSelection = false;
  442. });
  443. },
  444. toggleSelectAll() {
  445. if (this.allSelected) {
  446. this.selectedIds = [];
  447. if (this.viewMode === "list") {
  448. this.$refs.fileTableRef?.clearSelection();
  449. }
  450. return;
  451. }
  452. this.selectedIds = this.fileList.map((f) => f.id);
  453. if (this.viewMode === "list") {
  454. this.$nextTick(() => this.syncTableSelection());
  455. }
  456. },
  457. onTableSelectionChange(rows) {
  458. if (this.syncingTableSelection) return;
  459. this.selectedIds = rows.map((r) => r.id);
  460. },
  461. previewItem(item) {
  462. if (!item?.url) {
  463. this.$message.warning("暂无预览地址");
  464. return;
  465. }
  466. this.previewUrl = item.url;
  467. this.previewTitle = item.name || "预览";
  468. this.previewType = this.fileType === "2" ? "video" : "image";
  469. this.previewVisible = true;
  470. },
  471. onPreviewClosed() {
  472. this.previewUrl = "";
  473. this.previewTitle = "";
  474. },
  475. async removeOne(item) {
  476. const confirm = await this.$confirm(`确认删除「${item.name}」吗?`, "提示", {
  477. type: "warning",
  478. }).catch(() => {});
  479. if (confirm !== "confirm") return;
  480. const res = await this.$API.material.fileDelete.delete([item.id]);
  481. if (!this.apiOk(res)) {
  482. this.$message.error(res.msg || res.message || "删除失败");
  483. return;
  484. }
  485. this.$message.success(res.msg || res.message || "删除成功");
  486. this.selectedIds = this.selectedIds.filter((id) => String(id) !== String(item.id));
  487. this.loadFiles();
  488. },
  489. async removeSelected() {
  490. if (!this.selectedIds.length) return;
  491. const confirm = await this.$confirm(
  492. `确认删除选中的 ${this.selectedIds.length} 个素材吗?`,
  493. "提示",
  494. { type: "warning" }
  495. ).catch(() => {});
  496. if (confirm !== "confirm") return;
  497. const res = await this.$API.material.fileDelete.delete(this.selectedIds);
  498. if (!this.apiOk(res)) {
  499. this.$message.error(res.msg || res.message || "删除失败");
  500. return;
  501. }
  502. this.$message.success(res.msg || res.message || "删除成功");
  503. this.selectedIds = [];
  504. this.loadFiles();
  505. },
  506. uploadRequest(param) {
  507. const data = new FormData();
  508. data.append(uploadConfig.filename || "file", param.file);
  509. if (this.activeCategoryId !== "") {
  510. data.append("pid", String(this.activeCategoryId));
  511. data.append("category_id", String(this.activeCategoryId));
  512. }
  513. this.$API.material.upload
  514. .post(this.fileType, data, {
  515. onUploadProgress: (e) => {
  516. if (e.total) {
  517. const percent = Math.round((e.loaded / e.total) * 100);
  518. param.onProgress({ percent });
  519. }
  520. },
  521. })
  522. .then((res) => {
  523. const parsed = uploadConfig.parseData(res);
  524. const uploadCode = parsed.code ?? res.status ?? res.code;
  525. if (uploadCode == uploadConfig.successCode || this.apiOk(res)) {
  526. param.onSuccess(res);
  527. this.loadFiles();
  528. } else {
  529. param.onError(parsed.msg || res.msg || "上传失败");
  530. }
  531. })
  532. .catch((err) => {
  533. param.onError(err);
  534. });
  535. },
  536. resetCategoryForm() {
  537. this.categoryForm = { id: "", name: "", pid: "0" };
  538. },
  539. async openCategoryDialog(row) {
  540. await this.loadParentCategories();
  541. if (row) {
  542. const raw = row.raw || row;
  543. this.categoryForm = {
  544. id: String(row.id),
  545. name: row.name,
  546. pid: String(raw.pid ?? "0"),
  547. };
  548. } else {
  549. this.resetCategoryForm();
  550. if (this.activeCategoryId !== "") {
  551. this.categoryForm.pid = String(this.activeCategoryId);
  552. }
  553. }
  554. this.categoryDialogVisible = true;
  555. this.$nextTick(() => this.$refs.categoryFormRef?.clearValidate());
  556. },
  557. submitCategory() {
  558. this.$refs.categoryFormRef.validate(async (valid) => {
  559. if (!valid) return false;
  560. this.categorySaving = true;
  561. try {
  562. const payload = {
  563. name: this.categoryForm.name,
  564. pid: String(this.categoryForm.pid || "0"),
  565. file_type: this.fileType,
  566. };
  567. const res = await this.$API.material.categorySave.post(
  568. this.categoryForm.id || 0,
  569. payload
  570. );
  571. if (!this.apiOk(res)) {
  572. this.$message.error(res.msg || res.message || "保存失败");
  573. return false;
  574. }
  575. this.$message.success(res.msg || res.message || "保存成功");
  576. this.categoryDialogVisible = false;
  577. await this.loadCategories();
  578. } finally {
  579. this.categorySaving = false;
  580. }
  581. });
  582. },
  583. async removeCategory(row) {
  584. const confirm = await this.$confirm(`确认删除分类「${row.name}」吗?`, "提示", {
  585. type: "warning",
  586. }).catch(() => {});
  587. if (confirm !== "confirm") return;
  588. const res = await this.$API.material.categoryDelete.delete(row.id);
  589. if (!this.apiOk(res)) {
  590. this.$message.error(res.msg || res.message || "删除失败");
  591. return;
  592. }
  593. this.$message.success(res.msg || res.message || "删除成功");
  594. if (String(this.activeCategoryId) === String(row.id)) {
  595. this.activeCategoryId = "";
  596. }
  597. await this.loadCategories();
  598. this.loadFiles();
  599. },
  600. },
  601. };
  602. </script>
  603. <style scoped>
  604. .material-main {
  605. padding: 0;
  606. }
  607. .material-card :deep(.el-card__body) {
  608. padding-top: 8px;
  609. }
  610. .material-tabs {
  611. margin-bottom: 0;
  612. }
  613. .material-body {
  614. display: flex;
  615. min-height: calc(100vh - 220px);
  616. border-top: 1px solid var(--el-border-color-lighter);
  617. margin-top: 8px;
  618. }
  619. .material-side {
  620. width: 220px;
  621. flex-shrink: 0;
  622. border-right: 1px solid var(--el-border-color-lighter);
  623. display: flex;
  624. flex-direction: column;
  625. }
  626. .category-head {
  627. display: flex;
  628. align-items: center;
  629. justify-content: space-between;
  630. padding: 12px 12px 8px;
  631. font-size: 13px;
  632. color: var(--el-text-color-secondary);
  633. }
  634. .category-scroll {
  635. flex: 1;
  636. height: 0;
  637. }
  638. .category-list {
  639. list-style: none;
  640. margin: 0;
  641. padding: 0 0 12px;
  642. }
  643. .category-list li {
  644. display: flex;
  645. align-items: center;
  646. gap: 6px;
  647. padding: 10px 12px;
  648. cursor: pointer;
  649. font-size: 14px;
  650. transition: background 0.15s;
  651. }
  652. .category-list li:hover {
  653. background: var(--el-fill-color-light);
  654. }
  655. .category-list li.active {
  656. background: var(--el-color-primary-light-9);
  657. color: var(--el-color-primary);
  658. }
  659. .category-name {
  660. flex: 1;
  661. overflow: hidden;
  662. text-overflow: ellipsis;
  663. white-space: nowrap;
  664. }
  665. .category-actions {
  666. display: none;
  667. flex-shrink: 0;
  668. }
  669. .category-list li:hover .category-actions {
  670. display: flex;
  671. gap: 2px;
  672. }
  673. .material-content {
  674. flex: 1;
  675. min-width: 0;
  676. display: flex;
  677. flex-direction: column;
  678. padding: 16px;
  679. }
  680. .toolbar {
  681. display: flex;
  682. flex-wrap: wrap;
  683. align-items: center;
  684. justify-content: space-between;
  685. gap: 12px;
  686. margin-bottom: 16px;
  687. }
  688. .toolbar-left,
  689. .toolbar-right {
  690. display: flex;
  691. flex-wrap: wrap;
  692. align-items: center;
  693. gap: 10px;
  694. }
  695. .material-upload {
  696. display: inline-block;
  697. }
  698. .view-toggle {
  699. margin-left: 4px;
  700. }
  701. .file-panel {
  702. flex: 1;
  703. min-height: 320px;
  704. }
  705. .file-grid-scroll {
  706. height: calc(100vh - 320px);
  707. }
  708. .file-grid {
  709. --material-thumb-size: 120px;
  710. display: grid;
  711. grid-template-columns: repeat(auto-fill, var(--material-thumb-size));
  712. gap: 16px;
  713. padding: 4px 4px 16px;
  714. justify-content: start;
  715. }
  716. .file-item {
  717. width: var(--material-thumb-size);
  718. user-select: none;
  719. }
  720. .file-item .file-thumb {
  721. cursor: pointer;
  722. }
  723. .file-thumb {
  724. position: relative;
  725. width: var(--material-thumb-size);
  726. height: var(--material-thumb-size);
  727. flex-shrink: 0;
  728. border: 1px solid var(--el-border-color-lighter);
  729. border-radius: 4px;
  730. overflow: hidden;
  731. background: var(--el-fill-color-lighter);
  732. }
  733. .file-thumb :deep(.el-image) {
  734. display: block;
  735. width: 100%;
  736. height: 100%;
  737. }
  738. .file-thumb :deep(.el-image__inner) {
  739. width: 100%;
  740. height: 100%;
  741. object-fit: cover;
  742. }
  743. .video-thumb {
  744. position: absolute;
  745. inset: 0;
  746. display: flex;
  747. align-items: center;
  748. justify-content: center;
  749. background: #1a1a1a;
  750. }
  751. .video-thumb video {
  752. width: 100%;
  753. height: 100%;
  754. max-width: 100%;
  755. max-height: 100%;
  756. object-fit: cover;
  757. display: block;
  758. }
  759. .video-icon {
  760. font-size: 36px;
  761. color: #fff;
  762. }
  763. .file-check {
  764. position: absolute;
  765. top: 6px;
  766. right: 6px;
  767. z-index: 2;
  768. width: 20px;
  769. height: 20px;
  770. border: 1px solid #fff;
  771. background: rgba(0, 0, 0, 0.35);
  772. display: flex;
  773. align-items: center;
  774. justify-content: center;
  775. color: #fff;
  776. font-size: 12px;
  777. box-sizing: border-box;
  778. }
  779. .file-item.selected .file-thumb {
  780. border-color: var(--el-color-primary);
  781. box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
  782. }
  783. .file-item.selected .file-check {
  784. background: var(--el-color-primary);
  785. border-color: var(--el-color-primary);
  786. }
  787. .file-item.selected .file-check .el-icon {
  788. display: flex;
  789. }
  790. .file-check .el-icon {
  791. display: none;
  792. }
  793. .file-name {
  794. width: var(--material-thumb-size);
  795. margin: 8px 0 0;
  796. font-size: 12px;
  797. line-height: 1.4;
  798. text-align: center;
  799. overflow: hidden;
  800. text-overflow: ellipsis;
  801. white-space: nowrap;
  802. color: var(--el-text-color-regular);
  803. }
  804. .file-actions {
  805. display: none;
  806. align-items: center;
  807. justify-content: center;
  808. gap: 16px;
  809. margin-top: 6px;
  810. font-size: 12px;
  811. line-height: 1.5;
  812. }
  813. .file-item:hover .file-actions {
  814. display: flex;
  815. }
  816. .file-actions span {
  817. color: var(--el-color-primary);
  818. cursor: pointer;
  819. }
  820. .file-actions span:hover {
  821. opacity: 0.85;
  822. }
  823. .file-actions span.danger {
  824. color: var(--el-color-danger);
  825. }
  826. .material-preview-dialog :deep(.el-dialog__body) {
  827. padding: 12px 20px 20px;
  828. text-align: center;
  829. }
  830. .preview-media {
  831. display: block;
  832. max-width: min(90vw, 960px);
  833. max-height: 75vh;
  834. margin: 0 auto;
  835. object-fit: contain;
  836. }
  837. .pager {
  838. margin-top: 16px;
  839. display: flex;
  840. justify-content: flex-end;
  841. }
  842. </style>