refactor(ui): 重构素材上传弹窗组件,优化用户交互体验
Some checks are pending
Build and Deploy / deploy (push) Waiting to run
Some checks are pending
Build and Deploy / deploy (push) Waiting to run
- 重构整个上传流程为清晰的三阶段结构:拖拽区、文件列表、上传进度 - 使用 BEM 命名规范替换原有嵌套 CSS 选择器,提升可维护性 - 简化模板逻辑和模板代码,移除冗余状态变量和条件判断 - 优化进度显示,新增剩余时间估算功能 - 替换 `Progress` 组件为原生样式实现,减少外部组件依赖 - 统一文件操作和上传逻辑的错误处理流程 - 优化视觉设计:调整弹窗宽度、改进拖拽区样式、精简按钮布局
This commit is contained in:
@@ -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 />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user