refactor(ui): 重构素材上传弹窗组件,优化用户交互体验
Some checks failed
Build and Deploy / deploy (push) Has been cancelled

- 重构整个上传流程为清晰的三阶段结构:拖拽区、文件列表、上传进度
- 使用 BEM 命名规范替换原有嵌套 CSS 选择器,提升可维护性
- 简化模板逻辑和模板代码,移除冗余状态变量和条件判断
- 优化进度显示,新增剩余时间估算功能
- 替换 `Progress` 组件为原生样式实现,减少外部组件依赖
- 统一文件操作和上传逻辑的错误处理流程
- 优化视觉设计:调整弹窗宽度、改进拖拽区样式、精简按钮布局
This commit is contained in:
2026-06-04 01:16:22 +08:00
parent 499698cb0d
commit 2ea88316d2

View File

@@ -1,129 +1,98 @@
<template>
<Dialog :open="props.visible" @update:open="handleVisibleChange">
<DialogContent class="max-w-[600px]">
<DialogContent class="max-w-[560px]">
<DialogHeader>
<DialogTitle>上传素材</DialogTitle>
</DialogHeader>
<div class="upload-modal-content">
<!-- 文件上传区域 -->
<div class="upload-area" v-if="!uploading">
<div
class="upload-dragger"
:class="{ 'drag-over': isDragOver }"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
>
<input
ref="fileInputRef"
type="file"
multiple
:accept="acceptTypes"
class="hidden"
@change="handleFileSelect"
/>
<div class="upload-icon">
<Icon icon="lucide:cloud-upload" class="text-4xl text-blue-500" />
</div>
<p class="upload-text">点击或拖拽文件到此处上传</p>
<p class="upload-hint">
支持多文件上传单文件最大 1GB
<br />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</p>
<div class="upload-body">
<!-- 阶段 1拖拽区 -->
<div v-if="!uploading && fileList.length === 0" class="drop-zone" :class="{ 'drop-zone--active': isDragOver }"
@click="triggerFileInput" @drop.prevent="handleDrop" @dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false">
<input ref="fileInputRef" type="file" multiple :accept="acceptTypes" hidden @change="handleFileSelect" />
<div class="drop-zone__icon">
<Icon icon="lucide:cloud-upload" />
</div>
<p class="drop-zone__title">拖拽文件到此处或点击选择</p>
<p class="drop-zone__hint">视频 · 图片 · 音频 单文件最大 1GB</p>
</div>
<!-- 已选文件列表 -->
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
<div class="upload-file-list-title">
已选 {{ fileList.length }} 个文件总大小{{ getTotalSize() }}
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" class="text-red-500 ml-2">
已超出 1GB 限制
<!-- 阶段 2已选文件列表 -->
<div v-if="fileList.length > 0 && !uploading" class="file-pick-list">
<div class="file-pick-list__header">
<span>已选 <strong>{{ fileList.length }}</strong> 个文件</span>
<span class="file-pick-list__total" :class="{ 'text-danger': getTotalSizeBytes() > MAX_TOTAL_SIZE }">
{{ getTotalSize() }} / 1GB
</span>
</div>
<div class="upload-file-items">
<div
v-for="(fileItem, index) in fileList"
:key="fileItem.uid || index"
class="upload-file-item"
>
<Icon icon="lucide:file" class="file-icon" />
<span class="file-name">{{ getFileName(fileItem) }}</span>
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
<Button
variant="ghost"
size="sm"
class="text-red-500 hover:text-red-600"
@click="handleRemove(fileItem)"
>
删除
</Button>
<div class="file-pick-list__items">
<div v-for="(fileItem, index) in fileList" :key="fileItem.uid || index" class="pick-item">
<Icon icon="lucide:file" class="pick-item__icon" />
<span class="pick-item__name" :title="getFileName(fileItem)">{{ getFileName(fileItem) }}</span>
<span class="pick-item__size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
<button class="pick-item__remove" @click="handleRemove(fileItem)" title="移除">
<Icon icon="lucide:x" />
</button>
</div>
</div>
</div>
<!-- 上传进度区域 -->
<div v-if="uploading" class="upload-progress-area">
<div class="upload-progress-header">
<span class="custom-spinner"></span>
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
<!-- 阶段 3上传进度 -->
<div v-if="uploading" class="upload-stage">
<!-- 头部状态 -->
<div class="upload-stage__status">
<span class="upload-stage__spinner" />
<span class="upload-stage__label">{{ currentUploadIndex }} / {{ totalUploadCount }}</span>
<span class="upload-stage__eta" v-if="currentFileProgress > 0 && currentFileProgress < 100">
剩余约 {{ estimatedRemaining }}
</span>
</div>
<!-- 总体进度 -->
<div class="upload-total-progress">
<div class="progress-info">
<span>总体进度</span>
<span>{{ totalProgress }}%</span>
<!-- 进度 -->
<div class="total-bar">
<div class="total-bar__track">
<div class="total-bar__fill" :style="{ width: totalProgress + '%' }" />
</div>
<Progress :value="totalProgress" class="h-2" />
<span class="total-bar__pct">{{ totalProgress }}%</span>
</div>
<!-- 当前文件进度 -->
<div class="upload-current-file">
<div class="current-file-name">
<!-- 当前文件 -->
<div class="live-file">
<div class="live-file__name">
<Icon icon="lucide:file" />
<span>{{ currentFileName }}</span>
</div>
<div class="current-file-progress">
<div class="progress-info">
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
<span>{{ currentFileProgress }}%</span>
<div class="live-file__bar">
<div class="live-file__track">
<div class="live-file__fill" :style="{ width: currentFileProgress + '%' }" />
</div>
<Progress :value="currentFileProgress" class="h-1.5" />
<span class="live-file__pct">{{ currentFileProgress }}%</span>
</div>
<div class="live-file__bytes">{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize)
}}</div>
</div>
<!-- 已完成文件列表 -->
<div v-if="completedFiles.length > 0" class="completed-files">
<div class="completed-header">
<Icon icon="lucide:check-circle" class="text-green-500" />
<span>已完成 {{ completedFiles.length }} 个文件</span>
</div>
<div class="completed-list">
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
<Icon icon="lucide:check" class="text-green-500 text-xs" />
<span>{{ file.name }}</span>
</div>
<!-- 已完成 / 失败 列表 -->
<div v-if="completedFiles.length > 0" class="done-list">
<div v-for="(f, i) in completedFiles" :key="i" class="done-item" :class="f.success ? 'done-item--ok' : 'done-item--fail'">
<Icon :icon="f.success ? 'lucide:check-circle' : 'lucide:x-circle'" class="done-item__icon" />
<span class="done-item__name">{{ f.name }}</span>
<span class="done-item__tag">{{ f.success ? '成功' : '失败' }}</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="upload-actions">
<div class="flex gap-2 justify-end">
<Button v-if="!uploading" variant="outline" @click="handleCancel">取消</Button>
<Button
v-if="!uploading"
:disabled="fileList.length === 0"
@click="handleConfirm"
>
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
<!-- 底部操作栏 -->
<div class="upload-footer">
<template v-if="!uploading">
<Button variant="outline" size="sm" @click="handleCancel">取消</Button>
<Button size="sm" :disabled="fileList.length === 0" @click="handleConfirm">
<Icon icon="lucide:upload" class="mr-1.5 size-4" />
上传 {{ fileList.length }} 个文件
</Button>
<span v-if="uploading" class="upload-tip">上传中请勿关闭窗口...</span>
</div>
</template>
<p v-else class="upload-footer__hint">上传中请勿关闭窗口</p>
</div>
</div>
</DialogContent>
@@ -135,47 +104,31 @@ import { ref, watch, computed } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
import { useUpload } from '@/composables/useUpload'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
fileCategory: {
type: String,
default: 'video'
},
groupId: {
type: Number,
default: null
}
visible: { type: Boolean, default: false },
fileCategory: { type: String, default: 'video' },
groupId: { type: Number, default: null }
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'complete'])
// Hooks
const { upload } = useUpload()
// 数据
// ── 状态 ──
const fileList = ref([])
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
const isDragOver = ref(false)
const fileInputRef = ref(null)
// 常量
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024 // 1GB (单个文件最大1GB)
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024
// 上传状态
const uploading = ref(false)
const currentUploadIndex = ref(0)
const totalUploadCount = ref(0)
@@ -185,458 +138,337 @@ const currentFileSize = ref(0)
const currentFileUploaded = ref(0)
const completedFiles = ref([])
// 计算总进度
// ── 计算 ──
const totalProgress = computed(() => {
if (totalUploadCount.value === 0) return 0
const baseProgress = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
const currentContribution = (currentFileProgress.value / 100) / totalUploadCount.value * 100
return Math.round(baseProgress + currentContribution)
const base = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
const cur = (currentFileProgress.value / 100) / totalUploadCount.value * 100
return Math.round(base + cur)
})
// 计算总大小(字节)
const getTotalSizeBytes = () => {
return fileList.value.reduce((total, item) => {
return total + getFileSize(item)
}, 0)
}
const estimatedRemaining = computed(() => {
if (currentFileProgress.value === 0) return '计算中…'
const remainingFiles = totalUploadCount.value - currentUploadIndex.value
const totalRemaining = remainingFiles * currentFileSize.value + (currentFileSize.value - currentFileUploaded.value)
if (totalRemaining < 1024 * 1024) return '不到 1 分钟'
return '约 ' + Math.ceil(totalRemaining / (10 * 1024 * 1024)) + ' 分钟'
})
// 计算总大小(格式化)
const getTotalSize = () => {
return formatFileSize(getTotalSizeBytes())
}
const getTotalSizeBytes = () => fileList.value.reduce((t, i) => t + getFileSize(i), 0)
const getTotalSize = () => formatFileSize(getTotalSizeBytes())
// 触发文件选择
const triggerFileInput = () => {
fileInputRef.value?.click()
}
// ── 文件操作 ──
const triggerFileInput = () => fileInputRef.value?.click()
// 处理文件选择
const handleFileSelect = (e) => {
const files = e.target.files
if (files) {
addFiles(Array.from(files))
}
// 重置 input 以便可以再次选择相同文件
if (e.target.files) addFiles(Array.from(e.target.files))
e.target.value = ''
}
// 处理拖放
const handleDrop = (e) => {
isDragOver.value = false
const files = e.dataTransfer?.files
if (files) {
addFiles(Array.from(files))
}
if (e.dataTransfer?.files) addFiles(Array.from(e.dataTransfer.files))
}
// 添加文件
const addFiles = (files) => {
files.forEach(file => {
// 检查单个文件大小
if (file.size > MAX_SINGLE_FILE_SIZE) {
toast.warning(`文件 ${file.name} 超过 1GB已跳过`)
return
}
// 计算当前文件列表的总大小(包括新文件)
const currentTotalSize = getTotalSizeBytes()
const newTotalSize = currentTotalSize + file.size
// 检查总大小
if (newTotalSize > MAX_TOTAL_SIZE) {
toast.warning(`文件总大小超过 1GB当前${formatFileSize(currentTotalSize)},新增:${formatFileSize(file.size)}),已跳过`)
return
}
// 检查是否已存在相同文件
const exists = fileList.value.some(item => {
const itemName = getFileName(item)
const itemSize = getFileSize(item)
return itemName === file.name && itemSize === file.size
})
if (exists) {
toast.warning(`文件 ${file.name} 已存在,已跳过`)
return
}
fileList.value.push({
uid: `${Date.now()}-${Math.random()}`,
file,
name: file.name,
size: file.size
})
if (file.size > MAX_SINGLE_FILE_SIZE) return toast.warning(`文件 ${file.name} 超过 1GB已跳过`)
const currentTotal = getTotalSizeBytes()
if (currentTotal + file.size > MAX_TOTAL_SIZE) return toast.warning(`总大小超过 1GB已跳过 ${file.name}`)
if (fileList.value.some(i => getFileName(i) === file.name && getFileSize(i) === file.size)) return
fileList.value.push({ uid: `${Date.now()}-${Math.random()}`, file, name: file.name, size: file.size })
})
}
// 监听 visible 变化,重置文件列表
watch(() => props.visible, (newVal) => {
if (!newVal) {
fileList.value = []
resetUploadState()
}
})
// 重置上传状态
const resetUploadState = () => {
uploading.value = false
currentUploadIndex.value = 0
totalUploadCount.value = 0
currentFileName.value = ''
currentFileProgress.value = 0
currentFileSize.value = 0
currentFileUploaded.value = 0
completedFiles.value = []
}
// 移除文件
const handleRemove = (fileItem) => {
const index = fileList.value.findIndex(item =>
(item.uid && item.uid === fileItem.uid) ||
(getFileName(item) === getFileName(fileItem))
)
if (index > -1) {
fileList.value.splice(index, 1)
}
const idx = fileList.value.findIndex(i => (i.uid && i.uid === fileItem.uid) || getFileName(i) === getFileName(fileItem))
if (idx > -1) fileList.value.splice(idx, 1)
}
// 确认上传
// ── 生命周期 ──
watch(() => props.visible, (v) => { if (!v) { fileList.value = []; resetUploadState() } })
const resetUploadState = () => {
uploading.value = false; currentUploadIndex.value = 0; totalUploadCount.value = 0
currentFileName.value = ''; currentFileProgress.value = 0; currentFileSize.value = 0
currentFileUploaded.value = 0; completedFiles.value = []
}
// ── 上传 ──
const handleConfirm = async () => {
if (fileList.value.length === 0) {
toast.warning('请选择文件')
return
}
// 检查总大小
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) {
toast.warning(`文件总大小超过 1GB 限制,请移除部分文件`)
return
}
// 提取文件对象
const files = fileList.value
.map(item => {
const fileObj = item.file || item.originFileObj || item
if (!(fileObj instanceof File)) return null
return fileObj
})
.filter(item => item !== null)
if (files.length === 0) {
toast.error('无法获取文件对象,请重新选择文件')
return
}
// 开始上传
if (fileList.value.length === 0) return toast.warning('请选择文件')
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) return toast.warning('总大小超过 1GB请移除部分文件')
const files = fileList.value.map(i => i.file || i.originFileObj || i).filter(f => f instanceof File)
if (files.length === 0) return toast.error('无法获取文件对象,请重新选择')
await performUpload(files)
}
// 执行上传
const performUpload = async (files) => {
uploading.value = true
totalUploadCount.value = files.length
currentUploadIndex.value = 0
completedFiles.value = []
// 使用传入的fileCategory
const category = props.fileCategory || 'video'
let successCount = 0
let failCount = 0
uploading.value = true; totalUploadCount.value = files.length; currentUploadIndex.value = 0; completedFiles.value = []
let ok = 0, fail = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
currentUploadIndex.value = i + 1
currentFileName.value = file.name
currentFileSize.value = file.size
currentFileUploaded.value = 0
currentFileProgress.value = 0
currentUploadIndex.value = i + 1; currentFileName.value = file.name
currentFileSize.value = file.size; currentFileUploaded.value = 0; currentFileProgress.value = 0
try {
await upload(file, {
fileCategory: category,
fileCategory: props.fileCategory || 'video',
groupId: props.groupId,
onProgress: (progress) => {
currentFileProgress.value = progress
currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value)
},
onSuccess: () => {
console.log('文件上传成功:', file.name)
successCount++
completedFiles.value.push({
name: file.name,
success: true
})
},
onError: (error) => {
console.error('文件上传失败:', file.name, error)
failCount++
completedFiles.value.push({
name: file.name,
success: false
})
}
onProgress: (p) => { currentFileProgress.value = p; currentFileUploaded.value = Math.round((p / 100) * file.size) },
onSuccess: () => { ok++; completedFiles.value.push({ name: file.name, success: true }) },
onError: () => { fail++; completedFiles.value.push({ name: file.name, success: false }) }
})
} catch (error) {
console.error('上传异常:', error)
failCount++
completedFiles.value.push({
name: file.name,
success: false
})
}
} catch { fail++; completedFiles.value.push({ name: file.name, success: false }) }
}
uploading.value = false
// 显示结果
if (successCount > 0 && failCount === 0) {
toast.success(`成功上传 ${successCount} 个文件`)
} else if (successCount > 0 && failCount > 0) {
toast.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
} else {
toast.error('上传失败,请重试')
}
// 通知父组件上传完成
emit('complete', { successCount, failCount })
// 关闭弹窗
if (ok > 0 && fail === 0) toast.success(`成功上传 ${ok} 个文件`)
else if (ok > 0) toast.warning(`上传完成:${ok} 成功,${fail} 失败`)
else toast.error('上传失败,请重试')
emit('complete', { successCount: ok, failCount: fail })
emit('update:visible', false)
}
// 处理 visible 变化
const handleVisibleChange = (value) => {
if (!value && uploading.value) return
emit('update:visible', value)
}
// 取消
const handleCancel = () => {
if (uploading.value) return
emit('update:visible', false)
emit('cancel')
}
const handleVisibleChange = (v) => { if (!v && uploading.value) return; emit('update:visible', v) }
const handleCancel = () => { if (uploading.value) return; emit('update:visible', false); emit('cancel') }
</script>
<style scoped lang="less">
.upload-modal-content {
padding: 8px 0;
/* ═══════════════════════════════════════════════════════════════
上传弹窗 - 清晰三段式
═══════════════════════════════════════════════════════════════ */
.upload-body {
padding: 4px 0;
}
.upload-area {
margin-bottom: 24px;
}
.upload-dragger {
/* ── 拖拽区 ── */
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
border: 2px dashed var(--color-border);
padding: 40px 24px;
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
background: var(--color-surface);
cursor: pointer;
transition: all 0.2s ease;
transition: all .2s;
&:hover, &.drag-over {
border-color: #3B82F6;
background: rgba(59, 130, 246, 0.05);
&:hover,
&--active {
border-color: var(--primary);
background: hsl(var(--primary) / .04);
}
.upload-icon {
margin-bottom: 12px;
&__icon {
width: 52px; height: 52px;
display: flex; align-items: center; justify-content: center;
background: hsl(var(--primary) / .08);
border-radius: 50%;
color: var(--primary);
font-size: 24px;
margin-bottom: 16px;
}
.upload-text {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
margin-bottom: 8px;
&__title {
font-size: 15px; font-weight: 500; color: var(--foreground); margin-bottom: 6px;
}
.upload-hint {
font-size: 12px;
color: var(--color-text-3);
text-align: center;
line-height: 1.6;
&__hint {
font-size: 12px; color: var(--muted-foreground);
}
}
.hidden {
display: none;
}
.upload-file-list {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.upload-file-list-title {
font-weight: 500;
margin-bottom: 12px;
color: var(--color-text);
display: flex;
align-items: center;
flex-wrap: wrap;
}
.upload-file-items {
max-height: 200px;
overflow-y: auto;
}
.upload-file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 4px;
}
.upload-file-item:hover {
background: var(--color-bg-2);
}
.file-icon {
font-size: 20px;
color: var(--color-text-3);
}
.file-name {
flex: 1;
min-width: 0;
max-width: 240px;
font-size: 14px;
color: var(--color-text);
/* ── 已选文件列表 ── */
.file-pick-list {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&__header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px;
font-size: 13px; color: var(--muted-foreground);
background: var(--muted);
border-bottom: 1px solid var(--border);
strong { color: var(--foreground); }
}
&__total {
&.text-danger { color: var(--destructive); font-weight: 500; }
}
&__items {
max-height: 220px; overflow-y: auto;
}
}
.file-size {
font-size: 12px;
color: var(--color-text-3);
}
.upload-actions {
text-align: right;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.upload-tip {
color: var(--color-text-3);
.pick-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
&:last-child { border-bottom: none; }
&__icon { color: var(--muted-foreground); flex-shrink: 0; }
&__name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--foreground);
}
&__size {
color: var(--muted-foreground); font-size: 12px; flex-shrink: 0;
}
&__remove {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: 6px; border: none; background: transparent;
color: var(--muted-foreground); cursor: pointer;
&:hover { background: var(--destructive)/.1; color: var(--destructive); }
}
}
/* 上传进度区域 */
.upload-progress-area {
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
/* ── 上传进度 ── */
.upload-stage {
&__status {
display: flex; align-items: center; gap: 10px;
margin-bottom: 16px;
font-size: 14px; font-weight: 500;
}
&__spinner {
width: 18px; height: 18px;
border: 2px solid hsl(var(--primary) / .15);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin .7s linear infinite;
}
&__label { color: var(--foreground); }
&__eta {
margin-left: auto;
font-size: 12px; font-weight: 400; color: var(--muted-foreground);
}
}
.upload-progress-header {
display: flex;
align-items: center;
gap: 12px;
@keyframes spin { to { transform: rotate(360deg); } }
/* 总进度条 */
.total-bar {
display: flex; align-items: center; gap: 12px;
margin-bottom: 20px;
font-size: 16px;
font-weight: 500;
color: var(--color-text);
&__track {
flex: 1; height: 8px;
background: var(--muted);
border-radius: 4px; overflow: hidden;
}
&__fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), hsl(var(--primary) / .7));
border-radius: 4px;
transition: width .3s ease;
}
&__pct {
font-size: 14px; font-weight: 600; color: var(--primary);
min-width: 36px; text-align: right;
}
}
.custom-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(59, 130, 246, 0.2);
border-top-color: #3B82F6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.upload-total-progress {
margin-bottom: 20px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
color: var(--color-text-2);
}
.upload-current-file {
padding: 12px;
background: var(--color-bg-2);
border-radius: 8px;
margin-bottom: 16px;
}
.current-file-name {
display: flex;
align-items: center;
gap: 8px;
/* 当前文件 */
.live-file {
padding: 14px;
background: var(--muted);
border-radius: 10px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
&__name {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 500; color: var(--foreground);
margin-bottom: 10px;
span {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
}
&__bar {
display: flex; align-items: center; gap: 10px;
margin-bottom: 6px;
}
&__track {
flex: 1; height: 5px;
background: var(--border);
border-radius: 3px; overflow: hidden;
}
&__fill {
height: 100%; background: var(--primary);
border-radius: 3px;
transition: width .15s ease;
}
&__pct {
font-size: 12px; font-weight: 500; color: var(--primary);
min-width: 32px; text-align: right;
}
&__bytes {
font-size: 11px; color: var(--muted-foreground);
}
}
.current-file-name span {
min-width: 0;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* 已完成 / 失败 */
.done-list {
max-height: 140px; overflow-y: auto;
}
.current-file-progress .progress-info {
margin-bottom: 6px;
}
.completed-files {
margin-top: 12px;
}
.completed-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.done-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 0;
font-size: 13px;
font-weight: 500;
color: var(--color-text-2);
&__icon { flex-shrink: 0; }
&--ok &__icon { color: var(--success); }
&--fail &__icon { color: var(--destructive); }
&__name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--foreground);
}
&__tag {
font-size: 11px; font-weight: 500;
padding: 1px 8px; border-radius: 100px;
flex-shrink: 0;
.done-item--ok & {
background: hsl(var(--success) / .1);
color: var(--success);
}
.done-item--fail & {
background: hsl(var(--destructive) / .1);
color: var(--destructive);
}
}
}
.completed-list {
max-height: 120px;
overflow-y: auto;
}
/* ── 底部栏 ── */
.upload-footer {
display: flex; justify-content: flex-end; align-items: center; gap: 8px;
padding-top: 16px; margin-top: 4px;
border-top: 1px solid var(--border);
.completed-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 12px;
color: var(--color-text-3);
}
.completed-item span {
min-width: 0;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&__hint {
font-size: 13px; color: var(--muted-foreground); margin: 0;
}
}
</style>