refactor: 文件上传/图片上传重构(破坏性更新 不兼容之前的api)

This commit is contained in:
dap 2025-03-28 17:24:46 +08:00
parent 456f0e1112
commit 8c1cd617ad
17 changed files with 684 additions and 714 deletions

View File

@ -1,8 +1,14 @@
# 1.2.4
# 1.3.0
**REFACTOR**
- 文件上传/图片上传重构(破坏性更新 不兼容之前的api)
- 文件上传/图片上传**不再支持**url用法 强制使用ossId
**BUG FIX**
- 测试菜单 请假申请 选中删除 需要根据状态判断
- 修复文件/图片在Safari中无法上传 file-type库与Safari不兼容导致
**OTHER**

View File

@ -1,6 +1,6 @@
{
"name": "@vben/web-antd",
"version": "1.2.3",
"version": "1.3.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -29,7 +29,7 @@ export function ossList(params?: PageQuery) {
* @param ossIds id数组
* @returns
*/
export function ossInfo(ossIds: IDS) {
export function ossInfo(ossIds: ID | IDS) {
return requestClient.get<OssFile[]>(`${Api.ossInfo}/${ossIds}`);
}

View File

@ -1,243 +1,143 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
<!--
不再支持url 统一使用ossId
去除使用`file-type`库进行文件类型检测 在Safari无法使用
-->
<script setup lang="ts">
import type { UploadFile } from 'ant-design-vue';
import type { AxiosResponse } from '@vben/request';
import type { AxiosProgressEvent } from '#/api';
import { ref, toRefs, watch } from 'vue';
import { $t } from '@vben/locales';
import { $t, I18nT } from '@vben/locales';
import { UploadOutlined } from '@ant-design/icons-vue';
import { message, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString } from 'lodash-es';
import { Upload } from 'ant-design-vue';
import { uploadApi } from '#/api';
import { defaultFileAcceptExts, defaultFilePreview } from './helper';
import { useUpload } from './hook';
import { checkFileType } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
interface Props {
/**
* 文件上传失败 是否从展示列表中删除
* @default true
*/
removeOnError?: boolean;
/**
* 上传成功 是否展示提示信息
* @default true
*/
showSuccessMsg?: boolean;
/**
* 删除文件前是否需要确认
* @default false
*/
removeConfirm?: boolean;
/**
* 同antdv参数
*/
accept?: string;
/**
* 附带的请求参数
*/
data?: any;
/**
* 最大上传图片数量
* maxCount为1时 会被绑定为string而非string[]
* @default 1
*/
maxCount?: number;
/**
* 文件最大 单位M
* @default 5
*/
maxSize?: number;
/**
* 是否禁用
* @default false
*/
disabled?: boolean;
/**
* 是否显示文案 请上传不超过...
* @default true
*/
helpMessage?: boolean;
/**
* 是否支持多选文件ie10+ 支持开启后按住 ctrl 可选择多个文件
* @default false
*/
multiple?: boolean;
/**
* 自定义文件预览逻辑 比如: 你可以改为下载
* @param file file
*/
preview?: (file: UploadFile) => Promise<void> | void;
}
defineOptions({ name: 'FileUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/**
* 建议使用拓展名(不带.)
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
* 需自行改造 ./helper/checkFileType方法
*/
accept?: string[];
api?: (
file: Blob | File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
disabled?: boolean;
helpText?: string;
// Infinity
maxNumber?: number;
// MB
maxSize?: number;
//
multiple?: boolean;
// support xxx.xxx.xx
// url
resultField?: 'fileName' | 'ossId' | 'url' | string;
/**
* 是否显示下面的描述
*/
showDescription?: boolean;
value?: string[];
}>(),
{
value: () => [],
disabled: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: uploadApi,
resultField: '',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
const props = withDefaults(defineProps<Props>(), {
removeOnError: true,
showSuccessMsg: true,
removeConfirm: false,
accept: defaultFileAcceptExts.join(','),
data: () => undefined,
maxCount: 1,
maxSize: 5,
disabled: false,
helpMessage: true,
preview: defaultFilePreview,
});
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
// ossId
const ossIdList = defineModel<string | string[]>('value', {
default: () => [],
});
watch(
() => props.value,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = await checkFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
//
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
//
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
//
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api?.(info.file as File, progressEvent);
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
*/
info.onSuccess!(res);
message.success($t('component.upload.uploadSuccess'));
//
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// init{url: 'xx'}
if (item?.url) {
return item.url;
}
// key url
return item?.response?.url;
});
return list;
}
const {
uploadUrl,
headers,
acceptFormat,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
} = useUpload(props, ossIdList);
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:custom-request="customRequest"
v-model:file-list="innerFileList"
:action="uploadUrl"
:headers="headers"
:data="data"
:accept="accept"
:disabled="disabled"
:max-count="maxNumber"
:multiple="multiple"
list-type="text"
:max-count="maxCount"
:progress="{ showInfo: true }"
:multiple="multiple"
:before-upload="beforeUpload"
@preview="preview"
@change="handleChange"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<a-button>
<div v-if="innerFileList?.length < maxCount">
<a-button :disabled="disabled">
<UploadOutlined />
{{ $t('component.upload.upload') }}
</a-button>
</div>
<div v-if="showDescription" class="mt-2 flex flex-wrap items-center">
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
</Upload>
<I18nT
v-if="helpMessage"
scope="global"
keypath="component.upload.uploadHelpMessage"
tag="div"
class="mt-2"
>
<template #size>
<span class="text-primary mx-1 font-medium"> {{ maxSize }}MB </span>
</template>
<template #ext>
<span class="text-primary mx-1 font-medium">
{{ acceptFormat }}
</span>
</template>
</I18nT>
</div>
</template>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@ -1,51 +1,28 @@
import { fileTypeFromBlob } from '@vben/utils';
import type { UploadFile } from 'ant-design-vue';
/**
* txt文件 @see https://github.com/sindresorhus/file-type/issues/55
*
* @param file file对象
* @param accepts () (image/png等 image/*)
* @returns
*
*/
export async function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts?.length === 0) {
return true;
}
console.log(file);
const fileType = await fileTypeFromBlob(file);
if (!fileType) {
console.error('无法获取文件类型');
return false;
}
console.log('文件类型', fileType);
// 是否文件拓展名/文件头任意有一个匹配
return accepts.includes(fileType.ext) || accepts.includes(fileType.mime);
}
export const defaultImageAcceptExts = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
];
/**
*
*
*/
export const defaultImageAccept = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export const defaultFileAcceptExts = ['.xlsx', '.csv', '.docx', '.pdf'];
/**
*
* @param file file对象
* @param accepts () (image/png等 image/*)
* @returns
* ()
* 默认: window.open打开
* @param file file
*/
export async function checkImageFileType(file: File, accepts: string[]) {
// 空的accepts 使用默认规则
if (!accepts || accepts.length === 0) {
accepts = defaultImageAccept;
export function defaultFilePreview(file: UploadFile) {
if (file?.url) {
window.open(file.url);
}
const fileType = await fileTypeFromBlob(file);
if (!fileType) {
console.error('无法获取文件类型');
return false;
}
console.log('文件类型', fileType);
// 是否文件拓展名/文件头任意有一个匹配
if (accepts.includes(fileType.ext) || accepts.includes(fileType.mime)) {
return true;
}
return false;
}

View File

@ -0,0 +1,255 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { ModelRef } from 'vue';
import type { HttpResponse } from '@vben/request';
import type { UploadResult } from '#/api';
import type { OssFile } from '#/api/system/oss/model';
import { computed, ref, watch } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import { message, Modal } from 'ant-design-vue';
import { ossInfo } from '#/api/system/oss';
/**
* hook
* @returns
*/
export function useImagePreview() {
/**
* base64字符串
* @param file
* @returns base64字符串
*/
function getBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
}
// Modal可见
const previewVisible = ref(false);
// 预览的图片 url/base64
const previewImage = ref('');
// 预览的图片名称
const previewTitle = ref('');
function handleCancel() {
previewVisible.value = false;
previewTitle.value = '';
}
async function handlePreview(file: UploadFile) {
if (!file) {
return;
}
// 文件预览 取base64
if (!file.url && !file.preview && file.originFileObj) {
file.preview = (await getBase64(file.originFileObj)) as string;
}
// 这里不可能为空
const url = file.url ?? '';
previewImage.value = url || file.preview || '';
previewVisible.value = true;
previewTitle.value =
file.name || url.slice(Math.max(0, url.lastIndexOf('/') + 1));
}
return {
previewVisible,
previewImage,
previewTitle,
handleCancel,
handlePreview,
};
}
/**
* hook
* @param props props
* @param bindValue idList
* @returns hook
*/
export function useUpload(
props: Readonly<BaseUploadProps>,
bindValue: ModelRef<string | string[]>,
) {
const { apiURL, clientId } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
// 内部维护fileList
const innerFileList = ref<UploadFile[]>([]);
/** oss上传地址 */
const uploadUrl = `${apiURL}/resource/oss/upload`;
const accessStore = useAccessStore();
/**
* header参数 token和clientId
*/
const headers = computed(() => {
return {
Authorization: `Bearer ${accessStore.accessToken}`,
clientId,
};
});
const acceptFormat = computed(() => {
return props.accept
?.split(',')
.map((item) => {
if (item.startsWith('.')) {
return item.slice(1);
}
return item;
})
.join(', ');
});
function handleChange(info: UploadChangeParam) {
function removeCurrentFile(
currentFile: UploadChangeParam['file'],
currentFileList: UploadChangeParam['fileList'],
) {
if (props.removeOnError) {
currentFileList.splice(currentFileList.indexOf(currentFile), 1);
} else {
currentFile.status = 'error';
}
}
const { file: currentFile, fileList } = info;
switch (currentFile.status) {
// 上传成功 只是判断httpStatus 200 需要手动判断业务code
case 'done': {
if (!currentFile.response) {
return;
}
// 获取返回结果
const response = currentFile.response as HttpResponse<UploadResult>;
// 上传异常
if (response.code !== 200) {
message.error(response.msg);
removeCurrentFile(currentFile, fileList);
return;
}
// 上传成功 做转换
if (props.showSuccessMsg) {
message.success($t('component.upload.uploadSuccess'));
}
const { ossId, fileName, url } = response.data;
currentFile.url = url;
currentFile.fileName = fileName;
currentFile.uid = ossId;
// ossID添加 单个文件会被当做string
if (props.maxCount === 1) {
bindValue.value = ossId;
} else {
(bindValue.value as string[]).push(ossId);
}
break;
}
// 上传失败 网络原因导致httpStatus 不等于200
case 'error': {
message.error($t('component.upload.uploadError'));
removeCurrentFile(currentFile, fileList);
}
}
}
function handleRemove(currentFile: UploadFile) {
function remove() {
// fileList会自行处理删除 这里只需要处理ossId
if (props.maxCount === 1) {
bindValue.value = '';
} else {
(bindValue.value as string[]).splice(
bindValue.value.indexOf(currentFile.uid),
1,
);
}
}
if (!props.removeConfirm) {
remove();
return true;
}
return new Promise<boolean>((resolve) => {
Modal.confirm({
title: $t('pages.common.tip'),
content: $t('component.upload.confirmDelete', [currentFile.name]),
okButtonProps: { danger: true },
centered: true,
onOk() {
resolve(true);
remove();
},
onCancel() {
resolve(false);
},
});
});
}
function beforeUpload(file: FileType) {
const isLtMax = file.size / 1024 / 1024 < props.maxSize!;
if (!isLtMax) {
message.error($t('component.upload.maxSize', [props.maxSize]));
return false;
}
// 大坑 Safari不支持file-type库 去除文件类型的校验
return file;
}
/**
* list地址变化 watch
* immediate用于初始化触发
*/
watch(
() => bindValue.value,
async (value) => {
if (value.length === 0) {
return;
}
const resp = await ossInfo(value);
function transformFile(info: OssFile) {
const fileitem: UploadFile = {
uid: info.ossId,
name: info.originalName,
fileName: info.originalName,
url: info.url,
status: 'done',
};
return fileitem;
}
innerFileList.value = resp.map((item) => transformFile(item));
},
{ immediate: true },
);
return {
uploadUrl,
headers,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
acceptFormat,
};
}

View File

@ -1,326 +1,170 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
<!--
不再支持url 统一使用ossId
去除使用`file-type`库进行文件类型检测 在Safari无法使用
-->
<script setup lang="ts">
import type { UploadListType } from 'ant-design-vue/es/upload/interface';
import type { AxiosResponse } from '@vben/request';
import type { AxiosProgressEvent } from '#/api';
import { ref, toRefs, watch } from 'vue';
import { $t } from '@vben/locales';
import { $t, I18nT } from '@vben/locales';
import { PlusOutlined } from '@ant-design/icons-vue';
import { message, Modal, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
import { Image, ImagePreviewGroup, Upload } from 'ant-design-vue';
import { uploadApi } from '#/api';
import { ossInfo } from '#/api/system/oss';
import { defaultImageAcceptExts } from './helper';
import { useImagePreview, useUpload } from './hook';
import { checkImageFileType, defaultImageAccept } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
interface Props {
/**
* 文件上传失败 是否从展示列表中删除
* @default true
*/
removeOnError?: boolean;
/**
* 上传成功 是否展示提示信息
* @default true
*/
showSuccessMsg?: boolean;
/**
* 删除文件前是否需要确认
* @default false
*/
removeConfirm?: boolean;
/**
* 同antdv参数
*/
accept?: string;
/**
* 附带的请求参数
*/
data?: any;
/**
* 最大上传图片数量
* maxCount为1时 会被绑定为string而非string[]
* @default 1
*/
maxCount?: number;
/**
* 文件最大 单位M
* @default 5
*/
maxSize?: number;
/**
* 是否禁用
* @default false
*/
disabled?: boolean;
/**
* 同antdv的listType
* @default picture-card
*/
listType?: UploadListType;
/**
* 是否显示文案 请上传不超过...
* @default true
*/
helpMessage?: boolean;
/**
* 是否支持多选文件ie10+ 支持开启后按住 ctrl 可选择多个文件
* @default false
*/
multiple?: boolean;
}
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/**
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
*/
accept?: string[];
api?: (
file: Blob | File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
disabled?: boolean;
helpText?: string;
// eslint-disable-next-line no-use-before-define
listType?: ListType;
// Infinity
maxNumber?: number;
// MB
maxSize?: number;
//
multiple?: boolean;
// support xxx.xxx.xx
// url
resultField?: 'fileName' | 'ossId' | 'url';
/**
* 是否显示下面的描述
*/
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccept,
multiple: false,
api: uploadApi,
resultField: 'url',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
type ListType = 'picture' | 'picture-card' | 'text';
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
const props = withDefaults(defineProps<Props>(), {
removeOnError: true,
showSuccessMsg: true,
removeConfirm: false,
accept: defaultImageAcceptExts.join(','),
data: () => undefined,
maxCount: 1,
maxSize: 5,
disabled: false,
listType: 'picture-card',
helpMessage: true,
});
const previewOpen = ref<boolean>(false);
const previewImage = ref<string>('');
const previewTitle = ref<string>('');
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
// ossId
const ossIdList = defineModel<string | string[]>('value', {
default: () => [],
});
watch(
() => props.value,
async (v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string | string[] = [];
if (v) {
const _fileList: string[] = [];
if (isString(v)) {
_fileList.push(v);
}
if (isArray(v)) {
_fileList.push(...v);
}
// string | string[]
value = v;
const withUrlList: UploadProps['fileList'] = [];
for (const item of _fileList) {
// ossId
if (props.resultField === 'ossId') {
const resp = await ossInfo([item]);
if (item && isString(item)) {
withUrlList.push({
uid: item, // ossIduid 便getValue
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: resp?.[0]?.url,
});
} else if (item && isObject(item)) {
withUrlList.push({
...(item as any),
uid: item,
url: resp?.[0]?.url,
});
}
} else {
// ossId
if (item && isString(item)) {
withUrlList.push({
uid: uniqueId(),
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
});
} else if (item && isObject(item)) {
withUrlList.push(item);
}
}
}
fileList.value = withUrlList;
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
const {
uploadUrl,
headers,
acceptFormat,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
} = useUpload(props, ossIdList);
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => {
resolve(reader.result as T);
});
reader.addEventListener('error', (error) => reject(error));
});
}
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
}
previewImage.value = file.url || file.preview || '';
previewOpen.value = true;
previewTitle.value =
file.name ||
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
};
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const handleCancel = () => {
previewOpen.value = false;
previewTitle.value = '';
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = await checkImageFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
//
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
//
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
//
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api?.(info.file as File, progressEvent);
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
*/
info.onSuccess!(res);
message.success($t('component.upload.uploadSuccess'));
//
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
console.log(fileList.value);
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// ossId uidossId
if (props.resultField === 'ossId' && item.uid) {
return item.uid;
}
// init{url: 'xx'}
if (item?.url) {
return item.url;
}
// key url
return item?.response?.url;
});
// stringstring[]
if (props.maxNumber === 1 && list.length === 1) {
return list[0];
}
// &&
if (props.maxNumber === 1 && list.length === 0) {
return '';
}
return list;
}
const { previewVisible, previewImage, handleCancel, handlePreview } =
useImagePreview();
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:custom-request="customRequest"
:disabled="disabled"
v-model:file-list="innerFileList"
:list-type="listType"
:max-count="maxNumber"
:multiple="multiple"
:action="uploadUrl"
:headers="headers"
:data="data"
:accept="accept"
:disabled="disabled"
:max-count="maxCount"
:progress="{ showInfo: true }"
:multiple="multiple"
:before-upload="beforeUpload"
@preview="handlePreview"
@change="handleChange"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<div v-if="innerFileList?.length < maxCount">
<PlusOutlined />
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
<div class="mt-[8px]">{{ $t('component.upload.upload') }}</div>
</div>
</Upload>
<div
v-if="showDescription"
class="mt-2 flex flex-wrap items-center text-[14px]"
<I18nT
v-if="helpMessage"
scope="global"
keypath="component.upload.uploadHelpMessage"
tag="div"
>
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
<Modal
:footer="null"
:open="previewOpen"
:title="previewTitle"
@cancel="handleCancel"
<template #size>
<span class="text-primary mx-1 font-medium"> {{ maxSize }}MB </span>
</template>
<template #ext>
<span class="text-primary mx-1 font-medium">
{{ acceptFormat }}
</span>
</template>
</I18nT>
<ImagePreviewGroup
:preview="{
visible: previewVisible,
onVisibleChange: handleCancel,
}"
>
<img :src="previewImage" alt="" style="width: 100%" />
</Modal>
<Image class="hidden" :src="previewImage" />
</ImagePreviewGroup>
</div>
</template>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
<style lang="scss">
.ant-upload-select-picture-card {
i {
@apply text-[32px] text-[#999];
}
.ant-upload-text {
@apply mt-[8px] text-[#666];
}
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
.ant-upload-list-picture-card {
.ant-upload-list-item::before {
border-radius: 4px;
}
}
</style>

View File

@ -0,0 +1,22 @@
Safari在执行到beforeUpload方法
有两种情况
1. 不继续执行 也无法上传(没有调用上传)
2. 报错
Unhandled Promise Rejection: TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController
https://github.com/oven-sh/bun/issues/12908#issuecomment-2490151231
刚开始以为是异步的问题 由于`file-type`调用了异步方法 调试也是在这里没有后续打印了
使用别的异步代码测试结果是正常上传的
```js
return new Promise<FileType>((resolve) =>
setTimeout(() => resolve(file), 2000),
);
```
根本原因在于`file-typ`库可能不支持Safari 去掉可以正常上传

View File

@ -0,0 +1,51 @@
interface BaseUploadProps {
/**
*
* @default true
*/
removeOnError?: boolean;
/**
*
* @default true
*/
showSuccessMsg?: boolean;
/**
*
* @default false
*/
removeConfirm?: boolean;
/**
* antdv参数
*/
accept?: string;
/**
*
*/
data?: any;
/**
*
* maxCount为1时 string而非string[]
* @default 1
*/
maxCount?: number;
/**
* M
* @default 5
*/
maxSize?: number;
/**
*
* @default false
*/
disabled?: boolean;
/**
* ...
* @default true
*/
helpMessage?: boolean;
/**
* ie10+ ctrl
* @default false
*/
multiple?: boolean;
}

View File

@ -1,37 +0,0 @@
import type { Recordable } from '@vben/types';
export enum UploadResultStatus {
DONE = 'done',
ERROR = 'error',
SUCCESS = 'success',
UPLOADING = 'uploading',
}
export interface FileItem {
thumbUrl?: string;
name: string;
size: number | string;
type?: string;
percent: number;
file: File;
status?: UploadResultStatus;
response?: Recordable<any> | { fileName: string; ossId: string; url: string };
uuid: string;
}
export interface Wrapper {
record: FileItem;
uidKey: string;
valueKey: string;
}
export interface BaseFileItem {
uid: number | string;
url: string;
name?: string;
}
export interface PreviewFileItem {
url: string;
name: string;
type: string;
}

View File

@ -1,61 +0,0 @@
import type { Ref } from 'vue';
import { computed, unref } from 'vue';
import { $t } from '@vben/locales';
export function useUploadType({
acceptRef,
helpTextRef,
maxNumberRef,
maxSizeRef,
}: {
acceptRef: Ref<string[]>;
helpTextRef: Ref<string>;
maxNumberRef: Ref<number>;
maxSizeRef: Ref<number>;
}) {
// 文件类型限制
const getAccept = computed(() => {
const accept = unref(acceptRef);
if (accept && accept.length > 0) {
return accept;
}
return [];
});
const getStringAccept = computed(() => {
return unref(getAccept)
.map((item) => {
return item.indexOf('/') > 0 || item.startsWith('.')
? item
: `.${item}`;
})
.join(',');
});
// 支持jpg、jpeg、png格式不超过2M最多可选择10张图片
const getHelpText = computed(() => {
const helpText = unref(helpTextRef);
if (helpText) {
return helpText;
}
const helpTexts: string[] = [];
const accept = unref(acceptRef);
if (accept.length > 0) {
helpTexts.push($t('component.upload.accept', [accept.join(',')]));
}
const maxSize = unref(maxSizeRef);
if (maxSize) {
helpTexts.push($t('component.upload.maxSize', [maxSize]));
}
const maxNumber = unref(maxNumberRef);
if (maxNumber && maxNumber !== Infinity) {
helpTexts.push($t('component.upload.maxNumber', [maxNumber]));
}
return helpTexts.join('');
});
return { getAccept, getStringAccept, getHelpText };
}

View File

@ -50,6 +50,9 @@
"uploadError": "Upload failed",
"uploading": "Uploading",
"uploadWait": "Please wait for the file upload to finish",
"reUploadFailed": "Re-upload failed files"
"reUploadFailed": "Re-upload failed files",
"uploadHelpMessage": "Please upload a file in {ext} format that does not exceed {size} MB.",
"unknownFileType": "Unknown file type, unable to upload",
"confirmDelete": "Confirm file deletion {0}?"
}
}

View File

@ -18,6 +18,7 @@
"refresh": "Refresh",
"generate": "Generate",
"downloadLoading": "Downloading... Please wait.",
"preview": "Preview"
"preview": "Preview",
"tip": "Tip"
}
}

View File

@ -50,6 +50,9 @@
"uploadError": "上传失败",
"uploading": "上传中",
"uploadWait": "请等待文件上传结束后操作",
"reUploadFailed": "重新上传失败文件"
"reUploadFailed": "重新上传失败文件",
"uploadHelpMessage": "请上传不超过{size}MB的{ext}格式文件",
"unknownFileType": "未知的文件类型, 无法上传",
"confirmDelete": "确认删除文件 {0}?"
}
}

View File

@ -18,6 +18,7 @@
"refresh": "刷新",
"generate": "生成",
"downloadLoading": "下载中, 请稍后...",
"preview": "预览"
"preview": "预览",
"tip": "提示"
}
}

View File

@ -1,65 +1,70 @@
<script setup lang="ts">
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
import { h, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Card, Modal } from 'ant-design-vue';
import { FileUpload, ImageUpload } from '#/components/upload';
import { JsonPreview, Page } from '@vben/common-ui';
import { Alert, RadioGroup } from 'ant-design-vue';
import { ref } from 'vue';
const resultField = ref<'ossId' | 'url'>('ossId');
const singleImageId = ref('1905537674682916865');
const singleFileId = ref('1905191167882518529');
const multipleImageId = ref<string[]>(['1905537674682916865']);
const multipleFileId = ref<string[]>(['1905191167882518529']);
const imageList = ref([]);
const fileList = ref(['111', '2222']);
const fieldOptions = [
{ label: 'ossId', value: 'ossId' },
{ label: '链接地址', value: 'url' },
];
const fileAccept = ['xlsx', 'word', 'pdf'];
const signleImage = ref<string>('1745443704356782081');
function handlePreview(file: UploadFile) {
Modal.info({
content: h('div', { class: 'break-all' }, JSON.stringify(file, null, 2)),
});
}
</script>
<template>
<Page content-class="flex flex-col gap-[12px]">
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
<Alert
:show-icon="true"
message="新特性: 设置max-number为1时, 会被绑定为string而非string[]类型 省去手动转换"
/>
<ImageUpload
v-model:value="signleImage"
:max-number="1"
result-field="ossId"
/>
<JsonPreview :data="signleImage" />
</div>
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
<div class="flex gap-[8px]">
<span>返回字段: </span>
<RadioGroup v-model:value="resultField" :options="fieldOptions" />
</div>
<ImageUpload
v-model:value="imageList"
:max-number="3"
:result-field="resultField"
/>
<JsonPreview :data="imageList" />
</div>
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
<div class="flex gap-[8px]">
<span>返回字段: </span>
<RadioGroup v-model:value="resultField" :options="fieldOptions" />
</div>
<Alert
:message="`支持的文件类型:${fileAccept.join(', ')}`"
:show-icon="true"
type="info"
/>
<FileUpload
v-model:value="fileList"
:accept="fileAccept"
:max-number="3"
:result-field="resultField"
/>
<JsonPreview :data="fileList" />
<Page>
<div class="grid grid-cols-2 gap-4">
<Card title="单图片上传, 会绑定为string" size="small">
<ImageUpload v-model:value="singleImageId" />
当前绑定值: {{ singleImageId }}
</Card>
<Card title="单文件上传, 会绑定为string" size="small">
<FileUpload v-model:value="singleFileId" />
当前绑定值: {{ singleFileId }}
</Card>
<Card title="多图片上传, maxCount参数控制" size="small">
<ImageUpload v-model:value="multipleImageId" :max-count="3" />
当前绑定值: {{ multipleImageId }}
</Card>
<Card title="多文件上传, maxCount参数控制" size="small">
<FileUpload v-model:value="multipleFileId" :max-count="3" />
当前绑定值: {{ multipleFileId }}
</Card>
<Card title="文件自定义预览逻辑" size="small">
<Alert
message="你可以自定义预览逻辑, 比如改为下载, 回调参数为文件信息(图片有默认预览逻辑 不支持自定义)"
class="my-2"
/>
<FileUpload
v-model:value="multipleFileId"
:max-count="3"
:preview="handlePreview"
:help-message="false"
/>
当前绑定值: {{ multipleFileId }}
</Card>
<Card title="图片禁用上传" size="small">
<ImageUpload :disabled="true" :max-count="3" :help-message="false" />
</Card>
<Card title="文件禁用上传" size="small">
<FileUpload :disabled="true" :max-count="3" :help-message="false" />
</Card>
</div>
</Page>
</template>

View File

@ -25,6 +25,6 @@ export {
} from './typing';
export type { CompileError } from '@intlify/core-base';
export { useI18n } from 'vue-i18n';
export { I18nT, useI18n } from 'vue-i18n';
export type { Locale } from 'vue-i18n';