index.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  1. <template>
  2. <span class="sc-material-picker">
  3. <span class="sc-material-picker__trigger" @click="open">
  4. <slot>
  5. <el-button type="primary">{{ buttonText }}</el-button>
  6. </slot>
  7. </span>
  8. <el-dialog
  9. v-model="visible"
  10. :title="dialogTitle"
  11. width="1120px"
  12. class="sc-material-picker-dialog"
  13. append-to-body
  14. destroy-on-close
  15. :close-on-click-modal="false"
  16. @closed="onDialogClosed"
  17. >
  18. <el-tabs
  19. v-if="!lockFileType"
  20. v-model="fileType"
  21. class="picker-tabs"
  22. @tab-change="onFileTypeChange"
  23. >
  24. <el-tab-pane label="图片" name="1" />
  25. <el-tab-pane label="视频" name="2" />
  26. </el-tabs>
  27. <div class="picker-body">
  28. <aside class="picker-side" v-loading="categoryLoading">
  29. <el-input
  30. v-model="categoryKeyword"
  31. size="small"
  32. clearable
  33. placeholder="请输入分类名称"
  34. class="category-search"
  35. @keyup.enter="onCategorySearchEnter"
  36. @clear="onCategorySearchClear"
  37. >
  38. <template #prefix>
  39. <el-icon><el-icon-search /></el-icon>
  40. </template>
  41. </el-input>
  42. <el-scrollbar class="category-scroll">
  43. <ul class="category-list">
  44. <li
  45. :class="{ active: activeCategoryId === '' }"
  46. @click="selectCategory('')"
  47. >
  48. <el-icon><el-icon-folder /></el-icon>
  49. <span>{{ allCategoryLabel }}</span>
  50. </li>
  51. <li
  52. v-for="item in filteredCategories"
  53. :key="item.id"
  54. :class="{ active: String(activeCategoryId) === String(item.id) }"
  55. :style="{ paddingLeft: 12 + item.level * 14 + 'px' }"
  56. @click="selectCategory(item.id)"
  57. >
  58. <el-icon><el-icon-folder /></el-icon>
  59. <span class="category-name" :title="item.name">{{ item.name }}</span>
  60. </li>
  61. <li v-if="categoryKeyword && !filteredCategories.length" class="category-empty">
  62. 无匹配分类
  63. </li>
  64. </ul>
  65. </el-scrollbar>
  66. </aside>
  67. <section class="picker-main">
  68. <div class="picker-toolbar">
  69. <div class="picker-toolbar-left">
  70. <el-button type="primary" :disabled="!selectedCount" @click="confirmUse">
  71. {{ confirmText }}
  72. </el-button>
  73. <el-upload
  74. class="picker-upload"
  75. action=""
  76. multiple
  77. :show-file-list="false"
  78. :accept="uploadAccept"
  79. :http-request="uploadRequest"
  80. >
  81. <el-button>{{ uploadBtnText }}</el-button>
  82. </el-upload>
  83. <el-button
  84. type="danger"
  85. plain
  86. :disabled="!selectedCount"
  87. @click="removeSelected"
  88. >
  89. {{ deleteBtnText }}
  90. </el-button>
  91. </div>
  92. <el-input
  93. v-model="keyword"
  94. clearable
  95. :placeholder="searchPlaceholder"
  96. style="width: 240px"
  97. @keyup.enter="searchFiles"
  98. @clear="searchFiles"
  99. >
  100. <template #append>
  101. <el-button :icon="Search" @click="searchFiles" />
  102. </template>
  103. </el-input>
  104. </div>
  105. <div v-loading="listLoading" class="picker-grid-wrap">
  106. <el-empty v-if="!fileList.length && !listLoading" description="暂无素材" />
  107. <el-scrollbar v-else class="picker-grid-scroll">
  108. <div class="picker-grid">
  109. <div
  110. v-for="item in fileList"
  111. :key="item.id"
  112. class="picker-item"
  113. :class="{ selected: isSelectedItem(item) }"
  114. >
  115. <div class="picker-thumb" @click="toggleSelect(item)">
  116. <el-image
  117. v-if="!isVideoItem(item)"
  118. :src="item.url"
  119. fit="cover"
  120. lazy
  121. />
  122. <div v-else class="video-thumb">
  123. <video v-if="item.url" :src="item.url" preload="metadata" />
  124. <el-icon v-else class="video-icon"><el-icon-video-play /></el-icon>
  125. </div>
  126. <div class="picker-check">
  127. <el-icon v-if="isSelectedItem(item)"><el-icon-check /></el-icon>
  128. </div>
  129. </div>
  130. <p class="picker-name" :title="item.name">{{ item.name }}</p>
  131. <div class="picker-actions">
  132. <span @click.stop="previewItem(item)">查看</span>
  133. <span class="danger" @click.stop="removeOne(item)">删除</span>
  134. </div>
  135. </div>
  136. </div>
  137. </el-scrollbar>
  138. </div>
  139. <div class="picker-footer">
  140. <div class="picker-footer-left">
  141. <el-checkbox
  142. :model-value="allSelected"
  143. :indeterminate="indeterminate"
  144. @change="onSelectAllChange"
  145. >
  146. 全选
  147. </el-checkbox>
  148. <span class="selected-tip">{{ selectedCountTip }}</span>
  149. </div>
  150. <el-pagination
  151. v-if="total > 0"
  152. background
  153. layout="total, prev, pager, next, jumper"
  154. :total="total"
  155. :current-page="query.page"
  156. :page-size="query.limit"
  157. @current-change="handlePageChange"
  158. />
  159. </div>
  160. </section>
  161. </div>
  162. </el-dialog>
  163. <el-dialog
  164. v-model="previewVisible"
  165. :title="previewTitle"
  166. width="auto"
  167. class="material-preview-dialog"
  168. append-to-body
  169. destroy-on-close
  170. align-center
  171. @closed="onPreviewClosed"
  172. >
  173. <img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" alt="" />
  174. <video
  175. v-else-if="previewType === 'video' && previewUrl"
  176. :src="previewUrl"
  177. class="preview-media"
  178. controls
  179. autoplay
  180. />
  181. </el-dialog>
  182. </span>
  183. </template>
  184. <script>
  185. import { Search } from "@element-plus/icons-vue";
  186. import uploadConfig from "@/config/upload";
  187. export default {
  188. name: "ScMaterialPicker",
  189. props: {
  190. /** 选中值:对象数组 { id, url, name } 或 url 字符串/数组(由 valueType 决定) */
  191. modelValue: { type: [Array, Object, String], default: () => [] },
  192. /** 是否多选 */
  193. multiple: { type: Boolean, default: true },
  194. /** 最多可选数量,0 不限制 */
  195. max: { type: Number, default: 0 },
  196. /** 弹窗标题 */
  197. title: { type: String, default: "" },
  198. /** 默认触发按钮文案 */
  199. buttonText: { type: String, default: "选择素材" },
  200. /** 锁定类型:'1' 仅图片,'2' 仅视频,空则 Tab 切换 */
  201. lockFileType: { type: String, default: "" },
  202. /** 是否允许同时选中图片与视频(切换 Tab 不清空已选) */
  203. acceptBoth: { type: Boolean, default: true },
  204. /** 返回值类型 object | url */
  205. valueType: { type: String, default: "object" },
  206. },
  207. emits: ["update:modelValue", "confirm", "open", "close"],
  208. data() {
  209. return {
  210. Search,
  211. visible: false,
  212. fileType: "1",
  213. keyword: "",
  214. categoryKeyword: "",
  215. activeCategoryId: "",
  216. categoryLoading: false,
  217. listLoading: false,
  218. flatCategories: [],
  219. fileList: [],
  220. selectedMap: {},
  221. total: 0,
  222. query: { page: 1, limit: 18 },
  223. previewVisible: false,
  224. previewUrl: "",
  225. previewTitle: "",
  226. previewType: "image",
  227. };
  228. },
  229. computed: {
  230. /** 有 Tab 且未锁定单一类型时,可混选图片+视频 */
  231. isMixedMode() {
  232. return this.acceptBoth && !this.lockFileType;
  233. },
  234. dialogTitle() {
  235. if (this.title) return this.title;
  236. if (this.isMixedMode) return "选择素材";
  237. return this.fileType === "2" ? "上传视频" : "上传商品图";
  238. },
  239. confirmText() {
  240. if (this.isMixedMode) return "使用选中素材";
  241. return this.fileType === "2" ? "使用选中视频" : "使用选中图片";
  242. },
  243. allCategoryLabel() {
  244. return this.fileType === "1" ? "全部图片" : "全部视频";
  245. },
  246. uploadBtnText() {
  247. if (this.isMixedMode) return "上传素材";
  248. return this.fileType === "1" ? "上传图片" : "上传视频";
  249. },
  250. deleteBtnText() {
  251. if (this.isMixedMode) return "删除素材";
  252. return this.fileType === "1" ? "删除图片" : "删除视频";
  253. },
  254. searchPlaceholder() {
  255. if (this.isMixedMode) return "搜索素材名称";
  256. return this.fileType === "1" ? "搜索图片名称" : "搜索视频名称";
  257. },
  258. uploadAccept() {
  259. if (this.isMixedMode) return "image/*,video/*";
  260. return this.fileType === "1" ? "image/*" : "video/*";
  261. },
  262. selectedCountTip() {
  263. const items = this.getUniqueSelectedItems();
  264. const n = items.length;
  265. if (!n) return "已选 0 个";
  266. if (!this.isMixedMode) return `已选 ${n} 个`;
  267. const video = items.filter((i) => this.isVideoItem(i)).length;
  268. const image = n - video;
  269. return `已选 ${n} 个(图片 ${image} / 视频 ${video})`;
  270. },
  271. filteredCategories() {
  272. const kw = (this.categoryKeyword || "").trim().toLowerCase();
  273. if (!kw) return this.flatCategories;
  274. const matched = this.flatCategories.filter((c) =>
  275. String(c.name).toLowerCase().includes(kw)
  276. );
  277. if (!this.activeCategoryId) return matched;
  278. const active = this.flatCategories.find(
  279. (c) => String(c.id) === String(this.activeCategoryId)
  280. );
  281. if (active && !matched.some((c) => String(c.id) === String(active.id))) {
  282. return [active, ...matched];
  283. }
  284. return matched;
  285. },
  286. selectedCount() {
  287. return this.getUniqueSelectedItems().length;
  288. },
  289. allSelected() {
  290. if (!this.fileList.length) return false;
  291. return this.fileList.every((f) => this.isSelectedItem(f));
  292. },
  293. indeterminate() {
  294. if (!this.fileList.length || this.allSelected) return false;
  295. return this.fileList.some((f) => this.isSelectedItem(f));
  296. },
  297. },
  298. watch: {
  299. modelValue: {
  300. deep: true,
  301. handler() {
  302. if (this.visible) this.initSelectionFromModel();
  303. },
  304. },
  305. },
  306. methods: {
  307. apiOk(res) {
  308. if (!res) return false;
  309. const code = res.code ?? res.status;
  310. return code === 1 || code === 200;
  311. },
  312. open() {
  313. if (this.lockFileType) {
  314. this.fileType = String(this.lockFileType);
  315. }
  316. this.visible = true;
  317. this.initSelectionFromModel();
  318. this.$emit("open");
  319. this.bootstrap();
  320. },
  321. close() {
  322. this.visible = false;
  323. },
  324. onDialogClosed() {
  325. this.$emit("close");
  326. },
  327. urlSelectKey(url) {
  328. return url ? `url:${url}` : "";
  329. },
  330. getItemSelectKey(item) {
  331. if (!item) return "";
  332. if (item.id != null && item.id !== "") return String(item.id);
  333. if (item.url) return this.urlSelectKey(item.url);
  334. return "";
  335. },
  336. putSelectedItem(n) {
  337. const key = this.getItemSelectKey(n);
  338. if (!key) return;
  339. this.selectedMap = { ...this.selectedMap, [key]: n };
  340. },
  341. removeSelectedByItem(item) {
  342. const key = this.findSelectedKeyForItem(item);
  343. if (!key) return;
  344. const next = { ...this.selectedMap };
  345. delete next[key];
  346. this.selectedMap = next;
  347. },
  348. findSelectedKeyForItem(item) {
  349. if (!item) return "";
  350. if (item.id != null && item.id !== "" && this.selectedMap[String(item.id)]) {
  351. return String(item.id);
  352. }
  353. if (item.url) {
  354. const uk = this.urlSelectKey(item.url);
  355. if (this.selectedMap[uk]) return uk;
  356. const hit = Object.entries(this.selectedMap).find(
  357. ([, s]) => s.url && s.url === item.url
  358. );
  359. if (hit) return hit[0];
  360. }
  361. return "";
  362. },
  363. initSelectionFromModel() {
  364. const raw = this.modelValue;
  365. const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
  366. this.selectedMap = {};
  367. list.forEach((item) => {
  368. if (typeof item === "string") {
  369. const url = item.trim();
  370. if (!url) return;
  371. const row = { att_dir: url, real_name: url.split(/[/\\]/).pop() || url };
  372. this.putSelectedItem(this.normalizeFileRow(row));
  373. return;
  374. }
  375. if (item && typeof item === "object") {
  376. const n = this.normalizeFileRow({
  377. att_id: item.id ?? item.att_id,
  378. att_dir: item.url ?? item.att_dir,
  379. real_name: item.name ?? item.real_name,
  380. file_type: item.file_type,
  381. ...item,
  382. });
  383. this.putSelectedItem(n);
  384. }
  385. });
  386. },
  387. /** 编辑回显:按 att_id 或 url 与已选记录匹配 */
  388. isSelectedItem(item) {
  389. return !!this.findSelectedKeyForItem(item);
  390. },
  391. async bootstrap() {
  392. await this.loadCategories();
  393. await this.loadFiles();
  394. },
  395. onFileTypeChange() {
  396. this.activeCategoryId = "";
  397. this.keyword = "";
  398. this.categoryKeyword = "";
  399. this.query.page = 1;
  400. if (!this.isMixedMode && this.multiple) {
  401. this.selectedMap = {};
  402. }
  403. this.loadCategories();
  404. this.loadFiles();
  405. },
  406. isVideoItem(item) {
  407. return String(item?.file_type ?? "") === "2";
  408. },
  409. onCategorySearchClear() {
  410. this.categoryKeyword = "";
  411. },
  412. onCategorySearchEnter() {
  413. const kw = (this.categoryKeyword || "").trim();
  414. if (!kw) {
  415. this.selectCategory("");
  416. return;
  417. }
  418. const list = this.filteredCategories;
  419. if (!list.length) return;
  420. const exact = list.find((c) => String(c.name).toLowerCase() === kw.toLowerCase());
  421. this.selectCategory((exact || list[0]).id);
  422. },
  423. selectCategory(id) {
  424. this.activeCategoryId = id === "" ? "" : String(id);
  425. this.categoryKeyword = "";
  426. this.query.page = 1;
  427. this.loadFiles();
  428. },
  429. flattenCategories(nodes, level = 0, out = []) {
  430. (nodes || []).forEach((node) => {
  431. const id = node.id ?? node.cate_id ?? node.value;
  432. const name = node.name ?? node.title ?? node.label ?? node.cate_name ?? `分类#${id}`;
  433. if (id != null && id !== "") {
  434. out.push({
  435. id,
  436. name,
  437. level,
  438. raw: node,
  439. });
  440. }
  441. const children = node.children || node.child || [];
  442. if (children.length) this.flattenCategories(children, level + 1, out);
  443. });
  444. return out;
  445. },
  446. parseCategoryRows(data) {
  447. if (Array.isArray(data)) return data;
  448. const d = data || {};
  449. if (Array.isArray(d.list)) return d.list;
  450. if (Array.isArray(d.data)) return d.data;
  451. return d.children || [];
  452. },
  453. parseFileRows(data) {
  454. if (Array.isArray(data)) return { list: data, total: data.length };
  455. const d = data || {};
  456. const list = d.list || d.data || d.rows || [];
  457. const total = Number(d.count ?? d.total ?? list.length ?? 0);
  458. return { list, total };
  459. },
  460. normalizeFileRow(row, defaultType) {
  461. const rawId = row.att_id ?? row.id;
  462. const id = rawId != null && rawId !== "" ? String(rawId) : "";
  463. const url = row.att_dir || row.satt_dir || row.url || row.src || "";
  464. const name = row.real_name || row.name || row.file_name || `素材#${id || ""}`;
  465. const ft =
  466. row.file_type != null && row.file_type !== ""
  467. ? String(row.file_type)
  468. : String(defaultType ?? this.fileType);
  469. const file_type = ft === "2" ? "2" : "1";
  470. return { id, name, url, file_type, raw: row };
  471. },
  472. async loadCategories() {
  473. this.categoryLoading = true;
  474. try {
  475. const res = await this.$API.material.categoryList.get(this.fileType);
  476. if (!this.apiOk(res)) {
  477. this.flatCategories = [];
  478. return;
  479. }
  480. this.flatCategories = this.flattenCategories(this.parseCategoryRows(res.data));
  481. } finally {
  482. this.categoryLoading = false;
  483. }
  484. },
  485. buildFileQuery() {
  486. const params = {
  487. file_type: this.fileType,
  488. real_name: this.keyword || "",
  489. page: this.query.page,
  490. limit: this.query.limit,
  491. };
  492. if (this.activeCategoryId !== "") {
  493. params.pid = this.activeCategoryId;
  494. params.category_id = this.activeCategoryId;
  495. params.cate_id = this.activeCategoryId;
  496. params.relation_id = this.activeCategoryId;
  497. }
  498. return params;
  499. },
  500. async loadFiles() {
  501. this.listLoading = true;
  502. try {
  503. const res = await this.$API.material.fileList.get(this.buildFileQuery());
  504. if (!this.apiOk(res)) {
  505. this.fileList = [];
  506. this.total = 0;
  507. return;
  508. }
  509. const { list, total } = this.parseFileRows(res.data);
  510. this.fileList = list
  511. .map((row) => this.normalizeFileRow(row, this.fileType))
  512. .filter((x) => x.id != null);
  513. this.total = total;
  514. } finally {
  515. this.listLoading = false;
  516. }
  517. },
  518. searchFiles() {
  519. this.query.page = 1;
  520. this.loadFiles();
  521. },
  522. handlePageChange(page) {
  523. this.query.page = page;
  524. this.loadFiles();
  525. },
  526. getUniqueSelectedItems() {
  527. const items = [];
  528. const seen = new Set();
  529. Object.values(this.selectedMap).forEach((item) => {
  530. const dedupeKey = item.id ? `id:${item.id}` : item.url ? `url:${item.url}` : "";
  531. if (!dedupeKey || seen.has(dedupeKey)) return;
  532. seen.add(dedupeKey);
  533. items.push(item);
  534. });
  535. return items;
  536. },
  537. toggleSelect(item) {
  538. if (this.isSelectedItem(item)) {
  539. this.removeSelectedByItem(item);
  540. return;
  541. }
  542. if (!this.multiple) {
  543. this.selectedMap = {};
  544. this.putSelectedItem(item);
  545. return;
  546. }
  547. if (this.max > 0 && this.getUniqueSelectedItems().length >= this.max) {
  548. this.$message.warning(`最多选择 ${this.max} 个`);
  549. return;
  550. }
  551. this.putSelectedItem(item);
  552. },
  553. onSelectAllChange(checked) {
  554. if (!checked) {
  555. const next = { ...this.selectedMap };
  556. this.fileList.forEach((f) => {
  557. const k = this.findSelectedKeyForItem(f);
  558. if (k) delete next[k];
  559. });
  560. this.selectedMap = next;
  561. return;
  562. }
  563. if (!this.multiple) {
  564. if (this.fileList[0]) {
  565. this.selectedMap = {};
  566. this.putSelectedItem(this.fileList[0]);
  567. }
  568. return;
  569. }
  570. let hitMax = false;
  571. for (const f of this.fileList) {
  572. if (this.max > 0 && this.getUniqueSelectedItems().length >= this.max) {
  573. hitMax = true;
  574. break;
  575. }
  576. if (!this.isSelectedItem(f)) {
  577. this.putSelectedItem(f);
  578. }
  579. }
  580. if (hitMax) {
  581. this.$message.warning(`最多选择 ${this.max} 个`);
  582. }
  583. },
  584. confirmUse() {
  585. const items = this.getUniqueSelectedItems();
  586. if (!items.length) {
  587. this.$message.warning("请先选择素材");
  588. return;
  589. }
  590. let value;
  591. if (this.valueType === "url") {
  592. const urls = items.map((i) => i.url);
  593. value = this.multiple ? urls : urls[0];
  594. } else {
  595. value = this.multiple ? items : items[0];
  596. }
  597. this.$emit("update:modelValue", value);
  598. this.$emit("confirm", value, items);
  599. this.close();
  600. },
  601. previewItem(item) {
  602. if (!item?.url) {
  603. this.$message.warning("暂无预览地址");
  604. return;
  605. }
  606. this.previewUrl = item.url;
  607. this.previewTitle = item.name || "预览";
  608. this.previewType = this.isVideoItem(item) ? "video" : "image";
  609. this.previewVisible = true;
  610. },
  611. onPreviewClosed() {
  612. this.previewUrl = "";
  613. this.previewTitle = "";
  614. },
  615. async removeOne(item) {
  616. const confirm = await this.$confirm(`确认删除「${item.name}」吗?`, "提示", {
  617. type: "warning",
  618. }).catch(() => {});
  619. if (confirm !== "confirm") return;
  620. const res = await this.$API.material.fileDelete.delete([item.id]);
  621. if (!this.apiOk(res)) {
  622. this.$message.error(res.msg || res.message || "删除失败");
  623. return;
  624. }
  625. this.removeSelectedByItem(item);
  626. this.$message.success(res.msg || res.message || "删除成功");
  627. this.loadFiles();
  628. },
  629. async removeSelected() {
  630. if (!this.selectedCount) return;
  631. const confirm = await this.$confirm(
  632. `确认删除选中的 ${this.selectedCount} 个素材吗?`,
  633. "提示",
  634. { type: "warning" }
  635. ).catch(() => {});
  636. if (confirm !== "confirm") return;
  637. const items = this.getUniqueSelectedItems();
  638. const ids = items.map((i) => i.id).filter((id) => id != null && id !== "");
  639. const res = await this.$API.material.fileDelete.delete(ids);
  640. if (!this.apiOk(res)) {
  641. this.$message.error(res.msg || res.message || "删除失败");
  642. return;
  643. }
  644. this.selectedMap = {};
  645. this.$message.success(res.msg || res.message || "删除成功");
  646. this.loadFiles();
  647. },
  648. uploadRequest(param) {
  649. const uploadType =
  650. this.isMixedMode && param.file?.type?.startsWith("video/")
  651. ? "2"
  652. : this.fileType;
  653. const data = new FormData();
  654. data.append(uploadConfig.filename || "file", param.file);
  655. if (this.activeCategoryId !== "") {
  656. data.append("pid", String(this.activeCategoryId));
  657. data.append("category_id", String(this.activeCategoryId));
  658. }
  659. this.$API.material.upload
  660. .post(uploadType, data, {
  661. onUploadProgress: (e) => {
  662. if (e.total) {
  663. const percent = Math.round((e.loaded / e.total) * 100);
  664. param.onProgress({ percent });
  665. }
  666. },
  667. })
  668. .then((res) => {
  669. const parsed = uploadConfig.parseData(res);
  670. const uploadCode = parsed.code ?? res.status ?? res.code;
  671. if (uploadCode == uploadConfig.successCode || this.apiOk(res)) {
  672. param.onSuccess(res);
  673. this.loadFiles();
  674. } else {
  675. param.onError(parsed.msg || res.msg || "上传失败");
  676. }
  677. })
  678. .catch((err) => {
  679. param.onError(err);
  680. });
  681. },
  682. },
  683. };
  684. </script>
  685. <style scoped>
  686. .sc-material-picker {
  687. display: inline-block;
  688. }
  689. .sc-material-picker__trigger {
  690. display: inline-block;
  691. }
  692. .sc-material-picker-dialog :deep(.el-dialog__body) {
  693. padding: 0 20px 16px;
  694. }
  695. .picker-tabs {
  696. margin-bottom: 0;
  697. }
  698. .picker-body {
  699. display: flex;
  700. height: 560px;
  701. border-top: 1px solid var(--el-border-color-lighter);
  702. margin-top: 8px;
  703. }
  704. .picker-side {
  705. width: 168px;
  706. flex-shrink: 0;
  707. border-right: 1px solid var(--el-border-color-lighter);
  708. display: flex;
  709. flex-direction: column;
  710. padding: 10px 0;
  711. }
  712. .category-search {
  713. margin: 0 8px 8px;
  714. width: calc(100% - 16px);
  715. }
  716. .category-search :deep(.el-input__wrapper) {
  717. padding-left: 8px;
  718. padding-right: 8px;
  719. }
  720. .category-empty {
  721. padding: 12px;
  722. font-size: 12px;
  723. color: var(--el-text-color-secondary);
  724. text-align: center;
  725. cursor: default;
  726. }
  727. .category-scroll {
  728. flex: 1;
  729. height: 0;
  730. }
  731. .category-list {
  732. list-style: none;
  733. margin: 0;
  734. padding: 0 0 8px;
  735. }
  736. .category-list li {
  737. display: flex;
  738. align-items: center;
  739. gap: 6px;
  740. padding: 9px 12px;
  741. cursor: pointer;
  742. font-size: 13px;
  743. }
  744. .category-list li:hover {
  745. background: var(--el-fill-color-light);
  746. }
  747. .category-list li.active {
  748. background: var(--el-color-primary-light-9);
  749. color: var(--el-color-primary);
  750. }
  751. .category-name {
  752. overflow: hidden;
  753. text-overflow: ellipsis;
  754. white-space: nowrap;
  755. }
  756. .picker-main {
  757. flex: 1;
  758. min-width: 0;
  759. display: flex;
  760. flex-direction: column;
  761. padding: 12px 0 0 16px;
  762. }
  763. .picker-toolbar {
  764. display: flex;
  765. align-items: center;
  766. justify-content: space-between;
  767. gap: 12px;
  768. margin-bottom: 12px;
  769. flex-wrap: wrap;
  770. }
  771. .picker-toolbar-left {
  772. display: flex;
  773. flex-wrap: wrap;
  774. align-items: center;
  775. gap: 8px;
  776. }
  777. .picker-upload {
  778. display: inline-block;
  779. }
  780. .picker-grid-wrap {
  781. flex: 1;
  782. min-height: 0;
  783. }
  784. .picker-grid-scroll {
  785. height: 100%;
  786. }
  787. .picker-grid {
  788. --picker-thumb-size: 120px;
  789. display: grid;
  790. grid-template-columns: repeat(auto-fill, var(--picker-thumb-size));
  791. gap: 12px;
  792. padding: 4px 4px 12px;
  793. justify-content: start;
  794. }
  795. .picker-item {
  796. width: var(--picker-thumb-size);
  797. user-select: none;
  798. }
  799. .picker-thumb {
  800. position: relative;
  801. width: var(--picker-thumb-size);
  802. height: var(--picker-thumb-size);
  803. flex-shrink: 0;
  804. border: 1px solid var(--el-border-color-lighter);
  805. border-radius: 4px;
  806. overflow: hidden;
  807. background: var(--el-fill-color-lighter);
  808. cursor: pointer;
  809. }
  810. .picker-thumb :deep(.el-image) {
  811. display: block;
  812. width: 100%;
  813. height: 100%;
  814. }
  815. .picker-thumb :deep(.el-image__inner) {
  816. width: 100%;
  817. height: 100%;
  818. object-fit: cover;
  819. }
  820. .video-thumb {
  821. position: absolute;
  822. inset: 0;
  823. display: flex;
  824. align-items: center;
  825. justify-content: center;
  826. background: #1a1a1a;
  827. }
  828. .video-thumb video {
  829. width: 100%;
  830. height: 100%;
  831. max-width: 100%;
  832. max-height: 100%;
  833. object-fit: cover;
  834. display: block;
  835. }
  836. .video-icon {
  837. font-size: 32px;
  838. color: #fff;
  839. }
  840. .picker-check {
  841. position: absolute;
  842. top: 6px;
  843. right: 6px;
  844. z-index: 2;
  845. width: 18px;
  846. height: 18px;
  847. border: 1px solid #fff;
  848. background: rgba(0, 0, 0, 0.35);
  849. display: flex;
  850. align-items: center;
  851. justify-content: center;
  852. color: #fff;
  853. font-size: 12px;
  854. box-sizing: border-box;
  855. }
  856. .picker-item.selected .picker-thumb {
  857. border-color: var(--el-color-primary);
  858. box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
  859. }
  860. .picker-item.selected .picker-check {
  861. background: var(--el-color-primary);
  862. border-color: var(--el-color-primary);
  863. }
  864. .picker-item.selected .picker-check .el-icon {
  865. display: flex;
  866. }
  867. .picker-check .el-icon {
  868. display: none;
  869. }
  870. .picker-name {
  871. width: var(--picker-thumb-size);
  872. margin: 6px 0 0;
  873. font-size: 12px;
  874. text-align: center;
  875. overflow: hidden;
  876. text-overflow: ellipsis;
  877. white-space: nowrap;
  878. color: var(--el-text-color-regular);
  879. }
  880. .picker-actions {
  881. display: none;
  882. justify-content: center;
  883. gap: 12px;
  884. margin-top: 4px;
  885. font-size: 12px;
  886. }
  887. .picker-item:hover .picker-actions {
  888. display: flex;
  889. }
  890. .picker-actions span {
  891. color: var(--el-color-primary);
  892. cursor: pointer;
  893. }
  894. .picker-actions span.danger {
  895. color: var(--el-color-danger);
  896. }
  897. .picker-footer {
  898. display: flex;
  899. align-items: center;
  900. justify-content: space-between;
  901. padding-top: 12px;
  902. border-top: 1px solid var(--el-border-color-lighter);
  903. flex-wrap: wrap;
  904. gap: 8px;
  905. }
  906. .picker-footer-left {
  907. display: flex;
  908. align-items: center;
  909. gap: 16px;
  910. }
  911. .selected-tip {
  912. font-size: 13px;
  913. color: var(--el-text-color-secondary);
  914. }
  915. .material-preview-dialog :deep(.el-dialog__body) {
  916. padding: 12px 20px 20px;
  917. text-align: center;
  918. }
  919. .preview-media {
  920. display: block;
  921. max-width: min(90vw, 960px);
  922. max-height: 75vh;
  923. margin: 0 auto;
  924. object-fit: contain;
  925. }
  926. </style>