tn-image-upload.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. <template>
  2. <view v-if="!disabled" class="tn-image-upload-class tn-image-upload">
  3. <block v-if="showUploadList">
  4. <view
  5. v-for="(item, index) in lists"
  6. :key="index"
  7. class="tn-image-upload__item tn-image-upload__item-preview"
  8. :style="{
  9. width: $t.string.getLengthUnitValue(width),
  10. height: $t.string.getLengthUnitValue(height)
  11. }"
  12. >
  13. <!-- 删除按钮 -->
  14. <view
  15. v-if="deleteable"
  16. class="tn-image-upload__item-preview__delete"
  17. @tap.stop="deleteItem(index)"
  18. :style="{
  19. borderTopColor: deleteBackgroundColor
  20. }"
  21. >
  22. <view
  23. class="tn-image-upload__item-preview__delete--icon"
  24. :class="[`tn-icon-${deleteIcon}`]"
  25. :style="{
  26. color: deleteColor
  27. }"
  28. ></view>
  29. </view>
  30. <!-- 进度条 -->
  31. <tn-line-progress
  32. v-if="showProgress && item.progress > 0 && !item.error"
  33. class="tn-image-upload__item-preview__progress"
  34. :percent="item.progress"
  35. :showPercent="false"
  36. :round="false"
  37. :height="8"
  38. ></tn-line-progress>
  39. <!-- 重试按钮 -->
  40. <view v-if="item.error" class="tn-image-upload__item-preview__error-btn" @tap.stop="retry(index)">点击重试</view>
  41. <!-- 图片信息 -->
  42. <image
  43. class="tn-image-upload__item-preview__image"
  44. :src="item.url || item.path"
  45. :mode="imageMode"
  46. @tap.stop="doPreviewImage(item.url || item.path, index)"
  47. ></image>
  48. </view>
  49. </block>
  50. <!-- <view v-if="$slots.file || $slots.$file" style="width: 100%;">
  51. </view> -->
  52. <!-- 自定义图片展示列表 -->
  53. <slot name="file" :file="lists"></slot>
  54. <!-- 添加按钮 -->
  55. <view v-if="maxCount > lists.length" class="tn-image-upload__add" :class="{'tn-image-upload__add--custom': customBtn}" @tap="selectFile">
  56. <!-- 添加按钮 -->
  57. <view
  58. v-if="!customBtn"
  59. class="tn-image-upload__item tn-image-upload__item-add"
  60. hover-class="tn-hover-class"
  61. hover-stay-time="150"
  62. :style="{
  63. width: $t.string.getLengthUnitValue(width),
  64. height: $t.string.getLengthUnitValue(height)
  65. }"
  66. >
  67. <view class="tn-image-upload__item-add--icon tn-icon-add"></view>
  68. <view class="tn-image-upload__item-add__tips">{{ uploadText }}</view>
  69. </view>
  70. <!-- 自定义添加按钮 -->
  71. <view>
  72. <slot name="addBtn"></slot>
  73. </view>
  74. </view>
  75. </view>
  76. </template>
  77. <script>
  78. export default {
  79. name: 'tn-image-upload',
  80. props: {
  81. // 已上传的文件列表
  82. fileList: {
  83. type: Array,
  84. default() {
  85. return []
  86. }
  87. },
  88. // 上传图片地址
  89. action: {
  90. type: String,
  91. default: ''
  92. },
  93. // 上传文件的字段名称
  94. name: {
  95. type: String,
  96. default: 'file'
  97. },
  98. // 头部信息
  99. header: {
  100. type: Object,
  101. default() {
  102. return {}
  103. }
  104. },
  105. // 携带的参数
  106. formData: {
  107. type: Object,
  108. default() {
  109. return {}
  110. }
  111. },
  112. // 是否禁用
  113. disabled: {
  114. type: Boolean,
  115. default: false
  116. },
  117. // 是否自动上传
  118. autoUpload: {
  119. type: Boolean,
  120. default: true
  121. },
  122. // 最大上传数量
  123. maxCount: {
  124. type: Number,
  125. default: 9
  126. },
  127. // 是否显示组件自带的图片预览
  128. showUploadList: {
  129. type: Boolean,
  130. default: true
  131. },
  132. // 预览上传图片的裁剪模式
  133. imageMode: {
  134. type: String,
  135. default: 'aspectFill'
  136. },
  137. // 点击图片是否全屏预览
  138. previewFullImage: {
  139. type: Boolean,
  140. default: true
  141. },
  142. // 是否显示进度条
  143. showProgress: {
  144. type: Boolean,
  145. default: true
  146. },
  147. // 是否显示删除按钮
  148. deleteable: {
  149. type: Boolean,
  150. default: true
  151. },
  152. // 删除按钮图标
  153. deleteIcon: {
  154. type: String,
  155. default: 'close'
  156. },
  157. // 删除按钮的背景颜色
  158. deleteBackgroundColor: {
  159. type: String,
  160. default: ''
  161. },
  162. // 删除按钮的颜色
  163. deleteColor: {
  164. type: String,
  165. default: ''
  166. },
  167. // 上传区域提示文字
  168. uploadText: {
  169. type: String,
  170. default: '选择图片'
  171. },
  172. // 显示toast提示
  173. showTips: {
  174. type: Boolean,
  175. default: true
  176. },
  177. // 自定义选择图标按钮
  178. customBtn: {
  179. type: Boolean,
  180. default: false
  181. },
  182. // 预览图片和选择图片区域的宽度
  183. width: {
  184. type: Number,
  185. default: 200
  186. },
  187. // 预览图片和选择图片区域的高度
  188. height: {
  189. type: Number,
  190. default: 200
  191. },
  192. // 选择图片的尺寸
  193. // 参考上传文档 https://uniapp.dcloud.io/api/media/image
  194. sizeType: {
  195. type: Array,
  196. default() {
  197. return ['original', 'compressed']
  198. }
  199. },
  200. // 图片来源
  201. sourceType: {
  202. type: Array,
  203. default() {
  204. return ['album', 'camera']
  205. }
  206. },
  207. // 是否可以多选
  208. multiple: {
  209. type: Boolean,
  210. default: true
  211. },
  212. // 文件大小(byte)
  213. maxSize: {
  214. type: Number,
  215. default: 10 * 1024 * 1024
  216. },
  217. // 允许上传的类型
  218. limitType: {
  219. type: Array,
  220. default() {
  221. return ['png','jpg','jpeg','webp','gif','image']
  222. }
  223. },
  224. // 是否自定转换为json
  225. toJson: {
  226. type: Boolean,
  227. default: true
  228. },
  229. // 上传前钩子函数,每个文件上传前都会执行
  230. beforeUpload: {
  231. type: Function,
  232. default: null
  233. },
  234. // 删除文件前钩子函数
  235. beforeRemove: {
  236. type: Function,
  237. default: null
  238. },
  239. index: {
  240. type: [Number, String],
  241. default: ''
  242. }
  243. },
  244. computed: {
  245. },
  246. data() {
  247. return {
  248. lists: [],
  249. uploading: false
  250. }
  251. },
  252. watch: {
  253. fileList: {
  254. handler(val) {
  255. val.map(value => {
  256. // 首先检查内部是否已经添加过这张图片,因为外部绑定了一个对象给fileList的话(对象引用),进行修改外部fileList时,
  257. // 会触发watch,导致重新把原来的图片再次添加到this.lists
  258. // 数组的some方法意思是,只要数组元素有任意一个元素条件符合,就返回true,而另一个数组的every方法的意思是数组所有元素都符合条件才返回true
  259. let tmp = this.lists.some(listVal => {
  260. return listVal.url === value.url
  261. })
  262. // 如果内部没有这张图片,则添加到内部
  263. !tmp && this.lists.push({ url: value.url, error: false, progress: 100 })
  264. })
  265. },
  266. immediate: true
  267. },
  268. lists(val) {
  269. this.$emit('on-list-change', val, this.index)
  270. }
  271. },
  272. methods: {
  273. // 清除列表
  274. clear() {
  275. this.lists = []
  276. },
  277. // 重新上传队列中上传失败所有文件
  278. reUpload() {
  279. this.uploadFile()
  280. },
  281. // 选择图片
  282. selectFile() {
  283. if (this.disabled) return
  284. const {
  285. name = '',
  286. maxCount,
  287. multiple,
  288. maxSize,
  289. sizeType,
  290. lists,
  291. camera,
  292. compressed,
  293. sourceType
  294. } = this
  295. let chooseFile = null
  296. const newMaxCount = maxCount - lists.length
  297. // 只选择图片的时候使用 chooseImage 来实现
  298. chooseFile = new Promise((resolve, reject) => {
  299. uni.chooseImage({
  300. count: multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
  301. sourceType,
  302. sizeType,
  303. success: resolve,
  304. fail: reject
  305. })
  306. })
  307. chooseFile.then(res => {
  308. let file = null
  309. let listOldLength = lists.length
  310. res.tempFiles.map((val, index) => {
  311. if (!this.checkFileExt(val)) return
  312. // 是否超出最大限制数量
  313. if (!multiple && index >= 1) return
  314. if (val.size > maxSize) {
  315. this.$emit('on-oversize', val, lists, this.index)
  316. this.showToast('超出可允许文件大小')
  317. } else {
  318. if (maxCount <= lists.length) {
  319. this.$emit('on-exceed', val, lists, this.index)
  320. this.showToast('超出最大允许的文件数')
  321. return
  322. }
  323. lists.push({
  324. url: val.path,
  325. progress: 0,
  326. error: false,
  327. file: val
  328. })
  329. }
  330. })
  331. this.$emit('on-choose-complete', this.lists, this.index)
  332. if (this.autoUpload) this.uploadFile(listOldLength)
  333. }).catch(err => {
  334. this.$emit('on-choose-fail', err)
  335. })
  336. },
  337. // 提示用户信息
  338. showToast(message, force = false) {
  339. if (this.showTips || force) {
  340. this.$t.message.toast(message)
  341. }
  342. },
  343. // 手动上传,通过ref进行调用
  344. upload() {
  345. this.uploadFile()
  346. },
  347. // 对失败图片进行再次上传
  348. retry(index) {
  349. this.lists[index].progress = 0
  350. this.lists[index].error = false
  351. this.lists[index].response = null
  352. this.$t.message.loading('重新上传')
  353. this.uploadFile(index)
  354. },
  355. // 上传文件
  356. async uploadFile(index = 0) {
  357. if (this.disabled) return
  358. if (this.uploading) return
  359. // 全部上传完成
  360. if (index >= this.lists.length) {
  361. this.$emit('on-uploaded', this.lists, this.index)
  362. return
  363. }
  364. // 检查是否已经全部上传或者上传中
  365. if (this.lists[index].progress === 100) {
  366. this.lists[index].uploadTask = null
  367. if (this.autoUpload) this.uploadFile(index + 1)
  368. return
  369. }
  370. // 执行before-upload钩子
  371. if (this.beforeUpload && typeof(this.beforeUpload) === 'function') {
  372. // 在微信,支付宝等环境(H5正常),会导致父组件定义的函数体中的this变成子组件的this
  373. // 通过bind()方法,绑定父组件的this,让this的this为父组件的上下文
  374. // 因为upload组件可能会被嵌套在其他组件内,比如tn-form,这时this.$parent其实为tn-form的this,
  375. // 非页面的this,所以这里需要往上历遍,一直寻找到最顶端的$parent,这里用了this.$u.$parent.call(this)
  376. let beforeResponse = this.beforeUpload.bind(this.$t.$parent.call(this))(index, this.lists)
  377. // 判断是否返回了Promise
  378. if (!!beforeResponse && typeof beforeResponse.then === 'function') {
  379. await beforeResponse.then(res => {
  380. // promise返回成功,不进行操作继续
  381. }).catch(err => {
  382. // 进入catch回调的话,继续下一张
  383. return this.uploadFile(index + 1)
  384. })
  385. } else if (beforeResponse === false) {
  386. // 如果返回flase,继续下一张图片上传
  387. return this.uploadFile(index + 1)
  388. } else {
  389. // 为true的情况,不进行操作
  390. }
  391. }
  392. // 检查上传地址
  393. if (!this.action) {
  394. this.showToast('请配置上传地址', true)
  395. return
  396. }
  397. this.lists[index].error = false
  398. this.uploading = true
  399. // 创建上传对象
  400. const task = uni.uploadFile({
  401. url: this.action,
  402. filePath: this.lists[index].url,
  403. name: this.name,
  404. formData: this.formData,
  405. header: this.header,
  406. success: res => {
  407. // 判断啊是否为json字符串,将其转换为json格式
  408. let data = this.toJson && this.$t.test.jsonString(res.data) ? JSON.parse(res.data) : res.data
  409. if (![200, 201, 204].includes(res.statusCode)) {
  410. this.uploadError(index, data)
  411. } else {
  412. this.lists[index].response = data
  413. this.lists[index].progress = 100
  414. this.lists[index].error = false
  415. this.$emit('on-success', data, index, this.lists, this.index)
  416. }
  417. },
  418. fail: err => {
  419. this.uploadError(index, err)
  420. },
  421. complete: res => {
  422. this.$t.message.closeLoading()
  423. this.uploading = false
  424. this.uploadFile(index + 1)
  425. this.$emit('on-change', res, index, this.lists, this.index)
  426. }
  427. })
  428. this.lists[index].uploadTask = task
  429. task.onProgressUpdate(res => {
  430. if (res.progress > 0) {
  431. this.lists[index].progress = res.progress
  432. this.$emit('on-progress', res, index, this.lists, this.index)
  433. }
  434. })
  435. },
  436. // 上传失败
  437. uploadError(index, err) {
  438. this.lists[index].progress = 0
  439. this.lists[index].error = true
  440. this.lists[index].response = null
  441. this.showToast('上传失败,请重试')
  442. this.$emit('on-error', err, index, this.lists, this.index)
  443. },
  444. // 删除一个图片
  445. deleteItem(index) {
  446. if (!this.deleteable) return
  447. this.$t.message.modal(
  448. '提示',
  449. '您确定要删除吗?',
  450. async () => {
  451. // 先检查是否有定义before-remove移除前钩子
  452. // 执行before-remove钩子
  453. if (this.beforeRemove && typeof(this.beforeRemove) === 'function') {
  454. let beforeResponse = this.beforeRemove.bind(this.$t.$parent.call(this))(index, this.lists)
  455. // 判断是否返回promise
  456. if (!!beforeResponse && typeof beforeResponse.then === 'function') {
  457. await beforeResponse.then(res => {
  458. // promise返回成功不进行操作
  459. this.handlerDeleteItem(index)
  460. }).catch(err => {
  461. this.showToast('删除操作被中断')
  462. })
  463. } else if (beforeResponse === false) {
  464. this.showToast('删除操作被中断')
  465. } else {
  466. this.handlerDeleteItem(index)
  467. }
  468. } else {
  469. this.handlerDeleteItem(index)
  470. }
  471. }, true)
  472. },
  473. // 移除文件操作
  474. handlerDeleteItem(index) {
  475. // 如果文件正在上传中,终止上传任务
  476. if (this.lists[index].progress < 100 && this.lists[index].progress > 0) {
  477. typeof this.lists[index].uploadTask !== 'undefined' && this.lists[index].uploadTask.abort()
  478. }
  479. this.lists.splice(index, 1)
  480. this.$forceUpdate()
  481. this.$emit('on-remove', index, this.lists, this.index)
  482. this.showToast('删除成功')
  483. },
  484. // 移除文件,通过ref手动形式进行调用
  485. remove(index) {
  486. if (!this.deleteable) return
  487. // 判断索引合法
  488. if (index >= 0 && index < this.lists.length) {
  489. this.lists.splice(index, 1)
  490. }
  491. },
  492. // 预览图片
  493. doPreviewImage(url, index) {
  494. if (!this.previewFullImage) return
  495. const images = this.lists.map(item => item.url || item.path)
  496. uni.previewImage({
  497. urls: images,
  498. current: url,
  499. success: () => {
  500. this.$emit('on-preview', url, this.lists, this.index)
  501. },
  502. fail: () => {
  503. this.showToast('预览图片失败')
  504. }
  505. })
  506. },
  507. // 检查文件后缀是否合法
  508. checkFileExt(file) {
  509. // 是否为合法后缀
  510. let noArrowExt = false
  511. // 后缀名
  512. let fileExt = ''
  513. const reg = /.+\./
  514. // #ifdef H5
  515. fileExt = file.name.replace(reg, '').toLowerCase()
  516. // #endif
  517. // #ifndef H5
  518. fileExt = file.path.replace(reg, '').toLowerCase()
  519. // #endif
  520. noArrowExt = this.limitType.some(ext => {
  521. return ext.toLowerCase() === fileExt
  522. })
  523. if (!noArrowExt) this.showToast(`不支持${fileExt}格式的文件`)
  524. return noArrowExt
  525. }
  526. }
  527. }
  528. </script>
  529. <style lang="scss" scoped>
  530. .tn-image-upload {
  531. display: flex;
  532. flex-direction: row;
  533. flex-wrap: wrap;
  534. align-items: center;
  535. &__item {
  536. /* #ifndef APP-NVUE */
  537. display: flex;
  538. /* #endif */
  539. align-items: center;
  540. justify-content: center;
  541. width: 200rpx;
  542. height: 200rpx;
  543. overflow: hidden;
  544. margin: 12rpx;
  545. margin-left: 0;
  546. background-color: $tn-font-holder-color;
  547. position: relative;
  548. border-radius: 10rpx;
  549. &-preview {
  550. border: 1rpx solid $tn-border-solid-color;
  551. &__delete {
  552. display: flex;
  553. align-items: center;
  554. justify-content: center;
  555. position: absolute;
  556. top: 0;
  557. right: 0;
  558. z-index: 10;
  559. border-top: 60rpx solid;
  560. border-left: 60rpx solid transparent;
  561. border-top-color: $tn-color-red;
  562. width: 0rpx;
  563. height: 0rpx;
  564. &--icon {
  565. position: absolute;
  566. top: -50rpx;
  567. right: 6rpx;
  568. color: #FFFFFF;
  569. font-size: 24rpx;
  570. line-height: 1;
  571. }
  572. }
  573. &__progress {
  574. position: absolute;
  575. width: auto;
  576. bottom: 0rpx;
  577. left: 0rpx;
  578. right: 0rpx;
  579. z-index: 9;
  580. /* #ifdef MP-WEIXIN */
  581. display: inline-flex;
  582. /* #endif */
  583. }
  584. &__error-btn {
  585. position: absolute;
  586. bottom: 0;
  587. left: 0;
  588. right: 0;
  589. background-color: $tn-color-red;
  590. color: #FFFFFF;
  591. font-size: 20rpx;
  592. padding: 8rpx 0;
  593. text-align: center;
  594. z-index: 9;
  595. line-height: 1;
  596. }
  597. &__image {
  598. display: block;
  599. width: 100%;
  600. height: 100%;
  601. border-radius: 10rpx;
  602. }
  603. }
  604. &-add {
  605. flex-direction: column;
  606. color: $tn-content-color;
  607. font-size: 26rpx;
  608. &--icon {
  609. font-size: 40rpx;
  610. }
  611. &__tips {
  612. margin-top: 20rpx;
  613. line-height: 40rpx;
  614. }
  615. }
  616. }
  617. &__add {
  618. width: auto;
  619. display: inline-block;
  620. &--custom {
  621. width: 100%;
  622. }
  623. }
  624. }
  625. </style>