|
|
@@ -0,0 +1,487 @@
|
|
|
+<template>
|
|
|
+ <div class="w-full max-w-1200px mx-auto p-4 flex flex-col h-[85vh] box-border">
|
|
|
+ <div
|
|
|
+ class="relative mb-6 p-8 border-2 border-dashed rounded-xl transition-all duration-300 group"
|
|
|
+ :class="[
|
|
|
+ isDragOver
|
|
|
+ ? 'border-primary bg-primary-50 scale-[1.01]'
|
|
|
+ : 'border-gray-300 bg-white hover:border-primary-300',
|
|
|
+ uploading ? 'opacity-80 pointer-events-none' : ''
|
|
|
+ ]"
|
|
|
+ @dragover.prevent="isDragOver = true"
|
|
|
+ @dragleave.prevent="isDragOver = false"
|
|
|
+ @drop.prevent="handleDrop"
|
|
|
+ >
|
|
|
+ <div class="flex flex-col items-center justify-center gap-4 text-center">
|
|
|
+ <div class="p-4 rounded-full bg-gray-50 group-hover:bg-white transition-colors">
|
|
|
+ <el-icon
|
|
|
+ class="text-5xl text-gray-400 group-hover:text-primary transition-colors duration-300"
|
|
|
+ ><UploadFilled
|
|
|
+ /></el-icon>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="text-gray-500">
|
|
|
+ <p class="text-lg font-medium mb-1">拖拽文件到此处,或点击下方按钮</p>
|
|
|
+ <p class="text-xs text-gray-400">{{ uploadHintText }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex gap-4 mt-2">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="large"
|
|
|
+ round
|
|
|
+ :loading="uploading"
|
|
|
+ @click="handleFileUploadClick"
|
|
|
+ class="!px-8 shadow-md hover:shadow-lg transition-shadow"
|
|
|
+ >
|
|
|
+ <el-icon class="mr-2"><Document /></el-icon>
|
|
|
+ {{ uploading ? '上传中...' : '选择文件' }}
|
|
|
+ </el-button>
|
|
|
+
|
|
|
+ <el-button
|
|
|
+ v-if="showFolderButton"
|
|
|
+ type="success"
|
|
|
+ size="large"
|
|
|
+ round
|
|
|
+ plain
|
|
|
+ :loading="uploading"
|
|
|
+ @click="handleFolderUploadClick"
|
|
|
+ class="!px-8 shadow-md hover:shadow-lg transition-shadow"
|
|
|
+ >
|
|
|
+ <el-icon class="mr-2"><Folder /></el-icon>
|
|
|
+ 文件夹上传
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <input
|
|
|
+ ref="fileInput"
|
|
|
+ type="file"
|
|
|
+ class="hidden"
|
|
|
+ :multiple="true"
|
|
|
+ accept="*"
|
|
|
+ :webkitdirectory="isFolderMode"
|
|
|
+ :directory="isFolderMode"
|
|
|
+ @change="handleFileChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="uploadList.length > 0"
|
|
|
+ class="flex-1 overflow-hidden flex flex-col bg-white rounded-xl shadow-sm border border-gray-100"
|
|
|
+ >
|
|
|
+ <div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
|
|
+ <h3 class="font-bold text-gray-700 flex items-center gap-2">
|
|
|
+ <el-icon><List /></el-icon> 上传列表
|
|
|
+ <span
|
|
|
+ class="text-xs font-normal text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200"
|
|
|
+ >
|
|
|
+ {{ uploadList.length }} 项
|
|
|
+ </span>
|
|
|
+ </h3>
|
|
|
+ <el-button v-if="uploading" link type="danger" @click="cancelAll">全部取消</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex-1 overflow-y-auto custom-scrollbar p-2">
|
|
|
+ <el-table :data="uploadList" style="width: 100%" :show-header="true" class="custom-table">
|
|
|
+ <el-table-column label="名称" min-width="250">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div
|
|
|
+ class="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50 text-xl shrink-0"
|
|
|
+ >
|
|
|
+ <el-icon v-if="row.type === 'folder'" class="text-yellow-500"><Folder /></el-icon>
|
|
|
+ <el-icon v-else class="text-blue-500"><Document /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="flex flex-col overflow-hidden">
|
|
|
+ <span class="truncate font-medium text-gray-700" :title="row.name">{{
|
|
|
+ row.name
|
|
|
+ }}</span>
|
|
|
+ <span class="text-xs text-gray-400">{{ formatSize(null, null, row.size) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="状态" width="300">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <div class="pr-6">
|
|
|
+ <div class="flex justify-between text-xs mb-1">
|
|
|
+ <span :class="getStatusColor(row.status)">{{ getStatusText(row.status) }}</span>
|
|
|
+ <span class="text-gray-400">{{ row.progress }}%</span>
|
|
|
+ </div>
|
|
|
+ <el-progress
|
|
|
+ :percentage="row.progress"
|
|
|
+ :status="getProgressStatus(row.status)"
|
|
|
+ :stroke-width="6"
|
|
|
+ :show-text="false"
|
|
|
+ class="!m-0"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="操作" width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button
|
|
|
+ v-if="row.status === 'uploading' || row.status === 'wait'"
|
|
|
+ circle
|
|
|
+ size="small"
|
|
|
+ type="danger"
|
|
|
+ plain
|
|
|
+ @click.stop="handleCancelUpload(row)"
|
|
|
+ >
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ </el-button>
|
|
|
+
|
|
|
+ <el-icon v-else-if="row.status === 'success'" class="text-green-500 text-lg"
|
|
|
+ ><CircleCheckFilled
|
|
|
+ /></el-icon>
|
|
|
+ <el-icon v-else-if="row.status === 'error'" class="text-red-500 text-lg"
|
|
|
+ ><CircleCloseFilled
|
|
|
+ /></el-icon>
|
|
|
+ <span v-else-if="row.status === 'cancelled'" class="text-xs text-gray-400"
|
|
|
+ >已取消</span
|
|
|
+ >
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, nextTick } from 'vue'
|
|
|
+import axios from 'axios'
|
|
|
+import { ElMessage, ElNotification } from 'element-plus'
|
|
|
+import {
|
|
|
+ UploadFilled,
|
|
|
+ Folder,
|
|
|
+ Document,
|
|
|
+ Close,
|
|
|
+ CircleCheckFilled,
|
|
|
+ CircleCloseFilled,
|
|
|
+ List
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+
|
|
|
+// --- Props 定义 ---
|
|
|
+const props = defineProps({
|
|
|
+ deviceId: { type: String, default: '' },
|
|
|
+ allowFolderUpload: { type: Boolean, default: true },
|
|
|
+ showFolderButton: { type: Boolean, default: true },
|
|
|
+ maxFolderSize: { type: Number, default: 300 }, // MB
|
|
|
+ uploadUrl: {
|
|
|
+ type: String,
|
|
|
+ default: import.meta.env.VITE_BASE_URL + '/admin-api/rq/file/upload'
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// --- Events ---
|
|
|
+const emit = defineEmits(['uploadSuccess', 'uploadError', 'uploadComplete'])
|
|
|
+
|
|
|
+// --- State ---
|
|
|
+const fileInput = ref(null)
|
|
|
+const uploading = ref(false)
|
|
|
+const uploadList = ref([])
|
|
|
+const isFolderMode = ref(false)
|
|
|
+const isDragOver = ref(false)
|
|
|
+const uploadControllers = ref(new Map()) // 存储 AbortController 用于取消 axios 请求
|
|
|
+
|
|
|
+// --- Computed ---
|
|
|
+const uploadHintText = computed(() => {
|
|
|
+ return props.showFolderButton
|
|
|
+ ? '支持单个文件 (Max 50MB) 或整个文件夹上传 (Max 500MB)'
|
|
|
+ : '单个文件大小不能超过 50MB'
|
|
|
+})
|
|
|
+
|
|
|
+// --- Helpers ---
|
|
|
+const getProgressStatus = (status) => {
|
|
|
+ const map = { success: 'success', error: 'exception', uploading: '', wait: 'warning' }
|
|
|
+ return map[status] || ''
|
|
|
+}
|
|
|
+
|
|
|
+const getStatusColor = (status) => {
|
|
|
+ const map = {
|
|
|
+ success: 'text-green-500',
|
|
|
+ error: 'text-red-500',
|
|
|
+ uploading: 'text-primary',
|
|
|
+ wait: 'text-gray-400',
|
|
|
+ cancelled: 'text-gray-400'
|
|
|
+ }
|
|
|
+ return map[status]
|
|
|
+}
|
|
|
+
|
|
|
+const getStatusText = (status) => {
|
|
|
+ const map = {
|
|
|
+ success: '上传成功',
|
|
|
+ error: '上传失败',
|
|
|
+ uploading: '正在上传...',
|
|
|
+ wait: '等待中',
|
|
|
+ cancelled: '已取消'
|
|
|
+ }
|
|
|
+ return map[status]
|
|
|
+}
|
|
|
+
|
|
|
+const formatSize = (row, column, bytes) => {
|
|
|
+ if (bytes === 0) return '0 B'
|
|
|
+ const k = 1024
|
|
|
+ const sizes = ['B', 'KB', 'MB', 'GB']
|
|
|
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
|
+}
|
|
|
+
|
|
|
+const calculateFolderSize = (files) => {
|
|
|
+ return files.reduce((acc, file) => acc + file.size, 0)
|
|
|
+}
|
|
|
+
|
|
|
+// --- Actions ---
|
|
|
+
|
|
|
+// 点击上传文件
|
|
|
+const handleFileUploadClick = () => {
|
|
|
+ if (uploading.value) return
|
|
|
+ isFolderMode.value = false
|
|
|
+ triggerInput()
|
|
|
+}
|
|
|
+
|
|
|
+// 点击上传文件夹
|
|
|
+const handleFolderUploadClick = () => {
|
|
|
+ if (uploading.value) return
|
|
|
+ if (!props.allowFolderUpload) {
|
|
|
+ ElMessage.warning('当前不允许上传文件夹')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ isFolderMode.value = true
|
|
|
+ triggerInput()
|
|
|
+}
|
|
|
+
|
|
|
+const triggerInput = () => {
|
|
|
+ if (fileInput.value) fileInput.value.value = ''
|
|
|
+ nextTick(() => fileInput.value.click())
|
|
|
+}
|
|
|
+
|
|
|
+// 拖拽处理 (默认作为普通文件处理,浏览器对于拖拽文件夹有安全限制,这里简化处理)
|
|
|
+const handleDrop = (e) => {
|
|
|
+ isDragOver.value = false
|
|
|
+ if (uploading.value) return
|
|
|
+
|
|
|
+ const files = Array.from(e.dataTransfer.files)
|
|
|
+ if (files.length === 0) return
|
|
|
+
|
|
|
+ // 拖拽模式默认为文件模式处理
|
|
|
+ isFolderMode.value = false
|
|
|
+ processFiles(files)
|
|
|
+}
|
|
|
+
|
|
|
+// Input Change 处理
|
|
|
+const handleFileChange = (e) => {
|
|
|
+ const files = Array.from(e.target.files)
|
|
|
+ if (files.length === 0) return
|
|
|
+ e.target.value = '' // Reset input
|
|
|
+ processFiles(files)
|
|
|
+}
|
|
|
+
|
|
|
+// 统一文件处理逻辑
|
|
|
+const processFiles = async (files) => {
|
|
|
+ // 判断是否包含文件夹路径 (通过 input webkitdirectory 获取的文件会有 webkitRelativePath)
|
|
|
+ const hasRelativePath =
|
|
|
+ isFolderMode.value &&
|
|
|
+ files.some((f) => f.webkitRelativePath && f.webkitRelativePath.includes('/'))
|
|
|
+
|
|
|
+ if (hasRelativePath) {
|
|
|
+ // --- 文件夹模式 ---
|
|
|
+ const folderMap = new Map()
|
|
|
+
|
|
|
+ files.forEach((file) => {
|
|
|
+ const path = file.webkitRelativePath
|
|
|
+ const rootFolder = path.substring(0, path.indexOf('/'))
|
|
|
+ if (!folderMap.has(rootFolder)) {
|
|
|
+ folderMap.set(rootFolder, [])
|
|
|
+ }
|
|
|
+ folderMap.get(rootFolder).push(file)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 校验大小并加入列表
|
|
|
+ for (const [folderName, folderFiles] of folderMap) {
|
|
|
+ const size = calculateFolderSize(folderFiles)
|
|
|
+ if (size / 1024 / 1024 > props.maxFolderSize) {
|
|
|
+ ElNotification({
|
|
|
+ title: '超限警告',
|
|
|
+ message: `文件夹 "${folderName}" 超过 ${props.maxFolderSize}MB`,
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ addToUploadList({
|
|
|
+ name: folderName,
|
|
|
+ size: size,
|
|
|
+ type: 'folder',
|
|
|
+ files: folderFiles
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // --- 普通文件模式 ---
|
|
|
+ files.forEach((file) => {
|
|
|
+ addToUploadList({
|
|
|
+ name: file.name,
|
|
|
+ size: file.size,
|
|
|
+ type: 'file',
|
|
|
+ files: [file]
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ await startUploadQueue()
|
|
|
+}
|
|
|
+
|
|
|
+const addToUploadList = (item) => {
|
|
|
+ uploadList.value.push({
|
|
|
+ uid: Date.now() + Math.random().toString(36).substr(2, 9),
|
|
|
+ progress: 0,
|
|
|
+ status: 'wait',
|
|
|
+ ...item
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// --- Upload Logic (Axios) ---
|
|
|
+const startUploadQueue = async () => {
|
|
|
+ if (uploading.value) return // 避免重复触发
|
|
|
+ uploading.value = true
|
|
|
+
|
|
|
+ // 获取所有等待中的任务
|
|
|
+ const pendingItems = uploadList.value.filter((item) => item.status === 'wait')
|
|
|
+
|
|
|
+ // 并发上传控制(这里简化为全部并发,实际生产环境可能需要 p-limit)
|
|
|
+ const promises = pendingItems.map((item) => uploadSingleItem(item))
|
|
|
+
|
|
|
+ await Promise.allSettled(promises)
|
|
|
+
|
|
|
+ // 检查是否全部结束
|
|
|
+ checkAllComplete()
|
|
|
+}
|
|
|
+
|
|
|
+const uploadSingleItem = async (item) => {
|
|
|
+ const index = uploadList.value.findIndex((x) => x.uid === item.uid)
|
|
|
+ if (index === -1 || item.status === 'cancelled') return
|
|
|
+
|
|
|
+ // 更新状态
|
|
|
+ uploadList.value[index].status = 'uploading'
|
|
|
+
|
|
|
+ // 构建 FormData
|
|
|
+ const formData = new FormData()
|
|
|
+ item.files.forEach((file) => {
|
|
|
+ // 如果是文件夹上传,保留相对路径,否则使用文件名
|
|
|
+ const filename =
|
|
|
+ item.type === 'folder' && file.webkitRelativePath ? file.webkitRelativePath : file.name
|
|
|
+ formData.append('files', file, filename)
|
|
|
+ })
|
|
|
+
|
|
|
+ if (item.type === 'folder') {
|
|
|
+ formData.append('isFolder', 'true')
|
|
|
+ formData.append('folderPath', item.name)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建 CancelToken
|
|
|
+ const controller = new AbortController()
|
|
|
+ uploadControllers.value.set(item.uid, controller)
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await axios.post(props.uploadUrl, formData, {
|
|
|
+ headers: {
|
|
|
+ 'tenant-id': 1,
|
|
|
+ 'device-id': props.deviceId,
|
|
|
+ 'Content-Type': 'multipart/form-data'
|
|
|
+ },
|
|
|
+ signal: controller.signal,
|
|
|
+ onUploadProgress: (progressEvent) => {
|
|
|
+ if (progressEvent.total) {
|
|
|
+ const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
|
|
+ // 保持响应式更新
|
|
|
+ if (uploadList.value[index]) {
|
|
|
+ uploadList.value[index].progress = percent
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 成功处理
|
|
|
+ if (uploadList.value[index]) {
|
|
|
+ uploadList.value[index].status = 'success'
|
|
|
+ uploadList.value[index].progress = 100
|
|
|
+ emit('uploadSuccess', { uid: item.uid, name: item.name, response: response.data })
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (axios.isCancel(error)) {
|
|
|
+ if (uploadList.value[index]) uploadList.value[index].status = 'cancelled'
|
|
|
+ } else {
|
|
|
+ console.error('Upload Error:', error)
|
|
|
+ if (uploadList.value[index]) uploadList.value[index].status = 'error'
|
|
|
+ emit('uploadError', { uid: item.uid, name: item.name, error })
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ uploadControllers.value.delete(item.uid)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleCancelUpload = (item) => {
|
|
|
+ const controller = uploadControllers.value.get(item.uid)
|
|
|
+ if (controller) {
|
|
|
+ controller.abort()
|
|
|
+ } else {
|
|
|
+ // 如果还没开始上传(在wait状态),直接标记取消
|
|
|
+ const index = uploadList.value.findIndex((x) => x.uid === item.uid)
|
|
|
+ if (index !== -1) uploadList.value[index].status = 'cancelled'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const cancelAll = () => {
|
|
|
+ uploadList.value.forEach((item) => {
|
|
|
+ if (item.status === 'uploading' || item.status === 'wait') {
|
|
|
+ handleCancelUpload(item)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const checkAllComplete = () => {
|
|
|
+ const hasPending = uploadList.value.some((item) => ['wait', 'uploading'].includes(item.status))
|
|
|
+ if (!hasPending) {
|
|
|
+ uploading.value = false
|
|
|
+ emit('uploadComplete')
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* 自定义滚动条样式 */
|
|
|
+.custom-scrollbar::-webkit-scrollbar {
|
|
|
+ width: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-scrollbar::-webkit-scrollbar-track {
|
|
|
+ background: #f1f1f1;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
|
+ background: #d1d5db;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
|
+ background: #9ca3af;
|
|
|
+}
|
|
|
+
|
|
|
+/* 覆盖 Element 表格的一些默认样式以适配 UnoCSS 风格 */
|
|
|
+:deep(.custom-table .el-table__inner-wrapper::before) {
|
|
|
+ display: none; /* 移除表格底部边框 */
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.custom-table .el-table__cell) {
|
|
|
+ border-bottom: 1px solid #f3f4f6;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-progress-bar__inner) {
|
|
|
+ transition: width 0.3s ease;
|
|
|
+}
|
|
|
+</style>
|