s-uploader.vue 17 KB

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