Uploader.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. <template>
  2. <FlexCol>
  3. <Toast ref="toast" />
  4. <DialogRoot ref="dialog" />
  5. <!-- #ifndef MP -->
  6. <slot
  7. name="uploader"
  8. :onClick="onUploadPress"
  9. :items="currentUpladList"
  10. >
  11. <!-- #endif -->
  12. <FlexView
  13. v-if="showUpload"
  14. :direction="props.listType === 'grid' ? 'row' : 'column'"
  15. wrap
  16. :innerStyle="(props.itemListStyle as ViewStyle)"
  17. >
  18. <template
  19. v-for="(item, index) in currentUpladList"
  20. :key="index"
  21. >
  22. <!-- #ifndef MP -->
  23. <slot
  24. name="uploadItem"
  25. :index="index"
  26. :item="item"
  27. :onClick="() => onItemPress(item)"
  28. :onDeleteClick="() => onItemDeletePress(item)"
  29. :style="props.itemStyle"
  30. :imageStyle="props.itemImageStyle"
  31. :itemMaskStyle="props.itemMaskStyle"
  32. :itemMaskTextStyle="props.itemMaskTextStyle"
  33. :itemSize="itemSize"
  34. :showDelete="showDelete"
  35. :defaultSource="props.itemDefaultSource"
  36. >
  37. <!-- #endif -->
  38. <UploaderListItem
  39. :item="item"
  40. :showDelete="showDelete && !disabled && !readonly"
  41. :isListStyle="props.listType === 'list'"
  42. :style="itemStyle"
  43. :imageStyle="itemImageStyle"
  44. :itemMaskStyle="itemMaskStyle"
  45. :itemMaskTextStyle="itemMaskTextStyle"
  46. :defaultSource="itemDefaultSource"
  47. :itemSize="itemSize"
  48. @click="() => onItemPress(item)"
  49. @delete="() => onItemDeletePress(item)"
  50. />
  51. <!-- #ifndef MP -->
  52. </slot>
  53. <!-- #endif -->
  54. </template>
  55. <slot v-if="currentUpladList.length < maxUploadCount && !disabled && !readonly" name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
  56. <UploaderListAddItem :itemSize="itemSize" :style="itemStyle" @click="onUploadPress" :isListStyle="props.listType === 'list'" />
  57. </slot>
  58. <slot v-if="readonly && currentUpladList.length === 0">
  59. <Text color="text.second">暂无上传文件</Text>
  60. </slot>
  61. </FlexView>
  62. <slot v-else name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
  63. <UploaderListAddItem :itemSize="itemSize" :style="itemStyle" @click="onUploadPress" :isListStyle="props.listType === 'list'" />
  64. </slot>
  65. <!-- #ifndef MP -->
  66. </slot>
  67. <!-- #endif -->
  68. </FlexCol>
  69. </template>
  70. <script setup lang="ts">
  71. import { reactive, ref, watch } from 'vue';
  72. import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
  73. import type { ToastInstance } from '../feedback/Toast.vue';
  74. import Toast from '../feedback/Toast.vue';
  75. import DialogRoot, { type DialogAlertRoot } from '../dialog/DialogRoot.vue';
  76. import UploaderListAddItem from './UploaderListAddItem.vue';
  77. import UploaderListItem from './UploaderListItem.vue';
  78. import FlexView from '../layout/FlexView.vue';
  79. import FlexCol from '../layout/FlexCol.vue';
  80. import type { UploaderAction, UploaderItem } from './Uploader';
  81. import { Debounce, LogUtils } from '@imengyu/imengyu-utils';
  82. import Text from '../basic/Text.vue';
  83. import { actionSheet } from '../dialog/CommonRoot';
  84. const themeContext = useTheme();
  85. const TAG = 'Uploader';
  86. export interface UploaderProps {
  87. /**
  88. * 最大上传数
  89. * @default 1
  90. */
  91. maxUploadCount?: number;
  92. /**
  93. * 最大上传文件的大小(B),为 0 则不限制,超过大小的文件会被自动过滤,这些文件信息可以通过 onOverSize 事件获取。
  94. * @default 0 不限制
  95. */
  96. maxFileSize?: number;
  97. /**
  98. * 是否禁用文件上传。
  99. * @default false
  100. */
  101. disabled?: boolean;
  102. /**
  103. * 是否只读,只读状态下不会显示上传按钮,也不能删除已上传的文件。
  104. * @default false
  105. */
  106. readonly?: boolean;
  107. /**
  108. * 是否显示文件已上传列表。
  109. * @default true
  110. */
  111. showUpload?: boolean;
  112. /**
  113. * 是否在已上传列表条目中显示删除按钮。
  114. * @default true
  115. */
  116. showDelete?: boolean;
  117. /**
  118. * 上传列表的显示模式
  119. * * grid:网格模式,显示为图片网格
  120. * * list:列表模式,显示为文件列表
  121. * @default 'grid'
  122. */
  123. listType?: 'list'|'grid';
  124. /**
  125. * 是否在用户添加图片后自动进行上传,否则不会自动上传,你可以手动调用上传进行统一上传。
  126. * @default true
  127. */
  128. uploadWhenAdded?: boolean;
  129. /**
  130. * 当用户多选,或者手动统一上传多个条目时,要如何并发上传。
  131. * * all:同时触发多个上传任务
  132. * * sequential:按顺序执行上传任务,只有前一个上传完成,后一个才会开始。
  133. * @default 'all'
  134. */
  135. uploadQueueMode?: 'all'|'sequential';
  136. /**
  137. * 条目的默认图片
  138. */
  139. itemDefaultSource?: string|undefined;
  140. /**
  141. * 条目的大小
  142. * @default { width: 80, height: 80 }
  143. */
  144. itemSize?: { width: number, height: number };
  145. /**
  146. * 列表自定义外层样式
  147. */
  148. itemListStyle?: ViewStyle;
  149. /**
  150. * 条目的自定义外层样式
  151. */
  152. itemStyle?: ViewStyle;
  153. /**
  154. * 条目的自定义图片样式
  155. */
  156. itemImageStyle?: ViewStyle;
  157. /**
  158. * 条目的自定义信息遮罩样式
  159. */
  160. itemMaskTextStyle?: TextStyle;
  161. /**
  162. * 条目的自定义信息容器样式
  163. */
  164. itemMaskStyle?: ViewStyle;
  165. /**
  166. * 初始列表中的条目
  167. * @default []
  168. */
  169. intitalItems?: UploaderItem[];
  170. /**
  171. * 选择文件类型
  172. * * image:图片
  173. * * video:视频
  174. * * file: 文件(仅H5)
  175. * * select:根据用户选择(图片/视频)
  176. * @default 'image'
  177. */
  178. chooseType?: 'image'|'video'|'file'|'select'|'';
  179. /**
  180. * 是否是从消息中选择文件
  181. * @default true
  182. */
  183. formMessage?: boolean;
  184. /**
  185. * 上传处理。不提供则无法上传
  186. * @required true
  187. */
  188. upload: (item: UploaderAction) => (() => void);
  189. /**
  190. * 自定义选择文件组件,你可以调用自己的文件选择器。默认调用 ImagePicker 选择文件.
  191. */
  192. onPickImage?: () => Promise<UploaderItem[]>;
  193. /**
  194. * 通过此函数可以在上传前进行校验和处理,Promise.resolve 表示校验通过,Promise.reject 表示校验失败。支持返回一个新的文件对象,可用于自定义处理,例如压缩图片。
  195. */
  196. onBeforeAdd?: (item: UploaderItem) => Promise<UploaderItem|undefined>;
  197. /**
  198. * 自定义上传失败文件点击的事件。不提供默认是重新上传。
  199. */
  200. onRetryClick?: (item: UploaderItem) => void;
  201. /**
  202. * 自定义已上传文件点击的事件。不提供默认是调用 ImagePreview 进行预览。
  203. */
  204. onPreviewClick?: (item: UploaderItem) => void;
  205. /**
  206. * 自定义已上传文件点击删除按钮的事件。Promise.resolve 表示可以删除,Promise.reject 表示不可以删除。
  207. */
  208. onDeleteClick?: (item: UploaderItem) => Promise<void>;
  209. /**
  210. * 当上传文件超过大小时返回
  211. */
  212. onOverSize?: (item: UploaderItem) => void;
  213. /**
  214. * 当上传文件超过数量时返回
  215. */
  216. onOverCount?: (count: number, max: number) => void;
  217. }
  218. export interface UploaderInstance {
  219. /**
  220. * 获取已上传列表数据
  221. */
  222. getList: () => UploaderItem[];
  223. /**
  224. * 设置已上传列表数据
  225. */
  226. setList: (list: UploaderItem[]) => void;
  227. /**
  228. * 强制从已上传列表更新某个条目。如果条目在列表中不存在(按文件路径判断),则会添加到末尾。
  229. */
  230. updateListItem: (item: UploaderItem) => void;
  231. /**
  232. * 强制从已上传列表删除某个条目
  233. */
  234. deleteListItem: (item: UploaderItem) => void;
  235. /**
  236. * 添加条目到已上传列表,并且自动开始上传。
  237. * 注:不会限制最大上传数和不限制最大文件大小。
  238. * * 单选情况下会替换已上传列表中的条目。
  239. * * 多选情况下会添加到末尾。
  240. */
  241. addItemAndUpload: (item: UploaderItem) => void;
  242. /**
  243. * 开始手动上传所有条目
  244. */
  245. startUploadAll: () => Promise<void>;
  246. /**
  247. * 开始手动上传指定条目
  248. */
  249. startUpload: (item: UploaderItem) => Promise<void>;
  250. /**
  251. * 获取现在是否全部条目处于已上传并且完成状态
  252. */
  253. isAllUploadSuccess: () => boolean;
  254. /**
  255. * 获取现在是否有任意一个条目正在上传状态
  256. */
  257. isAnyUploading: () => boolean;
  258. /**
  259. * 获取现在是否有任意一个条目处于失败状态
  260. */
  261. isAnyFail: () => boolean;
  262. /**
  263. * 调用此函数与用户手动点击添加按钮效果相同
  264. */
  265. pick: () => void;
  266. }
  267. const toast = ref<ToastInstance>();
  268. const dialog = ref<DialogAlertRoot>();
  269. const emit = defineEmits([ 'click', 'updateList' ]);
  270. const props = withDefaults(defineProps<UploaderProps>(), {
  271. disabled: false,
  272. maxUploadCount: 1,
  273. maxFileSize: 0,
  274. showDelete: true,
  275. showUpload: true,
  276. uploadWhenAdded: true,
  277. autoUpdateUploadList: true,
  278. formMessage: true,
  279. uploadQueueMode: 'all',
  280. listType: 'grid',
  281. chooseType: 'image',
  282. itemSize: () => propGetThemeVar('UploaderItemSize', { width: 750 / 4 - 15, height: 750 / 4 - 15 }),
  283. });
  284. const currentUpladList = ref<UploaderItem[]>(props.intitalItems?.concat() || []);
  285. //上传按钮点击
  286. function onUploadPress() {
  287. if (props.disabled || props.readonly)
  288. return;
  289. if (props.maxUploadCount > 1 && props.maxUploadCount - currentUpladList.value.length <= 0) {
  290. props.onOverCount ?
  291. props.onOverCount(props.maxUploadCount, currentUpladList.value.length) :
  292. toast.value?.text(`最多上传 ${props.maxUploadCount} 个文件哦!`);
  293. return;
  294. }
  295. const items = props.onPickImage ?
  296. props.onPickImage() :
  297. new Promise<UploaderItem[]>((resolve, reject) => {
  298. function handleFiles(res: {
  299. path: string;
  300. size: number;
  301. }[]) {
  302. resolve(res.map((item) => {
  303. let isImage = typeof (item as any).type === 'string' ? (item as any).type.startsWith('image/') : false;
  304. if (!isImage) {
  305. isImage = isImagePath(item.path);
  306. }
  307. return {
  308. filePath: item.path,
  309. previewPath: item.path,
  310. size: item.size,
  311. state: 'notstart',
  312. isImage,
  313. message: '',
  314. progress: 0,
  315. } as UploaderItem;
  316. }))
  317. }
  318. //#ifdef MP
  319. if (props.formMessage) {
  320. actionSheet({
  321. title: '选择上传方式',
  322. actions: [
  323. {
  324. name: '从相册选择',
  325. subname: '从相机立即拍摄或者相册中选择照片/视频',
  326. },
  327. {
  328. name: '从微信聊天中选择',
  329. subname: '可在录音机或者WPS文档分享给文件传输助手\n然后选择任意文档/文件',
  330. },
  331. ],
  332. showCancel: true,
  333. }).then((index) => {
  334. if (index === 0) {
  335. chooseLocal(props.chooseType);
  336. } else if (index === 1) {
  337. uni.chooseMessageFile({
  338. type: props.chooseType === 'select' ? 'all' : (props.chooseType || 'all'),
  339. count: props.maxUploadCount - currentUpladList.value.length,
  340. success: (res) => {
  341. LogUtils.printLog(TAG, 'info', 'chooseMessageFile', res);
  342. handleFiles(res.tempFiles as { path: string; size: number; }[])
  343. },
  344. fail: (e) => {
  345. LogUtils.printLog(TAG, 'error', 'chooseMessageFile', e);
  346. reject(e);
  347. }
  348. });
  349. }
  350. });
  351. } else {
  352. chooseLocal(props.chooseType);
  353. }
  354. //#endif
  355. //#ifndef MP
  356. chooseLocal(props.chooseType);
  357. //#endif
  358. function chooseLocal(type: UploaderProps['chooseType']) {
  359. switch (type) {
  360. case 'video':
  361. uni.chooseVideo().then((res) => handleFiles([
  362. {
  363. path: res.tempFilePath,
  364. size: res.size,
  365. }
  366. ])).catch(reject);
  367. break;
  368. case 'file':
  369. uni.chooseFile().then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
  370. break;
  371. case 'select':
  372. actionSheet({
  373. title: '您想上传哪种类型的文件?',
  374. actions: [
  375. {
  376. name: '照片',
  377. },
  378. {
  379. name: '视频',
  380. },
  381. ],
  382. showCancel: true,
  383. onSelect(index, name) {
  384. },
  385. }).then((index) => {
  386. if (index === 0) {
  387. chooseLocal('image');
  388. } else if (index === 1) {
  389. chooseLocal('video');
  390. }
  391. });
  392. break;
  393. default:
  394. case 'image':
  395. uni.chooseImage({
  396. count: props.maxUploadCount - currentUpladList.value.length,
  397. }).then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
  398. break;
  399. }
  400. }
  401. });
  402. items
  403. .then((res) => {
  404. if (props.maxFileSize > 0) {
  405. res = res.filter((item) => {
  406. if (item.size && item.size > props.maxFileSize) {
  407. props.onOverSize?.(item);
  408. return false;
  409. }
  410. return true;
  411. });
  412. if (res.length === 0) {
  413. toast.value?.text('您选择的文件过大,请重新选择!');
  414. return;
  415. }
  416. }
  417. res = res.map((item) => {
  418. item.state = 'notstart';
  419. item.message = '';
  420. item.progress = 0;
  421. return reactive(item);
  422. });
  423. //添加条目
  424. currentUpladList.value = props.maxUploadCount > 1 ? currentUpladList.value.concat(res) : res;
  425. //自动上传
  426. if (props.uploadWhenAdded)
  427. startUploadMulitItem(currentUpladList.value);
  428. })
  429. .catch((e) => console.warn('PickImage failed', e));
  430. }
  431. //条目点击
  432. function onItemPress(item: UploaderItem) {
  433. if (props.readonly) {
  434. props.onPreviewClick ?
  435. props.onPreviewClick(item) :
  436. onItemPreview(item); //默认预览
  437. return;
  438. }
  439. if (item.state === 'fail') {
  440. props.onRetryClick ?
  441. props.onRetryClick(item) :
  442. //重试上传
  443. startUploadItem(item);
  444. } else {
  445. props.onPreviewClick ?
  446. props.onPreviewClick(item) :
  447. onItemPreview(item); //默认预览
  448. }
  449. }
  450. //条目预览
  451. function onItemPreview(item: UploaderItem) {
  452. //判断后缀是不是图片
  453. const previewPath = item.previewPath || item.uploadedPath || item.filePath;
  454. if (item.isImage || isImagePath(previewPath)) {
  455. uni.previewImage({
  456. urls: [
  457. previewPath
  458. ],
  459. });
  460. } else {
  461. uni.openDocument({
  462. filePath: previewPath,
  463. });
  464. }
  465. }
  466. //条目删除点击
  467. function onItemDeletePress(item: UploaderItem) {
  468. if (props.disabled || props.readonly)
  469. return;
  470. props.onDeleteClick ?
  471. props.onDeleteClick(item).then(() => {
  472. deleteListItem(item);
  473. }).catch(() => {}) :
  474. dialog.value?.confirm({
  475. title: '提示',
  476. content: '是否确认删除此文件?',
  477. }).then((confirm) => {
  478. if (confirm)
  479. deleteListItem(item);
  480. });
  481. }
  482. //更新列表条目
  483. function updateListItem(item: UploaderItem) {
  484. emit('updateList', currentUpladList.value);
  485. }
  486. //删除列表条目
  487. function deleteListItem(item: UploaderItem) {
  488. currentUpladList.value = currentUpladList.value.filter((k) => k.filePath !== item.filePath);
  489. //如果正在上传,先取消上传
  490. if (item.state === 'uploading') {
  491. item.cancelUpload?.();
  492. item.state = 'fail';
  493. }
  494. }
  495. function isImagePath(path: string) {
  496. return path.match(/\.(jpg|jpeg|png|gif|webp)$/) !== null;
  497. }
  498. //开始上传条目
  499. function startUploadItem(item: UploaderItem) {
  500. if (item.state === 'uploading')
  501. return;
  502. return new Promise<void>((resolve, reject) => {
  503. if (item.state === 'success') {
  504. resolve();
  505. return;
  506. }
  507. LogUtils.printLog(TAG, 'message', `调用上传文件 ${item.filePath}`);
  508. const updateProgressDebounce = new Debounce<number>(400, (precent) => {
  509. item.state = 'uploading';
  510. item.message = precent ? `上传中 ${precent}%` : '上传中...';
  511. item.progress = precent;
  512. updateListItem(item);
  513. });
  514. item.cancelUpload = props.upload({
  515. item,
  516. onError(error) {
  517. item.state = 'fail';
  518. item.message = ('' + error) || '上传失败';
  519. updateListItem(item);
  520. reject(error);
  521. LogUtils.printLog(TAG, 'error', `上传文件 ${item.filePath} 失败,错误信息:${error}`);
  522. },
  523. onFinish(result, message) {
  524. updateProgressDebounce.cancel();
  525. item.state = 'success';
  526. item.message = message || '上传完成';
  527. item.progress = 100;
  528. item.uploadedPath = result.uploadedUrl;
  529. if (result.previewUrl)
  530. item.previewPath = result.previewUrl;
  531. updateListItem(item);
  532. resolve();
  533. LogUtils.printLog(TAG, 'success', `上传文件 ${item.filePath} 成功,上传路径:${result.uploadedUrl}`);
  534. },
  535. onProgress(precent) {
  536. updateProgressDebounce.executeWithDelay(200, precent);
  537. },
  538. onStart(message) {
  539. item.state = 'uploading';
  540. item.message = message || '上传中...';
  541. item.progress = 0;
  542. updateListItem(item);
  543. LogUtils.printLog(TAG, 'message', `上传文件 ${item.filePath} 开始,信息:${item.message}`);
  544. },
  545. });
  546. });
  547. }
  548. //开始上传条目
  549. function startUploadMulitItem(items: UploaderItem[]) : Promise<void> {
  550. if (props.uploadQueueMode === 'sequential')
  551. return items.reduce((promiseChain, currentItem) =>
  552. promiseChain.then(() => startUploadItem(currentItem)).catch(() => startUploadItem(currentItem)),
  553. Promise.resolve()
  554. );
  555. else
  556. return Promise.all(items.map(item => startUploadItem(item))) as unknown as Promise<void>;
  557. }
  558. defineExpose<UploaderInstance>({
  559. startUploadAll() {
  560. return startUploadMulitItem(currentUpladList.value);
  561. },
  562. async startUpload(item) {
  563. return await startUploadItem(item);
  564. },
  565. setList(list) {
  566. const needRemoveItems = [] as string[];
  567. for (const item of currentUpladList.value) {
  568. if (list.findIndex(k => k.filePath === item.filePath) === -1)
  569. needRemoveItems.push(item.filePath);
  570. }
  571. for (const filePath of needRemoveItems)
  572. currentUpladList.value.splice(currentUpladList.value.findIndex(k => k.filePath === filePath), 1);
  573. list.forEach(item => {
  574. if (currentUpladList.value.findIndex(k => k.filePath === item.filePath) === -1)
  575. currentUpladList.value.push(item);
  576. });
  577. },
  578. getList() {
  579. return currentUpladList.value;
  580. },
  581. deleteListItem(item) {
  582. deleteListItem(item);
  583. },
  584. updateListItem(item) {
  585. updateListItem(item);
  586. },
  587. addItemAndUpload(item) {
  588. //如果是单选模式,且已存在上传项,则替换已存在项
  589. if (props.maxUploadCount === 1 && currentUpladList.value.length > 0)
  590. deleteListItem(currentUpladList.value[0]);
  591. currentUpladList.value.push(item);
  592. startUploadItem(item);
  593. },
  594. pick() {
  595. onUploadPress();
  596. },
  597. isAllUploadSuccess: () => {
  598. return currentUpladList.value.every(k => k.state === 'success');
  599. },
  600. isAnyUploading: () => {
  601. return currentUpladList.value.find(k => k.state === 'uploading') !== undefined;
  602. },
  603. isAnyFail: () => {
  604. return currentUpladList.value.find(k => k.state === 'fail') !== undefined;
  605. },
  606. });
  607. </script>