Uploader.vue 15 KB

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