feat:【antd/ele】文件上传的组件优化

This commit is contained in:
YunaiV
2025-12-27 18:50:42 +08:00
parent a109168c66
commit 63a8d562ce
11 changed files with 710 additions and 644 deletions

View File

@@ -51,12 +51,12 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize, maxSizeRef: maxSize,
}); });
// 计算当前绑定的值,优先使用 modelValue /** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => { const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue; return props.modelValue === undefined ? props.value : props.modelValue;
}); });
// 判断是否使用 modelValue /** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => { const isUsingModelValue = computed(() => {
return props.modelValue !== undefined; return props.modelValue !== undefined;
}); });
@@ -82,7 +82,8 @@ watch(
} else { } else {
value.push(v); value.push(v);
} }
fileList.value = value.map((item, i) => { fileList.value = value
.map((item, i) => {
if (item && isString(item)) { if (item && isString(item)) {
return { return {
uid: `${-i}`, uid: `${-i}`,
@@ -94,7 +95,8 @@ watch(
return item; return item;
} }
return null; return null;
}) as UploadProps['fileList']; })
.filter(Boolean) as UploadProps['fileList'];
} }
if (!isFirstRender.value) { if (!isFirstRender.value) {
emit('change', value); emit('change', value);
@@ -107,6 +109,7 @@ watch(
}, },
); );
/** 处理文件删除 */
async function handleRemove(file: UploadFile) { async function handleRemove(file: UploadFile) {
if (fileList.value) { if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid); const index = fileList.value.findIndex((item) => item.uid === file.uid);
@@ -120,17 +123,17 @@ async function handleRemove(file: UploadFile) {
} }
} }
// 处理文件预览 /** 处理文件预览 */
function handlePreview(file: UploadFile) { function handlePreview(file: UploadFile) {
emit('preview', file); emit('preview', file);
} }
// 处理文件数量超限 /** 处理文件数量超限 */
function handleExceed() { function handleExceed() {
message.error($t('ui.upload.maxNumber', [maxNumber.value])); message.error($t('ui.upload.maxNumber', [maxNumber.value]));
} }
// 处理上传错误 /** 处理上传错误 */
function handleUploadError(error: any) { function handleUploadError(error: any) {
console.error('上传错误:', error); console.error('上传错误:', error);
message.error($t('ui.upload.uploadError')); message.error($t('ui.upload.uploadError'));
@@ -138,6 +141,11 @@ function handleUploadError(error: any) {
uploadNumber.value = Math.max(0, uploadNumber.value - 1); uploadNumber.value = Math.max(0, uploadNumber.value - 1);
} }
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) { async function beforeUpload(file: File) {
const fileContent = await file.text(); const fileContent = await file.text();
emit('returnText', fileContent); emit('returnText', fileContent);
@@ -171,7 +179,8 @@ async function beforeUpload(file: File) {
return true; return true;
} }
async function customRequest(info: UploadRequestOption<any>) { /** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
let { api } = props; let { api } = props;
if (!api || !isFunction(api)) { if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest; api = useUpload(props.directory).httpRequest;
@@ -196,7 +205,11 @@ async function customRequest(info: UploadRequestOption<any>) {
} }
} }
// 处理上传成功 /**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) { function handleUploadSuccess(res: any, file: File) {
// 删除临时文件 // 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name); const index = fileList.value?.findIndex((item) => item.name === file.name);
@@ -228,6 +241,10 @@ function handleUploadSuccess(res: any, file: File) {
} }
} }
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() { function getValue() {
const list = (fileList.value || []) const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE) .filter((item) => item?.status === UploadResultStatus.DONE)

View File

@@ -55,12 +55,12 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize, maxSizeRef: maxSize,
}); });
// 计算当前绑定的值,优先使用 modelValue /** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => { const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue; return props.modelValue === undefined ? props.value : props.modelValue;
}); });
// 判断是否使用 modelValue /** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => { const isUsingModelValue = computed(() => {
return props.modelValue !== undefined; return props.modelValue !== undefined;
}); });
@@ -89,7 +89,8 @@ watch(
} else { } else {
value.push(v); value.push(v);
} }
fileList.value = value.map((item, i) => { fileList.value = value
.map((item, i) => {
if (item && isString(item)) { if (item && isString(item)) {
return { return {
uid: `${-i}`, uid: `${-i}`,
@@ -101,7 +102,8 @@ watch(
return item; return item;
} }
return null; return null;
}) as UploadProps['fileList']; })
.filter(Boolean) as UploadProps['fileList'];
} }
if (!isFirstRender.value) { if (!isFirstRender.value) {
emit('change', value); emit('change', value);
@@ -114,6 +116,7 @@ watch(
}, },
); );
/** 将文件转换为 Base64 格式 */
function getBase64<T extends ArrayBuffer | null | string>(file: File) { function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -125,6 +128,7 @@ function getBase64<T extends ArrayBuffer | null | string>(file: File) {
}); });
} }
/** 处理图片预览 */
async function handlePreview(file: UploadFile) { async function handlePreview(file: UploadFile) {
if (!file.url && !file.preview) { if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!); file.preview = await getBase64<string>(file.originFileObj!);
@@ -138,6 +142,7 @@ async function handlePreview(file: UploadFile) {
); );
} }
/** 处理文件删除 */
async function handleRemove(file: UploadFile) { async function handleRemove(file: UploadFile) {
if (fileList.value) { if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid); const index = fileList.value.findIndex((item) => item.uid === file.uid);
@@ -151,11 +156,17 @@ async function handleRemove(file: UploadFile) {
} }
} }
/** 关闭预览弹窗 */
function handleCancel() { function handleCancel() {
previewOpen.value = false; previewOpen.value = false;
previewTitle.value = ''; previewTitle.value = '';
} }
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) { async function beforeUpload(file: File) {
// 检查文件数量限制 // 检查文件数量限制
if (fileList.value!.length >= props.maxNumber) { if (fileList.value!.length >= props.maxNumber) {
@@ -186,7 +197,8 @@ async function beforeUpload(file: File) {
return true; return true;
} }
async function customRequest(info: UploadRequestOption<any>) { /** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
let { api } = props; let { api } = props;
if (!api || !isFunction(api)) { if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest; api = useUpload(props.directory).httpRequest;
@@ -211,7 +223,11 @@ async function customRequest(info: UploadRequestOption<any>) {
} }
} }
// 处理上传成功 /**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) { function handleUploadSuccess(res: any, file: File) {
// 删除临时文件 // 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name); const index = fileList.value?.findIndex((item) => item.name === file.name);
@@ -243,14 +259,18 @@ function handleUploadSuccess(res: any, file: File) {
} }
} }
// 处理上传错误 /** 处理上传错误 */
function handleUploadError(error: any) { function handleUploadError(error: any) {
console.error('上传错误:', error); console.error('上传错误:', error);
message.error('上传错误!!!'); message.error($t('ui.upload.uploadError'));
// 上传失败时减少计数器 // 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1); uploadNumber.value = Math.max(0, uploadNumber.value - 1);
} }
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() { function getValue() {
const list = (fileList.value || []) const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE) .filter((item) => item?.status === UploadResultStatus.DONE)

View File

@@ -6,7 +6,7 @@ import type { FileUploadProps } from './typing';
import { computed } from 'vue'; import { computed } from 'vue';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { Col, Input, Row, Textarea } from 'ant-design-vue'; import { Input, Textarea } from 'ant-design-vue';
import FileUpload from './file-upload.vue'; import FileUpload from './file-upload.vue';
@@ -30,6 +30,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
passive: true, passive: true,
}); });
/** 处理文件内容返回 */
function handleReturnText(text: string) { function handleReturnText(text: string) {
modelValue.value = text; modelValue.value = text;
emits('change', modelValue.value); emits('change', modelValue.value);
@@ -37,6 +38,7 @@ function handleReturnText(text: string) {
emits('update:modelValue', modelValue.value); emits('update:modelValue', modelValue.value);
} }
/** 计算输入框属性 */
const inputProps = computed(() => { const inputProps = computed(() => {
return { return {
...props.inputProps, ...props.inputProps,
@@ -44,6 +46,7 @@ const inputProps = computed(() => {
}; };
}); });
/** 计算文本域属性 */
const textareaProps = computed(() => { const textareaProps = computed(() => {
return { return {
...props.textareaProps, ...props.textareaProps,
@@ -51,6 +54,7 @@ const textareaProps = computed(() => {
}; };
}); });
/** 计算文件上传属性 */
const fileUploadProps = computed(() => { const fileUploadProps = computed(() => {
return { return {
...props.fileUploadProps, ...props.fileUploadProps,
@@ -58,17 +62,17 @@ const fileUploadProps = computed(() => {
}); });
</script> </script>
<template> <template>
<Row> <div class="w-full">
<Col :span="18"> <Input v-if="inputType === 'input'" readonly v-bind="inputProps">
<Input readonly v-if="inputType === 'input'" v-bind="inputProps" /> <template #suffix>
<Textarea readonly v-else :row="4" v-bind="textareaProps" /> <FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
</Col> </template>
<Col :span="6"> </Input>
<FileUpload <div v-else class="relative w-full">
class="ml-4" <Textarea readonly :rows="4" v-bind="textareaProps" />
v-bind="fileUploadProps" <div class="absolute bottom-2 right-2">
@return-text="handleReturnText" <FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
/> </div>
</Col> </div>
</Row> </div>
</template> </template>

View File

@@ -12,28 +12,21 @@ export enum UploadResultStatus {
export type UploadListType = 'picture' | 'picture-card' | 'text'; export type UploadListType = 'picture' | 'picture-card' | 'text';
export interface FileUploadProps { export interface FileUploadProps {
// 根据后缀,或者其他 accept?: string[]; // 根据后缀,或者其他
accept?: string[];
api?: ( api?: (
file: File, file: File,
onUploadProgress?: AxiosProgressEvent, onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>; ) => Promise<AxiosResponse>;
// 上传的目录 directory?: string; // 上传的目录
directory?: string;
disabled?: boolean; disabled?: boolean;
drag?: boolean; // 是否支持拖拽上传 drag?: boolean; // 是否支持拖拽上传
helpText?: string; helpText?: string;
listType?: UploadListType; listType?: UploadListType;
// 最大数量的文件Infinity不限制 maxNumber?: number; // 最大数量的文件Infinity不限制
maxNumber?: number;
modelValue?: string | string[]; // v-model 支持 modelValue?: string | string[]; // v-model 支持
// 文件最大多少MB maxSize?: number; // 文件最大多少MB
maxSize?: number; multiple?: boolean; // 是否支持多选
// 是否支持多选 resultField?: string; // support xxx.xxx.xx
multiple?: boolean; showDescription?: boolean; // 是否显示下面的描述
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[]; value?: string | string[];
} }

View File

@@ -22,6 +22,14 @@ enum UPLOAD_TYPE {
SERVER = 'server', SERVER = 'server',
} }
/**
* 上传类型钩子函数
* @param acceptRef 接受的文件类型
* @param helpTextRef 帮助文本
* @param maxNumberRef 最大文件数量
* @param maxSizeRef 最大文件大小
* @returns 文件类型限制和帮助文本的计算属性
*/
export function useUploadType({ export function useUploadType({
acceptRef, acceptRef,
helpTextRef, helpTextRef,
@@ -78,7 +86,11 @@ export function useUploadType({
return { getAccept, getStringAccept, getHelpText }; return { getAccept, getStringAccept, getHelpText };
} }
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构 /**
* 上传钩子函数
* @param directory 上传目录
* @returns 上传 URL 和自定义上传方法
*/
export function useUpload(directory?: string) { export function useUpload(directory?: string) {
// 后端上传地址 // 后端上传地址
const uploadUrl = getUploadUrl(); const uploadUrl = getUploadUrl();

View File

@@ -1,284 +1,383 @@
<script lang="ts" setup> <script lang="ts" setup>
// TODO @puhui999这个看看怎么和对应的 antd 【代码风格】,保持一致一些;
import type { import type {
UploadFile, UploadFile,
UploadInstance, UploadProgressEvent,
UploadProps,
UploadRawFile,
UploadRequestOptions, UploadRequestOptions,
UploadUserFile,
} from 'element-plus'; } from 'element-plus';
import { ref, watch } from 'vue'; import type { FileUploadProps } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { computed, ref, toRefs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { isString } from '@vben/utils'; import { $t } from '@vben/locales';
import { checkFileType, isFunction, isObject, isString } from '@vben/utils';
import { ElButton, ElLink, ElMessage, ElUpload } from 'element-plus'; import { ElButton, ElMessage, ElUpload } from 'element-plus';
import { useUpload } from './use-upload'; import { UploadResultStatus } from './typing';
import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'FileUpload', inheritAttrs: false }); defineOptions({ name: 'FileUpload', inheritAttrs: false });
const props = withDefaults( const props = withDefaults(defineProps<FileUploadProps>(), {
defineProps<{ value: () => [],
autoUpload?: boolean; modelValue: undefined,
directory?: string; directory: undefined,
disabled?: boolean; disabled: false,
drag?: boolean; drag: false,
fileSize?: number; helpText: '',
fileType?: string[]; maxSize: 2,
isShowTip?: boolean; maxNumber: 1,
limit?: number; accept: () => [],
modelValue: string | string[]; multiple: false,
}>(), api: undefined,
resultField: '',
showDescription: false,
});
const emit = defineEmits([
'change',
'update:value',
'update:modelValue',
'delete',
'returnText',
'preview',
]);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
/** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
/** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
const fileList = ref<UploadFile[]>([]);
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
const uploadNumber = ref<number>(0); // 上传文件计数器
const uploadList = ref<any[]>([]); // 临时上传列表
watch(
currentValue,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (Array.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: UploadResultStatus.SUCCESS,
url: item,
} as UploadFile;
} else if (item && isObject(item)) {
const file = item as unknown as Record<string, any>;
return {
uid: file.uid ?? -i,
name: file.name ?? '',
status: file.status ?? UploadResultStatus.SUCCESS,
url: file.url,
} as UploadFile;
}
return null;
})
.filter(Boolean) as UploadFile[];
} else {
// 值为空时清空文件列表
fileList.value = [];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{ {
fileType: () => ['doc', 'xls', 'ppt', 'txt', 'pdf'], // 文件类型, 例如['png', 'jpg', 'jpeg'] immediate: true,
fileSize: 5, // 大小限制(MB) deep: true,
limit: 5, // 数量限制
autoUpload: true, // 自动上传
drag: false, // 拖拽上传
isShowTip: true, // 是否显示提示
disabled: false, // 是否禁用上传组件 ==> 非必传(默认为 false
directory: undefined, // 上传目录 ==> 非必传(默认为 undefined
}, },
); );
const emit = defineEmits(['update:modelValue']); /** 处理文件删除 */
function handleRemove(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('update:modelValue', value);
emit('change', value);
emit('delete', file);
}
}
// ========== 上传相关 ========== /** 处理文件预览 */
const uploadRef = ref<UploadInstance>(); function handlePreview(file: UploadFile) {
const uploadList = ref<UploadUserFile[]>([]); emit('preview', file);
const fileList = ref<UploadUserFile[]>([]); if (file.url) {
const uploadNumber = ref<number>(0); window.open(file.url);
}
}
const { uploadUrl, httpRequest }: any = useUpload(props.directory); /** 处理文件数量超限 */
function handleExceed() {
ElMessage.error($t('ui.upload.maxNumber', [maxNumber.value]));
}
/** httpRequest 适配 ele */ /** 处理上传错误 */
const httpRequest0 = (options: UploadRequestOptions) => { function handleUploadError(error: any) {
return httpRequest(options.file); console.error('上传错误:', error);
}; ElMessage.error($t('ui.upload.uploadError'));
// 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
// 文件上传之前判断 /**
const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => { * 上传前校验
if (fileList.value.length >= props.limit) { * @param file 待上传的文件
ElMessage.error(`上传文件数量不能超过${props.limit}个!`); * @returns 是否允许上传
*/
/* eslint-disable unicorn/no-nested-ternary */
async function beforeUpload(file: File) {
const fileContent = await file.text();
emit('returnText', fileContent);
// 检查文件数量限制(使用 getValue 获取实际已上传的文件数量)
const currentFiles = getValue();
const currentCount = Array.isArray(currentFiles)
? currentFiles.length
: currentFiles
? 1
: 0;
if (currentCount >= props.maxNumber) {
ElMessage.error($t('ui.upload.maxNumber', [props.maxNumber]));
return false; return false;
} }
let fileExtension = '';
// eslint-disable-next-line unicorn/prefer-includes const { maxSize, accept } = props;
if (file.name.lastIndexOf('.') > -1) { const isAct = checkFileType(file, accept);
fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1); if (!isAct) {
} ElMessage.error($t('ui.upload.acceptUpload', [accept]));
const isImg = props.fileType.some((type: string) => { isActMsg.value = false;
// eslint-disable-next-line unicorn/prefer-includes // 防止弹出多个错误提示
if (file.type.indexOf(type) > -1) return true; setTimeout(() => (isActMsg.value = true), 1000);
// eslint-disable-next-line unicorn/prefer-includes
return !!(fileExtension && fileExtension.indexOf(type) > -1);
});
const isLimit = file.size < props.fileSize * 1024 * 1024;
if (!isImg) {
ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式!`);
return false; return false;
} }
if (!isLimit) { const isLt = file.size / 1024 / 1024 > maxSize;
ElMessage.error(`上传文件大小不能超过${props.fileSize}MB!`); if (isLt) {
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
return false; return false;
} }
ElMessage.success('正在上传文件,请稍候...');
// 只有在验证通过后才增加计数器 // 只有在验证通过后才增加计数器
uploadNumber.value++; uploadNumber.value++;
return true; return true;
}; }
// 文件上传成功 /** 自定义上传请求 */
const handleFileSuccess: UploadProps['onSuccess'] = (url: any): void => { async function customRequest(options: UploadRequestOptions) {
ElMessage.success('上传成功'); let { api } = props;
// 删除自身 if (!api || !isFunction(api)) {
const index = fileList.value.findIndex((item: any) => item.response === url); api = useUpload(props.directory).httpRequest;
fileList.value.splice(index, 1); }
uploadList.value.push({ name: url, url }); try {
if (uploadList.value.length === uploadNumber.value) { // 上传文件
fileList.value.push(...uploadList.value); const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
options.onProgress!({
percent,
total: e.total || 0,
loaded: e.loaded || 0,
lengthComputable: true,
} as unknown as UploadProgressEvent);
};
const res = await api?.(options.file, progressEvent);
// 处理上传成功后的逻辑
handleUploadSuccess(res, options.file as File);
options.onSuccess!(res);
ElMessage.success($t('ui.upload.uploadSuccess'));
} catch (error: any) {
console.error(error);
options.onError!(error);
handleUploadError(error);
}
}
/**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
if (index !== -1) {
fileList.value?.splice(index!, 1);
}
// 添加到临时上传列表
const fileUrl = res?.url || res?.data || res;
uploadList.value.push({
name: file.name,
url: fileUrl,
status: UploadResultStatus.SUCCESS,
uid: file.name + Date.now(),
});
// 检查是否所有文件都上传完成
if (uploadList.value.length >= uploadNumber.value) {
fileList.value?.push(...uploadList.value);
uploadList.value = []; uploadList.value = [];
uploadNumber.value = 0; uploadNumber.value = 0;
emitUpdateModelValue();
}
};
// 文件数超出提示
const handleExceed: UploadProps['onExceed'] = (): void => {
ElMessage.error(`上传文件数量不能超过${props.limit}个!`);
};
// 上传错误提示
const excelUploadError: UploadProps['onError'] = (): void => {
ElMessage.error('导入数据失败,请您重新上传!');
// 上传失败时减少计数器,避免后续上传被阻塞
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
};
// 删除上传文件
const handleRemove = (file: UploadFile) => {
const index = fileList.value.map((f) => f.name).indexOf(file.name);
if (index !== -1) {
fileList.value.splice(index, 1);
emitUpdateModelValue();
}
};
const handlePreview: UploadProps['onPreview'] = (_) => {
// console.log(uploadFile);
};
// 监听模型绑定值变动 // 更新值
watch( const value = getValue();
() => props.modelValue, isInnerOperate.value = true;
(val: string | string[]) => { emit('update:value', value);
if (!val) { emit('update:modelValue', value);
fileList.value = []; // fix处理掉缓存表单重置后上传组件的内容并没有重置 emit('change', value);
return; }
}
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response;
}
return item?.url || item?.response?.url || item?.response;
});
// 单个文件的情况,根据输入参数类型决定返回格式
if (props.maxNumber === 1) {
const singleValue = list.length > 0 ? list[0] : '';
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
if (
isString(props.value) ||
(isUsingModelValue.value && isString(props.modelValue))
) {
return singleValue;
}
return singleValue;
} }
fileList.value = []; // 保障数据为空 // 多文件情况,根据输入参数类型决定返回格式
// 情况1字符串 if (isUsingModelValue.value) {
if (isString(val)) { return Array.isArray(props.modelValue) ? list : list.join(',');
fileList.value.push(
...val.split(',').map((url) => ({
// eslint-disable-next-line unicorn/prefer-string-slice
name: url.substring(url.lastIndexOf('/') + 1),
url,
})),
);
return;
} }
// 情况2数组
fileList.value.push( return Array.isArray(props.value) ? list : list.join(',');
...(val as string[]).map((url) => ({ }
// eslint-disable-next-line unicorn/prefer-string-slice
name: url.substring(url.lastIndexOf('/') + 1),
url,
})),
);
},
{ immediate: true, deep: true },
);
// 发送文件链接列表更新
const emitUpdateModelValue = () => {
// 情况1数组结果
let result: string | string[] = fileList.value.map((file) => file.url!);
// 情况2逗号分隔的字符串
if (props.limit === 1 || isString(props.modelValue)) {
result = result.join(',');
}
emit('update:modelValue', result);
};
</script> </script>
<template> <template>
<div v-if="!disabled" class="upload-file"> <div>
<ElUpload <ElUpload
ref="uploadRef" v-bind="$attrs"
v-model:file-list="fileList" v-model:file-list="fileList"
:action="uploadUrl" :accept="getStringAccept"
:auto-upload="autoUpload"
:before-upload="beforeUpload" :before-upload="beforeUpload"
:http-request="customRequest"
:disabled="disabled" :disabled="disabled"
:limit="maxNumber"
:multiple="multiple"
:drag="drag" :drag="drag"
:http-request="httpRequest0" list-type="text"
:limit="props.limit"
:multiple="props.limit > 1"
:on-error="excelUploadError"
:on-exceed="handleExceed"
:on-preview="handlePreview"
:on-remove="handleRemove" :on-remove="handleRemove"
:on-success="handleFileSuccess" :on-preview="handlePreview"
:show-file-list="true" :on-exceed="handleExceed"
class="upload-file-uploader"
name="file"
> >
<ElButton type="primary"> <template v-if="drag">
<IconifyIcon icon="ep:upload-filled" /> <div class="upload-drag-area">
选取文件 <p class="upload-drag-icon">
</ElButton> <IconifyIcon icon="lucide:cloud-upload" :size="48" />
<template v-if="isShowTip" #tip> </p>
<div style="font-size: 8px"> <p class="upload-drag-text">点击或拖拽文件到此区域上传</p>
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> <p class="upload-drag-hint">
</div> 支持{{ accept.join('/') }}格式文件不超过{{ maxSize }}MB
<div style="font-size: 8px"> </p>
格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件
</div> </div>
</template> </template>
<template #file="row"> <template v-else>
<div class="flex items-center"> <ElButton v-if="fileList && fileList.length < maxNumber" type="primary">
<span>{{ row.file.name }}</span> <IconifyIcon icon="lucide:cloud-upload" class="mr-1" />
<div class="ml-10px"> {{ $t('ui.upload.upload') }}
<ElLink
:href="row.file.url"
:underline="false"
download
target="_blank"
type="primary"
>
下载
</ElLink>
</div>
<div class="ml-10px">
<ElButton link type="danger" @click="handleRemove(row.file)">
删除
</ElButton> </ElButton>
</div>
</div>
</template> </template>
</ElUpload> </ElUpload>
</div>
<!-- 上传操作禁用时 -->
<div v-if="disabled" class="upload-file">
<div <div
v-for="(file, index) in fileList" v-if="showDescription && !drag"
:key="index" class="mt-2 flex flex-wrap items-center text-sm"
class="file-list-item flex items-center"
> >
<span>{{ file.name }}</span> 请上传不超过
<div class="ml-10px"> <span class="mx-1 font-bold text-primary">{{ maxSize }}MB</span>
<ElLink
:href="file.url" <span class="mx-1 font-bold text-primary">{{ accept.join('/') }}</span>
:underline="false" 格式文件
download
target="_blank"
type="primary"
>
下载
</ElLink>
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.upload-file-uploader {
margin-bottom: 5px;
}
:deep(.upload-file-list .el-upload-list__item) { <style scoped>
position: relative; .upload-drag-area {
margin-bottom: 10px; padding: 20px;
line-height: 2; text-align: center;
border: 1px solid #e4e7ed; background-color: #fafafa;
}
:deep(.el-upload-list__item-file-name) {
max-width: 250px;
}
:deep(.upload-file-list .ele-upload-list__item-content) {
display: flex;
align-items: center;
justify-content: space-between;
color: inherit;
}
:deep(.ele-upload-list__item-content-action .el-link) {
margin-right: 10px;
}
.file-list-item {
border: 1px dashed var(--el-border-color-darker);
border-radius: 8px; border-radius: 8px;
transition: border-color 0.3s;
}
.upload-drag-area:hover {
border-color: var(--el-color-primary);
}
.upload-drag-icon {
margin-bottom: 16px;
color: #d9d9d9;
}
.upload-drag-text {
margin-bottom: 8px;
font-size: 16px;
color: #666;
}
.upload-drag-hint {
font-size: 14px;
color: #999;
} }
</style> </style>

View File

@@ -5,13 +5,11 @@ import type {
UploadRequestOptions, UploadRequestOptions,
} from 'element-plus'; } from 'element-plus';
import type { AxiosResponse } from '@vben/request'; import type { FileUploadProps } from './typing';
import type { UploadListType } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file'; import type { AxiosProgressEvent } from '#/api/infra/file';
import { ref, toRefs, watch } from 'vue'; import { computed, ref, toRefs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
@@ -23,47 +21,16 @@ import {
isString, isString,
} from '@vben/utils'; } from '@vben/utils';
import { ElMessage, ElUpload } from 'element-plus'; import { ElDialog, ElMessage, ElUpload } from 'element-plus';
import { UploadResultStatus } from './typing'; import { UploadResultStatus } from './typing';
import { useUpload, useUploadType } from './use-upload'; import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'ImageUpload', inheritAttrs: false }); defineOptions({ name: 'ImageUpload', inheritAttrs: false });
// TODO @xingyu这个要不要抽时间看看upload 组件,和 antd 要不要进一步对齐下;(主要是代码风格。微信沟通~~~ const props = withDefaults(defineProps<FileUploadProps>(), {
const props = withDefaults( value: () => [],
defineProps<{ modelValue: undefined,
// 根据后缀,或者其他
accept?: string[];
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 组件边框圆角
borderradius?: string;
// 上传的目录
directory?: string;
disabled?: boolean;
// 上传框高度
height?: number | string;
helpText?: string;
listType?: UploadListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
modelValue?: string | string[];
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
// 上传框宽度
width?: number | string;
}>(),
{
modelValue: () => [],
directory: undefined, directory: undefined,
disabled: false, disabled: false,
listType: 'picture-card', listType: 'picture-card',
@@ -75,15 +42,14 @@ const props = withDefaults(
api: undefined, api: undefined,
resultField: '', resultField: '',
showDescription: true, showDescription: true,
width: '', });
height: '', const emit = defineEmits([
borderradius: '8px', 'change',
}, 'update:value',
); 'update:modelValue',
'delete',
const emit = defineEmits(['change', 'update:modelValue', 'delete']); ]);
const { accept, helpText, maxNumber, maxSize, width, height, borderradius } = const { accept, helpText, maxNumber, maxSize } = toRefs(props);
toRefs(props);
const isInnerOperate = ref<boolean>(false); const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({ const { getStringAccept } = useUploadType({
acceptRef: accept, acceptRef: accept,
@@ -92,13 +58,28 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize, maxSizeRef: maxSize,
}); });
/** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
/** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
const previewOpen = ref<boolean>(false); // 是否展示预览
const previewImage = ref<string>(''); // 预览图片
const previewTitle = ref<string>(''); // 预览标题
const fileList = ref<UploadFile[]>([]); const fileList = ref<UploadFile[]>([]);
const isLtMsg = ref<boolean>(true); // 文件大小错误提示 const isLtMsg = ref<boolean>(true); // 文件大小错误提示
const isActMsg = ref<boolean>(true); // 文件类型错误提示 const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(true); // 是否第一次渲染 const isFirstRender = ref<boolean>(true); // 是否第一次渲染
const uploadNumber = ref<number>(0); // 上传文件计数器
const uploadList = ref<any[]>([]); // 临时上传列表
watch( watch(
() => props.modelValue, currentValue,
async (v) => { async (v) => {
if (isInnerOperate.value) { if (isInnerOperate.value) {
isInnerOperate.value = false; isInnerOperate.value = false;
@@ -121,11 +102,11 @@ watch(
url: item, url: item,
} as UploadFile; } as UploadFile;
} else if (item && isObject(item)) { } else if (item && isObject(item)) {
const file = item as Record<string, any>; const file = item as unknown as Record<string, any>;
return { return {
uid: file.uid || -i, uid: file.uid ?? -i,
name: file.name || '', name: file.name ?? '',
status: UploadResultStatus.SUCCESS, status: file.status ?? UploadResultStatus.SUCCESS,
url: file.url, url: file.url,
} as UploadFile; } as UploadFile;
} }
@@ -144,6 +125,7 @@ watch(
}, },
); );
/** 将文件转换为 Base64 格式 */
function getBase64<T extends ArrayBuffer | null | string>(file: File) { function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -155,28 +137,52 @@ function getBase64<T extends ArrayBuffer | null | string>(file: File) {
}); });
} }
const handlePreview = async (file: UploadFile) => { /** 处理图片预览 */
async function handlePreview(file: UploadFile) {
if (!file.url) { if (!file.url) {
const preview = await getBase64<string>(file.raw!); file.url = await getBase64<string>(file.raw!);
window.open(preview || '');
return;
} }
window.open(file.url); previewImage.value = file.url || '';
}; previewOpen.value = true;
previewTitle.value =
file.name ||
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
}
const handleRemove = async (file: UploadFile) => { /** 处理文件删除 */
async function handleRemove(file: UploadFile) {
if (fileList.value) { if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid); const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1); index !== -1 && fileList.value.splice(index, 1);
const value = getValue(); const value = getValue();
isInnerOperate.value = true; isInnerOperate.value = true;
emit('update:value', value);
emit('update:modelValue', value); emit('update:modelValue', value);
emit('change', value); emit('change', value);
emit('delete', file); emit('delete', file);
} }
}; }
/** 关闭预览弹窗 */
function handleCancel() {
previewOpen.value = false;
previewTitle.value = '';
}
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) {
// 检查文件数量限制
if (fileList.value!.length >= props.maxNumber) {
ElMessage.error($t('ui.upload.maxNumber', [props.maxNumber]));
return false;
}
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props; const { maxSize, accept } = props;
const isAct = isImage(file.name, accept); const isAct = isImage(file.name, accept);
if (!isAct) { if (!isAct) {
@@ -194,9 +200,13 @@ const beforeUpload = async (file: File) => {
setTimeout(() => (isLtMsg.value = true), 1000); setTimeout(() => (isLtMsg.value = true), 1000);
return false; return false;
} }
return true;
};
// 只有在验证通过后才增加计数器
uploadNumber.value++;
return true;
}
/** 自定义上传请求 */
async function customRequest(options: UploadRequestOptions) { async function customRequest(options: UploadRequestOptions) {
let { api } = props; let { api } = props;
if (!api || !isFunction(api)) { if (!api || !isFunction(api)) {
@@ -215,33 +225,66 @@ async function customRequest(options: UploadRequestOptions) {
}; };
const res = await api?.(options.file, progressEvent); const res = await api?.(options.file, progressEvent);
// TODO @xingyu看看有没更好的实现代码。 // 处理上传成功后的逻辑
// 更新 fileList 中对应文件的 URL 为服务器返回的真实 URL handleUploadSuccess(res, options.file as File);
const uploadedFile = fileList.value.find(
(file) => file.uid === (options.file as any).uid,
);
if (uploadedFile) {
const responseData = res?.data || res;
uploadedFile.url =
props.resultField && responseData[props.resultField]
? responseData[props.resultField]
: responseData.url || responseData;
}
options.onSuccess!(res); options.onSuccess!(res);
ElMessage.success($t('ui.upload.uploadSuccess')); ElMessage.success($t('ui.upload.uploadSuccess'));
// 更新文件
const value = getValue();
isInnerOperate.value = true;
emit('update:modelValue', value);
emit('change', value);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
options.onError!(error); options.onError!(error);
handleUploadError(error);
} }
} }
/**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
if (index !== -1) {
fileList.value?.splice(index!, 1);
}
// 添加到临时上传列表
const fileUrl = res?.url || res?.data || res;
uploadList.value.push({
name: file.name,
url: fileUrl,
status: UploadResultStatus.SUCCESS,
uid: file.name + Date.now(),
});
// 检查是否所有文件都上传完成
if (uploadList.value.length >= uploadNumber.value) {
fileList.value?.push(...uploadList.value);
uploadList.value = [];
uploadNumber.value = 0;
// 更新值
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('update:modelValue', value);
emit('change', value);
}
}
/** 处理上传错误 */
function handleUploadError(error: any) {
console.error('上传错误:', error);
ElMessage.error($t('ui.upload.uploadError'));
// 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() { function getValue() {
const list = (fileList.value || []) const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.SUCCESS) .filter((item) => item?.status === UploadResultStatus.SUCCESS)
@@ -249,51 +292,33 @@ function getValue() {
if (item?.response && props?.resultField) { if (item?.response && props?.resultField) {
return item?.response; return item?.response;
} }
return item?.response?.url || item?.response; return item?.url || item?.response?.url || item?.response;
}); });
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
// 单个文件的情况,根据输入参数类型决定返回格式
if (props.maxNumber === 1) { if (props.maxNumber === 1) {
return list.length > 0 ? list[0] : ''; const singleValue = list.length > 0 ? list[0] : '';
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
if (
isString(props.value) ||
(isUsingModelValue.value && isString(props.modelValue))
) {
return singleValue;
} }
return list; return singleValue;
}
// 多文件情况,根据输入参数类型决定返回格式
if (isUsingModelValue.value) {
return Array.isArray(props.modelValue) ? list : list.join(',');
}
return Array.isArray(props.value) ? list : list.join(',');
} }
</script> </script>
<template> <template>
<div <div>
class="upload-box"
:style="{
width: width || '150px',
height: height || '150px',
borderRadius: borderradius,
}"
>
<template
v-if="
fileList.length > 0 &&
fileList[0] &&
fileList[0].status === UploadResultStatus.SUCCESS
"
>
<div class="upload-image-wrapper">
<img :src="fileList[0].url" class="upload-image" />
<div class="upload-handle">
<div class="handle-icon" @click="handlePreview(fileList[0]!)">
<IconifyIcon icon="lucide:circle-plus" />
<span>详情</span>
</div>
<div
v-if="!disabled"
class="handle-icon"
@click="handleRemove(fileList[0]!)"
>
<IconifyIcon icon="lucide:trash" />
<span>删除</span>
</div>
</div>
</div>
</template>
<template v-else>
<ElUpload <ElUpload
v-bind="$attrs" v-bind="$attrs"
v-model:file-list="fileList" v-model:file-list="fileList"
@@ -306,114 +331,38 @@ function getValue() {
:multiple="multiple" :multiple="multiple"
:on-preview="handlePreview" :on-preview="handlePreview"
:on-remove="handleRemove" :on-remove="handleRemove"
class="upload" :class="{ 'upload-limit-reached': fileList.length >= maxNumber }"
:style="{
width: width || '150px',
height: height || '150px',
borderRadius: borderradius,
}"
> >
<div class="upload-content flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<IconifyIcon icon="lucide:plus" /> <IconifyIcon icon="lucide:cloud-upload" :size="24" />
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
</div> </div>
</ElUpload> </ElUpload>
</template> <div
<!-- TODO @xingyu相比 antd 来说EL 有点丑;貌似是这里展示的位置不太对; --> v-if="showDescription"
<div v-if="showDescription" class="mt-2 text-xs text-gray-500"> class="mt-2 flex flex-wrap items-center text-sm"
{{ getStringAccept }} >
请上传不超过
<span class="mx-1 font-bold text-primary">{{ maxSize }}MB</span>
<span class="mx-1 font-bold text-primary">{{ accept.join('/') }}</span>
格式文件
</div> </div>
<ElDialog v-model="previewOpen" :title="previewTitle" @close="handleCancel">
<img :src="previewImage" alt="" class="w-full" />
</ElDialog>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style scoped>
.upload-box { .el-upload--picture-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fafafa;
border: 1px dashed var(--el-border-color-darker);
transition: border-color 0.2s;
.upload {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100% !important; }
height: 100% !important;
background: transparent;
border: none !important;
}
.upload-content { /* 达到上传限制时隐藏上传按钮 */
display: flex; .upload-limit-reached :deep(.el-upload--picture-card) {
flex-direction: column; display: none;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
}
.upload-image-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
border-radius: inherit;
}
.upload-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
border-radius: inherit;
}
.upload-handle {
position: absolute;
top: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
background: rgb(0 0 0 / 50%);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
.handle-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0 8px;
font-size: 18px;
color: #fff;
span {
margin-top: 2px;
font-size: 12px;
}
}
}
.upload-image-wrapper:hover .upload-handle {
opacity: 1;
}
} }
</style> </style>

View File

@@ -6,7 +6,7 @@ import type { FileUploadProps } from './typing';
import { computed } from 'vue'; import { computed } from 'vue';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { ElCol, ElInput, ElRow } from 'element-plus'; import { ElInput } from 'element-plus';
import FileUpload from './file-upload.vue'; import FileUpload from './file-upload.vue';
@@ -30,6 +30,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
passive: true, passive: true,
}); });
/** 处理文件内容返回 */
function handleReturnText(text: string) { function handleReturnText(text: string) {
modelValue.value = text; modelValue.value = text;
emits('change', modelValue.value); emits('change', modelValue.value);
@@ -37,38 +38,42 @@ function handleReturnText(text: string) {
emits('update:modelValue', modelValue.value); emits('update:modelValue', modelValue.value);
} }
/** 计算输入框属性 */
const inputProps = computed(() => { const inputProps = computed(() => {
return { return {
...props.inputProps, ...props.inputProps,
value: modelValue.value, modelValue: modelValue.value,
}; };
}); });
/** 计算文本域属性 */
const textareaProps = computed(() => { const textareaProps = computed(() => {
return { return {
...props.textareaProps, ...props.textareaProps,
value: modelValue.value, modelValue: modelValue.value,
}; };
}); });
/** 计算文件上传属性 */
const fileUploadProps = computed(() => { const fileUploadProps = computed(() => {
return { return {
...props.fileUploadProps, ...props.fileUploadProps,
}; };
}); });
</script> </script>
<template> <template>
<ElRow> <div class="w-full">
<ElCol :span="18"> <ElInput v-if="inputType === 'input'" readonly v-bind="inputProps">
<ElInput v-if="inputType === 'input'" v-bind="inputProps" /> <template #suffix>
<ElInput v-else :row="4" type="textarea" v-bind="textareaProps" /> <FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
</ElCol> </template>
<ElCol :span="6"> </ElInput>
<FileUpload <div v-else class="relative w-full">
class="ml-4" <ElInput readonly :rows="4" type="textarea" v-bind="textareaProps" />
v-bind="fileUploadProps" <div class="absolute bottom-0 right-2">
@return-text="handleReturnText" <FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
/> </div>
</ElCol> </div>
</ElRow> </div>
</template> </template>

View File

@@ -2,76 +2,31 @@ import type { AxiosResponse } from '@vben/request';
import type { AxiosProgressEvent } from '#/api/infra/file'; import type { AxiosProgressEvent } from '#/api/infra/file';
export type UploadListType = 'picture' | 'picture-card' | 'text';
export type UploadStatus =
| 'error'
| 'fail'
| 'removed'
| 'success'
| 'uploading';
export enum UploadResultStatus { export enum UploadResultStatus {
DONE = 'done',
ERROR = 'error', ERROR = 'error',
REMOVED = 'removed',
SUCCESS = 'success', SUCCESS = 'success',
UPLOADING = 'uploading', UPLOADING = 'uploading',
} }
export interface CustomUploadFile { export type UploadListType = 'picture' | 'picture-card' | 'text';
uid: number;
name: string;
status: UploadStatus;
url?: string;
response?: any;
percentage?: number;
size?: number;
raw?: File;
}
export function convertToUploadStatus(
status: UploadResultStatus,
): UploadStatus {
switch (status) {
case UploadResultStatus.ERROR: {
return 'fail';
}
case UploadResultStatus.REMOVED: {
return 'removed';
}
case UploadResultStatus.SUCCESS: {
return 'success';
}
case UploadResultStatus.UPLOADING: {
return 'uploading';
}
default: {
return 'success';
}
}
}
export interface FileUploadProps { export interface FileUploadProps {
// 根据后缀,或者其他 accept?: string[]; // 根据后缀,或者其他
accept?: string[];
api?: ( api?: (
file: File, file: File,
onUploadProgress?: AxiosProgressEvent, onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>; ) => Promise<AxiosResponse>;
// 上传的目录 directory?: string; // 上传的目录
directory?: string;
disabled?: boolean; disabled?: boolean;
drag?: boolean; // 是否支持拖拽上传
helpText?: string; helpText?: string;
listType?: UploadListType; listType?: UploadListType;
// 最大数量的文件Infinity不限制 maxNumber?: number; // 最大数量的文件Infinity 不限制
maxNumber?: number; modelValue?: string | string[]; // v-model 支持
// 文件最大多少MB maxSize?: number; // 文件最大多少 MB
maxSize?: number; multiple?: boolean; // 是否支持多选
// 是否支持多选 resultField?: string; // support xxx.xxx.xx
multiple?: boolean; showDescription?: boolean; // 是否显示下面的描述
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[]; value?: string | string[];
} }

View File

@@ -22,6 +22,14 @@ enum UPLOAD_TYPE {
SERVER = 'server', SERVER = 'server',
} }
/**
* 上传类型钩子函数
* @param acceptRef 接受的文件类型
* @param helpTextRef 帮助文本
* @param maxNumberRef 最大文件数量
* @param maxSizeRef 最大文件大小
* @returns 文件类型限制和帮助文本的计算属性
*/
export function useUploadType({ export function useUploadType({
acceptRef, acceptRef,
helpTextRef, helpTextRef,
@@ -78,7 +86,11 @@ export function useUploadType({
return { getAccept, getStringAccept, getHelpText }; return { getAccept, getStringAccept, getHelpText };
} }
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构 /**
* 上传钩子函数
* @param directory 上传目录
* @returns 上传 URL 和自定义上传方法
*/
export function useUpload(directory?: string) { export function useUpload(directory?: string) {
// 后端上传地址 // 后端上传地址
const uploadUrl = getUploadUrl(); const uploadUrl = getUploadUrl();

64
pnpm-lock.yaml generated
View File

@@ -593,7 +593,7 @@ catalogs:
version: 4.1.0 version: 4.1.0
vxe-pc-ui: vxe-pc-ui:
specifier: ^4.10.22 specifier: ^4.10.22
version: 4.11.20 version: 4.11.21
vxe-table: vxe-table:
specifier: ^4.17.14 specifier: ^4.17.14
version: 4.17.35 version: 4.17.35
@@ -2010,7 +2010,7 @@ importers:
version: 3.5.26(typescript@5.9.3) version: 3.5.26(typescript@5.9.3)
vxe-pc-ui: vxe-pc-ui:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.11.20(vue@3.5.26(typescript@5.9.3)) version: 4.11.21(vue@3.5.26(typescript@5.9.3))
vxe-table: vxe-table:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.17.35(vue@3.5.26(typescript@5.9.3)) version: 4.17.35(vue@3.5.26(typescript@5.9.3))
@@ -2898,11 +2898,11 @@ packages:
'@bpmn-io/properties-panel@3.35.1': '@bpmn-io/properties-panel@3.35.1':
resolution: {integrity: sha512-bxZkrFzlHxTPC1CzMZ6rwpjdkUKKT/7A6rgNWNtmExyyipZjvUq0GMg395xPdVO9Yir+7O7A/XFpQo6PA1dyFA==} resolution: {integrity: sha512-bxZkrFzlHxTPC1CzMZ6rwpjdkUKKT/7A6rgNWNtmExyyipZjvUq0GMg395xPdVO9Yir+7O7A/XFpQo6PA1dyFA==}
'@cacheable/memory@2.0.6': '@cacheable/memory@2.0.7':
resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
'@cacheable/utils@2.3.2': '@cacheable/utils@2.3.3':
resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==}
'@camunda/feel-builtins@0.2.0': '@camunda/feel-builtins@0.2.0':
resolution: {integrity: sha512-Jusm8x3Onqze9E5Y0lGGdPj66bnFKLYNwDz+uG4otsEXgSL0FpF+koGHK48LkF9Jqo67KaP1y3zr2y/HIWRePw==} resolution: {integrity: sha512-Jusm8x3Onqze9E5Y0lGGdPj66bnFKLYNwDz+uG4otsEXgSL0FpF+koGHK48LkF9Jqo67KaP1y3zr2y/HIWRePw==}
@@ -5494,8 +5494,8 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.24 vue: ^3.5.24
'@vxe-ui/core@4.2.17': '@vxe-ui/core@4.2.18':
resolution: {integrity: sha512-0jsaTPeG3Zbu0Czytl7eI0jU+S/NGhBGmhuEdwlMaqHLH69x8YrXH4UZBfRtCzgleTPS76j3dZVPtVsCSgMn0g==} resolution: {integrity: sha512-StRs0zbCdshNwONYmZnFaJCencIo4rbVHbOcqLAddu6PTxPAsfUE9R5upMGj8EJMIpJggPcEUFvbjSp+8VJ8Uw==}
peerDependencies: peerDependencies:
vue: ^3.5.24 vue: ^3.5.24
@@ -6400,8 +6400,8 @@ packages:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
cssdb@8.5.2: cssdb@8.6.0:
resolution: {integrity: sha512-Pmoj9RmD8RIoIzA2EQWO4D4RMeDts0tgAH0VXdlNdxjuBGI3a9wMOIcUwaPNmD4r2qtIa06gqkIf7sECl+cBCg==} resolution: {integrity: sha512-7ZrRi/Z3cRL1d5I8RuXEWAkRFP3J4GeQRiyVknI4KC70RAU8hT4LysUZDe0y+fYNOktCbxE8sOPUOhyR12UqGQ==}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
@@ -8306,8 +8306,8 @@ packages:
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
ky@1.14.1: ky@1.14.2:
resolution: {integrity: sha512-hYje4L9JCmpEQBtudo+v52X5X8tgWXUYyPcxKSuxQNboqufecl9VMWjGiucAFH060AwPXHZuH+WB2rrqfkmafw==} resolution: {integrity: sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==}
engines: {node: '>=18'} engines: {node: '>=18'}
latest-version@9.0.0: latest-version@9.0.0:
@@ -11456,8 +11456,8 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.24 vue: ^3.5.24
vxe-pc-ui@4.11.20: vxe-pc-ui@4.11.21:
resolution: {integrity: sha512-6qTbCJOo7hQ7IEElGCWh/E3UCJsb3IF4OIHy+hsy9P68i4GtZgz+qMrRxgyGH608qIbOR8z5i7wetuiENeqyWQ==} resolution: {integrity: sha512-nt++HK+JUi0y43DxU86s5AQiT0847Zi+zA45wlPeA/m8PYZeKhVQ+WcQXDKPhkMfkv3zzm+hj03MvYIdsqFW/Q==}
vxe-table@4.17.35: vxe-table@4.17.35:
resolution: {integrity: sha512-64biGEeTW9f+2HjiAOGSQqDEYqZpbUOauQvZozKkFk5OdtC+F1bagOHmU7EXl+vueFAof92pUNalDh1uIZt6Wg==} resolution: {integrity: sha512-64biGEeTW9f+2HjiAOGSQqDEYqZpbUOauQvZozKkFk5OdtC+F1bagOHmU7EXl+vueFAof92pUNalDh1uIZt6Wg==}
@@ -11630,8 +11630,8 @@ packages:
resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
xe-utils@3.8.1: xe-utils@3.8.2:
resolution: {integrity: sha512-pI9QNQ1dhUqbrnMDcVo+nOzH4D5/Xrv0+0P8vGJPXezs6bQvdk7HX/+i6pa0jNLZtfzx/YshwMi/zSN9s8D43g==} resolution: {integrity: sha512-u7GMyaDeVHODMd6tUdSqfLl6sYX5qmRjuTbb+QMhYHmp2qlaqsUTD5FwEiM+p/82j4kmeen6TLvxfs7uXdQ5ow==}
xml-name-validator@4.0.0: xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
@@ -12689,14 +12689,14 @@ snapshots:
min-dash: 4.2.3 min-dash: 4.2.3
min-dom: 4.2.1 min-dom: 4.2.1
'@cacheable/memory@2.0.6': '@cacheable/memory@2.0.7':
dependencies: dependencies:
'@cacheable/utils': 2.3.2 '@cacheable/utils': 2.3.3
'@keyv/bigmap': 1.3.0(keyv@5.5.5) '@keyv/bigmap': 1.3.0(keyv@5.5.5)
hookified: 1.14.0 hookified: 1.14.0
keyv: 5.5.5 keyv: 5.5.5
'@cacheable/utils@2.3.2': '@cacheable/utils@2.3.3':
dependencies: dependencies:
hashery: 1.3.0 hashery: 1.3.0
keyv: 5.5.5 keyv: 5.5.5
@@ -15655,11 +15655,11 @@ snapshots:
dependencies: dependencies:
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
'@vxe-ui/core@4.2.17(vue@3.5.26(typescript@5.9.3))': '@vxe-ui/core@4.2.18(vue@3.5.26(typescript@5.9.3))':
dependencies: dependencies:
dom-zindex: 1.0.6 dom-zindex: 1.0.6
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
xe-utils: 3.8.1 xe-utils: 3.8.2
'@xmldom/xmldom@0.8.11': {} '@xmldom/xmldom@0.8.11': {}
@@ -16125,8 +16125,8 @@ snapshots:
cacheable@2.3.1: cacheable@2.3.1:
dependencies: dependencies:
'@cacheable/memory': 2.0.6 '@cacheable/memory': 2.0.7
'@cacheable/utils': 2.3.2 '@cacheable/utils': 2.3.3
hookified: 1.14.0 hookified: 1.14.0
keyv: 5.5.5 keyv: 5.5.5
qified: 0.5.3 qified: 0.5.3
@@ -16691,7 +16691,7 @@ snapshots:
css-what@6.2.2: {} css-what@6.2.2: {}
cssdb@8.5.2: {} cssdb@8.6.0: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
@@ -18745,7 +18745,7 @@ snapshots:
kolorist@1.8.0: {} kolorist@1.8.0: {}
ky@1.14.1: {} ky@1.14.2: {}
latest-version@9.0.0: latest-version@9.0.0:
dependencies: dependencies:
@@ -19627,7 +19627,7 @@ snapshots:
package-json@10.0.1: package-json@10.0.1:
dependencies: dependencies:
ky: 1.14.1 ky: 1.14.2
registry-auth-token: 5.1.0 registry-auth-token: 5.1.0
registry-url: 6.0.1 registry-url: 6.0.1
semver: 7.7.3 semver: 7.7.3
@@ -20159,7 +20159,7 @@ snapshots:
css-blank-pseudo: 7.0.1(postcss@8.5.6) css-blank-pseudo: 7.0.1(postcss@8.5.6)
css-has-pseudo: 7.0.3(postcss@8.5.6) css-has-pseudo: 7.0.3(postcss@8.5.6)
css-prefers-color-scheme: 10.0.0(postcss@8.5.6) css-prefers-color-scheme: 10.0.0(postcss@8.5.6)
cssdb: 8.5.2 cssdb: 8.6.0
postcss: 8.5.6 postcss: 8.5.6
postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.6) postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.6)
postcss-clamp: 4.1.0(postcss@8.5.6) postcss-clamp: 4.1.0(postcss@8.5.6)
@@ -21960,7 +21960,7 @@ snapshots:
'@rollup/pluginutils': 5.3.0(rollup@4.54.0) '@rollup/pluginutils': 5.3.0(rollup@4.54.0)
es-module-lexer: 1.7.0 es-module-lexer: 1.7.0
rollup: 4.54.0 rollup: 4.54.0
xe-utils: 3.8.1 xe-utils: 3.8.2
vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@25.0.3)(less@4.5.1)(sass@1.97.1)(terser@5.44.1))(workbox-build@7.4.0)(workbox-window@7.4.0): vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@25.0.3)(less@4.5.1)(sass@1.97.1)(terser@5.44.1))(workbox-build@7.4.0)(workbox-window@7.4.0):
dependencies: dependencies:
@@ -22304,15 +22304,15 @@ snapshots:
vooks: 0.2.12(vue@3.5.26(typescript@5.9.3)) vooks: 0.2.12(vue@3.5.26(typescript@5.9.3))
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
vxe-pc-ui@4.11.20(vue@3.5.26(typescript@5.9.3)): vxe-pc-ui@4.11.21(vue@3.5.26(typescript@5.9.3)):
dependencies: dependencies:
'@vxe-ui/core': 4.2.17(vue@3.5.26(typescript@5.9.3)) '@vxe-ui/core': 4.2.18(vue@3.5.26(typescript@5.9.3))
transitivePeerDependencies: transitivePeerDependencies:
- vue - vue
vxe-table@4.17.35(vue@3.5.26(typescript@5.9.3)): vxe-table@4.17.35(vue@3.5.26(typescript@5.9.3)):
dependencies: dependencies:
vxe-pc-ui: 4.11.20(vue@3.5.26(typescript@5.9.3)) vxe-pc-ui: 4.11.21(vue@3.5.26(typescript@5.9.3))
transitivePeerDependencies: transitivePeerDependencies:
- vue - vue
@@ -22571,7 +22571,7 @@ snapshots:
xdg-basedir@5.1.0: {} xdg-basedir@5.1.0: {}
xe-utils@3.8.1: {} xe-utils@3.8.2: {}
xml-name-validator@4.0.0: {} xml-name-validator@4.0.0: {}