s-uploader.vue 18 KB

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