返回笔记首页

批量上传图片

主题配置

完整代码 点击这里

基于运行后端node服务: /mock-server/server.js

初中级前端

markdown
企业级图片批量上传组件
- 核心职责:独立设计并实现支持拖拽、多选、预览、格式校验、实时进度的图片批量上传组件
- 性能优化:通过Promise并发池控制同时上传数量(3-5个),避免浏览器连接数限制,提升30%上传效率
- 大图处理:使用Web Worker进行图片压缩(Canvas离屏渲染),单张2MB+图片压缩至500KB,不阻塞主线程
- 兼容方案:集成heic2any库解决iOS HEIC格式兼容问题,自动转换为JPEG格式
- 稳定性:实现分片上传(2MB/片)+ 失败重试机制(指数退避策略),大文件上传成功率提升至98%
- 技术栈:Vue3 Composition API、XMLHttpRequest、Web Worker、FileReader API

高级前端

markdown
企业级通用图片批量上传组件
项目背景:
为解决企业内部多个业务线图片上传功能重复开发、用户体验不一致的问题,独立负责设计并实现一套可复用的图片批量上传组件,已在5+业务线落地使用。

核心功能:
- 交互体验:支持拖拽上传、多文件选择、实时预览、上传进度展示、失败重试等完整交互流程
- 格式校验:前端校验文件类型(jpg/png/gif/webp/heic)和大小限制(10MB),及时反馈错误信息
- 兼容处理:使用heic2any库自动转换iOS HEIC格式图片为JPEG,解决浏览器预览和后端处理兼容问题

技术亮点:
1. 并发控制:设计Promise并发池,控制同时上传3-5个文件,避免浏览器HTTP/1.1连接数限制(6个)和服务器压力,实测提升30%整体上传效率
2. 性能优化:将图片压缩任务(Canvas像素计算)放入Web Worker后台线程执行,单张2MB+大图压缩至500KB以下,主线程保持流畅
3. 可靠性保障:实现分片上传机制(2MB/片)+ 失败自动重试(最多3次,指数退避),大文件(>10MB)上传成功率从75%提升至98%
4. 进度反馈:基于XMLHttpRequest的upload.onprogress监听上传进度,Vue3响应式数据实时更新UI,用户体验良好

技术栈:Vue3 Composition API(setup语法糖)、Web Worker、Canvas API、heic2any、FileReader API

项目成果:组件已在企业内部5条业务线应用,日均处理3000+张图片上传,用户投诉率下降60%

详细版(面试官会深挖技术细节)

markdown
企业级通用图片批量上传组件(核心项目)

项目背景与价值:
针对企业内部多条业务线(商品管理、活动运营、用户认证等)图片上传功能重复开发、代码冗余、用户体验参差不齐的痛点,独立负责设计并实现一套企业级通用图片批量上传组件。该组件已成功在5条业务线落地,日均处理3000+张图片上传,减少研发成本约15人日/月,用户投诉率下降60%。

核心功能实现:
- 完整交互流程:
  - 多种上传方式:拖拽上传(drag/drop事件)、点击选择、支持multiple多选
  - 实时预览:FileReader API读取本地文件生成base64预览图
  - 格式校验:前端校验文件类型(jpg/png/gif/webp/heic/heif)和大小限制(可配置,默认10MB)
  - 状态管理:维护每张图片的状态机(pending/converting/compressing/uploading/success/error)
  - 进度展示:基于XMLHttpRequest.upload.onprogress实时显示上传百分比
  - 失败处理:错误提示 + 单张重试功能,用户体验友好

- iOS兼容方案:
  - 问题:iOS设备拍摄的照片默认为HEIC格式,浏览器无法预览,部分后端服务不支持
  - 方案:集成heic2any库,在上传前自动检测并转换HEIC/HEIF格式为JPEG
  - 效果:iOS用户上传成功率从45%提升至99%

技术难点与解决方案:
1. 大量图片并发上传的性能优化
   难点:用户一次选择几十张甚至上百张图片,不控制并发会导致:
   - 浏览器HTTP/1.1连接数限制(通常6个),多余请求排队等待
   - 服务器瞬时压力过大,容易503
   - 内存占用过高,页面卡顿

   解决方案:
   - 设计Promise并发池(参考p-limit思路),维护executing执行队列和waiting等待队列
   - 控制同时上传数量为3-5个(可配置),通过Promise.race动态调度
   - 实测数据:100张图片上传总耗时从120s降至80s,提升30%效率

   核心代码结构:
```javascript
   // 并发池核心逻辑
   const executing = []
   const enqueue = (fn) => {
     if (executing.length >= concurrency) {
       await Promise.race(executing)
     }
     const promise = fn()
     executing.push(promise)
     promise.finally(() => executing.splice(executing.indexOf(promise), 1))
     return promise
   }
```text

1. 大图压缩不阻塞主线程
   难点:
   - 图片压缩涉及Canvas drawImage、getImageData等CPU密集型操作
   - 单张5MB原图压缩耗时约800ms,10张同时压缩会导致页面卡顿3-5秒
   - 用户体验极差,无法操作页面

   解决方案:
   - 创建专用Web Worker,将压缩任务放入后台线程
   - 使用OffscreenCanvas进行离屏渲染,避免主线程Canvas操作
   - 主线程通过postMessage与Worker通信,监听压缩进度
   - 效果:压缩过程中主线程保持60fps流畅度,用户可正常操作页面

   技术细节:
   - Worker中使用createImageBitmap创建位图
   - OffscreenCanvas.convertToBlob生成压缩后的blob
   - 可配置压缩质量(默认0.8)和最大宽度(默认1920px)
   - 2MB+大图压缩至500KB以下,压缩率达75%

2. 分片上传与断点续传
   难点:
   - 单个大文件(>10MB)上传容易因网络波动失败
   - 失败后重传整个文件浪费流量和时间
   - 用户体验差,上传成功率仅75%

   解决方案:
   - File.slice分片:将大文件切分为2MB/片的多个blob
   - 计算文件hash(SparkMD5)作为唯一标识,支持断点续传
   - 每片独立上传,失败后仅重试单片
   - 失败重试策略:最多3次,指数退避(1s、2s、4s)
   - 所有分片上传完成后,通知后端合并文件
   - 效果:大文件上传成功率从75%提升至98%,用户满意度明显提升

3. 精细化状态管理
   挑战:
   - 每张图片有多个状态(待上传、格式转换、压缩中、上传中、成功、失败)
   - 需实时展示每张图的进度百分比
   - 状态切换逻辑复杂,容易出现状态不一致

   方案:
   - 使用Vue3 Composition API + reactive创建响应式状态对象
   - 每张图片维护独立的状态机,状态流转清晰
   - XMLHttpRequest.upload.onprogress监听上传进度,实时更新progress字段
   - UI层使用computed计算统计数据(总数、成功数、失败数)
   - 效果:状态展示准确率100%,无状态混乱bug

技术选型与架构:

- 框架层:Vue3 Composition API(setup语法糖),充分利用响应式系统和组合式函数复用
- 文件处理:FileReader API(预览)、File.slice(分片)、Canvas API(压缩)
- 并发控制:自研Promise并发池,灵活控制并发数
- 多线程:Web Worker处理CPU密集型压缩任务,OffscreenCanvas离屏渲染
- 网络层:XMLHttpRequest(支持进度监听),FormData传输文件
- 格式转换:heic2any库处理iOS HEIC格式

代码质量:

- 组件化设计:useUploadPool、useImageCompress、useHEICConverter等可复用hooks
- 错误边界:try-catch包裹每个环节,错误信息准确反馈给用户
- 内存管理:及时释放blob URL、终止Worker线程,避免内存泄漏
- 代码规范:符合Vue3官方规范,通过ESLint检查

项目成果:

- 业务价值:5条业务线复用,减少研发成本约15人日/月,日均处理3000+张图片
- 性能指标:上传效率提升30%,大图压缩率75%,大文件上传成功率98%
- 用户体验:投诉率下降60%,用户满意度显著提升
- 技术沉淀:形成企业级组件规范,为其他文件上传场景提供参考

核心难点和亮点

难点1:大量图片并发上传的性能优化 实际场景中用户可能一次选几十张甚至上百张图片,如果不控制并发数,浏览器会卡死,服务器也扛不住。你需要实现一个并发池来控制同时上传的数量。

难点2:HEIC格式兼容性处理 iOS设备拍的照片默认是HEIC格式,Web端很多浏览器不支持预览,后端也不一定能处理。需要在前端做格式转换。

难点3:大图压缩不阻塞主线程 压缩大图是CPU密集型操作,直接在主线程做会导致页面卡顿。用Web Worker放到后台处理。

难点4:分片上传和断点续传 大文件上传容易失败,需要切片上传,失败了能重试单个分片而不是整个文件。

难点5:上传状态的精细化管理 每张图片有多个状态(待上传、压缩中、上传中、成功、失败),还要显示进度百分比,状态管理比较复杂。

面试官可能问的问题和回答

问题1:Promise并发池是怎么实现的?为什么要控制并发数?

回答思路: "并发数不能太大也不能太小。太大的话浏览器有连接数限制(HTTP/1.1通常是6个),而且服务器压力大;太小的话上传慢。我控制在3-5个并发比较合适。

实现上,我维护一个执行队列和等待队列:

javascript

javascript
// composables/useUploadPool.js
export function useUploadPool(concurrency = 3) {
  const executing = ref([])
  const queue = ref([])

  async function add(fn) {
    // 如果达到并发上限,先等待
    if (executing.value.length >= concurrency) {
      await Promise.race(executing.value)
    }

    const promise = fn()
    const index = executing.value.length
    executing.value.push(promise)

    promise.finally(() => {
      executing.value.splice(
        executing.value.indexOf(promise),
        1
      )
    })

    return promise
  }

  return { add }
}

使用的时候:

javascript

javascript
const uploadPool = useUploadPool(3)

files.forEach(file => {
  uploadPool.add(() => uploadSingleFile(file))
})

这样就能保证同时最多只有3个上传任务在跑。"

问题2:Web Worker是怎么用的?为什么要用它?

回答思路: "图片压缩涉及大量的像素计算,是CPU密集型操作,直接在主线程做会阻塞页面交互,用户会感觉页面卡住了。用Web Worker可以把压缩任务放到后台线程执行,主线程继续响应用户操作。

我创建了一个专门的压缩Worker:

javascript

javascript
// workers/compressor.worker.js
self.addEventListener('message', async (e) => {
  const { file, quality, maxWidth } = e.data

  // 创建离屏Canvas进行压缩
  const bitmap = await createImageBitmap(file)
  const canvas = new OffscreenCanvas(maxWidth, maxWidth * bitmap.height / bitmap.width)
  const ctx = canvas.getContext('2d')

  ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)

  const blob = await canvas.convertToBlob({
    type: 'image/jpeg',
    quality: quality
  })

  self.postMessage({ blob, success: true })
})

在主线程中使用:

javascript

javascript
// composables/useImageCompress.js
export function useImageCompress() {
  const worker = ref(null)

  onMounted(() => {
    worker.value = new Worker(
      new URL('../workers/compressor.worker.js', import.meta.url),
      { type: 'module' }
    )
  })

  function compress(file, options = {}) {
    return new Promise((resolve, reject) => {
      const handler = (e) => {
        if (e.data.success) {
          resolve(e.data.blob)
        } else {
          reject(e.data.error)
        }
        worker.value.removeEventListener('message', handler)
      }

      worker.value.addEventListener('message', handler)
      worker.value.postMessage({
        file,
        quality: options.quality || 0.8,
        maxWidth: options.maxWidth || 1920
      })
    })
  }

  onUnmounted(() => {
    worker.value?.terminate()
  })

  return { compress }
}

这样压缩过程在后台进行,主线程不会卡顿。"

问题3:HEIC格式怎么处理的?

回答思路: "iOS拍的照片是HEIC格式,浏览器不支持预览,也没有原生API转换。我用了heic2any这个库来转换:

javascript

javascript
// composables/useHEICConverter.js
import heic2any from 'heic2any'

export function useHEICConverter() {
  async function convertIfNeeded(file) {
    // 检查是否是HEIC格式
    const isHEIC = /\.(heic|heif)$/i.test(file.name)

    if (!isHEIC) {
      return file
    }

    try {
      // 转换为JPEG
      const convertedBlob = await heic2any({
        blob: file,
        toType: 'image/jpeg',
        quality: 0.9
      })

      // 创建新的File对象
      return new File(
        [convertedBlob],
        file.name.replace(/\.(heic|heif)$/i, '.jpg'),
        { type: 'image/jpeg' }
      )
    } catch (error) {
      console.error('HEIC转换失败:', error)
      throw new Error('不支持的图片格式')
    }
  }

  return { convertIfNeeded }
}

在上传前先转换:

javascript

javascript
async function handleFiles(fileList) {
  const converter = useHEICConverter()

  for (let file of fileList) {
    const convertedFile = await converter.convertIfNeeded(file)
    await uploadFile(convertedFile)
  }
}

这样iOS用户也能正常上传照片了。"

问题4:分片上传是怎么实现的?

回答思路: "对于大文件,我会切成多个分片分别上传,这样单个分片失败了可以重试,不用重传整个文件。

javascript

javascript
// composables/useChunkUpload.js
export function useChunkUpload() {
  const CHUNK_SIZE = 2 * 1024 * 1024 // 2MB每片

  function createChunks(file) {
    const chunks = []
    let start = 0

    while (start < file.size) {
      const end = Math.min(start + CHUNK_SIZE, file.size)
      chunks.push({
        blob: file.slice(start, end),
        index: chunks.length,
        start,
        end
      })
      start = end
    }

    return chunks
  }

  async function uploadChunk(chunk, fileHash, totalChunks) {
    const formData = new FormData()
    formData.append('chunk', chunk.blob)
    formData.append('hash', fileHash)
    formData.append('index', chunk.index)
    formData.append('total', totalChunks)

    const response = await fetch('/api/upload/chunk', {
      method: 'POST',
      body: formData
    })

    return response.json()
  }

  async function uploadFile(file, onProgress) {
    // 计算文件hash用于断点续传
    const fileHash = await calculateHash(file)

    // 切片
    const chunks = createChunks(file)

    // 上传每个分片
    let uploaded = 0
    for (let chunk of chunks) {
      await uploadChunk(chunk, fileHash, chunks.length)
      uploaded++
      onProgress(uploaded / chunks.length * 100)
    }

    // 通知服务器合并
    await fetch('/api/upload/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash: fileHash, total: chunks.length })
    })
  }

  return { uploadFile }
}

配合重试机制:

javascript

javascript
async function uploadWithRetry(chunk, maxRetries = 3) {
  let retries = 0

  while (retries < maxRetries) {
    try {
      return await uploadChunk(chunk)
    } catch (error) {
      retries++
      if (retries >= maxRetries) throw error
      await new Promise(r => setTimeout(r, 1000 * retries)) // 指数退避
    }
  }
}

这样即使网络不好,也能保证上传成功。"

问题5:上传进度是怎么实时反馈的?

回答思路: "我用XMLHttpRequest的upload.onprogress事件来监听上传进度,然后用响应式数据更新UI:

javascript

javascript
// composables/useUpload.js
export function useUpload() {
  const uploadList = ref([])

  function uploadFile(file) {
    const item = reactive({
      id: Date.now() + Math.random(),
      file,
      name: file.name,
      status: 'pending', // pending/compressing/uploading/success/error
      progress: 0,
      error: null
    })

    uploadList.value.push(item)

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest()

      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
          item.progress = Math.round((e.loaded / e.total) * 100)
        }
      }

      xhr.onload = () => {
        if (xhr.status === 200) {
          item.status = 'success'
          resolve(JSON.parse(xhr.response))
        } else {
          item.status = 'error'
          item.error = '上传失败'
          reject(new Error('上传失败'))
        }
      }

      xhr.onerror = () => {
        item.status = 'error'
        item.error = '网络错误'
        reject(new Error('网络错误'))
      }

      const formData = new FormData()
      formData.append('file', file)

      xhr.open('POST', '/api/upload')
      item.status = 'uploading'
      xhr.send(formData)
    })
  }

  return { uploadList, uploadFile }
}

在模板中展示:

vue

vue
<template>
  <div v-for="item in uploadList" :key="item.id" class="upload-item">
    <img :src="getPreviewUrl(item.file)" />
    <div class="info">
      <div>{{ item.name }}</div>
      <div v-if="item.status === 'uploading'">
        <div class="progress-bar">
          <div class="progress" :style="{ width: item.progress + '%' }"></div>
        </div>
        <span>{{ item.progress }}%</span>
      </div>
      <div v-else-if="item.status === 'success'" class="success">✓ 上传成功</div>
      <div v-else-if="item.status === 'error'" class="error">
        {{ item.error }}
        <button @click="retry(item)">重试</button>
      </div>
    </div>
  </div>
</template>

用户能实时看到每张图片的上传进度。"

问题6:拖拽上传是怎么做的?

回答思路: "拖拽主要用drag和drop事件,要注意阻止浏览器默认行为:

javascript

javascript
// components/ImageUploader.vue
<script setup>
const isDragging = ref(false)
const { uploadFile } = useUpload()

function onDragOver(e) {
  e.preventDefault()
  e.stopPropagation()
  isDragging.value = true
}

function onDragLeave(e) {
  e.preventDefault()
  e.stopPropagation()
  isDragging.value = false
}

function onDrop(e) {
  e.preventDefault()
  e.stopPropagation()
  isDragging.value = false

  const files = Array.from(e.dataTransfer.files)
  const imageFiles = files.filter(file =>
    file.type.startsWith('image/')
  )

  if (imageFiles.length === 0) {
    alert('请拖拽图片文件')
    return
  }

  imageFiles.forEach(file => uploadFile(file))
}
</script>

<template>
  <div
    class="upload-area"
    :class="{ 'is-dragging': isDragging }"
    @dragover="onDragOver"
    @dragleave="onDragLeave"
    @drop="onDrop"
  >
    <input
      ref="fileInput"
      type="file"
      multiple
      accept="image/*"
      @change="onFileChange"
      style="display: none"
    />
    <div v-if="!isDragging">
      <p>拖拽图片到此处或</p>
      <button @click="fileInput.click()">点击选择</button>
    </div>
    <div v-else>
      <p>松开鼠标上传</p>
    </div>
  </div>
</template>

<style scoped>
.upload-area {
  border: 2px dashed #ccc;
  padding: 40px;
  text-align: center;
  transition: all 0.3s;
}

.upload-area.is-dragging {
  border-color: #409eff;
  background-color: #f0f9ff;
}
</style>

关键是preventDefault阻止浏览器打开文件,以及用dataTransfer.files获取拖拽的文件。"

完整的组件示例

javascript

javascript
// components/BatchImageUploader.vue
<script setup>
import { ref, reactive, computed } from 'vue'
import { useUploadPool } from '../composables/useUploadPool'
import { useImageCompress } from '../composables/useImageCompress'
import { useHEICConverter } from '../composables/useHEICConverter'

const fileInput = ref(null)
const isDragging = ref(false)
const uploadList = ref([])

const uploadPool = useUploadPool(3)
const { compress } = useImageCompress()
const { convertIfNeeded } = useHEICConverter()

// 格式校验
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif']
const MAX_SIZE = 10 * 1024 * 1024 // 10MB

function validateFile(file) {
  if (!ALLOWED_TYPES.includes(file.type) && !file.name.match(/\.(heic|heif)$/i)) {
    return '不支持的文件格式'
  }
  if (file.size > MAX_SIZE) {
    return '文件大小超过10MB'
  }
  return null
}

// 创建预览URL
function createPreview(file) {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.onload = (e) => resolve(e.target.result)
    reader.readAsDataURL(file)
  })
}

// 处理单个文件
async function processFile(file) {
  const item = reactive({
    id: Date.now() + Math.random(),
    file,
    name: file.name,
    size: file.size,
    preview: '',
    status: 'pending',
    progress: 0,
    error: null
  })

  uploadList.value.push(item)

  try {
    // 1. 格式校验
    const error = validateFile(file)
    if (error) {
      item.status = 'error'
      item.error = error
      return
    }

    // 2. 生成预览
    item.preview = await createPreview(file)

    // 3. HEIC转换
    item.status = 'converting'
    let processedFile = await convertIfNeeded(file)

    // 4. 压缩
    item.status = 'compressing'
    if (processedFile.size > 500 * 1024) {
      const compressed = await compress(processedFile, {
        quality: 0.8,
        maxWidth: 1920
      })
      processedFile = new File([compressed], processedFile.name, {
        type: 'image/jpeg'
      })
    }

    // 5. 上传
    item.status = 'uploading'
    await uploadToServer(processedFile, (progress) => {
      item.progress = progress
    })

    item.status = 'success'
  } catch (err) {
    item.status = 'error'
    item.error = err.message || '上传失败'
  }
}

// 上传到服务器
function uploadToServer(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round((e.loaded / e.total) * 100))
      }
    }

    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.response))
      } else {
        reject(new Error('上传失败'))
      }
    }

    xhr.onerror = () => reject(new Error('网络错误'))

    const formData = new FormData()
    formData.append('file', file)

    xhr.open('POST', '/api/upload')
    xhr.send(formData)
  })
}

// 文件选择
function onFileChange(e) {
  const files = Array.from(e.target.files)
  files.forEach(file => {
    uploadPool.add(() => processFile(file))
  })
  e.target.value = '' // 清空input,允许重复选择同一文件
}

// 拖拽
function onDragOver(e) {
  e.preventDefault()
  isDragging.value = true
}

function onDragLeave(e) {
  e.preventDefault()
  isDragging.value = false
}

function onDrop(e) {
  e.preventDefault()
  isDragging.value = false

  const files = Array.from(e.dataTransfer.files).filter(f =>
    f.type.startsWith('image/')
  )

  files.forEach(file => {
    uploadPool.add(() => processFile(file))
  })
}

// 重试
function retry(item) {
  item.status = 'pending'
  item.progress = 0
  item.error = null
  uploadPool.add(() => processFile(item.file))
}

// 删除
function remove(item) {
  const index = uploadList.value.indexOf(item)
  if (index > -1) {
    uploadList.value.splice(index, 1)
  }
}

// 统计
const stats = computed(() => ({
  total: uploadList.value.length,
  success: uploadList.value.filter(i => i.status === 'success').length,
  error: uploadList.value.filter(i => i.status === 'error').length,
  pending: uploadList.value.filter(i =>
    ['pending', 'converting', 'compressing', 'uploading'].includes(i.status)
  ).length
}))
</script>

<template>
  <div class="batch-uploader">
    <div
      class="upload-area"
      :class="{ 'is-dragging': isDragging }"
      @dragover="onDragOver"
      @dragleave="onDragLeave"
      @drop="onDrop"
      @click="fileInput.click()"
    >
      <input
        ref="fileInput"
        type="file"
        multiple
        accept="image/*,.heic,.heif"
        @change="onFileChange"
      />
      <div class="upload-tip">
        <span v-if="!isDragging">点击或拖拽图片到此处上传</span>
        <span v-else>松开鼠标开始上传</span>
      </div>
    </div>

    <div v-if="stats.total > 0" class="stats">
      共{{ stats.total }}张,成功{{ stats.success }}张,失败{{ stats.error }}张,进行中{{ stats.pending }}张
    </div>

    <div class="image-list">
      <div
        v-for="item in uploadList"
        :key="item.id"
        class="image-item"
        :class="item.status"
      >
        <div class="preview">
          <img v-if="item.preview" :src="item.preview" />
          <div v-else class="placeholder">加载中...</div>
        </div>

        <div class="info">
          <div class="name">{{ item.name }}</div>

          <div v-if="item.status === 'converting'" class="status">
            正在转换格式...
          </div>

          <div v-else-if="item.status === 'compressing'" class="status">
            正在压缩...
          </div>

          <div v-else-if="item.status === 'uploading'" class="status">
            <div class="progress-bar">
              <div class="bar" :style="{ width: item.progress + '%' }"></div>
            </div>
            <span>{{ item.progress }}%</span>
          </div>

          <div v-else-if="item.status === 'success'" class="status success">
            ✓ 上传成功
          </div>

          <div v-else-if="item.status === 'error'" class="status error">
            <span>{{ item.error }}</span>
            <button @click="retry(item)">重试</button>
          </div>
        </div>

        <button class="remove" @click="remove(item)">×</button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.batch-uploader {
  padding: 20px;
}

.upload-area {
  border: 2px dashed #d9d9d9;
  border-radius: 8px;
  padding: 60px 20px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s;
}

.upload-area:hover {
  border-color: #40a9ff;
}

.upload-area.is-dragging {
  border-color: #1890ff;
  background-color: #e6f7ff;
}

.upload-area input {
  display: none;
}

.upload-tip {
  font-size: 16px;
  color: #666;
}

.stats {
  margin: 20px 0;
  font-size: 14px;
  color: #666;
}

.image-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
  margin-top: 20px;
}

.image-item {
  position: relative;
  border: 1px solid #e8e8e8;
  border-radius: 8px;
  overflow: hidden;
}

.preview {
  width: 100%;
  height: 150px;
  background: #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.preview img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

.info {
  padding: 8px;
  font-size: 12px;
}

.name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-bottom: 4px;
}

.status {
  color: #666;
}

.status.success {
  color: #52c41a;
}

.status.error {
  color: #ff4d4f;
}

.progress-bar {
  height: 4px;
  background: #f0f0f0;
  border-radius: 2px;
  overflow: hidden;
  margin: 4px 0;
}

.progress-bar .bar {
  height: 100%;
  background: #1890ff;
  transition: width 0.3s;
}

.remove {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 24px;
  height: 24px;
  border: none;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  border-radius: 50%;
  cursor: pointer;
  font-size: 18px;
  line-height: 1;
}
</style>