s-uploader.vue 19 KB

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