uni-file-picker.vue 16 KB

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