Uploader.vue 15 KB

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