一、功能概述
useUpload 是一个功能强大的Vue3 Composition API文件上传Hook,它提供了以下核心特性:
-
完整的上传生命周期管理
- 支持上传前校验
- 上传进度跟踪
- 成功/失败状态处理
- 错误重试机制
-
灵活的配置选项
- 自定义上传API
- 并发控制
- 自动/手动上传模式
- 自定义参数传递
-
强大的文件管理能力
- 文件状态追踪
- 重复文件检测
- 批量操作支持
- 文件元信息获取
-
与Ant Design Vue完美集成
- 提供完整的Upload组件属性适配
- 支持自定义预览
- 支持文件列表展示
二、核心源码解析
1. 类型定义
// 文件对象接口
export interface UploadFile {
uid: string; // 客户端唯一标识
file: File | null; // 原始文件对象
fileName: string; // 本地文件名
clientFileType: string; // 客户端文件类型
fileSize: number; // 文件大小
uploadStatus: 'waiting' | 'uploading' | 'error' | 'success';
// ... 其他属性
}
// Hook配置选项
export interface UseUploadOptions {
uploadApi?: (data: FormData) => Promise<any>;
maxConcurrent?: number;
autoUpload?: boolean;
params?: Record<string, any>;
// ... 其他配置项
}
2. 状态管理
// 核心状态
const fileList = reactive<UploadFile[]>([]);
const uploading = ref(false);
const currentUploading = ref(0);
const uploadQueue = reactive<string[]>([]);
3. 上传控制流程
const uploadSingleFile = async (uid: string): Promise<void> => {
// 1. 获取并验证文件
const file = findFile(uid);
if (!file) return;
// 2. 更新状态
updateFileStatus(uid, 'uploading');
currentUploading.value += 1;
try {
// 3. 构建表单数据
const formData = new FormData();
formData.append('file', file.file!);
// 4. 调用上传API
const response = await uploadApi(formData);
// 5. 处理响应
updateFileStatus(uid, 'success');
updateFileServerData(uid, response);
} catch (error) {
// 6. 错误处理
updateFileStatus(uid, 'error');
}
};
三、基础用法
1. 最简单的使用方式
import { useUpload } from '@/hooks/useUpload';
export default defineComponent({
setup() {
const {
fileList,
uploading,
uploadProps
} = useUpload();
return {
uploadProps
}
}
});
<template>
<a-upload v-bind="uploadProps">
<a-button>
<upload-outlined /> 点击上传
</a-button>
</a-upload>
</template>
2. 自定义配置示例
const {
fileList,
upload,
retry
} = useUpload({
// 最大并发数
maxConcurrent: 2,
// 自动上传
autoUpload: true,
// 上传前校验
beforeUpload: (file) => {
if (file.size > 5 * 1024 * 1024) {
message.error('文件大小不能超过5MB');
return false;
}
return true;
},
// 成功回调
onSuccess: (file) => {
message.success(`${file.fileName} 上传成功`);
}
});
四、实际应用场景
1. 图片上传与预览场景
这是一个完整的图片上传组件示例,包含图片预览、进度显示、状态管理等功能。
<!-- ImageUploader.vue -->
<template>
<div class="image-uploader">
<!-- 上传区域 -->
<a-upload
v-bind="uploadProps"
list-type="picture-card"
:class="{ 'upload-list-inline': true }"
@preview="handlePreview"
>
<div v-if="fileList.length < maxCount">
<loading-outlined v-if="uploading" />
<plus-outlined v-else />
<div class="ant-upload-text">上传图片</div>
</div>
</a-upload>
<!-- 图片预览模态框 -->
<a-modal
:visible="previewVisible"
:title="previewTitle"
:footer="null"
@cancel="handlePreviewCancel"
>
<img :src="previewImage" :alt="previewTitle" style="width: 100%" />
</a-modal>
<!-- 上传统计信息 -->
<div class="upload-stats" v-if="fileList.length > 0">
<p>已上传: {{ successCount }} / {{ fileList.length }}</p>
<a-progress
:percent="uploadProgress"
:status="uploadStatus"
size="small"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
PlusOutlined,
LoadingOutlined
} from '@ant-design/icons-vue';
import { useUpload } from '@/hooks/useUpload';
export default defineComponent({
name: 'ImageUploader',
components: {
PlusOutlined,
LoadingOutlined
},
props: {
maxCount: {
type: Number,
default: 5
},
maxSize: {
type: Number,
default: 5 * 1024 * 1024 // 5MB
}
},
emits: ['upload-complete'],
setup(props, { emit }) {
// 预览相关的状态
const previewVisible = ref(false);
const previewImage = ref('');
const previewTitle = ref('');
// 使用upload hook
const {
fileList,
uploading,
uploadProps,
getSuccessFiles,
getErrorFiles
} = useUpload({
maxConcurrent: 2,
autoUpload: true,
beforeUpload: async (file) => {
// 验证文件类型
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件!');
return false;
}
// 验证文件大小
if (file.size > props.maxSize) {
message.error(`图片大小不能超过${props.maxSize / 1024 / 1024}MB!`);
return false;
}
// 验证图片尺寸
try {
const image = await createImageBitmap(file);
if (image.width > 4096 || image.height > 4096) {
message.error('图片尺寸不能超过4096x4096!');
return false;
}
image.close();
} catch (error) {
message.error('图片格式不正确!');
return false;
}
return true;
},
onSuccess: (file) => {
message.success(`${file.fileName} 上传成功`);
// 当所有文件都上传完成时,触发事件
if (getSuccessFiles().length === fileList.length) {
emit('upload-complete', getSuccessFiles());
}
},
onError: (file, error) => {
message.error(`${file.fileName} 上传失败: ${error.message}`);
}
});
// 计算上传进度
const uploadProgress = computed(() => {
if (fileList.length === 0) return 0;
return Math.round((getSuccessFiles().length / fileList.length) * 100);
});
// 计算上传状态
const uploadStatus = computed(() => {
if (getErrorFiles().length > 0) return 'exception';
if (uploadProgress.value === 100) return 'success';
return 'active';
});
// 成功上传的文件数量
const successCount = computed(() => getSuccessFiles().length);
// 处理图片预览
const handlePreview = async (file: any) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj);
}
previewImage.value = file.url || file.preview;
previewVisible.value = true;
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1);
};
// 关闭预览
const handlePreviewCancel = () => {
previewVisible.value = false;
};
// 工具函数:将文件转换为base64
const getBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = error => reject(error);
});
};
return {
fileList,
uploading,
uploadProps,
previewVisible,
previewImage,
previewTitle,
uploadProgress,
uploadStatus,
successCount,
handlePreview,
handlePreviewCancel
};
}
});
</script>
<style lang="less" scoped>
.image-uploader {
.upload-list-inline {
:deep(.ant-upload-list-item) {
margin-right: 8px;
margin-bottom: 8px;
}
}
.ant-upload-text {
margin-top: 8px;
color: #666;
}
.upload-stats {
margin-top: 16px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
p {
margin-bottom: 8px;
color: #666;
}
}
}
</style>
使用示例:
<!-- 在父组件中使用 -->
<template>
<div class="page-container">
<h2>产品图片上传</h2>
<image-uploader
:max-count="5"
:max-size="5 * 1024 * 1024"
@upload-complete="handleUploadComplete"
/>
<!-- 已上传图片展示 -->
<div class="image-preview" v-if="uploadedImages.length > 0">
<h3>已上传图片</h3>
<div class="image-grid">
<div
v-for="image in uploadedImages"
:key="image.serverId"
class="image-item"
>
<img :src="image.fileUrl" :alt="image.fileName" />
<div class="image-info">
<p>{{ image.fileName }}</p>
<p>{{ formatFileSize(image.fileSize) }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import ImageUploader from '@/components/ImageUploader.vue';
import type { UploadFile } from '@/hooks/useUpload';
export default defineComponent({
name: 'ProductImageUpload',
components: {
ImageUploader
},
setup() {
const uploadedImages = ref<UploadFile[]>([]);
const handleUploadComplete = (files: UploadFile[]) => {
uploadedImages.value = files;
console.log('所有图片上传完成:', files);
};
const formatFileSize = (size: number): string => {
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB';
return (size / 1024 / 1024).toFixed(2) + ' MB';
};
return {
uploadedImages,
handleUploadComplete,
formatFileSize
};
}
});
</script>
<style lang="less" scoped>
.page-container {
padding: 24px;
h2 {
margin-bottom: 24px;
color: #333;
}
.image-preview {
margin-top: 32px;
h3 {
margin-bottom: 16px;
color: #333;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
.image-item {
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
img {
width: 100%;
height: 200px;
object-fit: cover;
}
.image-info {
padding: 8px;
background: #fafafa;
p {
margin: 0;
font-size: 12px;
color: #666;
&:first-child {
margin-bottom: 4px;
color: #333;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
}
}
</style>
这个示例展示了如何将useUpload hook集成到实际的业务组件中,并提供了完整的用户界面和交互体验。组件可以直接使用,也可以根据具体需求进行定制化开发。
2、实际应用场景:九宫格图片上传组件
这是一个实际项目中的九宫格图片上传组件,展示了useUpload在真实业务场景中的应用。
1. 组件功能特点
- 支持多图片上传
- 显示上传进度和状态
- 错误处理和重试机制
- 图片预览功能
- 文件大小和尺寸显示
- 使用Tailwind CSS实现的响应式布局
2. 完整代码实现
<!-- CutNinePanel.vue -->
<template>
<div class="material-section mb-16px">
<!-- 头部操作区 -->
<div class="flex items-center pb-3">
<span class="min-w-[100px] required-field">原始图片</span>
<div class="material-actions">
<a-button class="mr-8px" @click="openMaterialManage">素材管理</a-button>
<!-- 上传按钮 -->
<a-upload
v-bind="uploadProps"
accept="image/*"
:multiple="true"
:show-upload-list="false"
>
<a-button :loading="uploading">本地素材</a-button>
</a-upload>
<!-- 上传状态显示 -->
<span v-if="displayList.length > 0" class="ml-16px text-gray-500">
已上传{{ successCount }}个素材
<span v-if="errorCount > 0" class="text-red-500 ml-8px">
({{ errorCount }}个失败)
<a-button
v-if="errorCount > 0"
type="link"
class="text-primary p-0 ml-4px"
@click="retryAllFailedItems"
>
重试全部
</a-button>
</span>
</span>
</div>
</div>
<!-- 图片预览网格 -->
<div v-if="displayList.length"
class="material-preview-container relative pl-[92px] max-h-[280px] overflow-y-auto pt-[12px]">
<div class="grid grid-cols-5 gap-4">
<!-- 图片项循环 -->
<div v-for="(item, index) in displayList"
:key="index"
class="material-preview-box relative mr-8px inline-block border rounded p-8px"
:class="{'bg-red-50': isErrorItem(item), 'border-gray-300': !isErrorItem(item)}">
<!-- 错误状态图标 -->
<div v-if="isErrorItem(item)"
class="absolute z-2 text-xl text-[#ff4d4f] top-1/3 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<exclamation-circle-filled class="text-red-500" />
</div>
<!-- 图片预览区域 -->
<img v-if="!isErrorItem(item)"
:src="item.path"
class="h-full w-full object-scale-down" />
<img v-else-if="(item as ErrorItem).fileUid && getLocalPreviewUrl((item as ErrorItem).fileUid)"
:src="getLocalPreviewUrl((item as ErrorItem).fileUid)"
class="h-full w-full object-scale-down opacity-50"
@load="addPreviewUrlToCache(getLocalPreviewUrl((item as ErrorItem).fileUid))" />
<div v-else class="h-[160px] w-full flex items-center justify-center bg-gray-100">
<file-exclamation-outlined class="text-red-500 text-3xl" />
</div>
<!-- 文件信息显示 -->
<div class="line-clamp-1 mt-4px text-xs break-all mb-2" :title="item.name">
{{ item.name }}
</div>
<div class="text-xs text-gray-500 break-all">
<span>{{ item.size }}</span>
<span v-if="!isErrorItem(item)" class="ml-4px">
({{ item.width }}x{{ item.height }})
</span>
</div>
<!-- 错误信息提示 -->
<a-tooltip v-if="isErrorItem(item)" :title="(item as ErrorItem).errorMsg">
<div class="text-xs text-red-500 truncate">
{{ (item as ErrorItem).errorMsg }}
</div>
</a-tooltip>
<!-- 删除按钮 -->
<a-button
type="link"
class="absolute z-1 right-4px top-4px h-20px w-20px flex items-center justify-center rounded-full bg-gray-700 p-0 opacity-80"
@click="removeMaterial(index)"
>
<close-outlined class="h-[14px] w-[16px] text-xs text-white" />
</a-button>
<!-- 重试按钮 -->
<a-button
v-if="isErrorItem(item)"
type="primary"
size="small"
class="retry-btn absolute bottom-6px right-6px"
@click="retryUpload(index)"
>
重试
</a-button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useUpload } from '@/hooks/useUpload';
import {
ExclamationCircleFilled,
FileExclamationOutlined,
CloseOutlined
} from '@ant-design/icons-vue';
// 常量定义
const MAX_MATERIAL_COUNT = 9;
// 类型定义
interface MaterialItem {
id: number;
name: string;
size: string;
path: string;
uuid: string;
width: number;
height: number;
md5: string;
isLocal: boolean;
}
interface ErrorItem {
name: string;
size: string;
fileUid: string;
errorMsg: string;
}
// 状态管理
const materialList = ref<MaterialItem[]>([]);
const errorList = ref<ErrorItem[]>([]);
// 使用useUpload钩子
const {
fileList,
uploading,
uploadProps,
retry,
remove: removeFile
} = useUpload({
params: {
bizType: '1' // 业务类型参数
},
beforeUpload: () => {
if (materialList.value.length + errorList.value.length >= MAX_MATERIAL_COUNT) {
message.warning(`最多只能添加${MAX_MATERIAL_COUNT}个素材`);
return false;
}
return true;
},
onSuccess: (file) => {
if (materialList.value.length + errorList.value.length >= MAX_MATERIAL_COUNT) {
message.warning(`最多只能添加${MAX_MATERIAL_COUNT}个素材`);
return;
}
const fileData = file.response.data || {};
materialList.value.push({
id: fileData.id,
name: file.fileName,
size: formatFileSize(file.fileSize),
path: fileData.fileUrl || fileData.url,
uuid: fileData.uuid,
width: file.width,
height: file.height,
md5: fileData.md5,
isLocal: true,
});
},
onError: (file) => {
errorList.value.push({
name: file.fileName,
size: formatFileSize(file.fileSize),
fileUid: file.uid,
errorMsg: file.error?.message || '上传失败'
});
}
});
// 计算属性
const displayList = computed(() => [...materialList.value, ...errorList.value]);
const successCount = computed(() => materialList.value.length);
const errorCount = computed(() => errorList.value.length);
// 工具函数
const formatFileSize = (size: number): string => {
if (size < 1024) return `${size}B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
return `${(size / 1024 / 1024).toFixed(1)}MB`;
};
const isErrorItem = (item: MaterialItem | ErrorItem): item is ErrorItem => {
return 'errorMsg' in item;
};
// 事件处理函数
const retryUpload = (index: number) => {
const item = displayList.value[index];
if (isErrorItem(item)) {
retry(item.fileUid);
}
};
const removeMaterial = (index: number) => {
const item = displayList.value[index];
if (isErrorItem(item)) {
const errorIndex = errorList.value.findIndex(e => e.fileUid === item.fileUid);
if (errorIndex > -1) {
errorList.value.splice(errorIndex, 1);
removeFile(item.fileUid);
}
} else {
materialList.value.splice(index, 1);
}
};
const retryAllFailedItems = () => {
errorList.value.forEach(item => {
retry(item.fileUid);
});
};
</script>
<style lang="less" scoped>
.material-section {
.material-preview-box {
height: 220px;
transition: all 0.3s;
&:hover {
.retry-btn {
opacity: 1;
}
}
}
.retry-btn {
opacity: 0;
transition: opacity 0.3s;
}
.required-field {
&::before {
content: '*';
color: #ff4d4f;
margin-right: 4px;
}
}
}
</style>
3. 组件特色功能
-
智能状态管理
- 分别维护成功和失败的上传列表
- 实时显示上传状态和进度
- 支持失败重试和批量重试
-
优雅的错误处理
- 显示错误状态和提示信息
- 支持单个文件重试
- 支持全部失败文件重试
-
完善的文件信息展示
- 显示文件名称和大小
- 显示图片尺寸信息
- 支持图片预览
-
良好的用户体验
- 使用Grid布局实现响应式
- 优雅的hover效果
- 清晰的状态反馈
4. 使用方式
<template>
<cut-nine-panel @upload-complete="handleUploadComplete" />
</template>
<script lang="ts" setup>
import CutNinePanel from './components/CutNinePanel.vue';
import type { MaterialItem } from './types';
const handleUploadComplete = (materials: MaterialItem[]) => {
console.log('上传完成的素材:', materials);
};
</script>
六、总结
useUpload Hook提供了一个完整的文件上传解决方案,它不仅封装了基础的上传功能,还提供了丰富的扩展性。通过合理的配置和扩展,可以满足各种复杂的业务场景需求。
建议在实际使用中根据具体需求选择合适的配置项,并结合业务场景进行必要的扩展。例如:
- 图片上传时添加压缩、裁剪功能
- 视频上传时自动生成封面图
- 大文件上传时启用分片上传
- 特殊文件类型的预览处理
通过这些扩展,可以打造出更加强大和易用的文件上传功能。
完整代码
import { ref, reactive, computed, watch, nextTick, Ref } from 'vue';
import { message } from 'ant-design-vue';
import { v4 as uuidv4 } from 'uuid';
import type { UploadProps } from 'ant-design-vue/es/upload';
import { uploadFiles } from '@/api/common';
// 文件对象接口定义
export interface UploadFile {
// 客户端属性(上传前)
uid: string; // 客户端唯一标识
file: File | null; // 原始文件对象,上传成功后可释放
fileName: string; // 本地文件名
clientFileType: string; // 客户端文件类型
fileSize: number; // 文件大小(字节)
width?: number; // 宽度(可选)
height?: number; // 高度(可选)
abortCheckpoint?: number; // 保留字段(可选)
uploadStatus: 'waiting' | 'uploading' | 'error' | 'success'; // 上传状态
// 服务端属性(上传后更新)
serverId?: number; // 服务端文件ID
serverUuid?: string; // 服务端UUID
fileUrl?: string; // 文件URL
coverUrl?: string | null; // 封面URL
serverFileName?: string; // 服务端文件名
serverFileType?: string; // 服务端文件类型
md5?: string; // 文件MD5
mediaEncrypted?: boolean; // 是否加密
warnMsg?: string | null; // 警告信息
// 控制属性
response?: any; // 原始上传响应
error?: Error; // 错误信息
percent?: number; // 上传进度百分比
}
// Hooks配置项接口
export interface UseUploadOptions {
// 上传接口函数,默认使用 uploadNewFiles
uploadApi?: (data: FormData) => Promise<any>;
// 最大并发数
maxConcurrent?: number;
// 自动上传
autoUpload?: boolean;
// 自定义参数
params?: Record<string, any>;
// 上传前的处理函数
beforeUpload?: (file: File) => boolean | Promise<boolean | File>;
// 成功回调
onSuccess?: (file: UploadFile) => void;
// 失败回调
onError?: (file: UploadFile, error: Error) => void;
// 进度回调
onProgress?: (file: UploadFile, percent: number) => void;
// 其他 a-upload 组件支持的配置
uploadProps?: Partial<UploadProps>;
}
// Hooks返回值接口
export interface UseUploadReturn {
// 文件列表 - 包含完整文件信息
fileList: UploadFile[];
// 上传状态
uploading: Ref<boolean>;
// 上传文件方法
upload: (files: File[]) => void;
// 重试上传方法 - 接收文件对象或UID
retry: (fileOrUid: UploadFile | string) => void;
// 重试所有失败文件
retryAll: () => number;
// 删除文件
remove: (uid: string) => void;
// 清空文件列表
clear: () => void;
// 获取上传成功的文件
getSuccessFiles: () => UploadFile[];
// 获取上传失败的文件
getErrorFiles: () => UploadFile[];
// 获取当前正在上传的文件数量
currentUploading: Ref<number>;
// 获取等待上传的文件队列
uploadQueue: string[];
// a-upload 所需的属性和方法
uploadProps: Partial<UploadProps>;
}
/**
* 文件上传Hooks
* @param options 上传配置选项
* @returns UseUploadReturn
*/
export default function useUpload(options: UseUploadOptions = {}): UseUploadReturn {
// 解构配置选项,设置默认值
const {
uploadApi = uploadNewFiles,
maxConcurrent = 3,
autoUpload = true,
params = {},
beforeUpload,
onSuccess,
onError,
onProgress,
uploadProps = {}
} = options;
// ============= 状态管理模块 =============
// 文件列表状态
const fileList = reactive<UploadFile[]>([]);
// 上传中状态
const uploading = ref(false);
// 当前正在上传的文件数量
const currentUploading = ref(0);
// 等待上传的文件队列
const uploadQueue = reactive<string[]>([]);
/**
* 创建文件对象
* @param file 原始File对象
* @returns UploadFile对象
*/
const createFileObject = async (file: File): Promise<UploadFile> => {
const fileObj: UploadFile = {
uid: uuidv4(),
file,
fileName: file.name,
clientFileType: file.type,
fileSize: file.size,
uploadStatus: 'waiting',
percent: 0
};
// 如果是图片文件,使用createImageBitmap获取尺寸
if (file.type.startsWith('image/')) {
try {
const imageBitmap = await createImageBitmap(file);
fileObj.width = imageBitmap.width;
fileObj.height = imageBitmap.height;
imageBitmap.close(); // 释放资源
} catch (error) {
console.warn(`无法获取图片 ${file.name} 的尺寸信息:`, error);
}
}
return fileObj;
};
/**
* 获取成功上传的文件
* @returns 成功上传的文件列表
*/
const getSuccessFiles = (): UploadFile[] => {
return fileList.filter(file => file.uploadStatus === 'success');
};
/**
* 获取上传失败的文件
* @returns 上传失败的文件列表
*/
const getErrorFiles = (): UploadFile[] => {
return fileList.filter(file => file.uploadStatus === 'error');
};
/**
* 根据uid查找文件
* @param uid 文件唯一标识
* @returns 文件对象或undefined
*/
const findFile = (uid: string): UploadFile | undefined => {
return fileList.find(file => file.uid === uid);
};
/**
* 更新文件状态
* @param uid 文件唯一标识
* @param status 新状态
*/
const updateFileStatus = (uid: string, status: UploadFile['uploadStatus']): void => {
const file = findFile(uid);
if (file) {
file.uploadStatus = status;
}
};
/**
* 更新文件上传进度
* @param uid 文件唯一标识
* @param percent 进度百分比
*/
const updateFileProgress = (uid: string, percent: number): void => {
const file = findFile(uid);
if (file) {
file.percent = percent;
// 调用进度回调
onProgress?.(file, percent);
}
};
/**
* 更新文件服务端信息
* @param uid 文件唯一标识
* @param serverData 服务端返回的数据
*/
const updateFileServerData = (uid: string, serverData: any): void => {
const file = findFile(uid);
if (!file || !serverData) return;
// 获取真正的数据对象,处理嵌套的响应结构
const data = serverData.data || serverData;
// 更新服务端属性
file.serverId = data.id;
file.serverUuid = data.uuid;
file.fileUrl = data.url || data.fileUrl;
file.coverUrl = data.coverUrl;
file.serverFileName = data.fileName || data.name;
file.serverFileType = data.fileType || data.type;
file.md5 = data.md5;
file.mediaEncrypted = data.mediaEncrypted;
file.warnMsg = data.warnMsg;
file.response = serverData; // 保留整个响应对象
// 只有当上传成功时才清除原始文件引用,错误状态下保留原始文件以支持重试
if (file.uploadStatus === 'success') {
file.file = null as any;
}
};
/**
* 添加文件到列表
* @param files 文件数组
* @returns 添加的文件对象数组
*/
const addFiles = async (files: File[]): Promise<UploadFile[]> => {
const newFiles: UploadFile[] = [];
for (const file of files) {
// 检查是否已存在相同文件名的文件(可选的重复检查)
const isDuplicate = fileList.some(existingFile =>
existingFile.fileName === file.name &&
existingFile.fileSize === file.size
);
if (isDuplicate) {
message.warning(`文件 ${file.name} 已存在`);
continue;
}
const fileObj = await createFileObject(file);
fileList.push(fileObj);
newFiles.push(fileObj);
// 如果是自动上传,将文件加入上传队列
if (autoUpload) {
uploadQueue.push(fileObj.uid);
}
}
return newFiles;
};
/**
* 移除文件
* @param uid 文件唯一标识
*/
const remove = (uid: string): void => {
const index = fileList.findIndex(file => file.uid === uid);
if (index !== -1) {
// 清除原始File对象引用
fileList[index].file = null;
fileList.splice(index, 1);
}
// 如果在上传队列中,也一并移除
const queueIndex = uploadQueue.indexOf(uid);
if (queueIndex !== -1) {
uploadQueue.splice(queueIndex, 1);
}
};
/**
* 清空文件列表
*/
const clear = (): void => {
// 清除所有文件的原始File对象引用
fileList.forEach(file => {
file.file = null;
});
fileList.splice(0, fileList.length);
uploadQueue.splice(0, uploadQueue.length);
currentUploading.value = 0;
uploading.value = false;
};
// ============= 上传控制模块 =============
/**
* 上传单个文件
* @param uid 文件唯一标识
*/
const uploadSingleFile = async (uid: string): Promise<void> => {
const file = findFile(uid);
if (!file) {
console.warn(`找不到UID为${uid}的文件,可能已被删除`);
checkQueue();
return;
}
// 如果文件已上传成功或已被取消,跳过处理
if (file.uploadStatus === 'success') {
checkQueue();
return;
}
// 保存原始文件引用,以备错误处理时使用
const originalFile = file.file;
// 更新文件状态为上传中
updateFileStatus(uid, 'uploading');
currentUploading.value += 1;
uploading.value = true;
try {
// 创建FormData对象
const formData = new FormData();
// 确保file不为null
if (!file.file) {
throw new Error('文件对象丢失');
}
formData.append('file', file.file);
// 添加自定义参数
Object.keys(params).forEach(key => {
formData.append(key, params[key]);
});
// 调用上传API
const response = await uploadApi(formData);
// 检查业务逻辑状态码,如果不是'0000'则表示业务逻辑错误
if (response.code !== '0000') {
throw new Error(response.message || response.msg || '业务处理失败');
}
// 上传完成后,再次检查文件是否存在(可能在上传过程中被删除)
const updatedFile = findFile(uid);
if (!updatedFile) {
console.warn(`文件${uid}已在上传过程中被删除`);
return;
}
// 更新文件状态和服务端数据
updateFileStatus(uid, 'success');
updateFileProgress(uid, 100);
updateFileServerData(uid, response);
// 调用成功回调
if (onSuccess && updatedFile) {
onSuccess(updatedFile);
}
} catch (error) {
// 再次检查文件是否存在(上传过程中可能被删除)
const errorFile = findFile(uid);
if (!errorFile) {
console.warn(`文件${uid}已在上传过程中被删除`);
return;
}
// 确保错误项保留原始文件引用(用于重试和预览)
if (!errorFile.file && originalFile) {
errorFile.file = originalFile;
}
updateFileStatus(uid, 'error');
errorFile.error = error as Error;
const errorMsg = (error as Error).message || '未知错误';
// 调用错误回调
if (onError) {
onError(errorFile, error as Error);
}
message.error(`${errorFile.fileName} 上传失败: ${errorMsg}`);
} finally {
currentUploading.value = Math.max(0, currentUploading.value - 1);
// 如果没有正在上传的文件且队列为空,设置uploading为false
if (currentUploading.value === 0 && uploadQueue.length === 0) {
uploading.value = false;
}
// 检查队列,继续上传
checkQueue();
}
};
/**
* 检查队列,开始上传下一个文件
*/
const checkQueue = (): void => {
// 如果没有等待上传的文件,或者当前上传数量已达到最大并发数,不处理
if (uploadQueue.length === 0 || currentUploading.value >= maxConcurrent) {
return;
}
// 计算可以同时开始的上传数量
const availableSlots = Math.max(0, maxConcurrent - currentUploading.value);
const filesToUpload = Math.min(availableSlots, uploadQueue.length);
// 获取要上传的文件UID
const uidsToUpload = uploadQueue.splice(0, filesToUpload);
// 对每个文件启动上传
uidsToUpload.forEach(uid => {
// 使用nextTick避免可能的递归调用堆栈溢出
nextTick(() => uploadSingleFile(uid));
});
};
/**
* 启动上传队列处理
*/
const startUpload = (): void => {
// 如果没有等待上传的文件,不处理
if (uploadQueue.length === 0) {
return;
}
// 计算可以同时上传的文件数量
const availableSlots = Math.max(0, maxConcurrent - currentUploading.value);
// 只启动可用的并发槽数量的上传
for (let i = 0; i < availableSlots && uploadQueue.length > 0; i++) {
const uid = uploadQueue[0];
uploadQueue.shift(); // 从队列中移除
uploadSingleFile(uid);
}
};
// 监听上传队列变化,自动开始上传 - 优化监听逻辑避免递归触发
watch(uploadQueue, (newQueue) => {
// 只有当有文件在队列中且当前没有达到最大并发数时才启动上传
if (newQueue.length > 0 && currentUploading.value < maxConcurrent) {
// 使用 nextTick 延迟执行,避免递归触发
nextTick(startUpload);
}
});
// ============= 错误处理与重试模块 =============
/**
* 重试上传单个文件
* @param fileOrUid 文件对象或UID
*/
const retry = (fileOrUid: UploadFile | string): void => {
const uid = typeof fileOrUid === 'string' ? fileOrUid : fileOrUid.uid;
const file = findFile(uid);
if (!file) {
console.error(`找不到UID为${uid}的文件`);
return;
}
// 只允许重试错误状态的文件
if (file.uploadStatus !== 'error') {
console.warn(`文件${file.fileName}当前不是错误状态,无法重试`);
return;
}
// 重置文件状态
file.error = undefined;
file.percent = 0;
file.uploadStatus = 'waiting';
// 确保文件引用有效
if (!file.file) {
message.error(`文件${file.fileName}的原始数据已释放,无法重试上传`);
file.uploadStatus = 'error';
file.error = new Error('文件原始数据已释放');
return;
}
// 加入上传队列
if (!uploadQueue.includes(uid)) {
uploadQueue.push(uid);
// 如果当前没有正在上传的文件,启动上传
if (currentUploading.value === 0) {
nextTick(checkQueue);
}
}
};
/**
* 重试所有失败的文件
* @returns 重试的文件数量
*/
const retryAll = (): number => {
const errorFiles = getErrorFiles();
// 过滤出可以重试的文件(文件引用未被释放)
const retriableFiles = errorFiles.filter(file => !!file.file);
if (retriableFiles.length === 0) {
if (errorFiles.length > 0) {
message.warning('所有失败文件的原始数据已释放,无法重试');
}
return 0;
}
// 重试每个文件
retriableFiles.forEach(file => {
retry(file);
});
return retriableFiles.length;
};
/**
* 上传文件
* @param files 文件数组
*/
const upload = async (files: File[]): Promise<void> => {
if (!files || files.length === 0) {
return;
}
// 添加文件到列表
const newFiles = await addFiles(files);
// 如果不是自动上传,需要手动触发
if (!autoUpload && newFiles.length > 0) {
// 使用 nextTick 延迟处理,避免频繁触发监听器
nextTick(() => {
// 将新添加的文件加入上传队列
newFiles.forEach(file => {
if (!uploadQueue.includes(file.uid)) {
uploadQueue.push(file.uid);
}
});
});
}
};
// ============= 与Ant Design Vue的a-upload组件集成 =============
/**
* 处理a-upload的beforeUpload事件
*/
const handleBeforeUpload = async (file: File): Promise<boolean> => {
// 如果设置了自定义的beforeUpload钩子,先执行验证
if (beforeUpload) {
try {
const result = await beforeUpload(file);
// 如果返回false,表示验证失败,不添加文件
if (result === false) {
return false;
}
// 如果返回新的File对象,使用新文件
if (result instanceof File) {
upload([result]);
return false; // 阻止默认上传行为
}
} catch (error) {
message.error(`文件校验失败: ${(error as Error).message}`);
return false;
}
}
// 验证通过,添加文件
upload([file]);
// 返回false,阻止组件默认上传行为,由我们接管上传过程
return false;
};
/**
* 处理a-upload的remove事件
*/
const handleRemove = (file: any): void => {
// ant-design-vue的Upload组件传入的file对象结构与我们的不同
// 需要通过uid找到对应的文件
const uid = file.uid;
remove(uid);
};
/**
* 为a-upload组件准备的配置项
*/
const antUploadProps = computed<Partial<UploadProps>>(() => {
return {
...uploadProps,
fileList: fileList.map(file => {
// 转换上传状态为ant design格式
let status: 'error' | 'success' | 'uploading' | 'done' | undefined;
switch (file.uploadStatus) {
case 'error':
status = 'error';
break;
case 'success':
status = 'done';
break;
case 'uploading':
case 'waiting':
status = 'uploading';
break;
}
return {
uid: file.uid,
name: file.fileName,
status,
percent: file.percent,
url: file.fileUrl || undefined,
thumbUrl: file.coverUrl || undefined,
response: file.response,
// 增加类型和大小信息,有助于预览
type: file.clientFileType,
size: file.fileSize,
};
}),
beforeUpload: handleBeforeUpload,
customRequest: () => {}, // 使用自定义上传,不执行默认的上传行为
onRemove: handleRemove,
// 处理预览事件
onPreview: uploadProps.onPreview,
};
});
return {
fileList,
uploading,
upload,
retry,
retryAll,
remove,
clear,
getSuccessFiles,
getErrorFiles,
currentUploading,
uploadQueue,
uploadProps: antUploadProps.value
};
}