完整代码 点击这里
基于运行后端node服务: /mock-server/server.js
初中级前端
企业级图片批量上传组件
- 核心职责:独立设计并实现支持拖拽、多选、预览、格式校验、实时进度的图片批量上传组件
- 性能优化:通过Promise并发池控制同时上传数量(3-5个),避免浏览器连接数限制,提升30%上传效率
- 大图处理:使用Web Worker进行图片压缩(Canvas离屏渲染),单张2MB+图片压缩至500KB,不阻塞主线程
- 兼容方案:集成heic2any库解决iOS HEIC格式兼容问题,自动转换为JPEG格式
- 稳定性:实现分片上传(2MB/片)+ 失败重试机制(指数退避策略),大文件上传成功率提升至98%
- 技术栈:Vue3 Composition API、XMLHttpRequest、Web Worker、FileReader API
高级前端
企业级通用图片批量上传组件
项目背景:
为解决企业内部多个业务线图片上传功能重复开发、用户体验不一致的问题,独立负责设计并实现一套可复用的图片批量上传组件,已在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%
详细版(面试官会深挖技术细节)
企业级通用图片批量上传组件(核心项目)
项目背景与价值:
针对企业内部多条业务线(商品管理、活动运营、用户认证等)图片上传功能重复开发、代码冗余、用户体验参差不齐的痛点,独立负责设计并实现一套企业级通用图片批量上传组件。该组件已成功在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
// 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
const uploadPool = useUploadPool(3)
files.forEach(file => {
uploadPool.add(() => uploadSingleFile(file))
})
这样就能保证同时最多只有3个上传任务在跑。"
问题2:Web Worker是怎么用的?为什么要用它?
回答思路: "图片压缩涉及大量的像素计算,是CPU密集型操作,直接在主线程做会阻塞页面交互,用户会感觉页面卡住了。用Web Worker可以把压缩任务放到后台线程执行,主线程继续响应用户操作。
我创建了一个专门的压缩Worker:
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
// 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
// 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
async function handleFiles(fileList) {
const converter = useHEICConverter()
for (let file of fileList) {
const convertedFile = await converter.convertIfNeeded(file)
await uploadFile(convertedFile)
}
}
这样iOS用户也能正常上传照片了。"
问题4:分片上传是怎么实现的?
回答思路: "对于大文件,我会切成多个分片分别上传,这样单个分片失败了可以重试,不用重传整个文件。
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
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
// 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
<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
// 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
// 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>