Uploader.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  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 } from 'vue';
  72. import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
  73. import { Debounce, LogUtils } from '@imengyu/imengyu-utils';
  74. import { actionSheet } from '../dialog/CommonRoot';
  75. import type { ToastInstance } from '../feedback/Toast.vue';
  76. import type { UploaderAction, UploaderItem } from './Uploader';
  77. import Toast from '../feedback/Toast.vue';
  78. import Text from '../basic/Text.vue';
  79. import DialogRoot, { type DialogAlertRoot } from '../dialog/DialogRoot.vue';
  80. import UploaderListAddItem from './UploaderListAddItem.vue';
  81. import UploaderListItem from './UploaderListItem.vue';
  82. import FlexView from '../layout/FlexView.vue';
  83. import FlexCol from '../layout/FlexCol.vue';
  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. * @default 'image'
  175. */
  176. chooseType?: 'image'|'video'|'file'|'';
  177. /**
  178. * 是否是从消息中选择文件
  179. * @default true
  180. */
  181. formMessage?: boolean;
  182. /**
  183. * 上传处理。不提供则无法上传
  184. * @required true
  185. */
  186. upload: (item: UploaderAction) => (() => void);
  187. /**
  188. * 自定义选择文件组件,你可以调用自己的文件选择器。默认调用 ImagePicker 选择文件.
  189. */
  190. onPickImage?: () => Promise<UploaderItem[]>;
  191. /**
  192. * 通过此函数可以在上传前进行校验和处理,Promise.resolve 表示校验通过,Promise.reject 表示校验失败。支持返回一个新的文件对象,可用于自定义处理,例如压缩图片。
  193. */
  194. onBeforeAdd?: (item: UploaderItem) => Promise<UploaderItem|undefined>;
  195. /**
  196. * 自定义上传失败文件点击的事件。不提供默认是重新上传。
  197. */
  198. onRetryClick?: (item: UploaderItem) => void;
  199. /**
  200. * 自定义已上传文件点击的事件。不提供默认是调用 ImagePreview 进行预览。
  201. */
  202. onPreviewClick?: (item: UploaderItem) => void;
  203. /**
  204. * 自定义已上传文件点击删除按钮的事件。Promise.resolve 表示可以删除,Promise.reject 表示不可以删除。
  205. */
  206. onDeleteClick?: (item: UploaderItem) => Promise<void>;
  207. /**
  208. * 当上传文件超过大小时返回
  209. */
  210. onOverSize?: (item: UploaderItem) => void;
  211. /**
  212. * 当上传文件超过数量时返回
  213. */
  214. onOverCount?: (count: number, max: number) => void;
  215. }
  216. export interface UploaderInstance {
  217. /**
  218. * 获取已上传列表数据
  219. */
  220. getList: () => UploaderItem[];
  221. /**
  222. * 设置已上传列表数据
  223. */
  224. setList: (list: UploaderItem[]) => void;
  225. /**
  226. * 强制从已上传列表更新某个条目。如果条目在列表中不存在(按文件路径判断),则会添加到末尾。
  227. */
  228. updateListItem: (item: UploaderItem) => void;
  229. /**
  230. * 强制从已上传列表删除某个条目
  231. */
  232. deleteListItem: (item: UploaderItem) => void;
  233. /**
  234. * 添加条目到已上传列表,并且自动开始上传。
  235. * 注:不会限制最大上传数和不限制最大文件大小。
  236. * * 单选情况下会替换已上传列表中的条目。
  237. * * 多选情况下会添加到末尾。
  238. */
  239. addItemAndUpload: (item: UploaderItem) => void;
  240. /**
  241. * 开始手动上传所有条目
  242. */
  243. startUploadAll: () => Promise<void>;
  244. /**
  245. * 开始手动上传指定条目
  246. */
  247. startUpload: (item: UploaderItem) => Promise<void>;
  248. /**
  249. * 获取现在是否全部条目处于已上传并且完成状态
  250. */
  251. isAllUploadSuccess: () => boolean;
  252. /**
  253. * 获取现在是否有任意一个条目正在上传状态
  254. */
  255. isAnyUploading: () => boolean;
  256. /**
  257. * 获取现在是否有任意一个条目处于失败状态
  258. */
  259. isAnyFail: () => boolean;
  260. /**
  261. * 调用此函数与用户手动点击添加按钮效果相同
  262. */
  263. pick: () => void;
  264. }
  265. const toast = ref<ToastInstance>();
  266. const dialog = ref<DialogAlertRoot>();
  267. const emit = defineEmits([ 'click', 'updateList' ]);
  268. const props = withDefaults(defineProps<UploaderProps>(), {
  269. disabled: false,
  270. maxUploadCount: 1,
  271. maxFileSize: 0,
  272. showDelete: true,
  273. showUpload: true,
  274. uploadWhenAdded: true,
  275. autoUpdateUploadList: true,
  276. formMessage: true,
  277. uploadQueueMode: 'all',
  278. listType: 'grid',
  279. chooseType: 'image',
  280. itemSize: () => propGetThemeVar('UploaderItemSize', { width: 750 / 4 - 15, height: 750 / 4 - 15 }),
  281. });
  282. const currentUpladList = ref<UploaderItem[]>(props.intitalItems?.concat() || []);
  283. //上传按钮点击
  284. function onUploadPress() {
  285. if (props.disabled || props.readonly)
  286. return;
  287. if (props.maxUploadCount > 1 && props.maxUploadCount - currentUpladList.value.length <= 0) {
  288. props.onOverCount ?
  289. props.onOverCount(props.maxUploadCount, currentUpladList.value.length) :
  290. toast.value?.text(`最多上传 ${props.maxUploadCount} 个文件哦!`);
  291. return;
  292. }
  293. const items = props.onPickImage ?
  294. props.onPickImage() :
  295. new Promise<UploaderItem[]>((resolve, reject) => {
  296. function handleFiles(res: {
  297. path: string;
  298. size: number;
  299. }[]) {
  300. resolve(res.map((item) => {
  301. let isImage = typeof (item as any).type === 'string' ? (item as any).type.startsWith('image/') : false;
  302. if (!isImage) {
  303. isImage = isImagePath(item.path);
  304. }
  305. return {
  306. filePath: item.path,
  307. previewPath: item.path,
  308. size: item.size,
  309. state: 'notstart',
  310. isImage,
  311. message: '',
  312. progress: 0,
  313. } as UploaderItem;
  314. }))
  315. }
  316. //#ifdef MP
  317. if (props.formMessage) {
  318. actionSheet({
  319. title: '选择上传方式',
  320. actions: [
  321. {
  322. name: '从相册选择',
  323. subname: '从相机立即拍摄或者相册中选择照片/视频',
  324. },
  325. {
  326. name: '从微信聊天中选择',
  327. subname: '可在录音机或者WPS文档分享给文件传输助手\n然后选择任意文档/文件',
  328. },
  329. ],
  330. showCancel: true,
  331. onSelect(index, name) {
  332. },
  333. }).then((index) => {
  334. if (index === 0) {
  335. chooseLocal();
  336. } else if (index === 1) {
  337. uni.chooseMessageFile({
  338. type: 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();
  353. }
  354. //#endif
  355. //#ifndef MP
  356. chooseLocal();
  357. //#endif
  358. function chooseLocal() {
  359. switch (props.chooseType) {
  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. default:
  372. case 'image':
  373. uni.chooseImage({
  374. count: props.maxUploadCount - currentUpladList.value.length,
  375. }).then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
  376. break;
  377. }
  378. }
  379. });
  380. items
  381. .then((res) => {
  382. if (props.maxFileSize > 0) {
  383. res = res.filter((item) => {
  384. if (item.size && item.size > props.maxFileSize) {
  385. props.onOverSize?.(item);
  386. return false;
  387. }
  388. return true;
  389. });
  390. if (res.length === 0) {
  391. toast.value?.text('您选择的文件过大,请重新选择!');
  392. return;
  393. }
  394. }
  395. res = res.map((item) => {
  396. item.state = 'notstart';
  397. item.message = '';
  398. item.progress = 0;
  399. return reactive(item);
  400. });
  401. //添加条目
  402. currentUpladList.value = props.maxUploadCount > 1 ? currentUpladList.value.concat(res) : res;
  403. //自动上传
  404. if (props.uploadWhenAdded)
  405. startUploadMulitItem(currentUpladList.value);
  406. })
  407. .catch((e) => console.warn('PickImage failed', e));
  408. }
  409. //条目点击
  410. function onItemPress(item: UploaderItem) {
  411. if (props.readonly) {
  412. props.onPreviewClick ?
  413. props.onPreviewClick(item) :
  414. onItemPreview(item); //默认预览
  415. return;
  416. }
  417. if (item.state === 'fail') {
  418. props.onRetryClick ?
  419. props.onRetryClick(item) :
  420. //重试上传
  421. startUploadItem(item);
  422. } else {
  423. props.onPreviewClick ?
  424. props.onPreviewClick(item) :
  425. onItemPreview(item); //默认预览
  426. }
  427. }
  428. //条目预览
  429. function onItemPreview(item: UploaderItem) {
  430. //判断后缀是不是图片
  431. const previewPath = item.previewPath || item.uploadedPath || item.filePath;
  432. if (item.isImage || isImagePath(previewPath)) {
  433. uni.previewImage({
  434. urls: [
  435. previewPath
  436. ],
  437. });
  438. } else {
  439. uni.openDocument({
  440. filePath: previewPath,
  441. });
  442. }
  443. }
  444. //条目删除点击
  445. function onItemDeletePress(item: UploaderItem) {
  446. if (props.disabled || props.readonly)
  447. return;
  448. props.onDeleteClick ?
  449. props.onDeleteClick(item).then(() => {
  450. deleteListItem(item);
  451. }).catch(() => {}) :
  452. dialog.value?.confirm({
  453. title: '提示',
  454. content: '是否确认删除此文件?',
  455. }).then((confirm) => {
  456. if (confirm)
  457. deleteListItem(item);
  458. });
  459. }
  460. //更新列表条目
  461. function updateListItem(item: UploaderItem) {
  462. emit('updateList', currentUpladList.value);
  463. }
  464. //删除列表条目
  465. function deleteListItem(item: UploaderItem) {
  466. currentUpladList.value = currentUpladList.value.filter((k) => k.filePath !== item.filePath);
  467. //如果正在上传,先取消上传
  468. if (item.state === 'uploading') {
  469. item.cancelUpload?.();
  470. item.state = 'fail';
  471. }
  472. }
  473. function isImagePath(path: string) {
  474. return path.match(/\.(jpg|jpeg|png|gif|webp)$/) !== null;
  475. }
  476. //开始上传条目
  477. function startUploadItem(item: UploaderItem) {
  478. if (item.state === 'uploading')
  479. return;
  480. return new Promise<void>((resolve, reject) => {
  481. if (item.state === 'success') {
  482. resolve();
  483. return;
  484. }
  485. LogUtils.printLog(TAG, 'message', `调用上传文件 ${item.filePath}`);
  486. const updateProgressDebounce = new Debounce<number>(400, (precent) => {
  487. item.state = 'uploading';
  488. item.message = precent ? `上传中 ${precent}%` : '上传中...';
  489. item.progress = precent;
  490. updateListItem(item);
  491. });
  492. item.cancelUpload = props.upload({
  493. item,
  494. onError(error) {
  495. item.state = 'fail';
  496. item.message = ('' + error) || '上传失败';
  497. updateListItem(item);
  498. reject(error);
  499. LogUtils.printLog(TAG, 'error', `上传文件 ${item.filePath} 失败,错误信息:${error}`);
  500. },
  501. onFinish(result, message) {
  502. updateProgressDebounce.cancel();
  503. item.state = 'success';
  504. item.message = message || '上传完成';
  505. item.progress = 100;
  506. item.uploadedPath = result.uploadedUrl;
  507. if (result.previewUrl)
  508. item.previewPath = result.previewUrl;
  509. updateListItem(item);
  510. resolve();
  511. LogUtils.printLog(TAG, 'success', `上传文件 ${item.filePath} 成功,上传路径:${result.uploadedUrl}`);
  512. },
  513. onProgress(precent) {
  514. updateProgressDebounce.executeWithDelay(200, precent);
  515. },
  516. onStart(message) {
  517. item.state = 'uploading';
  518. item.message = message || '上传中...';
  519. item.progress = 0;
  520. updateListItem(item);
  521. LogUtils.printLog(TAG, 'message', `上传文件 ${item.filePath} 开始,信息:${item.message}`);
  522. },
  523. });
  524. });
  525. }
  526. //开始上传条目
  527. function startUploadMulitItem(items: UploaderItem[]) : Promise<void> {
  528. if (props.uploadQueueMode === 'sequential')
  529. return items.reduce((promiseChain, currentItem) =>
  530. promiseChain.then(() => startUploadItem(currentItem)).catch(() => startUploadItem(currentItem)),
  531. Promise.resolve()
  532. );
  533. else
  534. return Promise.all(items.map(item => startUploadItem(item))) as unknown as Promise<void>;
  535. }
  536. defineExpose<UploaderInstance>({
  537. startUploadAll() {
  538. return startUploadMulitItem(currentUpladList.value);
  539. },
  540. async startUpload(item) {
  541. return await startUploadItem(item);
  542. },
  543. setList(list) {
  544. const needRemoveItems = [] as string[];
  545. for (const item of currentUpladList.value) {
  546. if (list.findIndex(k => k.filePath === item.filePath) === -1)
  547. needRemoveItems.push(item.filePath);
  548. }
  549. for (const filePath of needRemoveItems)
  550. currentUpladList.value.splice(currentUpladList.value.findIndex(k => k.filePath === filePath), 1);
  551. list.forEach(item => {
  552. if (currentUpladList.value.findIndex(k => k.filePath === item.filePath) === -1)
  553. currentUpladList.value.push(item);
  554. });
  555. },
  556. getList() {
  557. return currentUpladList.value;
  558. },
  559. deleteListItem(item) {
  560. deleteListItem(item);
  561. },
  562. updateListItem(item) {
  563. updateListItem(item);
  564. },
  565. addItemAndUpload(item) {
  566. //如果是单选模式,且已存在上传项,则替换已存在项
  567. if (props.maxUploadCount === 1 && currentUpladList.value.length > 0)
  568. deleteListItem(currentUpladList.value[0]);
  569. currentUpladList.value.push(item);
  570. startUploadItem(item);
  571. },
  572. pick() {
  573. onUploadPress();
  574. },
  575. isAllUploadSuccess: () => {
  576. return currentUpladList.value.every(k => k.state === 'success');
  577. },
  578. isAnyUploading: () => {
  579. return currentUpladList.value.find(k => k.state === 'uploading') !== undefined;
  580. },
  581. isAnyFail: () => {
  582. return currentUpladList.value.find(k => k.state === 'fail') !== undefined;
  583. },
  584. });
  585. </script>