vue3大文件切片上传断点续传

79 阅读18分钟

ChunkUploadManager 是一个用于实现大文件分片上传的 TypeScript 类,它提供了完整的文件分片上传、断点续传、并发控制等功能。

核心功能

1. 文件分片处理

  • 自动计算分片大小:根据文件大小自动计算合理的分片大小,确保分片数量不超过100个
  • 创建文件分片:将大文件切割成多个小分片(Blob对象)
  • 分片信息管理:记录每个分片的索引、大小、上传状态等信息

2. MD5校验计算

  • 文件级MD5:计算整个文件的MD5值,用于服务器端完整性校验
  • 分片级MD5:为每个分片计算独立的MD5值
  • Web Worker支持:使用Web Worker进行并发计算,避免阻塞主线程
  • 降级处理:当Web Worker不可用时,自动降级到主线程计算

3. 上传控制

  • 并发控制:通过信号量(Semaphore)限制同时上传的分片数量
  • 自动重试:上传失败时自动重试,最多重试3次
  • 进度跟踪:实时跟踪每个分片的上传进度
  • 暂停/恢复:支持上传任务的暂停和恢复

4. 断点续传

  • 服务器状态检查:从服务器获取已上传分片信息
  • 自动续传:只上传未完成的分片
  • 完整性验证:确保续传的文件与原始文件一致

5. 分片合并

  • 自动合并:当所有分片上传完成后,自动触发合并请求
  • 状态检查:确保合并时任务处于正确状态
  • 错误处理:合并失败时回滚状态

性能优化

  1. Web Worker并行计算:MD5计算在Worker线程中执行,不阻塞UI
  2. 并发控制:限制同时上传的分片数量,避免浏览器资源耗尽
  3. 内存优化:及时清理已完成的任务和控制器
  4. 错误隔离:单个分片失败不影响其他分片上传

断点续传主要方法(部分代码使用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>