upload.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  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-drag': drag
  10. }
  11. ]"
  12. >
  13. <template v-if="!drag">
  14. <div class="cl-upload__file-btn" @click="space.open()" 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="uploadDisabled"
  26. >
  27. <slot>
  28. <el-button type="success">{{ text }}</el-button>
  29. </slot>
  30. </el-upload>
  31. </div>
  32. </template>
  33. <!-- 列表 -->
  34. <draggable
  35. class="cl-upload__list"
  36. tag="div"
  37. v-model="list"
  38. ghost-class="Ghost"
  39. drag-class="Drag"
  40. :options="{
  41. group: 'Upload',
  42. animation: 300,
  43. draggable: '.is-drag',
  44. disabled: !draggable
  45. }"
  46. item-key="uid"
  47. @end="update"
  48. v-if="showFileList"
  49. >
  50. <template #footer>
  51. <div
  52. class="cl-upload__footer"
  53. @click="space.open()"
  54. v-if="(type == 'image' || drag) && isAdd"
  55. >
  56. <el-upload
  57. action=""
  58. :drag="drag"
  59. :ref="setRefs('upload')"
  60. :accept="accept"
  61. :show-file-list="false"
  62. :before-upload="onBeforeUpload"
  63. :http-request="httpRequest"
  64. :headers="headers"
  65. :multiple="multiple"
  66. :disabled="uploadDisabled"
  67. >
  68. <slot>
  69. <div class="cl-upload__demo is-dragger" v-if="drag">
  70. <el-icon :size="46">
  71. <upload-filled />
  72. </el-icon>
  73. <div>
  74. 点击上传或将文件拖动到此处,文件大小限制{{ limitSize }}M
  75. </div>
  76. </div>
  77. <div class="cl-upload__demo" v-else>
  78. <el-icon :size="36">
  79. <component :is="icon" v-if="icon" />
  80. <picture-filled v-else />
  81. </el-icon>
  82. <span v-if="text">{{ text }}</span>
  83. </div>
  84. </slot>
  85. </el-upload>
  86. </div>
  87. </template>
  88. <template #item="{ element: item, index }">
  89. <el-upload
  90. class="is-drag"
  91. action=""
  92. :accept="accept"
  93. :show-file-list="false"
  94. :http-request="
  95. (req) => {
  96. return httpRequest(req, item);
  97. }
  98. "
  99. :before-upload="
  100. (file) => {
  101. onBeforeUpload(file, item);
  102. }
  103. "
  104. :headers="headers"
  105. :disabled="uploadDisabled"
  106. @click="space.open(item)"
  107. v-if="showFileList"
  108. >
  109. <slot name="item" :item="item" :index="index">
  110. <div class="cl-upload__item">
  111. <upload-item
  112. :item="item"
  113. :list="list"
  114. :disabled="disabled"
  115. @remove="remove(index)"
  116. />
  117. </div>
  118. </slot>
  119. </el-upload>
  120. </template>
  121. </draggable>
  122. </div>
  123. </div>
  124. <!-- 文件空间 -->
  125. <cl-upload-space
  126. :ref="setRefs('space')"
  127. :show-btn="false"
  128. :accept="accept"
  129. @confirm="space.onConfirm"
  130. v-if="isSpace"
  131. />
  132. </template>
  133. <script lang="ts" setup name="cl-upload">
  134. import { computed, ref, watch, PropType, nextTick } from "vue";
  135. import { isArray, isNumber } from "lodash-es";
  136. import { type AxiosProgressEvent } from "axios";
  137. import Draggable from "vuedraggable";
  138. import { ElMessage } from "element-plus";
  139. import { PictureFilled, UploadFilled } from "@element-plus/icons-vue";
  140. import { useForm } from "@cool-vue/crud";
  141. import { useCool, module } from "/@/cool";
  142. import { useBase } from "/$/base";
  143. import { uuid, isPromise } from "/@/cool/utils";
  144. import { getUrls, getType, pathJoin } from "../utils";
  145. import { Upload } from "../types";
  146. import UploadItem from "./upload-item/index.vue";
  147. const props = defineProps({
  148. // 绑定值,单选时字符串,多选时字符串数组
  149. modelValue: {
  150. type: [String, Array],
  151. default: () => []
  152. },
  153. // 上传类型
  154. type: {
  155. type: String as PropType<"image" | "file">,
  156. default: "image"
  157. },
  158. // 允许上传的文件类型
  159. accept: String,
  160. // 是否多选
  161. multiple: Boolean,
  162. // 限制数量
  163. limit: Number,
  164. // 限制大小
  165. limitSize: Number,
  166. // 是否自动上传
  167. autoUpload: {
  168. type: Boolean,
  169. default: true
  170. },
  171. // 元素大小
  172. size: [String, Number, Array],
  173. // 显示图标
  174. icon: null,
  175. // 显示文案
  176. text: String,
  177. // 是否显示上传列表
  178. showFileList: {
  179. type: Boolean,
  180. default: true
  181. },
  182. // 列表是否可拖拽
  183. draggable: Boolean,
  184. // 是否拖拽到特定区域以进行上传
  185. drag: Boolean,
  186. // 是否禁用
  187. disabled: Boolean,
  188. // 自定义样式名
  189. customClass: String,
  190. // 上传前钩子
  191. beforeUpload: Function,
  192. // 是否使用文件空间
  193. isSpace: Boolean,
  194. // 云端上传路径前缀
  195. prefixPath: {
  196. type: String,
  197. default: "app"
  198. },
  199. // 云端上传路径
  200. menu: {
  201. type: String,
  202. default: "base"
  203. },
  204. // CRUD穿透值
  205. isEdit: Boolean,
  206. scope: Object,
  207. prop: String,
  208. isDisabled: Boolean
  209. });
  210. const emit = defineEmits(["update:modelValue", "upload", "success", "error", "progress"]);
  211. const { service, refs, setRefs } = useCool();
  212. const { user } = useBase();
  213. const Form = useForm();
  214. // 模块配置
  215. const { options } = module.get("upload");
  216. // 元素尺寸
  217. const size = computed(() => {
  218. const d = props.size || options.size;
  219. return (isArray(d) ? d : [d, d]).map((e: string | number) => (isNumber(e) ? e + "px" : e));
  220. });
  221. // 是否禁用
  222. const disabled = computed(() => {
  223. return props.isDisabled || props.disabled;
  224. });
  225. // 上传禁用
  226. const uploadDisabled = computed(() => {
  227. return disabled.value || props.isSpace;
  228. });
  229. // 最大上传数量
  230. const limit = props.limit || options.limit.upload;
  231. // 图片大小限制
  232. const limitSize = props.limitSize || options.limit.size;
  233. // 文案
  234. const text = computed(() => {
  235. if (props.text) {
  236. return props.text;
  237. } else {
  238. switch (props.type) {
  239. case "file":
  240. return "选择文件";
  241. case "image":
  242. return "选择图片";
  243. default:
  244. return "";
  245. }
  246. }
  247. });
  248. // 请求头
  249. const headers = computed(() => {
  250. return {
  251. Authorization: user.token
  252. };
  253. });
  254. // 列表
  255. const list = ref<Upload.Item[]>([]);
  256. // 文件格式
  257. const accept = computed(() => {
  258. return props.accept || (props.type == "file" ? "" : "image/*");
  259. });
  260. // 能否添加
  261. const isAdd = computed(() => {
  262. return props.multiple
  263. ? !disabled.value && limit - list.value.length > 0
  264. : list.value.length == 0;
  265. });
  266. // 上传前
  267. async function onBeforeUpload(file: any, item?: Upload.Item) {
  268. function next() {
  269. const d = {
  270. uid: file.uid,
  271. size: file.size,
  272. name: file.name,
  273. type: getType(file.name),
  274. progress: 0,
  275. url: "",
  276. preload: "",
  277. error: ""
  278. };
  279. // 图片预览地址
  280. if (d.type == "image") {
  281. if (file instanceof File) {
  282. d.preload = window.webkitURL.createObjectURL(file);
  283. }
  284. }
  285. // 上传事件
  286. emit("upload", d, file);
  287. // 是否自动上传
  288. if (props.autoUpload) {
  289. // 赋值
  290. if (item) {
  291. Object.assign(item, d);
  292. } else {
  293. if (props.multiple) {
  294. if (!isAdd.value) {
  295. ElMessage.warning(`最多只能上传${limit.value}个文件`);
  296. } else {
  297. list.value.push(d);
  298. }
  299. } else {
  300. list.value = [d];
  301. }
  302. }
  303. return true;
  304. }
  305. return true;
  306. }
  307. // 自定义上传事件
  308. if (props.beforeUpload) {
  309. const r = props.beforeUpload(file, item, { next });
  310. if (isPromise(r)) {
  311. r.then(next).catch(() => null);
  312. } else {
  313. if (r) {
  314. next();
  315. }
  316. }
  317. return r;
  318. } else {
  319. if (file.size / 1024 / 1024 >= limitSize) {
  320. ElMessage.error(`上传文件大小不能超过 ${limitSize}MB!`);
  321. return false;
  322. }
  323. return next();
  324. }
  325. }
  326. // 移除
  327. function remove(index: number) {
  328. list.value.splice(index, 1);
  329. update();
  330. }
  331. // 清空
  332. function clear() {
  333. list.value = [];
  334. }
  335. // 文件上传请求
  336. async function httpRequest(req: any, item?: any) {
  337. if (!item) {
  338. item = list.value.find((e) => e.uid == req.file.uid);
  339. }
  340. if (!item) {
  341. return false;
  342. }
  343. // 文件id
  344. const fileId = uuid("");
  345. try {
  346. // 上传模式、类型
  347. const { mode, type } = await service.base.comm.uploadMode();
  348. // 本地上传
  349. const isLocal = mode == "local";
  350. // 文件名
  351. const fileName = fileId + "_" + req.file.name;
  352. // Key
  353. const key = isLocal ? fileName : pathJoin(props.prefixPath, props.menu, fileName);
  354. // 多种上传请求
  355. return new Promise((resolve, reject) => {
  356. // 上传到云端
  357. async function next({
  358. host,
  359. preview,
  360. data
  361. }: {
  362. host: string;
  363. preview?: string;
  364. data?: any;
  365. }) {
  366. const fd = new FormData();
  367. // key
  368. fd.append("key", key);
  369. // 签名数据
  370. for (const i in data) {
  371. if (!fd.has(i)) {
  372. fd.append(i, data[i]);
  373. }
  374. }
  375. // 文件
  376. fd.append("file", req.file);
  377. // 上传
  378. await service
  379. .request({
  380. url: host,
  381. method: "POST",
  382. headers: {
  383. "Content-Type": "multipart/form-data",
  384. Authorization: isLocal ? user.token : null
  385. },
  386. timeout: 600000,
  387. data: fd,
  388. onUploadProgress(e: AxiosProgressEvent) {
  389. item.progress = e.total ? Math.floor((e.loaded / e.total) * 100) : 0;
  390. emit("progress", item);
  391. },
  392. proxy: isLocal,
  393. NProgress: false
  394. })
  395. .then((res) => {
  396. if (isLocal) {
  397. item.url = res;
  398. } else {
  399. item.url = pathJoin(preview || host, key);
  400. }
  401. item.fileId = fileId;
  402. item.key = key;
  403. emit("success", item);
  404. resolve(item.url);
  405. update();
  406. })
  407. .catch((err) => {
  408. ElMessage.error(err.message);
  409. item.error = err.message;
  410. emit("error", item);
  411. reject(err);
  412. });
  413. }
  414. if (isLocal) {
  415. next({
  416. host: "/admin/base/comm/upload"
  417. });
  418. } else {
  419. service.base.comm
  420. .upload(
  421. type == "aws"
  422. ? {
  423. key
  424. }
  425. : {}
  426. )
  427. .then((res) => {
  428. switch (type) {
  429. // 腾讯
  430. case "cos":
  431. next({
  432. host: res.url,
  433. data: res.credentials
  434. });
  435. break;
  436. // 阿里
  437. case "oss":
  438. next({
  439. host: res.host,
  440. data: {
  441. OSSAccessKeyId: res.OSSAccessKeyId,
  442. policy: res.policy,
  443. signature: res.signature
  444. }
  445. });
  446. break;
  447. // 七牛
  448. case "qiniu":
  449. next({
  450. host: res.uploadUrl,
  451. preview: res.publicDomain,
  452. data: {
  453. token: res.token
  454. }
  455. });
  456. break;
  457. // aws
  458. case "aws":
  459. next({
  460. host: res.url,
  461. data: res.fields
  462. });
  463. break;
  464. }
  465. })
  466. .catch(reject);
  467. }
  468. });
  469. } catch (err) {
  470. ElMessage.error("上传配置错误");
  471. }
  472. }
  473. // 检测是否还有未上传的文件
  474. function check() {
  475. return list.value.find((e) => !e.url);
  476. }
  477. // 更新
  478. function update() {
  479. if (!check()) {
  480. const urls = getUrls(list.value);
  481. emit("update:modelValue", props.multiple ? getUrls(list.value) : urls[0]);
  482. nextTick(() => {
  483. if (props.prop) {
  484. Form.value?.validateField(props.prop);
  485. }
  486. // 清空
  487. refs.upload?.clearFiles();
  488. });
  489. }
  490. }
  491. // 手动上传
  492. function upload(file: File) {
  493. clear();
  494. refs.upload?.clearFiles();
  495. nextTick(() => {
  496. refs.upload?.handleStart(file);
  497. refs.upload?.submit();
  498. });
  499. }
  500. // 文件空间
  501. const space = {
  502. data: undefined as Upload.Item | undefined,
  503. open(data?: Upload.Item) {
  504. space.data = data;
  505. refs.space?.open({
  506. limit: data || !props.multiple ? 1 : limit
  507. });
  508. },
  509. onConfirm(selection: Upload.Item[]) {
  510. if (space.data) {
  511. Object.assign(space.data, selection[0]);
  512. space.data = undefined;
  513. } else {
  514. list.value.push(...selection);
  515. }
  516. update();
  517. }
  518. };
  519. // 监听绑定值
  520. watch(
  521. () => props.modelValue,
  522. (val: any[] | string) => {
  523. if (check()) {
  524. return false;
  525. }
  526. const urls = (isArray(val) ? val : [val]).filter(Boolean);
  527. list.value = urls
  528. .map((url, index) => {
  529. return Object.assign(
  530. {
  531. type: getType(url),
  532. progress: 100,
  533. uid: uuid(),
  534. url
  535. },
  536. list.value[index]
  537. );
  538. })
  539. .filter((_, i) => {
  540. return props.multiple ? true : i == 0;
  541. });
  542. },
  543. {
  544. immediate: true
  545. }
  546. );
  547. // 导出
  548. defineExpose({
  549. isAdd,
  550. list,
  551. check,
  552. clear,
  553. remove,
  554. upload
  555. });
  556. </script>
  557. <style lang="scss" scoped>
  558. .cl-upload {
  559. line-height: normal;
  560. .Ghost {
  561. .cl-upload__item {
  562. border: 1px dashed var(--el-color-primary) !important;
  563. }
  564. }
  565. &__file {
  566. width: 100%;
  567. }
  568. &__list {
  569. display: flex;
  570. flex-wrap: wrap;
  571. }
  572. &__item,
  573. &__demo {
  574. display: flex;
  575. flex-direction: column;
  576. align-items: center;
  577. justify-content: center;
  578. height: v-bind("size[0]");
  579. width: v-bind("size[1]");
  580. background-color: var(--el-fill-color-light);
  581. color: var(--el-text-color-regular);
  582. border-radius: 6px;
  583. cursor: pointer;
  584. box-sizing: border-box;
  585. position: relative;
  586. user-select: none;
  587. }
  588. &__item {
  589. margin: 0 5px 5px 0;
  590. }
  591. &__demo {
  592. font-size: 13px;
  593. .el-icon {
  594. font-size: 46px;
  595. margin-bottom: 5px;
  596. }
  597. &.is-dragger {
  598. padding: 20px;
  599. }
  600. &:hover {
  601. color: var(--el-color-primary);
  602. }
  603. }
  604. :deep(.el-upload) {
  605. &.is-drag {
  606. .el-upload-dragger {
  607. padding: 0;
  608. border: 0;
  609. background-color: transparent !important;
  610. position: relative;
  611. background-color: red;
  612. $color: var(--el-color-primary);
  613. &.is-dragover {
  614. &::after {
  615. display: block;
  616. content: "";
  617. position: absolute;
  618. left: 0;
  619. top: 0;
  620. height: 100%;
  621. width: 100%;
  622. pointer-events: none;
  623. border-radius: 8px;
  624. box-sizing: border-box;
  625. border: 1px dashed var(--color-primary);
  626. }
  627. }
  628. }
  629. }
  630. }
  631. &.is-disabled {
  632. :deep(.cl-upload__item) {
  633. cursor: not-allowed;
  634. background-color: var(--el-disabled-bg-color);
  635. }
  636. }
  637. &--file {
  638. &:not(.is-drag) {
  639. .cl-upload__list {
  640. margin-top: 10px;
  641. }
  642. }
  643. }
  644. }
  645. </style>