refactor(ui): 重构素材上传弹窗组件,优化用户交互体验
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 重构整个上传流程为清晰的三阶段结构:拖拽区、文件列表、上传进度 - 使用 BEM 命名规范替换原有嵌套 CSS 选择器,提升可维护性 - 简化模板逻辑和模板代码,移除冗余状态变量和条件判断 - 优化进度显示,新增剩余时间估算功能 - 替换 `Progress` 组件为原生样式实现,减少外部组件依赖 - 统一文件操作和上传逻辑的错误处理流程 - 优化视觉设计:调整弹窗宽度、改进拖拽区样式、精简按钮布局
This commit is contained in:
@@ -1,129 +1,98 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog :open="props.visible" @update:open="handleVisibleChange">
|
<Dialog :open="props.visible" @update:open="handleVisibleChange">
|
||||||
<DialogContent class="max-w-[600px]">
|
<DialogContent class="max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>上传素材</DialogTitle>
|
<DialogTitle>上传素材</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="upload-modal-content">
|
<div class="upload-body">
|
||||||
<!-- 文件上传区域 -->
|
<!-- ═══ 阶段 1:拖拽区 ═══ -->
|
||||||
<div class="upload-area" v-if="!uploading">
|
<div v-if="!uploading && fileList.length === 0" class="drop-zone" :class="{ 'drop-zone--active': isDragOver }"
|
||||||
<div
|
@click="triggerFileInput" @drop.prevent="handleDrop" @dragover.prevent="isDragOver = true"
|
||||||
class="upload-dragger"
|
@dragleave.prevent="isDragOver = false">
|
||||||
:class="{ 'drag-over': isDragOver }"
|
<input ref="fileInputRef" type="file" multiple :accept="acceptTypes" hidden @change="handleFileSelect" />
|
||||||
@click="triggerFileInput"
|
<div class="drop-zone__icon">
|
||||||
@drop.prevent="handleDrop"
|
<Icon icon="lucide:cloud-upload" />
|
||||||
@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>
|
</div>
|
||||||
|
<p class="drop-zone__title">拖拽文件到此处,或点击选择</p>
|
||||||
|
<p class="drop-zone__hint">视频 · 图片 · 音频 | 单文件最大 1GB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已选文件列表 -->
|
<!-- ═══ 阶段 2:已选文件列表 ═══ -->
|
||||||
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
|
<div v-if="fileList.length > 0 && !uploading" class="file-pick-list">
|
||||||
<div class="upload-file-list-title">
|
<div class="file-pick-list__header">
|
||||||
已选择 {{ fileList.length }} 个文件,总大小:{{ getTotalSize() }}
|
<span>已选 <strong>{{ fileList.length }}</strong> 个文件</span>
|
||||||
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" class="text-red-500 ml-2">
|
<span class="file-pick-list__total" :class="{ 'text-danger': getTotalSizeBytes() > MAX_TOTAL_SIZE }">
|
||||||
(已超出 1GB 限制)
|
{{ getTotalSize() }} / 1GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-file-items">
|
<div class="file-pick-list__items">
|
||||||
<div
|
<div v-for="(fileItem, index) in fileList" :key="fileItem.uid || index" class="pick-item">
|
||||||
v-for="(fileItem, index) in fileList"
|
<Icon icon="lucide:file" class="pick-item__icon" />
|
||||||
:key="fileItem.uid || index"
|
<span class="pick-item__name" :title="getFileName(fileItem)">{{ getFileName(fileItem) }}</span>
|
||||||
class="upload-file-item"
|
<span class="pick-item__size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
|
||||||
>
|
<button class="pick-item__remove" @click="handleRemove(fileItem)" title="移除">
|
||||||
<Icon icon="lucide:file" class="file-icon" />
|
<Icon icon="lucide:x" />
|
||||||
<span class="file-name">{{ getFileName(fileItem) }}</span>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 上传进度区域 -->
|
<!-- ═══ 阶段 3:上传进度 ═══ -->
|
||||||
<div v-if="uploading" class="upload-progress-area">
|
<div v-if="uploading" class="upload-stage">
|
||||||
<div class="upload-progress-header">
|
<!-- 头部状态 -->
|
||||||
<span class="custom-spinner"></span>
|
<div class="upload-stage__status">
|
||||||
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
|
<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>
|
||||||
|
|
||||||
<!-- 总体进度 -->
|
<!-- 总进度条 -->
|
||||||
<div class="upload-total-progress">
|
<div class="total-bar">
|
||||||
<div class="progress-info">
|
<div class="total-bar__track">
|
||||||
<span>总体进度</span>
|
<div class="total-bar__fill" :style="{ width: totalProgress + '%' }" />
|
||||||
<span>{{ totalProgress }}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress :value="totalProgress" class="h-2" />
|
<span class="total-bar__pct">{{ totalProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 当前文件进度 -->
|
<!-- 当前文件 -->
|
||||||
<div class="upload-current-file">
|
<div class="live-file">
|
||||||
<div class="current-file-name">
|
<div class="live-file__name">
|
||||||
<Icon icon="lucide:file" />
|
<Icon icon="lucide:file" />
|
||||||
<span>{{ currentFileName }}</span>
|
<span>{{ currentFileName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="current-file-progress">
|
<div class="live-file__bar">
|
||||||
<div class="progress-info">
|
<div class="live-file__track">
|
||||||
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
|
<div class="live-file__fill" :style="{ width: currentFileProgress + '%' }" />
|
||||||
<span>{{ currentFileProgress }}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress :value="currentFileProgress" class="h-1.5" />
|
<span class="live-file__pct">{{ currentFileProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="live-file__bytes">{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize)
|
||||||
|
}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已完成文件列表 -->
|
<!-- 已完成 / 失败 列表 -->
|
||||||
<div v-if="completedFiles.length > 0" class="completed-files">
|
<div v-if="completedFiles.length > 0" class="done-list">
|
||||||
<div class="completed-header">
|
<div v-for="(f, i) in completedFiles" :key="i" class="done-item" :class="f.success ? 'done-item--ok' : 'done-item--fail'">
|
||||||
<Icon icon="lucide:check-circle" class="text-green-500" />
|
<Icon :icon="f.success ? 'lucide:check-circle' : 'lucide:x-circle'" class="done-item__icon" />
|
||||||
<span>已完成 {{ completedFiles.length }} 个文件</span>
|
<span class="done-item__name">{{ f.name }}</span>
|
||||||
</div>
|
<span class="done-item__tag">{{ f.success ? '成功' : '失败' }}</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- ═══ 底部操作栏 ═══ -->
|
||||||
<div class="upload-actions">
|
<div class="upload-footer">
|
||||||
<div class="flex gap-2 justify-end">
|
<template v-if="!uploading">
|
||||||
<Button v-if="!uploading" variant="outline" @click="handleCancel">取消</Button>
|
<Button variant="outline" size="sm" @click="handleCancel">取消</Button>
|
||||||
<Button
|
<Button size="sm" :disabled="fileList.length === 0" @click="handleConfirm">
|
||||||
v-if="!uploading"
|
<Icon icon="lucide:upload" class="mr-1.5 size-4" />
|
||||||
:disabled="fileList.length === 0"
|
上传 {{ fileList.length }} 个文件
|
||||||
@click="handleConfirm"
|
|
||||||
>
|
|
||||||
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
|
|
||||||
</Button>
|
</Button>
|
||||||
<span v-if="uploading" class="upload-tip">上传中请勿关闭窗口...</span>
|
</template>
|
||||||
</div>
|
<p v-else class="upload-footer__hint">上传中,请勿关闭窗口</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -135,47 +104,31 @@ import { ref, watch, computed } from 'vue'
|
|||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||||
import { useUpload } from '@/composables/useUpload'
|
import { useUpload } from '@/composables/useUpload'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: { type: Boolean, default: false },
|
||||||
type: Boolean,
|
fileCategory: { type: String, default: 'video' },
|
||||||
default: false
|
groupId: { type: Number, default: null }
|
||||||
},
|
|
||||||
fileCategory: {
|
|
||||||
type: String,
|
|
||||||
default: 'video'
|
|
||||||
},
|
|
||||||
groupId: {
|
|
||||||
type: Number,
|
|
||||||
default: null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'complete'])
|
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'complete'])
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const { upload } = useUpload()
|
const { upload } = useUpload()
|
||||||
|
|
||||||
// 数据
|
// ── 状态 ──
|
||||||
const fileList = ref([])
|
const fileList = ref([])
|
||||||
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
|
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
|
||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false)
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
|
|
||||||
// 常量
|
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024
|
||||||
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
|
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024
|
||||||
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024 // 1GB (单个文件最大1GB)
|
|
||||||
|
|
||||||
// 上传状态
|
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const currentUploadIndex = ref(0)
|
const currentUploadIndex = ref(0)
|
||||||
const totalUploadCount = ref(0)
|
const totalUploadCount = ref(0)
|
||||||
@@ -185,458 +138,337 @@ const currentFileSize = ref(0)
|
|||||||
const currentFileUploaded = ref(0)
|
const currentFileUploaded = ref(0)
|
||||||
const completedFiles = ref([])
|
const completedFiles = ref([])
|
||||||
|
|
||||||
// 计算总进度
|
// ── 计算 ──
|
||||||
const totalProgress = computed(() => {
|
const totalProgress = computed(() => {
|
||||||
if (totalUploadCount.value === 0) return 0
|
if (totalUploadCount.value === 0) return 0
|
||||||
const baseProgress = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
|
const base = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
|
||||||
const currentContribution = (currentFileProgress.value / 100) / totalUploadCount.value * 100
|
const cur = (currentFileProgress.value / 100) / totalUploadCount.value * 100
|
||||||
return Math.round(baseProgress + currentContribution)
|
return Math.round(base + cur)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算总大小(字节)
|
const estimatedRemaining = computed(() => {
|
||||||
const getTotalSizeBytes = () => {
|
if (currentFileProgress.value === 0) return '计算中…'
|
||||||
return fileList.value.reduce((total, item) => {
|
const remainingFiles = totalUploadCount.value - currentUploadIndex.value
|
||||||
return total + getFileSize(item)
|
const totalRemaining = remainingFiles * currentFileSize.value + (currentFileSize.value - currentFileUploaded.value)
|
||||||
}, 0)
|
if (totalRemaining < 1024 * 1024) return '不到 1 分钟'
|
||||||
}
|
return '约 ' + Math.ceil(totalRemaining / (10 * 1024 * 1024)) + ' 分钟'
|
||||||
|
})
|
||||||
|
|
||||||
// 计算总大小(格式化)
|
const getTotalSizeBytes = () => fileList.value.reduce((t, i) => t + getFileSize(i), 0)
|
||||||
const getTotalSize = () => {
|
const getTotalSize = () => formatFileSize(getTotalSizeBytes())
|
||||||
return formatFileSize(getTotalSizeBytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发文件选择
|
// ── 文件操作 ──
|
||||||
const triggerFileInput = () => {
|
const triggerFileInput = () => fileInputRef.value?.click()
|
||||||
fileInputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理文件选择
|
|
||||||
const handleFileSelect = (e) => {
|
const handleFileSelect = (e) => {
|
||||||
const files = e.target.files
|
if (e.target.files) addFiles(Array.from(e.target.files))
|
||||||
if (files) {
|
|
||||||
addFiles(Array.from(files))
|
|
||||||
}
|
|
||||||
// 重置 input 以便可以再次选择相同文件
|
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理拖放
|
|
||||||
const handleDrop = (e) => {
|
const handleDrop = (e) => {
|
||||||
isDragOver.value = false
|
isDragOver.value = false
|
||||||
const files = e.dataTransfer?.files
|
if (e.dataTransfer?.files) addFiles(Array.from(e.dataTransfer.files))
|
||||||
if (files) {
|
|
||||||
addFiles(Array.from(files))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加文件
|
|
||||||
const addFiles = (files) => {
|
const addFiles = (files) => {
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
// 检查单个文件大小
|
if (file.size > MAX_SINGLE_FILE_SIZE) return toast.warning(`文件 ${file.name} 超过 1GB,已跳过`)
|
||||||
if (file.size > MAX_SINGLE_FILE_SIZE) {
|
const currentTotal = getTotalSizeBytes()
|
||||||
toast.warning(`文件 ${file.name} 超过 1GB,已跳过`)
|
if (currentTotal + file.size > MAX_TOTAL_SIZE) return toast.warning(`总大小超过 1GB,已跳过 ${file.name}`)
|
||||||
return
|
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 })
|
||||||
|
|
||||||
// 计算当前文件列表的总大小(包括新文件)
|
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 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 handleRemove = (fileItem) => {
|
||||||
const index = fileList.value.findIndex(item =>
|
const idx = fileList.value.findIndex(i => (i.uid && i.uid === fileItem.uid) || getFileName(i) === getFileName(fileItem))
|
||||||
(item.uid && item.uid === fileItem.uid) ||
|
if (idx > -1) fileList.value.splice(idx, 1)
|
||||||
(getFileName(item) === getFileName(fileItem))
|
|
||||||
)
|
|
||||||
if (index > -1) {
|
|
||||||
fileList.value.splice(index, 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 () => {
|
const handleConfirm = async () => {
|
||||||
if (fileList.value.length === 0) {
|
if (fileList.value.length === 0) return toast.warning('请选择文件')
|
||||||
toast.warning('请选择文件')
|
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) return toast.warning('总大小超过 1GB,请移除部分文件')
|
||||||
return
|
const files = fileList.value.map(i => i.file || i.originFileObj || i).filter(f => f instanceof File)
|
||||||
}
|
if (files.length === 0) return toast.error('无法获取文件对象,请重新选择')
|
||||||
|
|
||||||
// 检查总大小
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始上传
|
|
||||||
await performUpload(files)
|
await performUpload(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行上传
|
|
||||||
const performUpload = async (files) => {
|
const performUpload = async (files) => {
|
||||||
uploading.value = true
|
uploading.value = true; totalUploadCount.value = files.length; currentUploadIndex.value = 0; completedFiles.value = []
|
||||||
totalUploadCount.value = files.length
|
let ok = 0, fail = 0
|
||||||
currentUploadIndex.value = 0
|
|
||||||
completedFiles.value = []
|
|
||||||
|
|
||||||
// 使用传入的fileCategory
|
|
||||||
const category = props.fileCategory || 'video'
|
|
||||||
let successCount = 0
|
|
||||||
let failCount = 0
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i]
|
const file = files[i]
|
||||||
currentUploadIndex.value = i + 1
|
currentUploadIndex.value = i + 1; currentFileName.value = file.name
|
||||||
currentFileName.value = file.name
|
currentFileSize.value = file.size; currentFileUploaded.value = 0; currentFileProgress.value = 0
|
||||||
currentFileSize.value = file.size
|
|
||||||
currentFileUploaded.value = 0
|
|
||||||
currentFileProgress.value = 0
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await upload(file, {
|
await upload(file, {
|
||||||
fileCategory: category,
|
fileCategory: props.fileCategory || 'video',
|
||||||
groupId: props.groupId,
|
groupId: props.groupId,
|
||||||
onProgress: (progress) => {
|
onProgress: (p) => { currentFileProgress.value = p; currentFileUploaded.value = Math.round((p / 100) * file.size) },
|
||||||
currentFileProgress.value = progress
|
onSuccess: () => { ok++; completedFiles.value.push({ name: file.name, success: true }) },
|
||||||
currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value)
|
onError: () => { fail++; completedFiles.value.push({ name: file.name, success: false }) }
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
console.log('文件上传成功:', file.name)
|
|
||||||
successCount++
|
|
||||||
completedFiles.value.push({
|
|
||||||
name: file.name,
|
|
||||||
success: true
|
|
||||||
})
|
})
|
||||||
},
|
} catch { fail++; completedFiles.value.push({ name: file.name, success: false }) }
|
||||||
onError: (error) => {
|
|
||||||
console.error('文件上传失败:', file.name, error)
|
|
||||||
failCount++
|
|
||||||
completedFiles.value.push({
|
|
||||||
name: file.name,
|
|
||||||
success: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('上传异常:', error)
|
|
||||||
failCount++
|
|
||||||
completedFiles.value.push({
|
|
||||||
name: file.name,
|
|
||||||
success: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
|
if (ok > 0 && fail === 0) toast.success(`成功上传 ${ok} 个文件`)
|
||||||
// 显示结果
|
else if (ok > 0) toast.warning(`上传完成:${ok} 成功,${fail} 失败`)
|
||||||
if (successCount > 0 && failCount === 0) {
|
else toast.error('上传失败,请重试')
|
||||||
toast.success(`成功上传 ${successCount} 个文件`)
|
emit('complete', { successCount: ok, failCount: fail })
|
||||||
} else if (successCount > 0 && failCount > 0) {
|
|
||||||
toast.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
|
|
||||||
} else {
|
|
||||||
toast.error('上传失败,请重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通知父组件上传完成
|
|
||||||
emit('complete', { successCount, failCount })
|
|
||||||
|
|
||||||
// 关闭弹窗
|
|
||||||
emit('update:visible', false)
|
emit('update:visible', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 visible 变化
|
const handleVisibleChange = (v) => { if (!v && uploading.value) return; emit('update:visible', v) }
|
||||||
const handleVisibleChange = (value) => {
|
const handleCancel = () => { if (uploading.value) return; emit('update:visible', false); emit('cancel') }
|
||||||
if (!value && uploading.value) return
|
|
||||||
emit('update:visible', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (uploading.value) return
|
|
||||||
emit('update:visible', false)
|
|
||||||
emit('cancel')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.upload-modal-content {
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
padding: 8px 0;
|
上传弹窗 - 清晰三段式
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
.upload-body {
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area {
|
/* ── 拖拽区 ── */
|
||||||
margin-bottom: 24px;
|
.drop-zone {
|
||||||
}
|
|
||||||
|
|
||||||
.upload-dragger {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding: 40px 24px;
|
||||||
padding: 32px;
|
border: 2px dashed var(--border);
|
||||||
border: 2px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-surface);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all .2s;
|
||||||
|
|
||||||
&:hover, &.drag-over {
|
&:hover,
|
||||||
border-color: #3B82F6;
|
&--active {
|
||||||
background: rgba(59, 130, 246, 0.05);
|
border-color: var(--primary);
|
||||||
|
background: hsl(var(--primary) / .04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
&__icon {
|
||||||
margin-bottom: 12px;
|
width: 52px; height: 52px;
|
||||||
}
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: hsl(var(--primary) / .08);
|
||||||
.upload-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 上传进度区域 */
|
|
||||||
.upload-progress-area {
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-progress-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-spinner {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid rgba(59, 130, 246, 0.2);
|
|
||||||
border-top-color: #3B82F6;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
color: var(--primary);
|
||||||
}
|
font-size: 24px;
|
||||||
|
|
||||||
@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;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-file-name {
|
&__title {
|
||||||
display: flex;
|
font-size: 15px; font-weight: 500; color: var(--foreground); margin-bottom: 6px;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-file-name span {
|
&__hint {
|
||||||
min-width: 0;
|
font-size: 12px; color: var(--muted-foreground);
|
||||||
max-width: 300px;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 已选文件列表 ── */
|
||||||
|
.file-pick-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-file-progress .progress-info {
|
&__total {
|
||||||
|
&.text-danger { color: var(--destructive); font-weight: 500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
max-height: 220px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 总进度条 */
|
||||||
|
.total-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前文件 */
|
||||||
|
.live-file {
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&__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;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.completed-files {
|
&__track {
|
||||||
margin-top: 12px;
|
flex: 1; height: 5px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px; overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.completed-header {
|
&__fill {
|
||||||
display: flex;
|
height: 100%; background: var(--primary);
|
||||||
align-items: center;
|
border-radius: 3px;
|
||||||
gap: 8px;
|
transition: width .15s ease;
|
||||||
margin-bottom: 8px;
|
}
|
||||||
|
|
||||||
|
&__pct {
|
||||||
|
font-size: 12px; font-weight: 500; color: var(--primary);
|
||||||
|
min-width: 32px; text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bytes {
|
||||||
|
font-size: 11px; color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已完成 / 失败 */
|
||||||
|
.done-list {
|
||||||
|
max-height: 140px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 7px 0;
|
||||||
font-size: 13px;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.completed-list {
|
&__tag {
|
||||||
max-height: 120px;
|
font-size: 11px; font-weight: 500;
|
||||||
overflow-y: auto;
|
padding: 1px 8px; border-radius: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.done-item--ok & {
|
||||||
|
background: hsl(var(--success) / .1);
|
||||||
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.completed-item {
|
.done-item--fail & {
|
||||||
display: flex;
|
background: hsl(var(--destructive) / .1);
|
||||||
align-items: center;
|
color: var(--destructive);
|
||||||
gap: 8px;
|
}
|
||||||
padding: 6px 0;
|
}
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.completed-item span {
|
/* ── 底部栏 ── */
|
||||||
min-width: 0;
|
.upload-footer {
|
||||||
max-width: 300px;
|
display: flex; justify-content: flex-end; align-items: center; gap: 8px;
|
||||||
overflow: hidden;
|
padding-top: 16px; margin-top: 4px;
|
||||||
text-overflow: ellipsis;
|
border-top: 1px solid var(--border);
|
||||||
white-space: nowrap;
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 13px; color: var(--muted-foreground); margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user