uni-file-picker.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. <template>
  2. <view class="uni-file-picker">
  3. <view v-if="title" class="uni-file-picker__header">
  4. <text class="file-title">{{ title }}</text>
  5. <text class="file-count">{{ filesList.length }}/{{ limitLength }}</text>
  6. </view>
  7. <upload-image v-if="fileMediatype === 'image' && showType === 'grid'" :readonly="readonly"
  8. :image-styles="imageStyles" :files-list="filesList" :limit="limitLength" :disablePreview="disablePreview"
  9. :delIcon="delIcon" @uploadFiles="uploadFiles" @choose="choose" @delFile="delFile">
  10. <slot>
  11. <view class="icon-add"></view>
  12. <view class="icon-add rotate"></view>
  13. </slot>
  14. </upload-image>
  15. <upload-file v-if="fileMediatype !== 'image' || showType !== 'grid'" :readonly="readonly"
  16. :list-styles="listStyles" :files-list="filesList" :showType="showType" :delIcon="delIcon"
  17. @uploadFiles="uploadFiles" @choose="choose" @delFile="delFile">
  18. <slot><button type="primary" size="mini">选择文件</button></slot>
  19. </upload-file>
  20. </view>
  21. </template>
  22. <script>
  23. import {
  24. chooseAndUploadFile,
  25. uploadCloudFiles
  26. } from './choose-and-upload-file.js'
  27. import {
  28. get_file_ext,
  29. get_extname,
  30. get_files_and_is_max,
  31. get_file_info,
  32. get_file_data
  33. } from './utils.js'
  34. import uploadImage from './upload-image.vue'
  35. import uploadFile from './upload-file.vue'
  36. let fileInput = null
  37. /**
  38. * FilePicker 文件选择上传
  39. * @description 文件选择上传组件,可以选择图片、视频等任意文件并上传到当前绑定的服务空间
  40. * @tutorial https://ext.dcloud.net.cn/plugin?id=4079
  41. * @property {Object|Array} value 组件数据,通常用来回显 ,类型由return-type属性决定
  42. * @property {Boolean} disabled = [true|false] 组件禁用
  43. * @value true 禁用
  44. * @value false 取消禁用
  45. * @property {Boolean} readonly = [true|false] 组件只读,不可选择,不显示进度,不显示删除按钮
  46. * @value true 只读
  47. * @value false 取消只读
  48. * @property {String} return-type = [array|object] 限制 value 格式,当为 object 时 ,组件只能单选,且会覆盖
  49. * @value array 规定 value 属性的类型为数组
  50. * @value object 规定 value 属性的类型为对象
  51. * @property {Boolean} disable-preview = [true|false] 禁用图片预览,仅 mode:grid 时生效
  52. * @value true 禁用图片预览
  53. * @value false 取消禁用图片预览
  54. * @property {Boolean} del-icon = [true|false] 是否显示删除按钮
  55. * @value true 显示删除按钮
  56. * @value false 不显示删除按钮
  57. * @property {Boolean} auto-upload = [true|false] 是否自动上传,值为true则只触发@select,可自行上传
  58. * @value true 自动上传
  59. * @value false 取消自动上传
  60. * @property {Number|String} limit 最大选择个数 ,h5 会自动忽略多选的部分
  61. * @property {String} title 组件标题,右侧显示上传计数
  62. * @property {String} mode = [list|grid] 选择文件后的文件列表样式
  63. * @value list 列表显示
  64. * @value grid 宫格显示
  65. * @property {String} file-mediatype = [image|video|all] 选择文件类型
  66. * @value image 只选择图片
  67. * @value video 只选择视频
  68. * @value all 选择所有文件
  69. * @property {Array} file-extname 选择文件后缀,根据 file-mediatype 属性而不同
  70. * @property {Object} list-style mode:list 时的样式
  71. * @property {Object} image-styles 选择文件后缀,根据 file-mediatype 属性而不同
  72. * @event {Function} select 选择文件后触发
  73. * @event {Function} progress 文件上传时触发
  74. * @event {Function} success 上传成功触发
  75. * @event {Function} fail 上传失败触发
  76. * @event {Function} delete 文件从列表移除时触发
  77. */
  78. export default {
  79. name: 'uniFilePicker',
  80. components: {
  81. uploadImage,
  82. uploadFile
  83. },
  84. options: {
  85. virtualHost: true
  86. },
  87. emits: ['select', 'success', 'fail', 'progress', 'delete', 'update:modelValue', 'input'],
  88. props: {
  89. modelValue: {
  90. type: [Array, Object],
  91. default () {
  92. return []
  93. }
  94. },
  95. value: {
  96. type: [Array, Object],
  97. default () {
  98. return []
  99. }
  100. },
  101. disabled: {
  102. type: Boolean,
  103. default: false
  104. },
  105. disablePreview: {
  106. type: Boolean,
  107. default: false
  108. },
  109. delIcon: {
  110. type: Boolean,
  111. default: true
  112. },
  113. // 自动上传
  114. autoUpload: {
  115. type: Boolean,
  116. default: true
  117. },
  118. // 最大选择个数 ,h5只能限制单选或是多选
  119. limit: {
  120. type: [Number, String],
  121. default: 9
  122. },
  123. // 列表样式 grid | list | list-card
  124. mode: {
  125. type: String,
  126. default: 'grid'
  127. },
  128. // 选择文件类型 image/video/all
  129. fileMediatype: {
  130. type: String,
  131. default: 'image'
  132. },
  133. // 文件类型筛选
  134. fileExtname: {
  135. type: [Array, String],
  136. default () {
  137. return []
  138. }
  139. },
  140. title: {
  141. type: String,
  142. default: ''
  143. },
  144. listStyles: {
  145. type: Object,
  146. default () {
  147. return {
  148. // 是否显示边框
  149. border: true,
  150. // 是否显示分隔线
  151. dividline: true,
  152. // 线条样式
  153. borderStyle: {}
  154. }
  155. }
  156. },
  157. imageStyles: {
  158. type: Object,
  159. default () {
  160. return {
  161. width: 'auto',
  162. height: 'auto'
  163. }
  164. }
  165. },
  166. readonly: {
  167. type: Boolean,
  168. default: false
  169. },
  170. returnType: {
  171. type: String,
  172. default: 'array'
  173. },
  174. sizeType: {
  175. type: Array,
  176. default () {
  177. return ['original', 'compressed']
  178. }
  179. },
  180. sourceType: {
  181. type: Array,
  182. default () {
  183. return ['album', 'camera']
  184. }
  185. },
  186. provider: {
  187. type: String,
  188. default: '' // 默认上传到 unicloud 内置存储 extStorage 扩展存储
  189. }
  190. },
  191. data() {
  192. return {
  193. files: [],
  194. localValue: []
  195. }
  196. },
  197. watch: {
  198. value: {
  199. handler(newVal, oldVal) {
  200. this.setValue(newVal, oldVal)
  201. },
  202. immediate: true
  203. },
  204. modelValue: {
  205. handler(newVal, oldVal) {
  206. this.setValue(newVal, oldVal)
  207. },
  208. immediate: true
  209. },
  210. },
  211. computed: {
  212. filesList() {
  213. let files = []
  214. this.files.forEach(v => {
  215. files.push(v)
  216. })
  217. return files
  218. },
  219. showType() {
  220. if (this.fileMediatype === 'image') {
  221. return this.mode
  222. }
  223. return 'list'
  224. },
  225. limitLength() {
  226. if (this.returnType === 'object') {
  227. return 1
  228. }
  229. if (!this.limit) {
  230. return 1
  231. }
  232. if (this.limit >= 9) {
  233. return 9
  234. }
  235. return this.limit
  236. }
  237. },
  238. created() {
  239. // TODO 兼容不开通服务空间的情况
  240. if (!(uniCloud.config && uniCloud.config.provider)) {
  241. this.noSpace = true
  242. uniCloud.chooseAndUploadFile = chooseAndUploadFile
  243. }
  244. this.form = this.getForm('uniForms')
  245. this.formItem = this.getForm('uniFormsItem')
  246. if (this.form && this.formItem) {
  247. if (this.formItem.name) {
  248. this.rename = this.formItem.name
  249. this.form.inputChildrens.push(this)
  250. }
  251. }
  252. },
  253. methods: {
  254. /**
  255. * 公开用户使用,清空文件
  256. * @param {Object} index
  257. */
  258. clearFiles(index) {
  259. if (index !== 0 && !index) {
  260. this.files = []
  261. this.$nextTick(() => {
  262. this.setEmit()
  263. })
  264. } else {
  265. this.files.splice(index, 1)
  266. }
  267. this.$nextTick(() => {
  268. this.setEmit()
  269. })
  270. },
  271. /**
  272. * 公开用户使用,继续上传
  273. */
  274. upload() {
  275. let files = []
  276. this.files.forEach((v, index) => {
  277. if (v.status === 'ready' || v.status === 'error') {
  278. files.push(Object.assign({}, v))
  279. }
  280. })
  281. return this.uploadFiles(files)
  282. },
  283. async setValue(newVal, oldVal) {
  284. const newData = async (v) => {
  285. const reg = /cloud:\/\/([\w.]+\/?)\S*/
  286. let url = ''
  287. if(v.fileID){
  288. url = v.fileID
  289. }else{
  290. url = v.url
  291. }
  292. if (reg.test(url)) {
  293. v.fileID = url
  294. v.url = await this.getTempFileURL(url)
  295. }
  296. if(v.url) v.path = v.url
  297. return v
  298. }
  299. if (this.returnType === 'object') {
  300. if (newVal) {
  301. await newData(newVal)
  302. } else {
  303. newVal = {}
  304. }
  305. } else {
  306. if (!newVal) newVal = []
  307. for(let i =0 ;i < newVal.length ;i++){
  308. let v = newVal[i]
  309. await newData(v)
  310. }
  311. }
  312. this.localValue = newVal
  313. if (this.form && this.formItem &&!this.is_reset) {
  314. this.is_reset = false
  315. this.formItem.setValue(this.localValue)
  316. }
  317. let filesData = Object.keys(newVal).length > 0 ? newVal : [];
  318. this.files = [].concat(filesData)
  319. },
  320. /**
  321. * 选择文件
  322. */
  323. choose() {
  324. if (this.disabled) return
  325. if (this.files.length >= Number(this.limitLength) && this.showType !== 'grid' && this.returnType ===
  326. 'array') {
  327. uni.showToast({
  328. title: `您最多选择 ${this.limitLength} 个文件`,
  329. icon: 'none'
  330. })
  331. return
  332. }
  333. this.chooseFiles()
  334. },
  335. /**
  336. * 选择文件并上传
  337. */
  338. chooseFiles() {
  339. const _extname = get_extname(this.fileExtname)
  340. // 获取后缀
  341. uniCloud
  342. .chooseAndUploadFile({
  343. type: this.fileMediatype,
  344. compressed: false,
  345. sizeType: this.sizeType,
  346. sourceType: this.sourceType,
  347. // TODO 如果为空,video 有问题
  348. extension: _extname.length > 0 ? _extname : undefined,
  349. count: this.limitLength - this.files.length, //默认9
  350. onChooseFile: this.chooseFileCallback,
  351. onUploadProgress: progressEvent => {
  352. this.setProgress(progressEvent, progressEvent.index)
  353. }
  354. })
  355. .then(result => {
  356. this.setSuccessAndError(result.tempFiles)
  357. })
  358. .catch(err => {
  359. console.log('选择失败', err)
  360. })
  361. },
  362. /**
  363. * 选择文件回调
  364. * @param {Object} res
  365. */
  366. async chooseFileCallback(res) {
  367. const _extname = get_extname(this.fileExtname)
  368. const is_one = (Number(this.limitLength) === 1 &&
  369. this.disablePreview &&
  370. !this.disabled) ||
  371. this.returnType === 'object'
  372. // 如果这有一个文件 ,需要清空本地缓存数据
  373. if (is_one) {
  374. this.files = []
  375. }
  376. let {
  377. filePaths,
  378. files
  379. } = get_files_and_is_max(res, _extname)
  380. if (!(_extname && _extname.length > 0)) {
  381. filePaths = res.tempFilePaths
  382. files = res.tempFiles
  383. }
  384. let currentData = []
  385. for (let i = 0; i < files.length; i++) {
  386. if (this.limitLength - this.files.length <= 0) break
  387. files[i].uuid = Date.now()
  388. let filedata = await get_file_data(files[i], this.fileMediatype)
  389. filedata.progress = 0
  390. filedata.status = 'ready'
  391. this.files.push(filedata)
  392. currentData.push({
  393. ...filedata,
  394. file: files[i]
  395. })
  396. }
  397. this.$emit('select', {
  398. tempFiles: currentData,
  399. tempFilePaths: filePaths
  400. })
  401. res.tempFiles = files
  402. // 停止自动上传
  403. if (!this.autoUpload || this.noSpace) {
  404. res.tempFiles = []
  405. }
  406. res.tempFiles.forEach((fileItem, index) => {
  407. this.provider && (fileItem.provider = this.provider);
  408. const fileNameSplit = fileItem.name.split('.')
  409. const ext = fileNameSplit.pop()
  410. const fileName = fileNameSplit.join('.').replace(/[\s\/\?<>\\:\*\|":]/g, '_')
  411. fileItem.cloudPath = fileName + '_' + Date.now() + '_' + index + '.' + ext
  412. })
  413. },
  414. /**
  415. * 批传
  416. * @param {Object} e
  417. */
  418. uploadFiles(files) {
  419. files = [].concat(files)
  420. return uploadCloudFiles.call(this, files, 5, res => {
  421. this.setProgress(res, res.index, true)
  422. })
  423. .then(result => {
  424. this.setSuccessAndError(result)
  425. return result;
  426. })
  427. .catch(err => {
  428. console.log(err)
  429. })
  430. },
  431. /**
  432. * 成功或失败
  433. */
  434. async setSuccessAndError(res, fn) {
  435. let successData = []
  436. let errorData = []
  437. let tempFilePath = []
  438. let errorTempFilePath = []
  439. for (let i = 0; i < res.length; i++) {
  440. const item = res[i]
  441. const index = item.uuid ? this.files.findIndex(p => p.uuid === item.uuid) : item.index
  442. if (index === -1 || !this.files) break
  443. if (item.errMsg === 'request:fail') {
  444. this.files[index].url = item.path
  445. this.files[index].status = 'error'
  446. this.files[index].errMsg = item.errMsg
  447. // this.files[index].progress = -1
  448. errorData.push(this.files[index])
  449. errorTempFilePath.push(this.files[index].url)
  450. } else {
  451. this.files[index].errMsg = ''
  452. this.files[index].fileID = item.url
  453. const reg = /cloud:\/\/([\w.]+\/?)\S*/
  454. if (reg.test(item.url)) {
  455. this.files[index].url = await this.getTempFileURL(item.url)
  456. }else{
  457. this.files[index].url = item.url
  458. }
  459. this.files[index].status = 'success'
  460. this.files[index].progress += 1
  461. successData.push(this.files[index])
  462. tempFilePath.push(this.files[index].fileID)
  463. }
  464. }
  465. if (successData.length > 0) {
  466. this.setEmit()
  467. // 状态改变返回
  468. this.$emit('success', {
  469. tempFiles: this.backObject(successData),
  470. tempFilePaths: tempFilePath
  471. })
  472. }
  473. if (errorData.length > 0) {
  474. this.$emit('fail', {
  475. tempFiles: this.backObject(errorData),
  476. tempFilePaths: errorTempFilePath
  477. })
  478. }
  479. },
  480. /**
  481. * 获取进度
  482. * @param {Object} progressEvent
  483. * @param {Object} index
  484. * @param {Object} type
  485. */
  486. setProgress(progressEvent, index, type) {
  487. const fileLenth = this.files.length
  488. const percentNum = (index / fileLenth) * 100
  489. const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
  490. let idx = index
  491. if (!type) {
  492. idx = this.files.findIndex(p => p.uuid === progressEvent.tempFile.uuid)
  493. }
  494. if (idx === -1 || !this.files[idx]) return
  495. // fix by mehaotian 100 就会消失,-1 是为了让进度条消失
  496. this.files[idx].progress = percentCompleted - 1
  497. // 上传中
  498. this.$emit('progress', {
  499. index: idx,
  500. progress: parseInt(percentCompleted),
  501. tempFile: this.files[idx]
  502. })
  503. },
  504. /**
  505. * 删除文件
  506. * @param {Object} index
  507. */
  508. delFile(index) {
  509. this.$emit('delete', {
  510. index,
  511. tempFile: this.files[index],
  512. tempFilePath: this.files[index].url
  513. })
  514. this.files.splice(index, 1)
  515. this.$nextTick(() => {
  516. this.setEmit()
  517. })
  518. },
  519. /**
  520. * 获取文件名和后缀
  521. * @param {Object} name
  522. */
  523. getFileExt(name) {
  524. const last_len = name.lastIndexOf('.')
  525. const len = name.length
  526. return {
  527. name: name.substring(0, last_len),
  528. ext: name.substring(last_len + 1, len)
  529. }
  530. },
  531. /**
  532. * 处理返回事件
  533. */
  534. setEmit() {
  535. let data = []
  536. if (this.returnType === 'object') {
  537. data = this.backObject(this.files)[0]
  538. this.localValue = data?data:null
  539. } else {
  540. data = this.backObject(this.files)
  541. if (!this.localValue) {
  542. this.localValue = []
  543. }
  544. this.localValue = [...data]
  545. }
  546. // #ifdef VUE3
  547. this.$emit('update:modelValue', this.localValue)
  548. // #endif
  549. // #ifndef VUE3
  550. this.$emit('input', this.localValue)
  551. // #endif
  552. },
  553. /**
  554. * 处理返回参数
  555. * @param {Object} files
  556. */
  557. backObject(files) {
  558. let newFilesData = []
  559. files.forEach(v => {
  560. newFilesData.push({
  561. extname: v.extname,
  562. fileType: v.fileType,
  563. image: v.image,
  564. name: v.name,
  565. path: v.path,
  566. size: v.size,
  567. fileID:v.fileID,
  568. url: v.url,
  569. // 修改删除一个文件后不能再上传的bug, #694
  570. uuid: v.uuid,
  571. status: v.status,
  572. cloudPath: v.cloudPath
  573. })
  574. })
  575. return newFilesData
  576. },
  577. async getTempFileURL(fileList) {
  578. fileList = {
  579. fileList: [].concat(fileList)
  580. }
  581. const urls = await uniCloud.getTempFileURL(fileList)
  582. return urls.fileList[0].tempFileURL || ''
  583. },
  584. /**
  585. * 获取父元素实例
  586. */
  587. getForm(name = 'uniForms') {
  588. let parent = this.$parent;
  589. let parentName = parent.$options.name;
  590. while (parentName !== name) {
  591. parent = parent.$parent;
  592. if (!parent) return false;
  593. parentName = parent.$options.name;
  594. }
  595. return parent;
  596. }
  597. }
  598. }
  599. </script>
  600. <style>
  601. .uni-file-picker {
  602. /* #ifndef APP-NVUE */
  603. box-sizing: border-box;
  604. overflow: hidden;
  605. width: 100%;
  606. /* #endif */
  607. flex: 1;
  608. }
  609. .uni-file-picker__header {
  610. padding-top: 5px;
  611. padding-bottom: 10px;
  612. /* #ifndef APP-NVUE */
  613. display: flex;
  614. /* #endif */
  615. justify-content: space-between;
  616. }
  617. .file-title {
  618. font-size: 14px;
  619. color: #333;
  620. }
  621. .file-count {
  622. font-size: 14px;
  623. color: #999;
  624. }
  625. .icon-add {
  626. width: 50px;
  627. height: 5px;
  628. background-color: #f1f1f1;
  629. border-radius: 2px;
  630. }
  631. .rotate {
  632. position: absolute;
  633. transform: rotate(90deg);
  634. }
  635. </style>