upload.vue 11 KB

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