|
@@ -0,0 +1,449 @@
|
|
|
|
+<template>
|
|
|
|
+ <div class="file-upload-container">
|
|
|
|
+ <!-- 上传按钮区域 -->
|
|
|
|
+ <div class="upload-btn-area">
|
|
|
|
+ <el-button
|
|
|
|
+ type="primary"
|
|
|
|
+ :loading="uploading"
|
|
|
|
+ @click="handleUploadClick"
|
|
|
|
+ class="upload-btn"
|
|
|
|
+ >
|
|
|
|
+ <el-icon><Upload /></el-icon>
|
|
|
|
+ {{ uploading ? '上传中...' : '选择文件或文件夹' }}
|
|
|
|
+ </el-button>
|
|
|
|
+ <input
|
|
|
|
+ ref="fileInput"
|
|
|
|
+ type="file"
|
|
|
|
+ class="file-input"
|
|
|
|
+ :multiple="true"
|
|
|
|
+ :webkitdirectory="allowFolderUpload"
|
|
|
|
+ :directory="allowFolderUpload"
|
|
|
|
+ @change="handleFileChange"
|
|
|
|
+ />
|
|
|
|
+ <p class="upload-hint">单个文件夹总大小不能超过300M</p>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <!-- 上传列表与进度 - 使用Element UI组件 -->
|
|
|
|
+ <el-card v-if="uploadList.length > 0" class="upload-list-card">
|
|
|
|
+ <el-table
|
|
|
|
+ :data="uploadList"
|
|
|
|
+ border
|
|
|
|
+ style="width: 100%; margin-bottom: 0px;"
|
|
|
|
+ >
|
|
|
|
+ <el-table-column
|
|
|
|
+ prop="name"
|
|
|
|
+ label="文件名/文件夹名"
|
|
|
|
+ width="300"
|
|
|
|
+ >
|
|
|
|
+ <template #default="scope">
|
|
|
|
+ <div class="file-name">
|
|
|
|
+ <el-icon v-if="scope.row.type === 'folder'"><Folder /></el-icon>
|
|
|
|
+ <el-icon v-else><Document /></el-icon>
|
|
|
|
+ <span>{{ scope.row.name }}</span>
|
|
|
|
+ </div>
|
|
|
|
+ </template>
|
|
|
|
+ </el-table-column>
|
|
|
|
+ <el-table-column
|
|
|
|
+ prop="size"
|
|
|
|
+ label="大小"
|
|
|
|
+ width="120"
|
|
|
|
+ :formatter="formatSize"
|
|
|
|
+ />
|
|
|
|
+ <el-table-column
|
|
|
|
+ prop="type"
|
|
|
|
+ label="类型"
|
|
|
|
+ width="120"
|
|
|
|
+ >
|
|
|
|
+ <template #default="scope">
|
|
|
|
+ <el-tag size="small" :type="scope.row.type === 'folder' ? 'info' : 'primary'">
|
|
|
|
+ {{ scope.row.type || '文件夹' }}
|
|
|
|
+ </el-tag>
|
|
|
|
+ </template>
|
|
|
|
+ </el-table-column>
|
|
|
|
+ <el-table-column
|
|
|
|
+ prop="progress"
|
|
|
|
+ label="进度"
|
|
|
|
+ min-width="200"
|
|
|
|
+ >
|
|
|
|
+ <template #default="scope">
|
|
|
|
+ <el-progress
|
|
|
|
+ :percentage="scope.row.progress"
|
|
|
|
+ :status="getProgressStatus(scope.row.status)"
|
|
|
|
+ stroke-width="6"
|
|
|
|
+ />
|
|
|
|
+ </template>
|
|
|
|
+ </el-table-column>
|
|
|
|
+ <el-table-column
|
|
|
|
+ label="操作"
|
|
|
|
+ width="120"
|
|
|
|
+ >
|
|
|
|
+ <template #default="scope">
|
|
|
|
+ <el-button
|
|
|
|
+ v-if="scope.row.status === 'uploading'"
|
|
|
|
+ size="small"
|
|
|
|
+ type="text"
|
|
|
|
+ @click.stop="handleCancelUpload(scope.row)"
|
|
|
|
+ text
|
|
|
|
+ >
|
|
|
|
+ 取消
|
|
|
|
+ </el-button>
|
|
|
|
+ <el-icon v-if="scope.row.status === 'success'" color="#10b981">
|
|
|
|
+<!-- <CheckCircle />-->
|
|
|
|
+ </el-icon>
|
|
|
|
+ <el-icon v-if="scope.row.status === 'error'" color="#ef4444">
|
|
|
|
+<!-- <CloseCircle />-->
|
|
|
|
+ </el-icon>
|
|
|
|
+ <span v-if="scope.row.status === 'cancelled'">已取消</span>
|
|
|
|
+ </template>
|
|
|
|
+ </el-table-column>
|
|
|
|
+ </el-table>
|
|
|
|
+ </el-card>
|
|
|
|
+
|
|
|
|
+ <!-- 错误提示 -->
|
|
|
|
+ <el-dialog
|
|
|
|
+ v-model="showErrorModal"
|
|
|
|
+ title="上传错误"
|
|
|
|
+ @close="showErrorModal = false"
|
|
|
|
+ >
|
|
|
|
+ <p>{{ errorMessage }}</p>
|
|
|
|
+ <template #footer>
|
|
|
|
+ <el-button @click="showErrorModal = false">确定</el-button>
|
|
|
|
+ </template>
|
|
|
|
+ </el-dialog>
|
|
|
|
+ </div>
|
|
|
|
+</template>
|
|
|
|
+
|
|
|
|
+<script setup>
|
|
|
|
+import { ref, computed, onMounted } from 'vue';
|
|
|
|
+import {
|
|
|
|
+ Upload,
|
|
|
|
+ Folder,
|
|
|
|
+ Document,
|
|
|
|
+} from '@element-plus/icons-vue';
|
|
|
|
+
|
|
|
|
+// 组件参数
|
|
|
|
+const props = defineProps({
|
|
|
|
+ // 是否允许文件夹上传
|
|
|
|
+ allowFolderUpload: {
|
|
|
|
+ type: Boolean,
|
|
|
|
+ default: true
|
|
|
|
+ },
|
|
|
|
+ // 最大文件夹大小限制(M)
|
|
|
|
+ maxFolderSize: {
|
|
|
|
+ type: Number,
|
|
|
|
+ default: 300
|
|
|
|
+ },
|
|
|
|
+ // 上传接口地址,使用代理路径
|
|
|
|
+ uploadUrl: {
|
|
|
|
+ type: String,
|
|
|
|
+ default: 'http://localhost:48080/admin-api/rq/file/upload'
|
|
|
|
+ }
|
|
|
|
+});
|
|
|
|
+
|
|
|
|
+// 组件事件
|
|
|
|
+const emit = defineEmits(['uploadSuccess', 'uploadError', 'uploadComplete']);
|
|
|
|
+
|
|
|
|
+// 状态管理
|
|
|
|
+const fileInput = ref(null);
|
|
|
|
+const uploading = ref(false);
|
|
|
|
+const uploadList = ref([]);
|
|
|
|
+const showErrorModal = ref(false);
|
|
|
|
+const errorMessage = ref('');
|
|
|
|
+const uploadTasks = ref(new Map()); // 用于存储上传任务,便于取消
|
|
|
|
+
|
|
|
|
+// 获取进度条状态
|
|
|
|
+const getProgressStatus = (status) => {
|
|
|
|
+ switch(status) {
|
|
|
|
+ case 'success':
|
|
|
|
+ return 'success';
|
|
|
|
+ case 'error':
|
|
|
|
+ return 'exception';
|
|
|
|
+ case 'uploading':
|
|
|
|
+ return 'processing';
|
|
|
|
+ default:
|
|
|
|
+ return '';
|
|
|
|
+ }
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// 格式化文件大小
|
|
|
|
+const formatSize = (row, column, bytes) => {
|
|
|
|
+ if (bytes === 0) return '0 Bytes';
|
|
|
|
+ const k = 1024;
|
|
|
|
+ const sizes = ['Bytes', '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) => {
|
|
|
|
+ let totalSize = 0;
|
|
|
|
+ files.forEach(file => {
|
|
|
|
+ totalSize += file.size;
|
|
|
|
+ });
|
|
|
|
+ return totalSize;
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// 处理上传点击
|
|
|
|
+const handleUploadClick = () => {
|
|
|
|
+ if (uploading.value) return;
|
|
|
|
+ fileInput.value.click();
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// 处理文件选择
|
|
|
|
+const handleFileChange = async (e) => {
|
|
|
|
+ const files = Array.from(e.target.files);
|
|
|
|
+ if (files.length === 0) return;
|
|
|
|
+
|
|
|
|
+ // 清空input值,允许重复选择同一文件
|
|
|
|
+ e.target.value = '';
|
|
|
|
+
|
|
|
|
+ // 检查是否包含文件夹
|
|
|
|
+ const hasFolders = files.some(file => file.webkitRelativePath.includes('/'));
|
|
|
|
+
|
|
|
|
+ if (hasFolders) {
|
|
|
|
+ // 按文件夹分组
|
|
|
|
+ const folderMap = new Map();
|
|
|
|
+
|
|
|
|
+ files.forEach(file => {
|
|
|
|
+ // 提取文件夹路径
|
|
|
|
+ const relativePath = file.webkitRelativePath;
|
|
|
|
+ const folderPath = relativePath.substring(0, relativePath.lastIndexOf('/') + 1);
|
|
|
|
+
|
|
|
|
+ if (!folderMap.has(folderPath)) {
|
|
|
|
+ folderMap.set(folderPath, []);
|
|
|
|
+ }
|
|
|
|
+ folderMap.get(folderPath).push(file);
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 检查每个文件夹大小
|
|
|
|
+ for (const [folderPath, folderFiles] of folderMap) {
|
|
|
|
+ const folderSize = calculateFolderSize(folderFiles);
|
|
|
|
+ const folderSizeMB = folderSize / (1024 * 1024);
|
|
|
|
+
|
|
|
|
+ if (folderSizeMB > props.maxFolderSize) {
|
|
|
|
+ errorMessage.value = `文件夹 "${folderPath}" 大小超过${props.maxFolderSize}M限制,无法上传`;
|
|
|
|
+ showErrorModal.value = true;
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 添加文件夹到上传列表
|
|
|
|
+ folderMap.forEach((folderFiles, folderPath) => {
|
|
|
|
+ const folderSize = calculateFolderSize(folderFiles);
|
|
|
|
+ const uid = Date.now() + Math.random().toString(36).substr(2, 9);
|
|
|
|
+
|
|
|
|
+ uploadList.value.push({
|
|
|
|
+ uid,
|
|
|
|
+ name: folderPath,
|
|
|
|
+ size: folderSize,
|
|
|
|
+ type: 'folder',
|
|
|
|
+ progress: 0,
|
|
|
|
+ status: 'wait',
|
|
|
|
+ files: folderFiles
|
|
|
|
+ });
|
|
|
|
+ });
|
|
|
|
+ } else {
|
|
|
|
+ // 单个或多个文件
|
|
|
|
+ files.forEach(file => {
|
|
|
|
+ uploadList.value.push({
|
|
|
|
+ uid: Date.now() + Math.random().toString(36).substr(2, 9),
|
|
|
|
+ name: file.name,
|
|
|
|
+ size: file.size,
|
|
|
|
+ type: file.type || 'file',
|
|
|
|
+ progress: 0,
|
|
|
|
+ status: 'wait',
|
|
|
|
+ files: [file]
|
|
|
|
+ });
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+debugger
|
|
|
|
+ // 开始上传
|
|
|
|
+ await startUpload();
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// 开始上传
|
|
|
|
+const startUpload = async () => {
|
|
|
|
+ uploading.value = true;
|
|
|
|
+
|
|
|
|
+ // 过滤出等待上传的文件/文件夹
|
|
|
|
+ const pendingFiles = uploadList.value.filter(item => item.status === 'wait');
|
|
|
|
+
|
|
|
|
+ for (const fileItem of pendingFiles) {
|
|
|
|
+ try {
|
|
|
|
+ // 创建FormData
|
|
|
|
+ const formData = new FormData();
|
|
|
|
+
|
|
|
|
+ // 添加文件
|
|
|
|
+ fileItem.files.forEach(file => {
|
|
|
|
+ formData.append('files', file, file.webkitRelativePath || file.name);
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 添加文件夹标识和路径
|
|
|
|
+ if (fileItem.type === 'folder') {
|
|
|
|
+ formData.append('isFolder', 'true');
|
|
|
|
+ formData.append('folderPath', fileItem.name);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 创建上传任务
|
|
|
|
+ const xhr = new XMLHttpRequest();
|
|
|
|
+ uploadTasks.value.set(fileItem.uid, xhr);
|
|
|
|
+
|
|
|
|
+ // 监听进度
|
|
|
|
+ xhr.upload.addEventListener('progress', (e) => {
|
|
|
|
+ if (e.lengthComputable) {
|
|
|
|
+ const percent = Math.round((e.loaded / e.total) * 100);
|
|
|
|
+ const index = uploadList.value.findIndex(item => item.uid === fileItem.uid);
|
|
|
|
+ if (index !== -1) {
|
|
|
|
+ uploadList.value[index].progress = percent;
|
|
|
|
+ uploadList.value[index].status = 'uploading';
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 监听完成
|
|
|
|
+ xhr.addEventListener('load', () => {
|
|
|
|
+ const index = uploadList.value.findIndex(item => item.uid === fileItem.uid);
|
|
|
|
+ if (index !== -1) {
|
|
|
|
+ if (xhr.status >= 200 && xhr.status < 300) {
|
|
|
|
+ uploadList.value[index].status = 'success';
|
|
|
|
+ uploadList.value[index].progress = 100;
|
|
|
|
+ emit('uploadSuccess', {
|
|
|
|
+ uid: fileItem.uid,
|
|
|
|
+ name: fileItem.name,
|
|
|
|
+ response: JSON.parse(xhr.responseText)
|
|
|
|
+ });
|
|
|
|
+ } else {
|
|
|
|
+ uploadList.value[index].status = 'error';
|
|
|
|
+ emit('uploadError', {
|
|
|
|
+ uid: fileItem.uid,
|
|
|
|
+ name: fileItem.name,
|
|
|
|
+ error: new Error(`上传失败: ${xhr.statusText}`)
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ uploadTasks.value.delete(fileItem.uid);
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 监听错误
|
|
|
|
+ xhr.addEventListener('error', () => {
|
|
|
|
+ const index = uploadList.value.findIndex(item => item.uid === fileItem.uid);
|
|
|
|
+ if (index !== -1) {
|
|
|
|
+ uploadList.value[index].status = 'error';
|
|
|
|
+ emit('uploadError', {
|
|
|
|
+ uid: fileItem.uid,
|
|
|
|
+ name: fileItem.name,
|
|
|
|
+ error: new Error('网络错误,上传失败')
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ uploadTasks.value.delete(fileItem.uid);
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 发送请求
|
|
|
|
+ xhr.open('POST', props.uploadUrl, true);
|
|
|
|
+ xhr.setRequestHeader('tenant-id', 1);
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ xhr.send(formData);
|
|
|
|
+
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('上传失败', error);
|
|
|
|
+ const index = uploadList.value.findIndex(item => item.uid === fileItem.uid);
|
|
|
|
+ if (index !== -1) {
|
|
|
|
+ uploadList.value[index].status = 'error';
|
|
|
|
+ }
|
|
|
|
+ emit('uploadError', {
|
|
|
|
+ uid: fileItem.uid,
|
|
|
|
+ name: fileItem.name,
|
|
|
|
+ error
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 检查是否所有上传都已完成
|
|
|
|
+ const checkUploadComplete = setInterval(() => {
|
|
|
|
+ const allCompleted = uploadList.value.every(item =>
|
|
|
|
+ item.status === 'success' || item.status === 'error' || item.status === 'cancelled'
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ if (allCompleted) {
|
|
|
|
+ clearInterval(checkUploadComplete);
|
|
|
|
+ uploading.value = false;
|
|
|
|
+ emit('uploadComplete');
|
|
|
|
+ }
|
|
|
|
+ }, 1000);
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// 取消上传
|
|
|
|
+const handleCancelUpload = (fileItem) => {
|
|
|
|
+ const xhr = uploadTasks.value.get(fileItem.uid);
|
|
|
|
+ if (xhr) {
|
|
|
|
+ xhr.abort();
|
|
|
|
+ uploadTasks.value.delete(fileItem.uid);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const index = uploadList.value.findIndex(item => item.uid === fileItem.uid);
|
|
|
|
+ if (index !== -1) {
|
|
|
|
+ uploadList.value[index].status = 'cancelled';
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 检查是否还有正在上传的任务
|
|
|
|
+ const hasUploading = uploadList.value.some(item => item.status === 'uploading');
|
|
|
|
+ if (!hasUploading) {
|
|
|
|
+ uploading.value = false;
|
|
|
|
+ }
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// 组件挂载时设置独占窗口样式
|
|
|
|
+onMounted(() => {
|
|
|
|
+ // 为组件容器添加独占窗口样式
|
|
|
|
+ const container = document.querySelector('.file-upload-container');
|
|
|
|
+ if (container) {
|
|
|
|
+ container.style.height = '8vh';
|
|
|
|
+ container.style.display = 'flex';
|
|
|
|
+ container.style.flexDirection = 'column';
|
|
|
|
+ container.style.padding = '20px';
|
|
|
|
+ container.style.boxSizing = 'border-box';
|
|
|
|
+ }
|
|
|
|
+});
|
|
|
|
+</script>
|
|
|
|
+
|
|
|
|
+<style scoped>
|
|
|
|
+.file-upload-container {
|
|
|
|
+ width: 100%;
|
|
|
|
+ max-width: 1200px;
|
|
|
|
+ margin: 0 auto;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.upload-btn-area {
|
|
|
|
+ display: flex;
|
|
|
|
+ flex-direction: row;
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
+ text-align: left;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.file-input {
|
|
|
|
+ display: none;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.upload-btn {
|
|
|
|
+ margin-bottom: 10px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.upload-hint {
|
|
|
|
+ color: #666;
|
|
|
|
+ font-size: 10px;
|
|
|
|
+ margin: 0;
|
|
|
|
+ color: orangered;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.upload-list-card {
|
|
|
|
+ flex: 1;
|
|
|
|
+ overflow-y: auto;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.file-name {
|
|
|
|
+ display: flex;
|
|
|
|
+ align-items: center;
|
|
|
|
+ gap: 8px;
|
|
|
|
+}
|
|
|
|
+</style>
|