upload.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <template>
  2. <div class="cl-upload__wrap" :class="[customClass]">
  3. <div
  4. class="cl-upload"
  5. :class="[
  6. `cl-upload--${type}`,
  7. {
  8. 'is-disabled': disabled,
  9. 'is-multiple': multiple,
  10. 'is-small': small
  11. }
  12. ]"
  13. >
  14. <template v-if="!drag">
  15. <div class="cl-upload__file-btn" v-if="type == 'file'">
  16. <el-upload
  17. :ref="setRefs('upload')"
  18. :drag="drag"
  19. action=""
  20. :accept="accept"
  21. :show-file-list="false"
  22. :before-upload="onBeforeUpload"
  23. :http-request="httpRequest"
  24. :headers="headers"
  25. :multiple="multiple"
  26. :disabled="disabled"
  27. >
  28. <slot>
  29. <el-button type="success">{{ text }}</el-button>
  30. </slot>
  31. </el-upload>
  32. </div>
  33. </template>
  34. <!-- 列表 -->
  35. <vue-draggable
  36. class="cl-upload__list"
  37. tag="div"
  38. v-model="list"
  39. ghost-class="Ghost"
  40. drag-class="Drag"
  41. item-key="uid"
  42. :disabled="!draggable"
  43. @end="update"
  44. v-if="showList"
  45. >
  46. <!-- 触发器 -->
  47. <template #footer>
  48. <div class="cl-upload__footer" v-if="(type == 'image' || drag) && isAdd">
  49. <el-upload
  50. action=""
  51. :drag="drag"
  52. :ref="setRefs('upload')"
  53. :accept="accept"
  54. :show-file-list="false"
  55. :before-upload="onBeforeUpload"
  56. :http-request="httpRequest"
  57. :headers="headers"
  58. :multiple="multiple"
  59. :disabled="disabled"
  60. >
  61. <slot>
  62. <!-- 拖拽方式 -->
  63. <div class="cl-upload__demo is-dragger" v-if="drag">
  64. <el-icon :size="46">
  65. <upload-filled />
  66. </el-icon>
  67. <div>
  68. 点击上传或将文件拖动到此处,文件大小限制{{ limitSize }}M
  69. </div>
  70. </div>
  71. <!-- 点击方式 -->
  72. <div class="cl-upload__demo" v-else>
  73. <el-icon :size="36">
  74. <component :is="icon" v-if="icon" />
  75. <picture-filled v-else />
  76. </el-icon>
  77. <span class="text" v-if="text">{{ text }}</span>
  78. </div>
  79. </slot>
  80. </el-upload>
  81. </div>
  82. </template>
  83. <!-- 列表 -->
  84. <template #item="{ element: item, index }">
  85. <el-upload
  86. action=""
  87. :accept="accept"
  88. :show-file-list="false"
  89. :http-request="
  90. (req) => {
  91. return httpRequest(req, item);
  92. }
  93. "
  94. :before-upload="
  95. (file) => {
  96. onBeforeUpload(file, item);
  97. }
  98. "
  99. :headers="headers"
  100. :disabled="disabled"
  101. >
  102. <slot name="item" :item="item" :index="index">
  103. <div class="cl-upload__item">
  104. <upload-item
  105. :show-tag="showTag"
  106. :item="item"
  107. :list="list"
  108. :disabled="disabled"
  109. :deletable="deletable"
  110. @remove="remove(index)"
  111. />
  112. <!-- 小图模式 -->
  113. <el-icon
  114. class="cl-upload__item-remove"
  115. v-if="small"
  116. @click.stop="remove(index)"
  117. >
  118. <circle-close-filled />
  119. </el-icon>
  120. </div>
  121. </slot>
  122. </el-upload>
  123. </template>
  124. </vue-draggable>
  125. </div>
  126. </div>
  127. </template>
  128. <script lang="ts" setup name="cl-upload">
  129. import { computed, ref, watch, type PropType, nextTick } from "vue";
  130. import { isArray, isEmpty, isNumber } from "lodash-es";
  131. import VueDraggable from "vuedraggable";
  132. import { ElMessage } from "element-plus";
  133. import { PictureFilled, UploadFilled, CircleCloseFilled } from "@element-plus/icons-vue";
  134. import { useForm } from "@cool-vue/crud";
  135. import { useCool } from "/@/cool";
  136. import { useBase } from "/$/base";
  137. import { uuid, isPromise } from "/@/cool/utils";
  138. import { getUrls, getType } from "../utils";
  139. import { useUpload } from "../hooks";
  140. import UploadItem from "./upload-item/index.vue";
  141. import type { Upload } from "../types";
  142. const props = defineProps({
  143. // 绑定值,单选时字符串,多选时字符串数组
  144. modelValue: {
  145. type: [String, Array],
  146. default: () => []
  147. },
  148. // 上传类型
  149. type: {
  150. type: String as PropType<"image" | "file">,
  151. default: "image"
  152. },
  153. // 允许上传的文件类型
  154. accept: String,
  155. // 是否多选
  156. multiple: Boolean,
  157. // 限制数量
  158. limit: Number,
  159. // 限制大小
  160. limitSize: Number,
  161. // 是否自动上传
  162. autoUpload: {
  163. type: Boolean,
  164. default: true
  165. },
  166. // 元素大小
  167. size: [String, Number, Array],
  168. // 小图模式
  169. small: Boolean,
  170. // 显示图标
  171. icon: null,
  172. // 显示文案
  173. text: String,
  174. // 显示角标
  175. showTag: {
  176. type: Boolean,
  177. default: true
  178. },
  179. // 是否显示上传列表
  180. showFileList: {
  181. type: Boolean,
  182. default: true
  183. },
  184. // 列表是否可拖拽
  185. draggable: Boolean,
  186. // 是否拖拽到特定区域以进行上传
  187. drag: Boolean,
  188. // 是否禁用
  189. disabled: Boolean,
  190. // 是否可删除
  191. deletable: Boolean,
  192. // 自定义样式名
  193. customClass: String,
  194. // 上传前钩子
  195. beforeUpload: Function,
  196. // 云端上传路径前缀
  197. prefixPath: String,
  198. // CRUD穿透值
  199. isEdit: Boolean,
  200. scope: Object,
  201. prop: String,
  202. isDisabled: Boolean
  203. });
  204. const emit = defineEmits(["update:modelValue", "change", "upload", "success", "error", "progress"]);
  205. const { refs, setRefs } = useCool();
  206. const { user } = useBase();
  207. const Form = useForm();
  208. const { options, toUpload } = useUpload();
  209. // 元素尺寸
  210. const size = computed(() => {
  211. const d = props.size || options.size;
  212. return (isArray(d) ? d : [d, d]).map((e: string | number) => (isNumber(e) ? e + "px" : e));
  213. });
  214. // 是否禁用
  215. const disabled = computed(() => {
  216. return props.isDisabled || props.disabled;
  217. });
  218. // 最大上传数量
  219. const limit = props.limit || options.limit.upload;
  220. // 图片大小限制
  221. const limitSize = props.limitSize || options.limit.size;
  222. // 文案
  223. const text = computed(() => {
  224. if (props.text !== undefined) {
  225. return props.text;
  226. } else {
  227. switch (props.type) {
  228. case "file":
  229. return "选择文件";
  230. case "image":
  231. return "选择图片";
  232. default:
  233. return "";
  234. }
  235. }
  236. });
  237. // 请求头
  238. const headers = computed(() => {
  239. return {
  240. Authorization: user.token
  241. };
  242. });
  243. // 列表
  244. const list = ref<Upload.Item[]>([]);
  245. // 显示上传列表
  246. const showList = computed(() => {
  247. if (props.type == "file") {
  248. return props.showFileList ? !isEmpty(list.value) : false;
  249. } else {
  250. return true;
  251. }
  252. });
  253. // 文件格式
  254. const accept = computed(() => {
  255. return props.accept || (props.type == "file" ? "" : "image/*");
  256. });
  257. // 能否添加
  258. const isAdd = computed(() => {
  259. let len = list.value.length;
  260. if (props.multiple && !disabled.value) {
  261. return limit - len > 0;
  262. }
  263. return len == 0;
  264. });
  265. // 上传前
  266. async function onBeforeUpload(file: any, item?: Upload.Item) {
  267. function next() {
  268. const d = {
  269. uid: file.uid,
  270. size: file.size,
  271. name: file.name,
  272. type: getType(file.name),
  273. progress: props.autoUpload ? 0 : 100, // 非自动上传时默认100%
  274. url: "",
  275. preload: "",
  276. error: ""
  277. };
  278. // 图片预览地址
  279. if (d.type == "image") {
  280. if (file instanceof File) {
  281. d.preload = window.webkitURL.createObjectURL(file);
  282. }
  283. }
  284. // 上传事件
  285. emit("upload", d, file);
  286. // 赋值
  287. if (item) {
  288. Object.assign(item, d);
  289. } else {
  290. if (props.multiple) {
  291. if (!isAdd.value) {
  292. ElMessage.warning(`最多只能上传${limit}个文件`);
  293. return false;
  294. } else {
  295. list.value.push(d);
  296. }
  297. } else {
  298. list.value = [d];
  299. }
  300. }
  301. return true;
  302. }
  303. // 自定义上传事件
  304. if (props.beforeUpload) {
  305. let r = props.beforeUpload(file, item, { next });
  306. if (isPromise(r)) {
  307. r.then(next).catch(() => null);
  308. } else {
  309. if (r) {
  310. r = next();
  311. }
  312. }
  313. return r;
  314. } else {
  315. if (file.size / 1024 / 1024 >= limitSize) {
  316. ElMessage.error(`上传文件大小不能超过 ${limitSize}MB!`);
  317. return false;
  318. }
  319. return next();
  320. }
  321. }
  322. // 移除
  323. function remove(index: number) {
  324. list.value.splice(index, 1);
  325. update();
  326. }
  327. // 清空
  328. function clear() {
  329. list.value = [];
  330. }
  331. // 文件上传请求
  332. async function httpRequest(req: any, item?: Upload.Item) {
  333. if (!item) {
  334. item = list.value.find((e) => e.uid == req.file.uid);
  335. }
  336. if (!item) {
  337. return false;
  338. }
  339. // 上传请求
  340. toUpload(req.file, {
  341. prefixPath: props.prefixPath,
  342. onProgress(progress) {
  343. item!.progress = progress;
  344. emit("progress", item);
  345. }
  346. })
  347. .then((res) => {
  348. Object.assign(item!, res);
  349. emit("success", item);
  350. update();
  351. })
  352. .catch((err) => {
  353. item!.error = err.message;
  354. emit("error", item);
  355. });
  356. }
  357. // 检测是否还有未上传的文件
  358. function check() {
  359. return list.value.find((e) => !e.url);
  360. }
  361. // 更新
  362. function update() {
  363. if (!check()) {
  364. const urls = getUrls(list.value);
  365. const val = props.multiple ? getUrls(list.value) : urls[0] || "";
  366. // 更新绑定值
  367. emit("update:modelValue", val);
  368. emit("change", val);
  369. nextTick(() => {
  370. if (props.prop) {
  371. Form.value?.validateField(props.prop);
  372. }
  373. // 清空
  374. refs.upload?.clearFiles();
  375. });
  376. }
  377. }
  378. // 手动上传
  379. function upload(file: File) {
  380. clear();
  381. refs.upload?.clearFiles();
  382. nextTick(() => {
  383. refs.upload?.handleStart(file);
  384. refs.upload?.submit();
  385. });
  386. }
  387. // 监听绑定值
  388. watch(
  389. () => props.modelValue,
  390. (val: any[] | string) => {
  391. if (check()) {
  392. return false;
  393. }
  394. const urls = (isArray(val) ? val : [val]).filter(Boolean);
  395. list.value = urls
  396. .map((url, index) => {
  397. const old = list.value[index] || {};
  398. return Object.assign(
  399. {
  400. progress: 100,
  401. uid: uuid()
  402. },
  403. old,
  404. {
  405. type: getType(url),
  406. url,
  407. preload: old.url == url ? old.preload : url // 防止重复预览
  408. }
  409. );
  410. })
  411. .filter((_, i) => {
  412. return props.multiple ? true : i == 0;
  413. });
  414. },
  415. {
  416. immediate: true
  417. }
  418. );
  419. // 导出
  420. defineExpose({
  421. isAdd,
  422. list,
  423. check,
  424. clear,
  425. remove,
  426. upload
  427. });
  428. </script>
  429. <style lang="scss" scoped>
  430. .cl-upload {
  431. line-height: normal;
  432. .Ghost {
  433. .cl-upload__item {
  434. border: 1px dashed var(--el-color-primary) !important;
  435. }
  436. }
  437. &__file {
  438. width: 100%;
  439. &-btn {
  440. width: fit-content;
  441. }
  442. }
  443. &__list {
  444. display: flex;
  445. flex-wrap: wrap;
  446. }
  447. &__item,
  448. &__demo {
  449. display: flex;
  450. flex-direction: column;
  451. align-items: center;
  452. justify-content: center;
  453. height: v-bind("size[0]");
  454. width: v-bind("size[1]");
  455. background-color: var(--el-fill-color-light);
  456. color: var(--el-text-color-regular);
  457. border-radius: 6px;
  458. cursor: pointer;
  459. box-sizing: border-box;
  460. position: relative;
  461. user-select: none;
  462. }
  463. &__demo {
  464. font-size: 13px;
  465. .el-icon {
  466. font-size: 46px;
  467. }
  468. .text {
  469. margin-top: 5px;
  470. }
  471. &.is-dragger {
  472. padding: 20px;
  473. }
  474. }
  475. &__file-btn {
  476. & + .cl-upload__list {
  477. margin-top: 10px;
  478. }
  479. }
  480. :deep(.el-upload) {
  481. display: block;
  482. .el-upload-dragger {
  483. padding: 0;
  484. border: 0;
  485. background-color: transparent !important;
  486. position: relative;
  487. &.is-dragover {
  488. &::after {
  489. display: block;
  490. content: "";
  491. position: absolute;
  492. left: 0;
  493. top: 0;
  494. height: 100%;
  495. width: 100%;
  496. pointer-events: none;
  497. border-radius: 8px;
  498. box-sizing: border-box;
  499. border: 1px dashed var(--el-color-primary);
  500. }
  501. }
  502. }
  503. }
  504. &.is-disabled {
  505. .cl-upload__demo {
  506. color: var(--el-text-color-placeholder);
  507. }
  508. :deep(.cl-upload__item) {
  509. cursor: not-allowed;
  510. background-color: var(--el-disabled-bg-color);
  511. }
  512. }
  513. &.is-multiple {
  514. .cl-upload__list {
  515. margin-bottom: -5px;
  516. }
  517. .cl-upload__item {
  518. margin: 0 5px 5px 0;
  519. }
  520. .cl-upload__footer {
  521. margin-bottom: 5px;
  522. }
  523. }
  524. &.is-small {
  525. .cl-upload__demo {
  526. .el-icon {
  527. font-size: 20px !important;
  528. }
  529. .text {
  530. display: none;
  531. }
  532. }
  533. .cl-upload__item-remove {
  534. position: absolute;
  535. right: 0px;
  536. top: 0px;
  537. color: var(--el-color-danger);
  538. background-color: #fff;
  539. border-radius: 100%;
  540. }
  541. :deep(.cl-upload-item) {
  542. .cl-upload-item__progress-bar,
  543. .cl-upload-item__actions,
  544. .cl-upload-item__tag {
  545. display: none;
  546. }
  547. .cl-upload-item__progress-value {
  548. font-size: 12px;
  549. }
  550. }
  551. }
  552. &:not(.is-disabled) {
  553. .cl-upload__demo {
  554. &:hover {
  555. color: var(--el-color-primary);
  556. }
  557. }
  558. }
  559. }
  560. </style>