uni-file-picker.vue 16 KB

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