wd-upload.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. <template>
  2. <view :class="['wd-upload', customClass]" :style="customStyle">
  3. <!-- 预览列表 -->
  4. <view :class="['wd-upload__preview', customPreviewClass]" v-for="(file, index) in uploadFiles" :key="index">
  5. <!-- 成功时展示图片 -->
  6. <view class="wd-upload__status-content">
  7. <image v-if="isImage(file)" :src="file.url" :mode="imageMode" class="wd-upload__picture" @click="onPreviewImage(file)" />
  8. <template v-else-if="isVideo(file)">
  9. <view class="wd-upload__video" v-if="file.thumb" @click="onPreviewVideo(file)">
  10. <image :src="file.thumb" :mode="imageMode" class="wd-upload__picture" />
  11. <wd-icon name="play-circle-filled" custom-class="wd-upload__video-paly"></wd-icon>
  12. </view>
  13. <view v-else class="wd-upload__video" @click="onPreviewVideo(file)">
  14. <!-- #ifdef APP-PLUS || MP-DINGTALK -->
  15. <wd-icon custom-class="wd-upload__video-icon" name="video"></wd-icon>
  16. <!-- #endif -->
  17. <!-- #ifndef APP-PLUS -->
  18. <!-- #ifndef MP-DINGTALK -->
  19. <video
  20. :src="file.url"
  21. :title="file.name || '视频' + index"
  22. object-fit="contain"
  23. :controls="false"
  24. :poster="file.thumb"
  25. :autoplay="false"
  26. :show-center-play-btn="false"
  27. :show-fullscreen-btn="false"
  28. :show-play-btn="false"
  29. :show-loading="false"
  30. :show-progress="false"
  31. :show-mute-btn="false"
  32. :enable-progress-gesture="false"
  33. :enableNative="true"
  34. class="wd-upload__video"
  35. ></video>
  36. <wd-icon name="play-circle-filled" custom-class="wd-upload__video-paly"></wd-icon>
  37. <!-- #endif -->
  38. <!-- #endif -->
  39. </view>
  40. </template>
  41. <view v-else class="wd-upload__file" @click="onPreviewFile(file)">
  42. <wd-icon name="file" custom-class="wd-upload__file-icon"></wd-icon>
  43. <view class="wd-upload__file-name">{{ file.name || file.url }}</view>
  44. </view>
  45. </view>
  46. <!-- <view v-if="file[props.statusKey] !== 'success'" class="wd-upload__mask wd-upload__status-content"> -->
  47. <!-- loading时展示loading图标和进度 -->
  48. <!-- <view v-if="file[props.statusKey] === 'loading'" class="wd-upload__status-content">
  49. <wd-loading :type="loadingType" :size="loadingSize" :color="loadingColor" />
  50. <text class="wd-upload__progress-txt">{{ file.percent }}%</text>
  51. </view> -->
  52. <!-- 失败时展示失败图标以及失败信息 -->
  53. <!-- <view v-if="file[props.statusKey] === 'fail'" class="wd-upload__status-content">
  54. <wd-icon name="close-outline" custom-class="wd-upload__icon"></wd-icon>
  55. <text class="wd-upload__progress-txt">{{ file.error || translate('error') }}</text>
  56. </view> -->
  57. <!-- </view> -->
  58. <!-- 上传状态为上传中时不展示移除按钮 -->
  59. <wd-icon
  60. v-if="file[props.statusKey] !== 'loading' && !disabled"
  61. name="error-fill"
  62. custom-class="wd-upload__close"
  63. @click="removeFile(index)"
  64. ></wd-icon>
  65. <!-- 自定义预览样式 -->
  66. <slot name="preview-cover" v-if="$slots['preview-cover']" :file="file" :index="index"></slot>
  67. </view>
  68. <block v-if="showUpload">
  69. <view :class="['wd-upload__evoke-slot', customEvokeClass]" v-if="$slots.default" @click="onEvokeClick">
  70. <slot></slot>
  71. </view>
  72. <!-- 唤起项 -->
  73. <view v-else @click="onEvokeClick" :class="['wd-upload__evoke', disabled ? 'is-disabled' : '', customEvokeClass]">
  74. <!-- 唤起项图标 -->
  75. <wd-icon class="wd-upload__evoke-icon" name="fill-camera"></wd-icon>
  76. <!-- 有限制个数时确认是否展示限制个数 -->
  77. <view v-if="limit && showLimitNum" class="wd-upload__evoke-num">({{ uploadFiles.length }}/{{ limit }})</view>
  78. </view>
  79. </block>
  80. </view>
  81. <wd-video-preview ref="videoPreview"></wd-video-preview>
  82. </template>
  83. <script lang="ts">
  84. export default {
  85. name: 'wd-upload',
  86. options: {
  87. addGlobalClass: true,
  88. virtualHost: true,
  89. styleIsolation: 'shared'
  90. }
  91. }
  92. </script>
  93. <script lang="ts" setup>
  94. import wdIcon from '../wd-icon/wd-icon.vue'
  95. import wdVideoPreview from '../wd-video-preview/wd-video-preview.vue'
  96. import wdLoading from '../wd-loading/wd-loading.vue'
  97. import { computed, ref, watch } from 'vue'
  98. import { context, isEqual, isImageUrl, isVideoUrl, isFunction, isDef, deepClone } from '../common/util'
  99. import { useTranslate } from '../composables/useTranslate'
  100. import { useUpload } from '../composables/useUpload'
  101. import {
  102. uploadProps,
  103. type UploadFileItem,
  104. type ChooseFile,
  105. type UploadExpose,
  106. type UploadErrorEvent,
  107. type UploadChangeEvent,
  108. type UploadSuccessEvent,
  109. type UploadProgressEvent,
  110. type UploadOversizeEvent,
  111. type UploadRemoveEvent,
  112. type UploadMethod
  113. } from './types'
  114. import type { VideoPreviewInstance } from '../wd-video-preview/types'
  115. const props = defineProps(uploadProps)
  116. const emit = defineEmits<{
  117. (e: 'fail', value: UploadErrorEvent): void
  118. (e: 'change', value: UploadChangeEvent): void
  119. (e: 'success', value: UploadSuccessEvent): void
  120. (e: 'progress', value: UploadProgressEvent): void
  121. (e: 'oversize', value: UploadOversizeEvent): void
  122. (e: 'chooseerror', value: any): void
  123. (e: 'remove', value: UploadRemoveEvent): void
  124. (e: 'update:fileList', value: UploadFileItem[]): void
  125. }>()
  126. defineExpose<UploadExpose>({
  127. submit: () => startUploadFiles(),
  128. abort: () => abort()
  129. })
  130. const { translate } = useTranslate('upload')
  131. const uploadFiles = ref<UploadFileItem[]>([])
  132. const showUpload = computed(() => !props.limit || uploadFiles.value.length < props.limit)
  133. const videoPreview = ref<VideoPreviewInstance>()
  134. const { startUpload, abort, chooseFile, UPLOAD_STATUS } = useUpload()
  135. watch(
  136. () => props.fileList,
  137. (val) => {
  138. const { statusKey } = props
  139. if (isEqual(val, uploadFiles.value)) return
  140. const uploadFileList: UploadFileItem[] = val.map((item) => {
  141. item[statusKey] = item[statusKey] || 'success'
  142. item.response = item.response || ''
  143. return { ...item, uid: context.id++ }
  144. })
  145. uploadFiles.value = uploadFileList
  146. },
  147. {
  148. deep: true,
  149. immediate: true
  150. }
  151. )
  152. watch(
  153. () => props.limit,
  154. (val) => {
  155. if (val && val < uploadFiles.value.length) {
  156. console.error('[wot-design]Error: props limit must less than fileList.length')
  157. }
  158. },
  159. {
  160. deep: true,
  161. immediate: true
  162. }
  163. )
  164. watch(
  165. () => props.beforePreview,
  166. (fn) => {
  167. if (fn && !isFunction(fn)) {
  168. console.error('The type of beforePreview must be Function')
  169. }
  170. },
  171. {
  172. deep: true,
  173. immediate: true
  174. }
  175. )
  176. watch(
  177. () => props.onPreviewFail,
  178. (fn) => {
  179. if (fn && !isFunction(fn)) {
  180. console.error('The type of onPreviewFail must be Function')
  181. }
  182. },
  183. {
  184. deep: true,
  185. immediate: true
  186. }
  187. )
  188. watch(
  189. () => props.beforeRemove,
  190. (fn) => {
  191. if (fn && !isFunction(fn)) {
  192. console.error('The type of beforeRemove must be Function')
  193. }
  194. },
  195. {
  196. deep: true,
  197. immediate: true
  198. }
  199. )
  200. watch(
  201. () => props.beforeUpload,
  202. (fn) => {
  203. if (fn && !isFunction(fn)) {
  204. console.error('The type of beforeUpload must be Function')
  205. }
  206. },
  207. {
  208. deep: true,
  209. immediate: true
  210. }
  211. )
  212. watch(
  213. () => props.beforeChoose,
  214. (fn) => {
  215. if (fn && !isFunction(fn)) {
  216. console.error('The type of beforeChoose must be Function')
  217. }
  218. },
  219. {
  220. deep: true,
  221. immediate: true
  222. }
  223. )
  224. watch(
  225. () => props.buildFormData,
  226. (fn) => {
  227. if (fn && !isFunction(fn)) {
  228. console.error('The type of buildFormData must be Function')
  229. }
  230. },
  231. {
  232. deep: true,
  233. immediate: true
  234. }
  235. )
  236. function emitFileList() {
  237. emit('update:fileList', uploadFiles.value)
  238. }
  239. /**
  240. * 开始上传文件
  241. */
  242. function startUploadFiles() {
  243. const { buildFormData, formData = {}, statusKey } = props
  244. const { action, name, header = {}, accept, successStatus, uploadMethod } = props
  245. const statusCode = isDef(successStatus) ? successStatus : 200
  246. for (const uploadFile of uploadFiles.value) {
  247. // 仅开始未上传的文件
  248. if (uploadFile[statusKey] === UPLOAD_STATUS.PENDING) {
  249. if (buildFormData) {
  250. buildFormData({
  251. file: uploadFile,
  252. formData,
  253. resolve: (formData: Record<string, any>) => {
  254. formData &&
  255. startUpload(uploadFile, {
  256. action,
  257. header,
  258. name,
  259. formData,
  260. fileType: accept as 'image' | 'video' | 'audio',
  261. statusCode,
  262. statusKey,
  263. uploadMethod,
  264. onSuccess: handleSuccess,
  265. onError: handleError,
  266. onProgress: handleProgress
  267. })
  268. }
  269. })
  270. } else {
  271. startUpload(uploadFile, {
  272. action,
  273. header,
  274. name,
  275. formData,
  276. fileType: accept as 'image' | 'video' | 'audio',
  277. statusCode,
  278. statusKey,
  279. uploadMethod,
  280. onSuccess: handleSuccess,
  281. onError: handleError,
  282. onProgress: handleProgress
  283. })
  284. }
  285. }
  286. }
  287. }
  288. /**
  289. * 获取图片信息
  290. * @param img
  291. */
  292. function getImageInfo(img: string) {
  293. return new Promise<UniApp.GetImageInfoSuccessData>((resolve, reject) => {
  294. uni.getImageInfo({
  295. src: img,
  296. success: (res) => {
  297. resolve(res)
  298. },
  299. fail: (error) => {
  300. reject(error)
  301. }
  302. })
  303. })
  304. }
  305. /**
  306. * @description 初始化文件数据
  307. * @param {Object} file 上传的文件
  308. */
  309. function initFile(file: ChooseFile, currentIndex?: number) {
  310. const { statusKey } = props
  311. // 状态初始化
  312. const initState: UploadFileItem = {
  313. uid: context.id++,
  314. // 仅h5支持 name
  315. name: file.name || '',
  316. thumb: file.thumb || '',
  317. [statusKey]: 'pending',
  318. size: file.size || 0,
  319. url: file.path,
  320. percent: 0
  321. }
  322. if (typeof currentIndex === 'number') {
  323. uploadFiles.value.splice(currentIndex, 1, initState)
  324. } else {
  325. uploadFiles.value.push(initState)
  326. }
  327. if (props.autoUpload) {
  328. startUploadFiles()
  329. }
  330. }
  331. /**
  332. * @description 上传失败捕获
  333. * @param {Object} err 错误返回信息
  334. * @param {Object} file 上传的文件
  335. */
  336. function handleError(err: Record<string, any>, file: UploadFileItem, formData: Record<string, any>) {
  337. const { statusKey } = props
  338. const index = uploadFiles.value.findIndex((item) => item.uid === file.uid)
  339. if (index > -1) {
  340. uploadFiles.value[index][statusKey] = 'fail'
  341. uploadFiles.value[index].error = err.message
  342. uploadFiles.value[index].response = err
  343. emit('fail', { error: err, file, formData })
  344. emitFileList()
  345. }
  346. }
  347. /**
  348. * @description 上传成功捕获
  349. * @param {Object} res 接口返回信息
  350. * @param {Object} file 上传的文件
  351. */
  352. function handleSuccess(res: Record<string, any>, file: UploadFileItem, formData: Record<string, any>) {
  353. const { statusKey } = props
  354. const index = uploadFiles.value.findIndex((item) => item.uid === file.uid)
  355. if (index > -1) {
  356. uploadFiles.value[index][statusKey] = 'success'
  357. uploadFiles.value[index].response = res.data
  358. emit('change', { fileList: uploadFiles.value })
  359. emit('success', { file, fileList: uploadFiles.value, formData })
  360. emitFileList()
  361. }
  362. }
  363. /**
  364. * @description 上传中捕获
  365. * @param {Object} res 接口返回信息
  366. * @param {Object} file 上传的文件
  367. */
  368. function handleProgress(res: UniApp.OnProgressUpdateResult, file: UploadFileItem) {
  369. const index = uploadFiles.value.findIndex((item) => item.uid === file.uid)
  370. if (index > -1) {
  371. uploadFiles.value[index].percent = res.progress
  372. emit('progress', { response: res, file })
  373. }
  374. }
  375. /**
  376. * @description 选择文件的实际操作,将chooseFile自己用promise包了一层
  377. */
  378. function onChooseFile(currentIndex?: number) {
  379. const { multiple, maxSize, accept, sizeType, limit, sourceType, compressed, maxDuration, camera, beforeUpload, extension } = props
  380. chooseFile({
  381. multiple: isDef(currentIndex) ? false : multiple,
  382. sizeType,
  383. sourceType,
  384. maxCount: limit ? limit - uploadFiles.value.length : limit,
  385. accept,
  386. compressed,
  387. maxDuration,
  388. camera,
  389. extension
  390. })
  391. .then((res) => {
  392. // 成功选择初始化file
  393. let files = res
  394. // 单选只有一个
  395. if (!multiple) {
  396. files = files.slice(0, 1)
  397. }
  398. // 遍历列表逐个初始化上传参数
  399. const mapFiles = async (files: ChooseFile[]) => {
  400. for (let index = 0; index < files.length; index++) {
  401. const file = files[index]
  402. if (file.type === 'image' && !file.size) {
  403. const imageInfo = await getImageInfo(file.path)
  404. file.size = imageInfo.width * imageInfo.height
  405. }
  406. Number(file.size) <= maxSize ? initFile(file, currentIndex) : emit('oversize', { file })
  407. }
  408. }
  409. // 上传前的钩子
  410. if (beforeUpload) {
  411. beforeUpload({
  412. files,
  413. fileList: uploadFiles.value,
  414. resolve: (isPass: boolean) => {
  415. isPass && mapFiles(files)
  416. }
  417. })
  418. } else {
  419. mapFiles(files)
  420. }
  421. })
  422. .catch((error) => {
  423. emit('chooseerror', { error })
  424. })
  425. }
  426. /**
  427. * @description 处理唤起选择文件的点击事件
  428. */
  429. function onEvokeClick() {
  430. handleChoose()
  431. }
  432. /**
  433. * @description 选择文件,内置拦截选择操作
  434. */
  435. function handleChoose(index?: number) {
  436. if (props.disabled) return
  437. const { beforeChoose } = props
  438. // 选择图片前的钩子
  439. if (beforeChoose) {
  440. beforeChoose({
  441. fileList: uploadFiles.value,
  442. resolve: (isPass: boolean) => {
  443. isPass && onChooseFile(index)
  444. }
  445. })
  446. } else {
  447. onChooseFile(index)
  448. }
  449. }
  450. /**
  451. * @description 移除文件
  452. * @param {Object} file 上传的文件
  453. * @param {Number} index 删除
  454. */
  455. function handleRemove(file: UploadFileItem) {
  456. uploadFiles.value.splice(
  457. uploadFiles.value.findIndex((item) => item.uid === file.uid),
  458. 1
  459. )
  460. emit('change', {
  461. fileList: uploadFiles.value
  462. })
  463. emit('remove', { file })
  464. emitFileList()
  465. }
  466. function removeFile(index: number) {
  467. const { beforeRemove } = props
  468. const intIndex: number = index
  469. const file = uploadFiles.value[intIndex]
  470. if (beforeRemove) {
  471. beforeRemove({
  472. file,
  473. index: intIndex,
  474. fileList: uploadFiles.value,
  475. resolve: (isPass: boolean) => {
  476. isPass && handleRemove(file)
  477. }
  478. })
  479. } else {
  480. handleRemove(file)
  481. }
  482. }
  483. /**
  484. * 预览文件
  485. * @param file
  486. */
  487. function handlePreviewFile(file: UploadFileItem) {
  488. uni.openDocument({
  489. filePath: file.url,
  490. showMenu: true
  491. })
  492. }
  493. /**
  494. * 预览图片
  495. * @param index
  496. * @param lists
  497. */
  498. function handlePreviewImage(index: number, lists: string[]) {
  499. const { onPreviewFail } = props
  500. uni.previewImage({
  501. urls: lists,
  502. current: lists[index],
  503. fail() {
  504. if (onPreviewFail) {
  505. onPreviewFail({
  506. index,
  507. imgList: lists
  508. })
  509. } else {
  510. uni.showToast({ title: '预览图片失败', icon: 'none' })
  511. }
  512. }
  513. })
  514. }
  515. /**
  516. * 预览视频
  517. * @param index
  518. * @param lists
  519. */
  520. function handlePreviewVieo(index: number, lists: UploadFileItem[]) {
  521. const { onPreviewFail } = props
  522. // #ifdef MP-WEIXIN
  523. uni.previewMedia({
  524. current: index,
  525. sources: lists.map((file) => {
  526. return {
  527. url: file.url,
  528. type: 'video',
  529. poster: file.thumb
  530. }
  531. }),
  532. fail() {
  533. if (onPreviewFail) {
  534. onPreviewFail({
  535. index,
  536. imgList: []
  537. })
  538. } else {
  539. uni.showToast({ title: '预览视频失败', icon: 'none' })
  540. }
  541. }
  542. })
  543. // #endif
  544. // #ifndef MP-WEIXIN
  545. videoPreview.value?.open({ url: lists[index].url, poster: lists[index].thumb, title: lists[index].name })
  546. // #endif
  547. }
  548. function onPreviewImage(file: UploadFileItem) {
  549. const { beforePreview, reupload } = props
  550. const fileList = deepClone(uploadFiles.value)
  551. const index: number = fileList.findIndex((item) => item.url === file.url)
  552. const imgList = fileList.filter((file) => isImage(file)).map((file) => file.url)
  553. const imgIndex: number = imgList.findIndex((item) => item === file.url)
  554. if (reupload) {
  555. handleChoose(index)
  556. } else {
  557. if (beforePreview) {
  558. beforePreview({
  559. file,
  560. index,
  561. fileList: fileList,
  562. imgList: imgList,
  563. resolve: (isPass: boolean) => {
  564. isPass && handlePreviewImage(imgIndex, imgList)
  565. }
  566. })
  567. } else {
  568. handlePreviewImage(imgIndex, imgList)
  569. }
  570. }
  571. }
  572. function onPreviewVideo(file: UploadFileItem) {
  573. const { beforePreview, reupload } = props
  574. const fileList = deepClone(uploadFiles.value)
  575. const index: number = fileList.findIndex((item) => item.url === file.url)
  576. const videoList = fileList.filter((file) => isVideo(file))
  577. const videoIndex: number = videoList.findIndex((item) => item.url === file.url)
  578. if (reupload) {
  579. handleChoose(index)
  580. } else {
  581. if (beforePreview) {
  582. beforePreview({
  583. file,
  584. index,
  585. imgList: [],
  586. fileList,
  587. resolve: (isPass: boolean) => {
  588. isPass && handlePreviewVieo(videoIndex, videoList)
  589. }
  590. })
  591. } else {
  592. handlePreviewVieo(videoIndex, videoList)
  593. }
  594. }
  595. }
  596. function onPreviewFile(file: UploadFileItem) {
  597. const { beforePreview, reupload } = props
  598. const fileList = deepClone(uploadFiles.value)
  599. const index: number = fileList.findIndex((item) => item.url === file.url)
  600. if (reupload) {
  601. handleChoose(index)
  602. } else {
  603. if (beforePreview) {
  604. beforePreview({
  605. file,
  606. index,
  607. imgList: [],
  608. fileList,
  609. resolve: (isPass: boolean) => {
  610. isPass && handlePreviewFile(file)
  611. }
  612. })
  613. } else {
  614. handlePreviewFile(file)
  615. }
  616. }
  617. }
  618. function isVideo(file: UploadFileItem) {
  619. return (file.name && isVideoUrl(file.name)) || isVideoUrl(file.url)
  620. }
  621. function isImage(file: UploadFileItem) {
  622. return (file.name && isImageUrl(file.name)) || isImageUrl(file.url)
  623. }
  624. </script>
  625. <style lang="scss" scoped>
  626. @import './index.scss';
  627. </style>