| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- <template>
- <FlexCol>
- <Toast ref="toast" />
- <DialogRoot ref="dialog" />
- <slot
- name="uploader"
- :onClick="onUploadPress"
- :items="currentUpladList"
- >
- <FlexView
- v-if="showUpload"
- :direction="props.listType === 'grid' ? 'row' : 'column'"
- wrap
- :innerStyle="(props.itemListStyle as ViewStyle)"
- >
- <template
- v-for="(item, index) in currentUpladList"
- :key="index"
- >
- <slot
- name="uploadItem"
- :index="index"
- :item="item"
- :onClick="() => onItemPress(item)"
- :onDeleteClick="() => onItemDeletePress(item)"
- :style="props.itemStyle"
- :imageStyle="props.itemImageStyle"
- :itemMaskStyle="props.itemMaskStyle"
- :itemMaskTextStyle="props.itemMaskTextStyle"
- :itemSize="itemSize"
- :showDelete="showDelete"
- :defaultSource="props.itemDefaultSource"
-
- >
- <UploaderListItem
- :item="item"
- :showDelete="showDelete"
- :isListStyle="props.listType === 'list'"
- :style="itemStyle"
- :imageStyle="itemImageStyle"
- :itemMaskStyle="itemMaskStyle"
- :itemMaskTextStyle="itemMaskTextStyle"
- :defaultSource="itemDefaultSource"
- :itemSize="itemSize"
- @click="() => onItemPress(item)"
- @delete="() => onItemDeletePress(item)"
- />
- </slot>
- </template>
- <slot v-if="currentUpladList.length < maxUploadCount" name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
- <UploaderListAddItem :itemSize="itemSize" :style="itemStyle" @click="onUploadPress" :isListStyle="props.listType === 'list'" />
- </slot>
- </FlexView>
- <slot v-else name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
- <UploaderListAddItem :itemSize="itemSize" :style="itemStyle" @click="onUploadPress" :isListStyle="props.listType === 'list'" />
- </slot>
- </slot>
- </FlexCol>
- </template>
- <script setup lang="ts">
- import { ref } from 'vue';
- import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
- import type { ToastInstance } from '../feedback/Toast.vue';
- import Toast from '../feedback/Toast.vue';
- import DialogRoot, { type DialogAlertRoot } from '../dialog/DialogRoot.vue';
- import UploaderListAddItem from './UploaderListAddItem.vue';
- import UploaderListItem from './UploaderListItem.vue';
- import FlexView from '../layout/FlexView.vue';
- import FlexCol from '../layout/FlexCol.vue';
- import type { UploaderAction, UploaderItem } from './Uploader';
- import { LogUtils } from '@imengyu/imengyu-utils';
- const themeContext = useTheme();
- const TAG = 'Uploader';
- export interface UploaderProps {
- /**
- * 最大上传数
- * @default 1
- */
- maxUploadCount?: number;
- /**
- * 最大上传文件的大小(B),为 0 则不限制,超过大小的文件会被自动过滤,这些文件信息可以通过 onOverSize 事件获取。
- * @default 0 不限制
- */
- maxFileSize?: number;
- /**
- * 是否禁用文件上传。
- * @default false
- */
- disabled?: boolean;
- /**
- * 是否显示文件已上传列表。
- * @default true
- */
- showUpload?: boolean;
- /**
- * 是否在已上传列表条目中显示删除按钮。
- * @default true
- */
- showDelete?: boolean;
- /**
- * 上传列表的显示模式
- * * grid:网格模式,显示为图片网格
- * * list:列表模式,显示为文件列表
- * @default 'grid'
- */
- listType?: 'list'|'grid';
- /**
- * 是否在用户添加图片后自动进行上传,否则不会自动上传,你可以手动调用上传进行统一上传。
- * @default true
- */
- uploadWhenAdded?: boolean;
- /**
- * 当用户多选,或者手动统一上传多个条目时,要如何并发上传。
- * * all:同时触发多个上传任务
- * * sequential:按顺序执行上传任务,只有前一个上传完成,后一个才会开始。
- * @default 'all'
- */
- uploadQueueMode?: 'all'|'sequential';
- /**
- * 条目的默认图片
- */
- itemDefaultSource?: string|undefined;
- /**
- * 条目的大小
- * @default { width: 80, height: 80 }
- */
- itemSize?: { width: number, height: number };
- /**
- * 列表自定义外层样式
- */
- itemListStyle?: ViewStyle;
- /**
- * 条目的自定义外层样式
- */
- itemStyle?: ViewStyle;
- /**
- * 条目的自定义图片样式
- */
- itemImageStyle?: ViewStyle;
- /**
- * 条目的自定义信息遮罩样式
- */
- itemMaskTextStyle?: TextStyle;
- /**
- * 条目的自定义信息容器样式
- */
- itemMaskStyle?: ViewStyle;
- /**
- * 初始列表中的条目
- * @default []
- */
- intitalItems?: UploaderItem[];
- /**
- * 选择文件类型
- * * image:图片
- * * video:视频
- * @default 'image'
- */
- chooseType?: 'image'|'video'|'file';
- /**
- * 上传处理。不提供则无法上传
- * @required true
- */
- upload: (item: UploaderAction) => void;
- /**
- * 自定义选择文件组件,你可以调用自己的文件选择器。默认调用 ImagePicker 选择文件.
- */
- onPickImage?: () => Promise<UploaderItem[]>;
- /**
- * 通过此函数可以在上传前进行校验和处理,Promise.resolve 表示校验通过,Promise.reject 表示校验失败。支持返回一个新的文件对象,可用于自定义处理,例如压缩图片。
- */
- onBeforeAdd?: (item: UploaderItem) => Promise<UploaderItem|undefined>;
- /**
- * 自定义上传失败文件点击的事件。不提供默认是重新上传。
- */
- onRetryClick?: (item: UploaderItem) => void;
- /**
- * 自定义已上传文件点击的事件。不提供默认是调用 ImagePreview 进行预览。
- */
- onPreviewClick?: (item: UploaderItem) => void;
- /**
- * 自定义已上传文件点击删除按钮的事件。Promise.resolve 表示可以删除,Promise.reject 表示不可以删除。
- */
- onDeleteClick?: (item: UploaderItem) => Promise<void>;
- /**
- * 当上传文件超过大小时返回
- */
- onOverSize?: (item: UploaderItem) => void;
- /**
- * 当上传文件超过数量时返回
- */
- onOverCount?: (count: number, max: number) => void;
- }
- export interface UploaderInstance {
- /**
- * 获取已上传列表数据
- */
- getList: () => UploaderItem[];
- /**
- * 设置已上传列表数据
- */
- setList: (list: UploaderItem[]) => void;
- /**
- * 强制从已上传列表更新某个条目。如果条目在列表中不存在(按文件路径判断),则会添加到末尾。
- */
- updateListItem: (item: UploaderItem) => void;
- /**
- * 强制从已上传列表删除某个条目
- */
- deleteListItem: (item: UploaderItem) => void;
- /**
- * 添加条目到已上传列表,并且自动开始上传。
- * 注:不会限制最大上传数和不限制最大文件大小。
- * * 单选情况下会替换已上传列表中的条目。
- * * 多选情况下会添加到末尾。
- */
- addItemAndUpload: (item: UploaderItem) => void;
- /**
- * 开始手动上传所有条目
- */
- startUploadAll: () => Promise<void>;
- /**
- * 开始手动上传指定条目
- */
- startUpload: (item: UploaderItem) => Promise<void>;
- /**
- * 获取现在是否全部条目处于已上传并且完成状态
- */
- isAllUploadSuccess: () => boolean;
- /**
- * 获取现在是否有任意一个条目正在上传状态
- */
- isAnyUploading: () => boolean;
- /**
- * 获取现在是否有任意一个条目处于失败状态
- */
- isAnyFail: () => boolean;
- /**
- * 调用此函数与用户手动点击添加按钮效果相同
- */
- pick: () => void;
- }
- const isImageExt = [
- '.png',
- '.jpg',
- '.jpeg',
- '.bmp',
- '.webp',
- ];
- const toast = ref<ToastInstance>();
- const dialog = ref<DialogAlertRoot>();
- const emit = defineEmits([ 'click', 'updateList' ]);
- const props = withDefaults(defineProps<UploaderProps>(), {
- disabled: false,
- maxUploadCount: 1,
- maxFileSize: 0,
- showDelete: true,
- showUpload: true,
- uploadWhenAdded: true,
- uploadQueueMode: 'all',
- listType: 'grid',
- chooseType: 'image',
- itemSize: () => propGetThemeVar('UploaderItemSize', { width: 750 / 4 - 15, height: 750 / 4 - 15 }),
- });
- const currentUpladList = ref<UploaderItem[]>(props.intitalItems || []);
- //上传按钮点击
- function onUploadPress() {
- if (props.disabled) {
- return;
- }
- if (props.maxUploadCount > 1 && props.maxUploadCount - currentUpladList.value.length <= 0) {
- props.onOverCount ?
- props.onOverCount(props.maxUploadCount, currentUpladList.value.length) :
- toast.value?.text(`最多上传 ${props.maxUploadCount} 个文件哦!`);
- return;
- }
- const items = props.onPickImage ?
- props.onPickImage() :
- new Promise<UploaderItem[]>((resolve, reject) => {
- function handleFiles(res: {
- path: string;
- size: number;
- }[]) {
- resolve(res.map((item) => {
- let isImage = typeof (item as any).type === 'string' ? (item as any).type.startsWith('image/') : false;
- for (const ext of isImageExt) {
- if (item.path.endsWith(ext)) {
- isImage = true;
- break;
- }
- }
- return {
- filePath: item.path,
- previewPath: item.path,
- size: item.size,
- state: 'notstart',
- isImage,
- } as UploaderItem
- }))
- }
- switch (props.chooseType) {
- case 'video':
- uni.chooseVideo().then((res) => handleFiles([
- {
- path: res.tempFilePath,
- size: res.size,
- }
- ])).catch(reject);
- break;
- case 'file':
- uni.chooseFile().then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
- break;
- default:
- case 'image':
- uni.chooseImage({
- count: props.maxUploadCount - currentUpladList.value.length,
- }).then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
- break;
- }
- });
- items
- .then((res) => {
- if (props.maxFileSize > 0)
- res = res.filter((item) => {
- if (item.size && item.size > props.maxFileSize) {
- props.onOverSize?.(item);
- return false;
- }
- return true;
- });
- //添加条目
- currentUpladList.value = props.maxUploadCount > 1 ? currentUpladList.value.concat(res) : res;
- //自动上传
- if (props.uploadWhenAdded)
- startUploadMulitItem(res);
- })
- .catch((e) => console.warn('PickImage failed', e));
- }
- //条目点击
- function onItemPress(item: UploaderItem) {
- if (item.state === 'fail') {
- props.onRetryClick ?
- props.onRetryClick(item) :
- //重试上传
- startUploadItem(item);
- } else {
- props.onPreviewClick ?
- props.onPreviewClick(item) :
- onItemPreview(item); //默认预览
- }
- }
- //条目预览
- function onItemPreview(item: UploaderItem) {
- //判断后缀是不是图片
- const previewPath = item.previewPath || item.uploadedPath || item.filePath;
- if (item.isImage) {
- uni.previewImage({
- urls: [
- previewPath
- ],
- });
- } else {
- uni.openDocument({
- filePath: previewPath,
- });
- }
- }
- //条目删除点击
- function onItemDeletePress(item: UploaderItem) {
- props.onDeleteClick ?
- props.onDeleteClick(item).then(() => {
- deleteListItem(item);
- }).catch(() => {}) :
- dialog.value?.confirm({
- title: '提示',
- content: '是否确认删除此文件?',
- }).then((confirm) => {
- if (confirm)
- deleteListItem(item);
- });
- }
- //更新列表条目
- function updateListItem(item: UploaderItem) {
- currentUpladList.value = ((prev) => {
- const newList = prev.concat();
- const index = prev.findIndex((k) => k.filePath === item.filePath);
- index >= 0 ? newList[index] = { ...item } : newList.push(item);
- return newList;
- })(currentUpladList.value);
- emit('updateList', currentUpladList.value);
- }
- //删除列表条目
- function deleteListItem(item: UploaderItem) {
- currentUpladList.value = currentUpladList.value.filter((k) => k.filePath !== item.filePath);
- }
- //开始上传条目
- function startUploadItem(item: UploaderItem) {
- return new Promise<void>((resolve, reject) => {
- if (item.state === 'success') {
- resolve();
- return;
- }
- LogUtils.printLog(TAG, 'message', `调用上传文件 ${item.filePath}`);
- props.upload({
- item,
- onError(error) {
- item.state = 'fail';
- item.message = ('' + error) || '上传失败';
- updateListItem(item);
- reject(error);
- LogUtils.printLog(TAG, 'error', `上传文件 ${item.filePath} 失败,错误信息:${error}`);
- },
- onFinish(result, message) {
- item.state = 'success';
- item.message = message || '上传完成';
- item.progress = 100;
- item.uploadedPath = result.uploadedUrl;
- if (result.previewUrl)
- item.previewPath = result.previewUrl;
- updateListItem(item);
- resolve();
- LogUtils.printLog(TAG, 'success', `上传文件 ${item.filePath} 成功,上传路径:${result.uploadedUrl}`);
- },
- onProgress(precent) {
- item.state = 'uploading';
- item.message = precent ? `${precent}%` : '上传中...';
- item.progress = precent;
- updateListItem(item);
- },
- onStart(message) {
- item.state = 'uploading';
- item.message = message || '上传中...';
- item.progress = 0;
- updateListItem(item);
- LogUtils.printLog(TAG, 'message', `上传文件 ${item.filePath} 开始,信息:${item.message}`);
- },
- });
- });
- }
- //开始上传条目
- function startUploadMulitItem(items: UploaderItem[]) : Promise<void> {
- if (props.uploadQueueMode === 'sequential')
- return items.reduce((promiseChain, currentItem) =>
- promiseChain.then(() => startUploadItem(currentItem)).catch(() => startUploadItem(currentItem)),
- Promise.resolve()
- );
- else
- return Promise.all(items.map(item => startUploadItem(item))) as unknown as Promise<void>;
- }
- defineExpose<UploaderInstance>({
- startUploadAll() {
- return startUploadMulitItem(currentUpladList.value);
- },
- startUpload(item) {
- return startUploadItem(item);
- },
- setList(list) {
- currentUpladList.value = list;
- },
- getList() {
- return currentUpladList.value;
- },
- deleteListItem(item) {
- deleteListItem(item);
- },
- updateListItem(item) {
- updateListItem(item);
- },
- addItemAndUpload(item) {
- //如果是单选模式,且已存在上传项,则替换已存在项
- if (props.maxUploadCount === 1 && currentUpladList.value.length > 0)
- deleteListItem(currentUpladList.value[0]);
- currentUpladList.value.push(item);
- startUploadItem(item);
- },
- pick() {
- onUploadPress();
- },
- isAllUploadSuccess: () => {
- return currentUpladList.value.every(k => k.state === 'success');
- },
- isAnyUploading: () => {
- return currentUpladList.value.find(k => k.state === 'uploading') !== undefined;
- },
- isAnyFail: () => {
- return currentUpladList.value.find(k => k.state === 'fail') !== undefined;
- },
- });
- </script>
|