s-uploader.vue 19 KB

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