Uploader.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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. startUploadAll: () => Promise<void>;
  214. /**
  215. * 开始手动上传指定条目
  216. */
  217. startUpload: (item: UploaderItem) => Promise<void>;
  218. /**
  219. * 获取现在是否全部条目处于已上传并且完成状态
  220. */
  221. isAllUploadSuccess: () => boolean;
  222. /**
  223. * 获取现在是否有任意一个条目正在上传状态
  224. */
  225. isAnyUploading: () => boolean;
  226. /**
  227. * 获取现在是否有任意一个条目处于失败状态
  228. */
  229. isAnyFail: () => boolean;
  230. /**
  231. * 调用此函数与用户手动点击添加按钮效果相同
  232. */
  233. pick: () => void;
  234. }
  235. const isImageExt = [
  236. '.png',
  237. '.jpg',
  238. '.jpeg',
  239. '.bmp',
  240. '.webp',
  241. ];
  242. const toast = ref<ToastInstance>();
  243. const dialog = ref<DialogAlertRoot>();
  244. const emit = defineEmits([ 'click', 'updateList' ]);
  245. const props = withDefaults(defineProps<UploaderProps>(), {
  246. disabled: false,
  247. maxUploadCount: 1,
  248. maxFileSize: 0,
  249. showDelete: true,
  250. showUpload: true,
  251. uploadWhenAdded: true,
  252. uploadQueueMode: 'all',
  253. listType: 'grid',
  254. chooseType: 'image',
  255. itemSize: () => propGetThemeVar('UploaderItemSize', { width: 750 / 4 - 15, height: 750 / 4 - 15 }),
  256. });
  257. const currentUpladList = ref<UploaderItem[]>(props.intitalItems || []);
  258. //上传按钮点击
  259. function onUploadPress() {
  260. if (props.disabled) {
  261. return;
  262. }
  263. if (props.maxUploadCount > 1 && props.maxUploadCount - currentUpladList.value.length <= 0) {
  264. props.onOverCount ?
  265. props.onOverCount(props.maxUploadCount, currentUpladList.value.length) :
  266. toast.value?.text(`最多上传 ${props.maxUploadCount} 个文件哦!`);
  267. return;
  268. }
  269. const items = props.onPickImage ?
  270. props.onPickImage() :
  271. new Promise<UploaderItem[]>((resolve, reject) => {
  272. function handleFiles(res: {
  273. path: string;
  274. size: number;
  275. }[]) {
  276. resolve(res.map((item) => {
  277. let isImage = typeof (item as any).type === 'string' ? (item as any).type.startsWith('image/') : false;
  278. for (const ext of isImageExt) {
  279. if (item.path.endsWith(ext)) {
  280. isImage = true;
  281. break;
  282. }
  283. }
  284. return {
  285. filePath: item.path,
  286. previewPath: item.path,
  287. size: item.size,
  288. state: 'notstart',
  289. isImage,
  290. } as UploaderItem
  291. }))
  292. }
  293. switch (props.chooseType) {
  294. case 'video':
  295. uni.chooseVideo().then((res) => handleFiles([
  296. {
  297. path: res.tempFilePath,
  298. size: res.size,
  299. }
  300. ])).catch(reject);
  301. break;
  302. case 'file':
  303. uni.chooseFile().then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
  304. break;
  305. default:
  306. case 'image':
  307. uni.chooseImage({
  308. count: props.maxUploadCount - currentUpladList.value.length,
  309. }).then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
  310. break;
  311. }
  312. });
  313. items
  314. .then((res) => {
  315. if (props.maxFileSize > 0)
  316. res = res.filter((item) => {
  317. if (item.size && item.size > props.maxFileSize) {
  318. props.onOverSize?.(item);
  319. return false;
  320. }
  321. return true;
  322. });
  323. //添加条目
  324. currentUpladList.value = props.maxUploadCount > 1 ? currentUpladList.value.concat(res) : res;
  325. //自动上传
  326. if (props.uploadWhenAdded)
  327. startUploadMulitItem(res);
  328. })
  329. .catch((e) => console.warn('PickImage failed', e));
  330. }
  331. //条目点击
  332. function onItemPress(item: UploaderItem) {
  333. if (item.state === 'fail') {
  334. props.onRetryClick ?
  335. props.onRetryClick(item) :
  336. //重试上传
  337. startUploadItem(item);
  338. } else {
  339. props.onPreviewClick ?
  340. props.onPreviewClick(item) :
  341. onItemPreview(item); //默认预览
  342. }
  343. }
  344. //条目预览
  345. function onItemPreview(item: UploaderItem) {
  346. //判断后缀是不是图片
  347. const previewPath = item.previewPath || item.uploadedPath || item.filePath;
  348. if (item.isImage) {
  349. uni.previewImage({
  350. urls: [
  351. previewPath
  352. ],
  353. });
  354. } else {
  355. uni.openDocument({
  356. filePath: previewPath,
  357. });
  358. }
  359. }
  360. //条目删除点击
  361. function onItemDeletePress(item: UploaderItem) {
  362. props.onDeleteClick ?
  363. props.onDeleteClick(item).then(() => {
  364. deleteListItem(item);
  365. }).catch(() => {}) :
  366. dialog.value?.confirm({
  367. title: '提示',
  368. content: '是否确认删除此文件?',
  369. }).then((confirm) => {
  370. if (confirm)
  371. deleteListItem(item);
  372. });
  373. }
  374. //更新列表条目
  375. function updateListItem(item: UploaderItem) {
  376. currentUpladList.value = ((prev) => {
  377. const newList = prev.concat();
  378. const index = prev.findIndex((k) => k.filePath === item.filePath);
  379. index >= 0 ? newList[index] = { ...item } : newList.push(item);
  380. return newList;
  381. })(currentUpladList.value);
  382. emit('updateList', currentUpladList.value);
  383. }
  384. //删除列表条目
  385. function deleteListItem(item: UploaderItem) {
  386. currentUpladList.value = currentUpladList.value.filter((k) => k.filePath !== item.filePath);
  387. }
  388. //开始上传条目
  389. function startUploadItem(item: UploaderItem) {
  390. return new Promise<void>((resolve, reject) => {
  391. if (item.state === 'success') {
  392. resolve();
  393. return;
  394. }
  395. LogUtils.printLog(TAG, 'message', `调用上传文件 ${item.filePath}`);
  396. props.upload({
  397. item,
  398. onError(error) {
  399. item.state = 'fail';
  400. item.message = ('' + error) || '上传失败';
  401. updateListItem(item);
  402. reject(error);
  403. LogUtils.printLog(TAG, 'error', `上传文件 ${item.filePath} 失败,错误信息:${error}`);
  404. },
  405. onFinish(result, message) {
  406. item.state = 'success';
  407. item.message = message || '上传完成';
  408. item.progress = 100;
  409. item.uploadedPath = result.uploadedUrl;
  410. if (result.previewUrl)
  411. item.previewPath = result.previewUrl;
  412. updateListItem(item);
  413. resolve();
  414. LogUtils.printLog(TAG, 'success', `上传文件 ${item.filePath} 成功,上传路径:${result.uploadedUrl}`);
  415. },
  416. onProgress(precent) {
  417. item.state = 'uploading';
  418. item.message = precent ? `${precent}%` : '上传中...';
  419. item.progress = precent;
  420. updateListItem(item);
  421. },
  422. onStart(message) {
  423. item.state = 'uploading';
  424. item.message = message || '上传中...';
  425. item.progress = 0;
  426. updateListItem(item);
  427. LogUtils.printLog(TAG, 'message', `上传文件 ${item.filePath} 开始,信息:${item.message}`);
  428. },
  429. });
  430. });
  431. }
  432. //开始上传条目
  433. function startUploadMulitItem(items: UploaderItem[]) : Promise<void> {
  434. if (props.uploadQueueMode === 'sequential')
  435. return items.reduce((promiseChain, currentItem) =>
  436. promiseChain.then(() => startUploadItem(currentItem)).catch(() => startUploadItem(currentItem)),
  437. Promise.resolve()
  438. );
  439. else
  440. return Promise.all(items.map(item => startUploadItem(item))) as unknown as Promise<void>;
  441. }
  442. defineExpose<UploaderInstance>({
  443. startUploadAll() {
  444. return startUploadMulitItem(currentUpladList.value);
  445. },
  446. startUpload(item) {
  447. return startUploadItem(item);
  448. },
  449. setList(list) {
  450. currentUpladList.value = list;
  451. },
  452. getList() {
  453. return currentUpladList.value;
  454. },
  455. deleteListItem(item) {
  456. deleteListItem(item);
  457. },
  458. updateListItem(item) {
  459. updateListItem(item);
  460. },
  461. pick() {
  462. onUploadPress();
  463. },
  464. isAllUploadSuccess: () => {
  465. return currentUpladList.value.every(k => k.state === 'success');
  466. },
  467. isAnyUploading: () => {
  468. return currentUpladList.value.find(k => k.state === 'uploading') !== undefined;
  469. },
  470. isAnyFail: () => {
  471. return currentUpladList.value.find(k => k.state === 'fail') !== undefined;
  472. },
  473. });
  474. </script>