ChunkUploadManager 是一个用于实现大文件分片上传的 TypeScript 类,它提供了完整的文件分片上传、断点续传、并发控制等功能。
核心功能
1. 文件分片处理
- 自动计算分片大小:根据文件大小自动计算合理的分片大小,确保分片数量不超过100个
- 创建文件分片:将大文件切割成多个小分片(Blob对象)
- 分片信息管理:记录每个分片的索引、大小、上传状态等信息
2. MD5校验计算
- 文件级MD5:计算整个文件的MD5值,用于服务器端完整性校验
- 分片级MD5:为每个分片计算独立的MD5值
- Web Worker支持:使用Web Worker进行并发计算,避免阻塞主线程
- 降级处理:当Web Worker不可用时,自动降级到主线程计算
3. 上传控制
- 并发控制:通过信号量(Semaphore)限制同时上传的分片数量
- 自动重试:上传失败时自动重试,最多重试3次
- 进度跟踪:实时跟踪每个分片的上传进度
- 暂停/恢复:支持上传任务的暂停和恢复
4. 断点续传
- 服务器状态检查:从服务器获取已上传分片信息
- 自动续传:只上传未完成的分片
- 完整性验证:确保续传的文件与原始文件一致
5. 分片合并
- 自动合并:当所有分片上传完成后,自动触发合并请求
- 状态检查:确保合并时任务处于正确状态
- 错误处理:合并失败时回滚状态
性能优化
- Web Worker并行计算:MD5计算在Worker线程中执行,不阻塞UI
- 并发控制:限制同时上传的分片数量,避免浏览器资源耗尽
- 内存优化:及时清理已完成的任务和控制器
- 错误隔离:单个分片失败不影响其他分片上传
断点续传主要方法(部分代码使用ai优化)
import { getAccessToken } from '@/utils/auth'
import { FileDetailApi } from '@/api/infra/fileDetail'
interface ChunkInfo {
index: number
start: number
end: number
size: number
blob: Blob
md5?: string
uploaded: boolean
uploading: boolean
progress: number
retryCount: number
}
interface FileUploadTask {
id: string
file: File
fileName: string
fileSize: number
fileMD5?: string
chunks: ChunkInfo[]
uploadedChunks: number
totalChunks: number
overallProgress: number
status: 'pending' | 'calculating' | 'uploading' | 'paused' | 'completed' | 'error'
dirId: string
uploadId?: string // 服务器返回的上传ID,用于断点续传
fileLabelList?: string[]
export class ChunkUploadManager {
private static instance: ChunkUploadManager
private tasks = new Map<string, FileUploadTask>()
private worker: Worker | null = null
private activeRequests = new Map<string, AbortController[]>()
private readonly MIN_CHUNK_SIZE = 5 * 1024 * 1024
private readonly MAX_CONCURRENT = 3
private readonly MAX_RETRY = 3
/**
* 计算切片大小
*/
private calculateChunkSize (fileSize: number): number {
// 默认切片大小为5MB
const defaultChunkSize = this.MIN_CHUNK_SIZE
// 最大切片数量不超过100个
const maxChunks = 100
// 动态计算切片大小,确保切片数量不超过maxChunks
const dynamicChunkSize = Math.ceil(fileSize / maxChunks)
// 取默认切片大小和动态计算大小的较大值
return Math.max(defaultChunkSize, dynamicChunkSize)
}
static getInstance (): ChunkUploadManager {
if (!ChunkUploadManager.instance) {
ChunkUploadManager.instance = new ChunkUploadManager()
}
return ChunkUploadManager.instance
}
constructor() {
this.initWorker()
}
private initWorker () {
try {
// 使用正确的 worker 文件路径
const workerUrl = new URL('@/utils/md5Worker.ts', import.meta.url).href;
this.worker = new Worker(workerUrl, {
type: 'module' // 统一使用 module 类型
});
this.worker.onmessage = this.handleWorkerMessage.bind(this);
this.worker.onerror = (error) => {
console.error('MD5 Worker 初始化错误:', {
message: error.message || '未知错误',
filename: error.filename || workerUrl,
lineno: error.lineno || '未知行号',
colno: error.colno || '未知列号',
error: error.error || '无错误对象'
});
this.worker = null;
// 降级到主线程计算
console.warn('MD5计算将降级到主线程执行');
};
} catch (error) {
console.error('Web Worker 初始化异常:', error);
this.worker = null;
}
}
private handleWorkerMessage (e: MessageEvent) {
const { type, md5, chunkIndex, taskId, progress, error } = e.data
const task = this.tasks.get(taskId)
if (!task) return
switch (type) {
case 'md5Result':
task.fileMD5 = md5
this.onFileMD5Calculated(task)
break
case 'chunkMd5Result':
if (typeof chunkIndex === 'number') {
task.chunks[chunkIndex].md5 = md5
this.checkAllChunksMD5Ready(task)
}
break
case 'progress':
// MD5计算进度回调
this.onProgressUpdate?.(taskId, progress, 'calculating')
break
case 'error':
task.status = 'error'
this.onError?.(taskId, error)
break
}
}
// 回调函数类型定义
onProgressUpdate?: (taskId: string, progress: number, stage: 'calculating' | 'uploading') => void
onStatusChange?: (taskId: string, status: FileUploadTask['status']) => void
onError?: (taskId: string, error: string) => void
onComplete?: (taskId: string) => void
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
private generateTaskId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2)
}
/**
* 添加上传任务
*/
async addUploadTask(file: File, dirId: string, fileLabelList?: string[]): Promise<string> {
const taskId = this.generateTaskId()
const task: FileUploadTask = {
id: taskId,
file,
fileName: file.name,
fileSize: file.size,
chunks: [],
uploadedChunks: 0,
totalChunks: 0,
overallProgress: 0,
status: 'pending',
dirId,
fileLabelList
}
this.tasks.set(taskId, task)
// 开始处理任务
this.processTask(task)
return taskId
}
private updateOverallProgress(task: FileUploadTask) {
const uploadedSize = task.chunks.reduce((sum, chunk) => {
return sum + (chunk.uploaded ? chunk.size : 0)
}, 0)
task.overallProgress = Math.round((uploadedSize / task.fileSize) * 100)
this.onProgressUpdate?.(task.id, task.overallProgress, 'uploading')
}
/**
* 获取上传任务
* @param taskId 任务ID
* @returns 任务信息或undefined
*/
getTask(taskId: string): FileUploadTask | undefined {
return this.tasks.get(taskId)
}
/**
* 处理上传任务
*/
private async processTask(task: FileUploadTask) {
try {
// 1. 先进行切片上传初始化,获取 uploadId
task.status = 'pending'
this.onStatusChange?.(task.id, task.status)
const initResult = await FileDetailApi.uploadFilePartInit({
dirId: task.dirId,
originalFilename: task.fileName,
fileTotalSize: task.fileSize,
fileLabel: task.fileLabelList?.toString()
})
if (initResult) {
task.uploadId = initResult
console.log('获取到 uploadId:', task.uploadId)
} else {
throw new Error('切片上传初始化失败,未获取到 uploadId')
}
// 2. 创建切片
task.status = 'calculating'
this.onStatusChange?.(task.id, task.status)
this.createChunks(task)
// 3. 计算文件MD5(用于服务器验证完整性)
await this.calculateFileMD5(task)
// 4. 计算每个切片的MD5(并发计算)
await this.calculateChunksMD5(task)
// 5. 检查断点续传
await this.checkResumeUpload(task)
// 6. 开始上传
task.status = 'uploading'
this.onStatusChange?.(task.id, task.status)
await this.uploadChunks(task)
// 7. 合并文件
await this.mergeChunks(task)
// 8. 完成 (这里统一处理完成状态)
if (task.status === 'completed') {
console.log('文件上传完成:', {
fileName: task.fileName,
fileSize: task.fileSize,
uploadType: task.uploadType
})
this.onComplete?.(task.id)
}
} catch (error) {
if (error.message === 'PAUSED_ERROR' || this.isTaskPaused(task.id)) {
console.log(`任务 ${task.id} 已暂停,保持暂停状态`)
task.status = 'paused'
this.onStatusChange?.(task.id, task.status)
return
}
// 其他错误才标记为失败
task.status = 'error'
this.onStatusChange?.(task.id, task.status)
this.onError?.(task.id, error instanceof Error ? error.message : 'Unknown error')
}
}
/**
* 处理断点续传任务
*/
private async processResumeTask (task: FileUploadTask, uploadedParts: any[]) {
try {
// 1. 创建切片
task.status = 'calculating'
this.onStatusChange?.(task.id, task.status)
this.createChunks(task)
// 2. 标记已上传的切片
this.markUploadedChunks(task, uploadedParts)
// 3. 计算文件MD5(用于服务器验证完整性)
await this.calculateFileMD5(task)
// 4. 计算未上传切片的MD5
await this.calculateRemainingChunksMD5(task)
// 5. 开始上传剩余切片
task.status = 'uploading'
this.onStatusChange?.(task.id, task.status)
await this.uploadChunks(task)
// 6. 合并文件
await this.mergeChunks(task)
// 7. 完成
task.status = 'completed'
this.onStatusChange?.(task.id, task.status)
this.onComplete?.(task.id)
} catch (error) {
task.status = 'error'
this.onStatusChange?.(task.id, task.status)
this.onError?.(task.id, error instanceof Error ? error.message : 'Unknown error')
}
}
/**
* 标记已上传的切片
*/
private markUploadedChunks (task: FileUploadTask, uploadedParts: any[]) {
uploadedParts.forEach((part: any) => {
const chunkIndex = part.partNumber - 1 // 后端partNumber从1开始,前端index从0开始
if (task.chunks[chunkIndex]) {
task.chunks[chunkIndex].uploaded = true
task.uploadedChunks++
}
})
this.updateOverallProgress(task)
console.log(`断点续传: ${task.uploadedChunks}/${task.totalChunks} 切片已上传`)
}
/**
* 计算剩余未上传切片的MD5
*/
private async calculateRemainingChunksMD5 (task: FileUploadTask): Promise<void> {
const remainingChunks = task.chunks.filter(chunk => !chunk.uploaded)
if (remainingChunks.length === 0) {
console.log('所有切片已上传,无需计算MD5')
return
}
if (this.worker) {
// 使用 Web Worker 并发计算剩余切片MD5
await new Promise<void>((resolve, reject) => {
let completedCount = 0
const totalRemainingChunks = remainingChunks.length
// 临时保存原有的消息处理器
const originalHandler = this.worker!.onmessage
// 设置临时消息处理器
this.worker!.onmessage = (e: MessageEvent) => {
const { type, md5, chunkIndex, taskId, error } = e.data
// 处理原有的消息
if (originalHandler) {
originalHandler.call(this.worker, e)
}
// 处理切片MD5计算结果
if (type === 'chunkMd5Result' && taskId === task.id) {
if (error) {
reject(new Error(`切片 ${chunkIndex + 1} MD5计算失败: ${error}`))
return
}
if (typeof chunkIndex === 'number' && task.chunks[chunkIndex]) {
task.chunks[chunkIndex].md5 = md5
completedCount++
console.log(`剩余切片 ${chunkIndex + 1} MD5计算完成: ${md5}`)
// 更新进度
const progress = Math.round((completedCount / totalRemainingChunks) * 100)
this.onProgressUpdate?.(taskId, progress, 'calculating')
// 所有剩余切片MD5计算完成
if (completedCount === totalRemainingChunks) {
console.log('所有剩余切片MD5计算完成')
resolve()
}
}
}
}
// 发送剩余切片MD5计算请求
remainingChunks.forEach((chunk) => {
this.worker!.postMessage({
type: 'calculateChunkMD5',
chunk: chunk.blob,
chunkIndex: chunk.index,
taskId: task.id
})
})
// 设置超时处理
setTimeout(() => {
reject(new Error('剩余切片MD5计算超时'))
}, 30000) // 30秒超时
})
} else {
// 降级到主线程计算
console.log(`使用主线程计算剩余 ${remainingChunks.length} 个切片的MD5...`)
for (let i = 0; i < remainingChunks.length; i++) {
const chunk = remainingChunks[i]
chunk.md5 = await this.calculateChunkMD5MainThread(chunk.blob)
console.log(`剩余切片 ${chunk.index + 1} MD5计算完成: ${chunk.md5}`)
// 更新进度
const progress = Math.round(((i + 1) / remainingChunks.length) * 100)
this.onProgressUpdate?.(task.id, progress, 'calculating')
}
this.checkAllChunksMD5Ready(task)
}
}
/**
* 创建文件切片
*/
private createChunks (task: FileUploadTask) {
const { file } = task
const chunkSize = this.calculateChunkSize(file.size)
const chunks: ChunkInfo[] = []
for (let i = 0; i < file.size; i += chunkSize) {
const start = i
const end = Math.min(i + chunkSize, file.size)
const size = end - start
const blob = file.slice(start, end)
chunks.push({
index: chunks.length,
start,
end,
size,
blob,
uploaded: false,
uploading: false,
progress: 0,
retryCount: 0
})
}
task.chunks = chunks
task.totalChunks = chunks.length
}
/**
* 计算文件MD5
*/
private async calculateFileMD5 (task: FileUploadTask): Promise<void> {
if (this.worker) {
// 使用 Web Worker 计算
this.worker.postMessage({
type: 'calculateMD5',
file: task.file,
taskId: task.id
})
} else {
// 降级到主线程计算
task.fileMD5 = await this.calculateFileMD5MainThread(task.file)
this.onFileMD5Calculated(task)
}
}
private async calculateFileMD5MainThread (file: File): Promise<string> {
// 主线程MD5计算的降级实现
const SparkMD5 = await import('spark-md5')
const spark = new SparkMD5.default.ArrayBuffer()
const chunkSize = 2 * 1024 * 1024
for (let i = 0; i < file.size; i += chunkSize) {
const chunk = file.slice(i, Math.min(i + chunkSize, file.size))
const buffer = await chunk.arrayBuffer()
spark.append(buffer)
}
return spark.end()
}
private onFileMD5Calculated (task: FileUploadTask) {
console.log(`File MD5 calculated: ${task.fileMD5}`)
}
/**
* 计算所有切片的MD5
*/
private async calculateChunksMD5 (task: FileUploadTask): Promise<void> {
if (this.worker) {
// 使用 Web Worker 并发计算切片MD5
await new Promise<void>((resolve, reject) => {
let completedCount = 0
const totalChunks = task.chunks.length
// 临时保存原有的消息处理器
const originalHandler = this.worker!.onmessage
// 设置临时消息处理器
this.worker!.onmessage = (e: MessageEvent) => {
const { type, md5, chunkIndex, taskId, error } = e.data
// 处理原有的消息
if (originalHandler) {
originalHandler.call(this.worker, e)
}
// 处理切片MD5计算结果
if (type === 'chunkMd5Result' && taskId === task.id) {
if (error) {
reject(new Error(`切片 ${chunkIndex + 1} MD5计算失败: ${error}`))
return
}
if (typeof chunkIndex === 'number' && task.chunks[chunkIndex]) {
task.chunks[chunkIndex].md5 = md5
completedCount++
console.log(`切片 ${chunkIndex + 1} MD5计算完成: ${md5}`)
// 更新进度
const progress = Math.round((completedCount / totalChunks) * 100)
this.onProgressUpdate?.(taskId, progress, 'calculating')
// 所有切片MD5计算完成
if (completedCount === totalChunks) {
console.log('所有切片MD5计算完成')
resolve()
}
}
}
}
// 发送所有切片MD5计算请求
task.chunks.forEach((chunk, index) => {
this.worker!.postMessage({
type: 'calculateChunkMD5',
chunk: chunk.blob,
chunkIndex: index,
taskId: task.id
})
})
// 设置超时处理
setTimeout(() => {
reject(new Error('切片MD5计算超时'))
}, 30000) // 30秒超时
})
} else {
// 降级到主线程计算
console.log('使用主线程计算切片MD5...')
for (let i = 0; i < task.chunks.length; i++) {
const chunk = task.chunks[i]
chunk.md5 = await this.calculateChunkMD5MainThread(chunk.blob)
console.log(`切片 ${i + 1} MD5计算完成: ${chunk.md5}`)
// 更新进度
const progress = Math.round(((i + 1) / task.chunks.length) * 100)
this.onProgressUpdate?.(task.id, progress, 'calculating')
}
this.checkAllChunksMD5Ready(task)
}
}
private async calculateChunkMD5MainThread (blob: Blob): Promise<string> {
const SparkMD5 = await import('spark-md5')
const spark = new SparkMD5.default.ArrayBuffer()
const buffer = await blob.arrayBuffer()
spark.append(buffer)
return spark.end()
}
private checkAllChunksMD5Ready (task: FileUploadTask) {
const allReady = task.chunks.every(chunk => chunk.md5)
if (allReady) {
console.log('All chunks MD5 calculated')
}
}
/**
* 检查断点续传
*/
private async checkResumeUpload (task: FileUploadTask): Promise<void> {
// 首次上传不需要检查,直接返回
if (!task.uploadId) {
console.log('首次上传,跳过断点续传检查')
return
}
try {
const result = await FileDetailApi.checkUpload({
dirId: task.dirId,
uploadId: task.uploadId
})
if (result && result.code === 0 && result.data) {
const uploadedParts = result.data || []
// 标记已上传的切片
uploadedParts.forEach((part: any) => {
const chunkIndex = part.partNumber - 1 // 后端partNumber从1开始,前端index从0开始
if (task.chunks[chunkIndex]) {
task.chunks[chunkIndex].uploaded = true
task.uploadedChunks++
}
})
this.updateOverallProgress(task)
console.log(`断点续传: ${task.uploadedChunks}/${task.totalChunks} 切片已上传`)
}
} catch (error) {
console.warn('检查断点续传失败:', error)
// 如果检查失败,从头开始上传
}
}
/**
* 统一上传切片方法
*/
private async uploadChunks(task: FileUploadTask): Promise<void> {
const pendingChunks = task.chunks.filter(chunk => !chunk.uploaded)
console.log(`uploadChunks: 剩余 ${pendingChunks.length} 个切片待上传`)
if (pendingChunks.length === 0) {
console.log(`没有需要上传的切片`)
return
}
const semaphore = new Semaphore(this.MAX_CONCURRENT)
const abortController = new AbortController()
// 保存控制器以便暂停时可以取消
if (!this.activeRequests.has(task.id)) {
this.activeRequests.set(task.id, [])
}
this.activeRequests.get(task.id)?.push(abortController)
try {
// 如果没有uploadId,先上传第一个切片获取uploadId
if (!task.uploadId && pendingChunks.length > 0) {
console.log('首次上传,先上传第一个切片获取uploadId...')
await this.uploadSingleChunk(task, pendingChunks[0], abortController.signal)
pendingChunks.shift()
}
// 并发上传剩余切片
const uploadPromises = pendingChunks.map(async (chunk) => {
if (task.status === 'error' || task.status === 'paused') {
throw new Error('上传已取消')
}
await semaphore.acquire()
try {
await this.uploadSingleChunk(task, chunk, abortController.signal)
} finally {
semaphore.release()
}
})
// 等待所有上传完成
const results = await Promise.allSettled(uploadPromises)
// 严格检查所有条件
const shouldMerge =
task.status === 'uploading' &&
!abortController.signal.aborted &&
!this.isTaskPaused(task.id) &&
task.chunks.every(chunk => chunk.uploaded) &&
results.every(result => result.status === 'fulfilled') &&
task.status !== 'paused'
console.log(`uploadChunks 检查合并条件:
status=${task.status},
aborted=${abortController.signal.aborted},
isPaused=${this.isTaskPaused(task.id)},
allUploaded=${task.chunks.every(chunk => chunk.uploaded)},
allFulfilled=${results.every(result => result.status === 'fulfilled')},
shouldMerge=${shouldMerge}`)
if (shouldMerge) {
console.log(`准备调用 mergeChunks`)
await this.mergeChunks(task)
return 行
} else {
console.log(`不满足合并条件,更新进度`)
if (task.status === 'uploading') {
this.updateOverallProgress(task)
}
return
}
} finally {
// 清理控制器
const controllers = this.activeRequests.get(task.id)
if (controllers) {
const index = controllers.indexOf(abortController)
if (index > -1) controllers.splice(index, 1)
}
}
}
/**
* 合并切片完成文件上传
*/
private async mergeChunks(task: FileUploadTask): Promise<void> {
console.log(`mergeChunks 开始: 任务 ${task.id} 状态=${task.status}`)
if (task.status === 'paused' || task.status === 'completed' || this.isTaskPaused(task.id)) {
console.log(`任务状态为 ${task.status},取消合并操作`)
return
}
try {
console.log(`调用合并API...`)
const result = await FileDetailApi.mergeChunks({
dirId: task.dirId,
uploadId: task.uploadId,
chunkTotalNum: task.totalChunks,
fileSize: task.fileSize,
});
console.log(`合并API返回`, result)
if (!result ) {
throw new Error(result?.msg || '文件合并失败')
}
console.log(`合并成功`)
task.status = 'completed'
this.onStatusChange?.(task.id, task.status)
} catch (error) {
console.error(`合并失败`, error)
throw error;
}
}
/**
* 暂停上传任务
*/
pauseUpload(taskId: string): void {
const task = this.tasks.get(taskId);
if (!task) return;
// 更新任务状态
task.status = 'paused';
this.onStatusChange?.(taskId, task.status);
// 取消所有进行中的请求
const controllers = this.activeRequests.get(taskId);
if (controllers) {
controllers.forEach(controller => controller.abort());
this.activeRequests.delete(taskId);
}
}
/**
* 恢复上传任务
*/
async resumeUpload(taskId: string) {
const task = this.tasks.get(taskId)
if (!task) {
console.error(`恢复任务 ${taskId} 失败: 任务不存在`, {
existingTasks: Array.from(this.tasks.keys())
})
throw new Error(`任务 ${taskId} 不存在`)
}
if (task.status !== 'paused') {
console.error(`${taskId} 失败: 任务状态不是paused`, {
currentStatus: task.status,
allowedStatus: 'paused'
})
throw new Error(`任务 ${taskId} 状态不是paused,当前状态: ${task.status}`)
}
console.log(`开始恢复任务 ${taskId}, 当前状态: ${task.status}`)
try {
// 1. 检查服务器端已上传的切片
console.log(`检查断点续传...`)
await this.checkResumeUpload(task)
// 2. 获取未上传的切片
const pendingChunks = task.chunks.filter(chunk => !chunk.uploaded)
console.log(`剩余未上传切片数量: ${pendingChunks.length}`)
if (pendingChunks.length > 0) {
// 3. 开始上传剩余切片
task.status = 'uploading'
this.onStatusChange?.(taskId, task.status)
console.log(`开始上传剩余切片...`)
await this.uploadChunks(task)
}
// 4. 严格检查合并条件
const canMerge = task.status === 'uploading' &&
!this.isTaskPaused(taskId) &&
task.chunks.every(chunk => chunk.uploaded)
console.log(`检查合并条件:
status=${task.status},
isPaused=${this.isTaskPaused(taskId)},
allUploaded=${task.chunks.every(chunk => chunk.uploaded)},
canMerge=${canMerge}`)
if (canMerge) {
console.log(`[DEBUG] 满足合并条件,开始合并...`)
await this.mergeChunks(task)
// 5. 只有在成功合并后才标记为完成
if (task.status === 'uploading' &&
!this.isTaskPaused(taskId) &&
task.chunks.every(chunk => chunk.uploaded)) {
console.log(`合并成功,标记任务为完成`)
task.status = 'completed'
this.onStatusChange?.(taskId, task.status)
this.onComplete?.(taskId)
}
} else {
console.log(`不满足合并条件,跳过合并`)
// 确保不会错误标记为失败
if (task.status === 'uploading') {
this.updateOverallProgress(task)
}
return
}
}catch (error) {
// 特别处理暂停导致的错误
if (error.message === 'PAUSED_ERROR' || this.isTaskPaused(taskId)) {
console.log(`[DEBUG] 任务 ${taskId} 已暂停,保持暂停状态`)
task.status = 'paused'
this.onStatusChange?.(taskId, task.status)
return
}
// 其他错误才标记为失败
console.error(`[DEBUG] 恢复任务 ${taskId} 出错:`, error)
task.status = 'error'
this.onStatusChange?.(taskId, task.status)
this.onError?.(taskId, error instanceof Error ? error.message : 'Unknown error')
}
}
/**
* 上传单个切片(支持AbortSignal)
*/
private async uploadSingleChunk(task: FileUploadTask, chunk: ChunkInfo, signal?: AbortSignal): Promise<void> {
// 检查任务状态,如果任务已经出错或暂停则不再继续上传
if (task.status === 'error' || task.status === 'paused' || this.isTaskPaused(task.id)) {
return
}
let retryCount = 0
// 确保切片MD5已计算完成
if (!chunk.md5) {
console.warn(`切片 ${chunk.index + 1} MD5未计算完成,等待MD5计算...`)
let waitTime = 0
const maxWaitTime = 30000 // 30秒
while (!chunk.md5 && waitTime < maxWaitTime) {
if (task.status === 'paused' || task.status === 'error') {
return
}
await this.delay(100)
waitTime += 100
}
if (!chunk.md5) {
throw new Error(`切片 ${chunk.index + 1} MD5计算超时,无法上传`)
}
}
while (retryCount <= this.MAX_RETRY) {
try {
// 再次检查任务状态
if (task.status === 'error' || task.status === 'paused') {
return
}
chunk.uploading = true
const formData = new FormData()
formData.append('chunkData', chunk.blob)
formData.append('chunkNumber', (chunk.index + 1).toString())
formData.append('chunkMD5', chunk.md5!)
formData.append('dirId', task.dirId)
formData.append('originalFilename', task.fileName)
formData.append('fileTotalSize', task.fileSize.toString())
formData.append('fileLabel', task.fileLabelList?.join(',') || '')
if (task.uploadId) {
formData.append('uploadId', task.uploadId)
} else {
throw new Error(`切片 ${chunk.index + 1} 缺少 uploadId,无法上传`)
}
const result = await FileDetailApi.uploadFilePart(formData, {
signal,
onUploadProgress: (progressEvent) => {
// 检查是否已暂停
if (task.status === 'paused') {
throw new Error('上传已暂停')
}
chunk.progress = Math.round((progressEvent.loaded / progressEvent.total) * 100)
}
})
if (result && result.code === 0) {
chunk.uploaded = true
chunk.uploading = false
chunk.progress = 100
task.uploadedChunks++
this.updateOverallProgress(task)
return
}
throw new Error(`上传失败: ${result?.msg || '未知错误'}`)
} catch (error) {
if (error.name === 'AbortError' || error.message === '上传已暂停') {
console.log(`切片 ${chunk.index + 1} 上传已取消`)
}
retryCount++
chunk.retryCount = retryCount
if (retryCount > this.MAX_RETRY) {
chunk.uploading = false
task.status = 'error'
this.onStatusChange?.(task.id, task.status)
this.onError?.(task.id, error instanceof Error ? error.message : 'Unknown error')
`)
}
await this.delay(1000 * retryCount)
}
}
}
private isTaskPaused (taskId: string): boolean {
const task = this.tasks.get(taskId);
return task?.status === 'paused';
}
destroy(): void {
// 终止所有进行中的上传请求
this.activeRequests.forEach((controllers, taskId) => {
controllers.forEach(controller => controller.abort())
})
this.activeRequests.clear()
// 终止 Worker
if (this.worker) {
this.worker.terminate()
this.worker = null
}
// 清空任务列表
this.tasks.clear()
}
/**
* 取消上传任务
*/
cancelUpload(taskId: string): void {
const task = this.tasks.get(taskId)
if (!task) return
// 更新任务状态
task.status = 'error'
this.onStatusChange?.(taskId, task.status)
// 取消所有进行中的请求
const controllers = this.activeRequests.get(taskId)
if (controllers) {
controllers.forEach(controller => controller.abort())
this.activeRequests.delete(taskId)
}
// 从任务列表中移除
this.tasks.delete(taskId)
}
}
class Semaphore {
private max: number
private current = 0
private queue: (() => void)[] = []
constructor(max: number) {
this.max = max
}
async acquire(): Promise<void> {
if (this.current < this.max) {
this.current++
return
}
return new Promise(resolve => this.queue.push(resolve))
}
release(): void {
const next = this.queue.shift()
if (next) {
next()
} else {
this.current--
}
}
private updateOverallProgress(task: FileUploadTask) {
const uploadedSize = task.chunks.reduce((sum, chunk) => {
return sum + (chunk.uploaded ? chunk.size : 0)
}, 0)
task.overallProgress = Math.round((uploadedSize / task.fileSize) * 100)
this.onProgressUpdate?.(task.id, task.overallProgress, 'uploading')
}
/**
* 获取上传任务
* @param taskId 任务ID
* @returns 任务信息或undefined
*/
getTask(taskId: string): FileUploadTask | undefined {
return this.tasks.get(taskId)
}
}
md5 web worker 一定要引入该文件
// MD5 Web Worker - 避免主线程阻塞
import SparkMD5 from 'spark-md5'
interface WorkerMessage {
type: 'calculateMD5' | 'calculateChunkMD5'
file?: File
chunk?: Blob
chunkIndex?: number
taskId: string
}
interface WorkerResponse {
type: 'md5Result' | 'chunkMd5Result' | 'progress' | 'error'
md5?: string
chunkIndex?: number
taskId: string
progress?: number
error?: string
}
self.onmessage = async function(e: MessageEvent<WorkerMessage>) {
const { type, file, chunk, chunkIndex, taskId } = e.data
try {
if (type === 'calculateMD5' && file) {
// 计算整个文件的MD5
const md5 = await calculateFileMD5(file, taskId)
self.postMessage({
type: 'md5Result',
md5,
taskId
} as WorkerResponse)
} else if (type === 'calculateChunkMD5' && chunk) {
// 计算切片的MD5
const md5 = await calculateChunkMD5(chunk)
self.postMessage({
type: 'chunkMd5Result',
md5,
chunkIndex,
taskId
} as WorkerResponse)
}
} catch (error) {
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
taskId
} as WorkerResponse)
}
}
async function calculateFileMD5(file: File, taskId: string): Promise<string> {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
const chunkSize = 5 * 1024 * 1024 // 5MB per chunk for reading
let currentChunk = 0
const chunks = Math.ceil(file.size / chunkSize)
fileReader.onload = function(e) {
if (e.target?.result) {
spark.append(e.target.result as ArrayBuffer)
currentChunk++
// 发送进度
const progress = Math.round((currentChunk / chunks) * 100)
self.postMessage({
type: 'progress',
progress,
taskId
} as WorkerResponse)
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
}
fileReader.onerror = function() {
reject(new Error('文件读取失败'))
}
function loadNext() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
fileReader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
async function calculateChunkMD5(chunk: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = function(e) {
if (e.target?.result) {
spark.append(e.target.result as ArrayBuffer)
resolve(spark.end())
}
}
fileReader.onerror = function() {
reject(new Error('切片读取失败'))
}
fileReader.readAsArrayBuffer(chunk)
})
}
实际使用组件
<template>
<Dialog title="文件上传" v-model="dialogVisible" width="800px">
<!-- 断点续传信息显示区域 -->
<div v-if="props.isResumeMode && resumeFileInfo" class="resume-info-section">
<el-alert title="断点续传模式" type="info" :closable="false" show-icon class="resume-alert">
<template #default>
<div class="resume-details">
<div class="resume-file-info">
<h4>文件信息</h4>
<div class="file-info-grid">
<div class="info-item">
<span class="label">文件名:</span>
<span class="value">{{ resumeFileInfo.fileName || '未知文件' }}</span>
</div>
<div class="info-item">
<span class="label">文件大小:</span>
<span class="value">{{ formatFileSize(resumeFileInfo.fileSize) }}</span>
</div>
<div class="info-item">
<span class="label">已上传切片:</span>
<span class="value success">{{ resumeFileInfo.uploadedParts.length }} 个</span>
</div>
</div>
</div>
<!-- 切片进度可视化 -->
<div class="chunk-visualization">
<h4>切片上传状态</h4>
<div class="chunk-progress-container">
<div class="chunk-stats">
<div class="stat-item uploaded">
<div class="stat-color"></div>
<span>已上传: {{ resumeFileInfo.uploadedParts.length }}</span>
</div>
<div class="stat-item pending">
<div class="stat-color"></div>
<span
>待上传:
{{
Math.max(0, estimatedTotalChunks - resumeFileInfo.uploadedParts.length)
}}</span
>
</div>
<div class="stat-item">
<span>完成度: {{ calculateResumeProgress() }}%</span>
</div>
</div>
<!-- 切片网格 -->
<div class="chunk-grid-container">
<div class="chunk-grid">
<div
v-for="(chunk, index) in getChunkVisualization()"
:key="index"
class="chunk-visual-item"
:class="{
'chunk-uploaded': chunk.uploaded,
'chunk-pending': !chunk.uploaded
}"
:title="`切片 ${index + 1}: ${chunk.uploaded ? '已上传' : '待上传'} ${chunk.uploaded ? '(大小: ' + formatFileSize(chunk.size) + ')' : ''}`"
></div>
</div>
<div v-if="estimatedTotalChunks > 50" class="chunk-more-info">
总共约 {{ estimatedTotalChunks }} 个切片,显示前 50 个
</div>
</div>
</div>
</div>
<!-- 已上传切片详细列表 -->
<el-collapse class="uploaded-chunks-detail" v-model="showUploadedChunks">
<el-collapse-item title="已上传切片详细信息" name="chunks">
<el-table
:data="resumeFileInfo.uploadedParts.slice(0, 20)"
size="small"
style="width: 100%"
max-height="200"
>
<el-table-column prop="partNumber" label="切片号" width="80" align="center" />
<el-table-column label="大小" width="100" align="center">
<template #default="{ row }">
{{ formatFileSize(row.partSize) }}
</template>
</el-table-column>
<!-- <el-table-column prop="etag" label="ETag" show-overflow-tooltip /> -->
<el-table-column label="上传时间" width="150" align="center">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
</el-table>
<div v-if="resumeFileInfo.uploadedParts.length > 20" class="table-more-info">
仅显示前20个切片,总共 {{ resumeFileInfo.uploadedParts.length }} 个已上传切片
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
</el-alert>
<!-- 提示信息 -->
<div class="resume-tips">
<el-icon class="tip-icon"><InfoFilled /></el-icon>
<span>请选择与上次上传失败相同的文件以继续断点续传</span>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-area">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
multiple
:auto-upload="false"
:show-file-list="false"
accept="*/*"
@change="handleFileChange"
>
<el-icon class="el-icon--upload">
<UploadFilled />
</el-icon>
<div class="el-upload__text"> {{ getUploadText() }}<em>点击选择</em> </div>
<template #tip>
<div class="el-upload__tip">
{{ getUploadTipText() }}
</div>
</template>
</el-upload>
<!-- 批量操作按钮 -->
<div class="batch-actions" v-if="uploadTasks.length > 0">
<el-button @click="startAllUploads" :disabled="allUploading" type="primary">
开始全部上传
</el-button>
<!-- <el-button @click="pauseAllUploads" :disabled="!allUploading"> 暂停全部 </el-button> -->
<el-button @click="clearCompleted"> 清除已完成 </el-button>
</div>
</div>
<!-- 文件列表 -->
<div class="file-list" v-if="uploadTasks.length > 0">
<div class="list-header">
<span>文件列表 ({{ uploadTasks.length }})</span>
<div class="header-info">
<span class="upload-stats">
上传中: {{ uploadingCount }} | 已完成: {{ completedCount }} | 失败: {{ errorCount }}
</span>
</div>
</div>
<div class="file-item" v-for="task in uploadTasks" :key="task.id">
<!-- 文件信息 -->
<div class="file-info">
<el-icon class="file-icon">
<Document />
</el-icon>
<div class="file-details">
<div class="file-name">{{ task.fileName }}</div>
<div class="file-meta">
<span class="file-size">{{ formatFileSize(task.fileSize) }}</span>
<span class="upload-type" :class="{ 'chunk-upload': task.uploadType === 'chunk' }">
· {{ task.uploadType === 'chunk' ? '切片上传' : '普通上传' }}
</span>
<span class="chunk-info" v-if="task.totalChunks > 1">
· {{ task.totalChunks }} 个切片
</span>
<span
class="upload-speed"
v-if="task.status === 'uploading' && getUploadSpeed(task.id)"
>
· {{ getUploadSpeed(task.id) }}/s
</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="file-progress">
<div class="progress-info">
<span class="status-text">{{ getStatusText(task.status) }}</span>
<span class="progress-text">{{ task.overallProgress }}%</span>
</div>
<el-progress
:percentage="task.overallProgress"
:status="getProgressStatus(task.status)"
:show-text="false"
class="progress-bar"
/>
<!-- 切片进度详情 -->
<div class="chunk-progress" v-if="showChunkProgress && task.totalChunks > 1">
<div class="chunk-grid">
<div
v-for="(chunk, index) in task.chunks.slice(0, 50)"
:key="index"
class="chunk-item"
:class="{
'chunk-uploaded': chunk.uploaded,
'chunk-uploading': chunk.uploading,
'chunk-error': chunk.retryCount > 0
}"
:title="`切片 ${index + 1}: ${chunk.uploaded ? '已完成' : chunk.uploading ? '上传中' : '等待中'}`"
></div>
<span v-if="task.chunks.length > 50" class="chunk-more">
+{{ task.chunks.length - 50 }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="file-actions">
<el-button
v-if="task.status === 'uploading'"
type="warning"
size="small"
link
@click="pauseUpload(task.id)"
>
暂停
</el-button>
<el-button
v-if="task.status === 'paused'"
type="primary"
size="small"
link
@click="resumeUpload(task.id)"
>
恢复
</el-button>
<el-button
type="danger"
size="small"
link
@click="removeTask(task.id)"
:disabled="task.status === 'uploading'"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 高级设置 -->
<!-- <el-collapse class="advanced-settings">
<el-collapse-item title="高级设置" name="advanced">
<el-form :model="settings" label-width="120px" size="small">
<el-form-item label="切片大小">
<el-select v-model="settings.chunkSize">
<el-option label="5MB" :value="5 * 1024 * 1024" />
<el-option label="10MB" :value="10 * 1024 * 1024" />
</el-select>
</el-form-item>
<el-form-item label="并发数">
<el-input-number v-model="settings.concurrent" :min="1" :max="10" />
</el-form-item>
<el-form-item label="显示切片进度">
<el-switch v-model="showChunkProgress" />
</el-form-item>
</el-form>
</el-collapse-item>
</el-collapse> -->
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
// 定义组件名称
defineOptions({ name: 'FileUpload' })
// import { Document, UploadFilled, InfoFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadFile } from 'element-plus'
import { FileDetailApi } from '@/api/infra/fileDetail'
import { ChunkUploadManager } from '@/utils/chunkUploadManager'
import { formatDate } from '@/utils/formatTime'
import { DownstreamAppApi, DownstreamAppVO } from '@/api/wjgl/downstreamApplicationManagement'
interface FileUploadTask {
id: string
fileName: string
fileSize: number
status: string
overallProgress: number
totalChunks: number
chunks: any[]
uploadType: 'chunk' | 'normal' // 新增:标识上传类型
fileLabel: string
}
const message = useMessage() // 消息弹窗
// 定义文件大小阈值(6MB)
const CHUNK_UPLOAD_THRESHOLD = 10 * 1024 * 1024 // 10MB
const fileLabelList = ref<string[]>([])
const props = defineProps<{
modelValue: boolean
dirId: string
isResumeMode?: boolean
resumeRecordId?: number
resumeFileName?: string
resumeFileSize?: number
}>()
// 新增:计算属性获取显示的文件名
const resumeFileName = computed(() => {
return props.resumeFileName || resumeFileInfo.value?.fileName || '未知文件'
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
// 响应式数据
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const uploadRef = ref()
const uploadTasks = ref<FileUploadTask[]>([])
const showChunkProgress = ref(false)
// 上传管理器实例
let uploadManager: ChunkUploadManager
// 设置
const settings = ref({
chunkSize: 5 * 1024 * 1024, // 5MB
concurrent: 3
})
// 统计信息
const uploadingCount = computed(
() => uploadTasks.value.filter((task) => task.status === 'uploading').length
)
const completedCount = computed(
() => uploadTasks.value.filter((task) => task.status === 'completed').length
)
const errorCount = computed(
() => uploadTasks.value.filter((task) => task.status === 'error').length
)
const allUploading = computed(() => uploadTasks.value.some((task) => task.status === 'uploading'))
const hasErrorTasks = computed(() => uploadTasks.value.some((task) => task.status === 'error'))
// 上传速度跟踪
const uploadSpeeds = ref<Map<string, string>>(new Map())
// 新增:断点续传相关响应式数据
const resumeFileInfo = ref<{
uploadId: string
fileName: string
fileSize: number
uploadedParts: any[]
maxPartNumber?: number
isConsecutive?: boolean
} | null>(null)
// 新增:控制已上传切片详情的显示
const showUploadedChunks = ref<string[]>([])
// 新增:估算总切片数
const estimatedTotalChunks = computed(() => {
if (!resumeFileInfo.value) return 0
const chunkSize = 5 * 1024 * 1024 // 5MB,与实际切片大小保持一致
return Math.ceil(resumeFileInfo.value.fileSize / chunkSize)
})
onMounted(async () => {
initUploadManager()
if (props.isResumeMode && props.resumeRecordId) {
await loadResumeFileInfo()
}
})
// 创建一个唯一的请求ID,用于防止竞态条件
const currentRequestId = ref<string>('')
// 生成请求ID
function generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 监听断点续传相关props的综合变化
watch(
() => ({
isResumeMode: props.isResumeMode, //是否为断点续传模式
resumeRecordId: props.resumeRecordId,
resumeFileName: props.resumeFileName,
resumeFileSize: props.resumeFileSize
}),
async (newProps, oldProps) => {
console.log('断点续传props变化:', {
新值: newProps,
旧值: oldProps
})
// 清除之前的状态
resumeFileInfo.value = null
if (newProps.isResumeMode && newProps.resumeRecordId) {
// 生成新的请求ID
const requestId = generateRequestId()
currentRequestId.value = requestId
console.log(`开始加载断点续传信息, 请求ID: ${requestId}`)
try {
await loadResumeFileInfo(requestId)
} catch (error) {
console.error('加载断点续传信息失败:', error)
// 只有当前请求才显示错误
if (currentRequestId.value === requestId) {
ElMessage.error('加载断点续传信息失败')
}
}
}
},
{
immediate: true,
deep: true // 深度监听对象变化
}
)
onUnmounted(() => {
if (uploadManager) {
uploadManager.destroy()
}
})
/**
* 初始化上传管理器
*/
function initUploadManager() {
uploadManager = ChunkUploadManager.getInstance()
// 设置回调函数
uploadManager.onProgressUpdate = (taskId: string, progress: number, stage: string) => {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (task) {
if (stage === 'uploading') {
task.overallProgress = progress
calculateUploadSpeed(taskId, progress)
}
}
}
uploadManager.onStatusChange = (taskId: string, status: string) => {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (task) {
task.status = status
// 更新任务的详细信息
const managerTask = uploadManager.getTask(taskId)
if (managerTask) {
task.totalChunks = managerTask.totalChunks
task.chunks = managerTask.chunks
}
}
}
uploadManager.onComplete = (taskId: string) => {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (task) {
console.log('文件上传完成:', {
fileName: task.fileName,
fileSize: formatFileSize(task.fileSize),
uploadType: task.uploadType
})
}
message.success('文件上传完成')
emit('success')
// 如果是重新上传模式,清理相关状态
if (!props.isResumeMode && (props.resumeFileName || props.resumeFileSize)) {
console.log('重新上传完成,清理状态')
setTimeout(() => {
handleClose()
}, 1500) // 延迟1.5秒后自动关闭
}
}
uploadManager.onError = (taskId: string, error: string) => {
message.error(`上传失败: ${error}`)
}
}
/**
* 处理文件选择
*/
function handleFileChange(uploadFile: UploadFile) {
if (!uploadFile.raw) return
const file = uploadFile.raw
// 检查文件大小限制(例如最大5GB)
const maxSize = 5 * 1024 * 1024 * 1024 // 5GB
if (file.size > maxSize) {
ElMessage.error('文件大小超过限制(最大5GB)')
return
}
// 如果是断点续传模式,先验证文件
if (props.isResumeMode && resumeFileInfo.value) {
validateResumeFile(file)
} else if (props.resumeFileName || props.resumeFileSize) {
// 重新上传模式(code 202情况),需要验证文件是否与原始文件一致
validateReuploadFile(file)
} else {
// 正常上传模式
addUploadTask(file)
}
}
/**
* 添加上传任务
*/
async function addUploadTask(file: File) {
try {
const isLargeFile = file.size > CHUNK_UPLOAD_THRESHOLD
if (isLargeFile) {
// 大文件使用切片上传,传递 fileLabelList
const taskId = await uploadManager.addUploadTask(file, props.dirId, fileLabelList.value)
const task: FileUploadTask = {
id: taskId,
fileName: file.name,
fileSize: file.size,
status: 'pending',
overallProgress: 0,
totalChunks: 0,
chunks: [],
uploadType: 'chunk',
fileLabel: fileLabelList.value.join(',')
}
uploadTasks.value.push(task)
ElMessage.success(`文件 ${file.name} 已添加到上传队列(切片上传)`)
} else {
// 小文件使用普通上传
const taskId = generateTaskId()
const task: FileUploadTask = {
id: taskId,
fileName: file.name,
fileSize: file.size,
status: 'pending',
overallProgress: 0,
totalChunks: 1,
chunks: [],
uploadType: 'normal',
fileLabel: fileLabelList.value.join(',')
}
uploadTasks.value.push(task)
// 立即开始普通上传
await startNormalUpload(task, file, fileLabelList.value) // 传递 fileLabelList
// ElMessage.success(`文件 ${file.name} 已添加到上传队列(普通上传)`)
}
} catch (error) {
// ElMessage.error('添加上传任务失败')
console.error('Add upload task error:', error)
}
}
/**
* 普通上传处理
*/
async function startNormalUpload(task: FileUploadTask, file: File) {
try {
// 设置初始状态
task.status = 'uploading'
task.overallProgress = 0
console.log('开始普通上传:', { fileName: file.name, fileSize: file.size })
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
formData.append('dirId', props.dirId)
formData.append('fileLabel', fileLabelList.value.join(','))
// 调用普通上传 API
const result = await FileDetailApi.uploadFile(formData, {
onUploadProgress: (progressEvent: any) => {
if (progressEvent.total && progressEvent.loaded) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 确保进度在有效范围内
const validProgress = Math.max(0, Math.min(100, progress))
// 更新任务进度
const taskIndex = uploadTasks.value.findIndex((t) => t.id === task.id)
if (taskIndex !== -1) {
uploadTasks.value[taskIndex].overallProgress = validProgress
// 计算上传速度
calculateUploadSpeed(task.id, validProgress)
console.log(`普通上传进度更新: ${task.fileName} - ${validProgress}%`, {
loaded: progressEvent.loaded,
total: progressEvent.total,
taskId: task.id
})
}
}
}
})
console.log('普通上传API响应:', result)
if (result && result.code === 0) {
// 确保最终状态更新
const taskIndex = uploadTasks.value.findIndex((t) => t.id === task.id)
if (taskIndex !== -1) {
uploadTasks.value[taskIndex].status = 'completed'
uploadTasks.value[taskIndex].overallProgress = 100
}
console.log('普通上传完成:', task.fileName)
ElMessage.success('文件上传完成')
emit('success')
} else {
throw new Error(result?.msg || '上传失败')
}
} catch (error) {
console.error('普通上传失败:', error)
// 确保错误状态更新
const taskIndex = uploadTasks.value.findIndex((t) => t.id === task.id)
if (taskIndex !== -1) {
uploadTasks.value[taskIndex].status = 'error'
}
ElMessage.error(`上传失败: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
/**
* 开始上传
*/
function startUpload(taskId: string) {
// 上传管理器会自动开始处理任务
const task = uploadTasks.value.find((t) => t.id === taskId)
if (!task) return
if (task.uploadType === 'chunk') {
// 切片上传由管理器处理
ElMessage.info('开始切片上传...')
} else {
// 普通上传需要重新处理
if (task.status === 'error' || task.status === 'paused') {
// 普通上传重试需要重新选择文件
ElMessage.warning('普通上传重试需要重新选择文件')
} else if (task.status === 'pending') {
// 如果是待上传状态,提示用户重新选择文件
ElMessage.warning('请重新选择文件开始上传')
}
}
}
/**
* 暂停上传
*/
function pauseUpload(taskId: string) {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (!task) return
if (task.uploadType === 'chunk') {
uploadManager.pauseUpload(taskId)
ElMessage.info('切片上传已暂停')
} else {
ElMessage.warning('普通上传暂不支持暂停功能')
}
}
/**
* 恢复上传
*/
function resumeUpload(taskId: string) {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (!task) return
if (task.uploadType === 'chunk') {
uploadManager.resumeUpload(taskId)
ElMessage.info('恢复切片上传...')
} else {
ElMessage.warning('普通上传暂不支持恢复功能')
}
}
/**
* 重试上传
*/
function retryUpload(taskId: string) {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (!task) return
if (task.uploadType === 'chunk') {
uploadManager.resumeUpload(taskId)
ElMessage.info('重试切片上传...')
} else {
// 普通上传重试:清除当前任务,提示重新选择文件
const taskIndex = uploadTasks.value.findIndex((t) => t.id === taskId)
if (taskIndex !== -1) {
uploadTasks.value.splice(taskIndex, 1)
}
ElMessage.warning('已清除失败任务,请重新选择文件上传')
}
}
/**
* 删除任务
*/
function removeTask(taskId: string) {
const task = uploadTasks.value.find((t) => t.id === taskId)
if (!task) return
if (task.status === 'uploading') {
ElMessage.warning('上传中的任务无法直接删除,请先暂停')
return
}
if (task.uploadType === 'chunk') {
uploadManager.cancelUpload(taskId)
}
const index = uploadTasks.value.findIndex((task) => task.id === taskId)
if (index > -1) {
uploadTasks.value.splice(index, 1)
}
ElMessage.success('任务已删除')
}
/**
* 开始全部上传
*/
function startAllUploads() {
uploadTasks.value.forEach((task) => {
if (task.status === 'pending' || task.status === 'paused') {
startUpload(task.id)
}
})
}
/**
* 暂停全部上传
*/
function pauseAllUploads() {
uploadTasks.value.forEach((task) => {
if (task.status === 'uploading' && task.uploadType === 'chunk') {
pauseUpload(task.id)
}
})
}
/**
* 生成任务 ID
*/
function generateTaskId(): string {
return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* 清除已完成的任务
*/
function clearCompleted() {
uploadTasks.value = uploadTasks.value.filter((task) => task.status !== 'completed')
}
/**
* 重试所有失败的上传
*/
function retryAllErrors() {
uploadTasks.value.forEach((task) => {
if (task.status === 'error') {
retryUpload(task.id)
}
})
}
/**
* 计算上传速度
*/
const lastProgressTime = new Map<string, number>()
const lastProgressValue = new Map<string, number>()
function calculateUploadSpeed(taskId: string, progress: number) {
const now = Date.now()
const lastTime = lastProgressTime.get(taskId) || now
const lastProgress = lastProgressValue.get(taskId) || 0
if (now - lastTime >= 1000) {
// 每秒更新一次
const task = uploadTasks.value.find((t) => t.id === taskId)
if (task) {
const timeDiff = (now - lastTime) / 1000
const progressDiff = progress - lastProgress
const bytesPerSecond = (task.fileSize * progressDiff) / 100 / timeDiff
uploadSpeeds.value.set(taskId, formatFileSize(bytesPerSecond))
lastProgressTime.set(taskId, now)
lastProgressValue.set(taskId, progress)
}
}
}
/**
* 获取上传速度
*/
function getUploadSpeed(taskId: string): string | undefined {
return uploadSpeeds.value.get(taskId)
}
/**
* 格式化文件大小
*/
function formatFileSize(size: number): string {
console.log(size, 'size文件大小--')
if (size < 1024) return `${size}B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB`
return `${(size / (1024 * 1024 * 1024)).toFixed(1)}GB`
}
/**
* 获取状态文本
*/
function getStatusText(status: string): string {
const statusMap: Record<string, string> = {
pending: '等待中',
calculating: '计算MD5',
uploading: '上传中',
paused: '已暂停',
completed: '已完成',
error: '上传失败'
}
return statusMap[status] || status
}
/**
* 获取进度条状态
*/
function getProgressStatus(status: string): string | undefined {
if (status === 'completed') return 'success'
if (status === 'error') return 'exception'
return undefined
}
/**
* 关闭弹窗
*/
function handleClose() {
// 重置数据
uploadTasks.value = []
uploadSpeeds.value.clear()
// 清理断点续传相关状态
resumeFileInfo.value = null
currentRequestId.value = ''
// 清理进度跟踪
lastProgressTime.clear()
lastProgressValue.clear()
dialogVisible.value = false
console.log('FileUpload组件已关闭,状态已清理')
}
// 新增:计算断点续传进度
function calculateResumeProgress(): number {
if (!resumeFileInfo.value || estimatedTotalChunks.value === 0) return 0
return Math.round((resumeFileInfo.value.uploadedParts.length / estimatedTotalChunks.value) * 100)
}
// 新增:生成切片可视化数据
function getChunkVisualization() {
if (!resumeFileInfo.value) return []
const totalChunks = Math.min(estimatedTotalChunks.value, 50) // 最多显示50个
const uploadedPartNumbers = new Set(
resumeFileInfo.value.uploadedParts.map((part) => part.partNumber)
)
return Array.from({ length: totalChunks }, (_, index) => ({
index: index + 1,
uploaded: uploadedPartNumbers.has(index + 1),
size:
index < resumeFileInfo.value!.uploadedParts.length
? resumeFileInfo.value!.uploadedParts[index]?.partSize || 0
: 0
}))
}
// 新增:格式化时间
function formatTime(timestamp: number): string {
if (!timestamp) return '-'
return formatDate(new Date(timestamp), 'YYYY-MM-DD HH:mm:ss')
}
// 新增:获取上传文本
function getUploadText(): string {
if (props.isResumeMode) {
return '选择要断点续传的文件,或将文件拖到此处,'
} else if (props.resumeFileName || props.resumeFileSize) {
return '选择要重新上传的文件,或将文件拖到此处,'
} else {
return '将文件拖到此处,或'
}
}
// 新增:获取上传提示文本
function getUploadTipText(): string {
if (props.isResumeMode) {
const fileName = props.resumeFileName || '未知文件'
const fileSize = props.resumeFileSize ? formatFileSize(props.resumeFileSize) : '未知大小'
return `断点续传模式:请选择与之前上传失败相同的文件 (${fileName}, ${fileSize})`
} else if (props.resumeFileName || props.resumeFileSize) {
const fileName = props.resumeFileName || '未知文件'
const fileSize = props.resumeFileSize ? formatFileSize(props.resumeFileSize) : '未知大小'
return `重新上传模式:请选择与原始文件完全相同的文件 (${fileName}, ${fileSize})`
} else {
return '支持大文件切片上传和断点续传,推荐上传大于10MB的文件使用切片模式'
}
}
// 修改:加载断点续传文件信息
async function loadResumeFileInfo(requestId?: string) {
if (!props.resumeRecordId) return
// 如果提供了requestId,检查是否还是当前有效的请求
if (requestId && currentRequestId.value !== requestId) {
console.log(`请求 ${requestId} 已过期,取消加载`)
return
}
try {
const result = await FileDetailApi.checkUpload({
dirId: props.dirId,
uploadId: props.resumeRecordId.toString()
})
if (result && result.code === 0 && result.data) {
// 再次检查请求有效性(防止异步竞态)
if (requestId && currentRequestId.value !== requestId) {
console.log(`请求 ${requestId} 在API调用后已过期,忽略结果`)
return
}
const uploadedParts = result.data || []
if (uploadedParts.length === 0) {
if (!requestId || currentRequestId.value === requestId) {
ElMessage.warning('未找到已上传的切片信息')
}
return
}
// 排序切片(按切片号排序)
uploadedParts.sort((a: any, b: any) => a.partNumber - b.partNumber)
// 从已上传的切片信息中推断文件信息
let fileName = ''
let maxPartNumber = 0
// 分析切片信息以获取更准确的文件信息
uploadedParts.forEach((part: any) => {
maxPartNumber = Math.max(maxPartNumber, part.partNumber)
})
// 使用props传递的文件信息,确保数据一致性
fileName = props.resumeFileName || '未知文件'
const fileSize = props.resumeFileSize || 0
// 最终检查请求有效性
if (requestId && currentRequestId.value !== requestId) {
console.log(`请求 ${requestId} 在设置状态前已过期,取消设置`)
return
}
resumeFileInfo.value = {
uploadId: props.resumeRecordId.toString(),
fileName: fileName,
fileSize: fileSize,
uploadedParts: uploadedParts,
maxPartNumber: maxPartNumber
}
console.log(`断点续传信息加载成功 (请求ID: ${requestId}):`, {
fileName,
fileSize: formatFileSize(fileSize),
uploadedParts: uploadedParts.length,
maxPartNumber
})
} else {
ElMessage.warning('未找到有效的断点续传信息')
}
} catch (error) {
ElMessage.error('加载断点续传信息失败')
console.error('Load resume file info error:', error)
}
}
// 修改:验证断点续传文件
async function validateResumeFile(file: File) {
try {
if (!resumeFileInfo.value) {
ElMessage.error('断点续传信息不存在')
return
}
console.log('开始验证断点续传文件:', {
selectedFile: { name: file.name, size: file.size },
resumeInfo: resumeFileInfo.value
})
// 1. 文件名验证
const expectedFileName = resumeFileInfo.value.fileName
let fileNameMatches = false
if (expectedFileName && expectedFileName !== `unknown_file_${props.resumeRecordId}`) {
// 如果有明确的文件名记录,进行严格匹配
fileNameMatches = file.name === expectedFileName
if (!fileNameMatches) {
const result = await ElMessageBox.confirm(
`文件名不匹配!\n` +
`期望文件名:${expectedFileName}\n` +
`当前文件名:${file.name}\n\n` +
`这可能不是同一个文件。是否仍要继续断点续传?`,
'文件名不匹配警告',
{
type: 'warning',
confirmButtonText: '继续上传',
cancelButtonText: '重新选择'
}
).catch(() => false)
if (!result) return
}
} else {
// 如果没有文件名记录,提示用户确认
const result = await ElMessageBox.confirm(
`无法获取原始文件名信息,请确认您选择的文件是否为之前上传失败的文件:\n\n` +
`当前选择:${file.name} (${formatFileSize(file.size)})\n` +
`已上传切片:${resumeFileInfo.value.uploadedParts.length} 个\n\n` +
`如果不是同一个文件,断点续传可能失败。`,
'确认文件身份',
{
type: 'warning',
confirmButtonText: '确认是同一文件',
cancelButtonText: '重新选择'
}
).catch(() => false)
if (!result) return
}
// 2. 文件大小验证
const estimatedSize = resumeFileInfo.value.fileSize
const sizeDifference = Math.abs(file.size - estimatedSize)
const tolerance = Math.max(1024 * 1024, estimatedSize * 0.05) // 1MB 或 5% 的容差
if (sizeDifference > tolerance) {
const result = await ElMessageBox.confirm(
`文件大小差异较大!\n\n` +
`根据已上传切片估算的大小:${formatFileSize(estimatedSize)}\n` +
`当前文件大小:${formatFileSize(file.size)}\n` +
`差异:${formatFileSize(sizeDifference)}\n\n` +
`这可能不是同一个文件,或者文件已被修改。\n` +
`继续可能导致上传失败或文件损坏。`,
'文件大小不匹配',
{
type: 'error',
confirmButtonText: '仍要继续',
cancelButtonText: '重新选择'
}
).catch(() => false)
if (!result) return
}
// 3. 切片合理性验证
const standardChunkSize = 5 * 1024 * 1024 // 5MB
const expectedChunks = Math.ceil(file.size / standardChunkSize)
const maxPartNumber =
resumeFileInfo.value.maxPartNumber || resumeFileInfo.value.uploadedParts.length
if (maxPartNumber > expectedChunks * 1.5) {
ElMessage.warning(
`切片数量异常!根据文件大小预期 ${expectedChunks} 个切片,但已有 ${maxPartNumber} 个切片。`
)
const result = await ElMessageBox.confirm(
'切片数量与文件大小不匹配,可能不是同一个文件。是否继续?',
'切片数量异常',
{ type: 'warning' }
).catch(() => false)
if (!result) return
}
ElMessage.info('正在准备断点续传...')
// 4. 可选:计算文件MD5进行更精确验证(耗时较长,可选)
if (file.size < 100 * 1024 * 1024) {
// 只对小于100MB的文件计算MD5
try {
ElMessage.info('正在计算文件校验值...')
await calculateFileMD5Internal(file)
ElMessage.success('文件校验完成')
} catch (error) {
console.warn('MD5计算失败:', error)
}
}
ElMessage.success('文件验证通过,开始断点续传...')
// 开始断点续传
await addResumeUploadTask(file)
} catch (error) {
ElMessage.error('文件验证失败')
console.error('File validation error:', error)
}
}
// 新增:计算文件MD5
async function calculateFileMD5Internal(file: File): Promise<string> {
const SparkMD5 = await import('spark-md5')
const spark = new SparkMD5.default.ArrayBuffer()
const chunkSize = 2 * 1024 * 1024 // 2MB chunks for MD5 calculation
for (let i = 0; i < file.size; i += chunkSize) {
const chunk = file.slice(i, Math.min(i + chunkSize, file.size))
const buffer = await chunk.arrayBuffer()
spark.append(buffer)
}
return spark.end()
}
// 新增:验证重新上传的文件
async function validateReuploadFile(file: File) {
try {
console.log('验证重新上传文件:', {
selectedFile: { name: file.name, size: file.size },
expectedFile: {
name: props.resumeFileName,
size: props.resumeFileSize
}
})
let isValid = true
const issues: string[] = []
// 1. 文件名验证
if (props.resumeFileName && props.resumeFileName !== '未知文件') {
if (file.name !== props.resumeFileName) {
issues.push(`文件名不匹配:期望 "${props.resumeFileName}",实际 "${file.name}"`)
isValid = false
}
}
// 2. 文件大小验证
if (props.resumeFileSize && props.resumeFileSize > 0) {
const sizeDifference = Math.abs(file.size - props.resumeFileSize)
const tolerance = Math.max(1024, props.resumeFileSize * 0.001) // 1KB 或 0.1% 的容差
if (sizeDifference > tolerance) {
issues.push(
`文件大小不匹配:期望 ${formatFileSize(props.resumeFileSize)},` +
`实际 ${formatFileSize(file.size)},差异 ${formatFileSize(sizeDifference)}`
)
isValid = false
}
}
if (!isValid) {
// 显示详细的不匹配信息
const result = await ElMessageBox.confirm(
`检测到文件信息不匹配:\n\n` +
issues.join('\n') +
'\n\n' +
`这可能不是原始上传失败的文件。继续上传可能导致:\n` +
`• 文件损坏或数据错误\n` +
`• 上传失败\n` +
`• 浪费网络流量\n\n` +
`建议:请选择与原始文件完全相同的文件。`,
'文件验证失败',
{
type: 'error',
confirmButtonText: '仍要继续',
cancelButtonText: '重新选择',
distinguishCancelAndClose: true
}
).catch(() => false)
if (!result) {
ElMessage.info('请重新选择正确的文件')
return
}
ElMessage.warning('已忽略文件验证警告,继续上传...')
} else {
ElMessage.success('文件验证通过')
}
// 验证通过或用户选择继续,开始重新上传(使用现有uploadId)
await addReuploadTask(file)
} catch (error) {
ElMessage.error('文件验证失败')
console.error('Reupload file validation error:', error)
}
}
// 新增:添加重新上传任务(使用现有uploadId)
async function addReuploadTask(file: File) {
try {
if (!props.resumeRecordId) {
ElMessage.error('缺少上传记录ID,无法重新上传')
return
}
console.log('开始重新上传,使用现有uploadId:', {
fileName: file.name,
fileSize: file.size,
uploadId: props.resumeRecordId,
dirId: props.dirId
})
// 检查文件大小,决定使用切片上传还是普通上传
const isLargeFile = file.size > CHUNK_UPLOAD_THRESHOLD
if (isLargeFile) {
// 大文件使用切片上传,但不传已上传的切片(因为要重新上传)
const taskId = await uploadManager.addUploadTask(file, props.dirId)
// 获取管理器中的任务并设置uploadId
const managerTask = uploadManager.getTask(taskId)
if (managerTask) {
managerTask.uploadId = props.resumeRecordId.toString()
console.log('已设置现有uploadId到任务:', managerTask.uploadId)
}
const task: FileUploadTask = {
id: taskId,
fileName: file.name,
fileSize: file.size,
status: 'pending',
overallProgress: 0,
totalChunks: 0,
chunks: [],
uploadType: 'chunk'
}
uploadTasks.value.push(task)
ElMessage.success(`文件 ${file.name} 已添加到重新上传队列(切片上传)`)
} else {
// 小文件使用普通上传
const taskId = generateTaskId()
const task: FileUploadTask = {
id: taskId,
fileName: file.name,
fileSize: file.size,
status: 'pending',
overallProgress: 0,
totalChunks: 1,
chunks: [],
uploadType: 'normal'
}
uploadTasks.value.push(task)
// 立即开始普通上传
await startNormalUpload(task, file)
}
} catch (error) {
ElMessage.error('添加重新上传任务失败')
console.error('Add reupload task error:', error)
}
}
// 新增:添加断点续传任务
async function addResumeUploadTask(file: File) {
try {
if (!resumeFileInfo.value) return
const taskId = await uploadManager.addResumeUploadTask(
file,
props.dirId,
resumeFileInfo.value.uploadId,
resumeFileInfo.value.uploadedParts
)
const task: FileUploadTask = {
id: taskId,
fileName: file.name,
fileSize: file.size,
status: 'pending',
overallProgress: 0,
totalChunks: 0,
chunks: [],
uploadType: 'chunk'
}
uploadTasks.value.push(task)
ElMessage.success(`开始断点续传:${file.name}`)
} catch (error) {
ElMessage.error('添加断点续传任务失败')
console.error('Add resume upload task error:', error)
}
}
const fileLabelOptions = ref([])
/** 获取启用状态选项 */
const fetchEnableStatusOptions = async () => {
try {
fileLabelOptions.value = await DownstreamAppApi.getFileLabelOptions()
} catch (error) {
console.error('获取启用状态选项失败:', error)
}
}
// 对外暴露方法
defineExpose({
open: () => {
dialogVisible.value = true
fetchEnableStatusOptions()
},
close: handleClose
})
</script>
<style scoped lang="scss">
:deep(.el-upload__tip) {
font-size: 14px;
margin-top: 10px;
}
.upload-area {
margin-bottom: 20px;
}
.batch-actions {
margin-top: 16px;
display: flex;
gap: 8px;
justify-content: center;
}
.file-list {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
max-height: 400px;
overflow-y: auto;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #f5f7fa;
border-bottom: 1px solid #dcdfe6;
font-weight: 500;
}
.header-info {
display: flex;
align-items: center;
gap: 16px;
}
.upload-stats {
font-size: 12px;
color: #606266;
}
.file-item {
display: flex;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid #ebeef5;
transition: background-color 0.3s;
}
.file-item:hover {
background-color: #f9f9f9;
}
.file-item:last-child {
border-bottom: none;
}
.file-info {
display: flex;
align-items: flex-start;
flex: 0 0 200px;
margin-right: 16px;
}
.file-icon {
font-size: 20px;
color: #606266;
margin-right: 12px;
margin-top: 2px;
}
.file-details {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
color: #303133;
margin-bottom: 4px;
word-break: break-all;
line-height: 1.4;
}
.file-meta {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.file-size {
color: #303133;
}
.upload-type {
color: #909399;
&.chunk-upload {
color: #409eff;
}
}
.chunk-info,
.upload-speed {
color: #409eff;
}
.file-progress {
flex: 1;
margin-right: 16px;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.status-text {
font-size: 12px;
color: #606266;
}
.progress-text {
font-size: 12px;
color: #606266;
font-weight: 500;
}
.progress-bar {
margin-bottom: 8px;
}
.chunk-progress {
margin-top: 8px;
}
.chunk-grid {
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
}
.chunk-item {
width: 8px;
height: 8px;
border-radius: 1px;
background-color: #e4e7ed;
transition: background-color 0.3s;
}
.chunk-item.chunk-uploaded {
background-color: #67c23a;
}
.chunk-item.chunk-uploading {
background-color: #409eff;
animation: pulse 1.5s infinite;
}
.chunk-item.chunk-error {
background-color: #f56c6c;
}
.chunk-more {
font-size: 10px;
color: #909399;
margin-left: 4px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.file-actions {
flex: 0 0 auto;
display: flex;
gap: 8px;
align-items: flex-start;
padding-top: 2px;
}
.advanced-settings {
margin-top: 16px;
}
.dialog-footer {
text-align: right;
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-item {
flex-direction: column;
align-items: stretch;
}
.file-info,
.file-progress {
margin-right: 0;
margin-bottom: 12px;
}
.file-actions {
justify-content: center;
}
}
/* 断点续传信息样式 */
.resume-info-section {
margin-bottom: 20px;
}
.resume-alert {
margin-bottom: 16px;
}
.resume-details {
margin-top: 12px;
}
.resume-file-info {
margin-bottom: 20px;
h4 {
margin: 0 0 12px 0;
color: #303133;
font-size: 14px;
font-weight: 600;
}
}
.file-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 8px;
}
.info-item {
display: flex;
align-items: center;
padding: 4px 0;
.label {
font-weight: 500;
color: #606266;
margin-right: 8px;
min-width: 80px;
}
.value {
color: #303133;
&.success {
color: #67c23a;
font-weight: 600;
}
}
}
.chunk-visualization {
h4 {
margin: 0 0 12px 0;
color: #303133;
font-size: 14px;
font-weight: 600;
}
}
.chunk-progress-container {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
.chunk-stats {
display: flex;
gap: 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #606266;
&.uploaded .stat-color {
background-color: #67c23a;
}
&.pending .stat-color {
background-color: #e4e7ed;
}
}
.stat-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.chunk-grid-container {
margin-top: 8px;
}
.chunk-grid {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-bottom: 8px;
}
.chunk-visual-item {
width: 12px;
height: 12px;
border-radius: 2px;
transition: all 0.3s ease;
cursor: pointer;
&.chunk-uploaded {
background-color: #67c23a;
}
&.chunk-pending {
background-color: #e4e7ed;
}
&:hover {
transform: scale(1.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.chunk-more-info,
.table-more-info {
font-size: 12px;
color: #909399;
text-align: center;
margin-top: 8px;
}
.uploaded-chunks-detail {
margin-top: 16px;
:deep(.el-collapse-item__header) {
font-size: 13px;
color: #606266;
}
}
.resume-tips {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 6px;
font-size: 13px;
color: #1890ff;
.tip-icon {
font-size: 16px;
}
}
/* 修改上传区域在断点续传模式下的样式 */
.upload-area {
.upload-demo {
transition: all 0.3s ease;
}
.el-upload__text {
transition: color 0.3s ease;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-info-grid {
grid-template-columns: 1fr;
}
.chunk-stats {
flex-direction: column;
gap: 8px;
}
.chunk-visual-item {
width: 10px;
height: 10px;
}
}
::v-deep.uploaded-chunks-detail .el-collapse-item__header {
padding: 16px;
}
</style>