Parcourir la source

附件文件夹上传

lipenghui il y a 2 semaines
Parent
commit
e50475cafe

+ 449 - 0
src/components/UploadFile/src/FileUpload.vue

@@ -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>

+ 42 - 12
src/views/pms/iotinfo/IotInfoFormTree.vue

@@ -1,5 +1,5 @@
 <template>
-  <Dialog :title="dialogTitle" v-model="dialogVisible">
+  <Dialog :title="dialogTitle" v-model="dialogVisible" style="width: 50vw">
     <el-form
       ref="formRef"
       :model="formData"
@@ -22,12 +22,18 @@
         />
       </el-form-item>
       <el-form-item :label="t('fileInfo.appendix')" prop="filePath" style="">
-        <UploadFileAll style="vertical-align: middle"
-          :is-show-tip="false"
-          :disabled="formType==='detail'"
-          v-model="formData.filePath"
-          :multiple="formType === 'create'"
-          :limit="formType === 'create' ? 5 : 1"
+<!--        <UploadFileAll style="vertical-align: middle"-->
+<!--          :is-show-tip="false"-->
+<!--          :disabled="formType==='detail'"-->
+<!--          v-model="formData.filePath"-->
+<!--          :multiple="formType === 'create'"-->
+<!--          :limit="formType === 'create' ? 5 : 1"-->
+<!--        />-->
+        <file-upload
+          style="height: 300px"
+          @uploadSuccess="handleUploadSuccess"
+          @uploadError="handleUploadError"
+          @uploadComplete="handleUploadComplete"
         />
       </el-form-item>
       <el-form-item :label="t('fileInfo.fileType')" prop="fileType">
@@ -56,6 +62,7 @@ import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import {defaultProps, handleTree} from "@/utils/tree";
 import * as FileClassifyApi from "@/api/pms/info";
 import {IotTreeApi} from "@/api/system/tree";
+import FileUpload from "@/components/UploadFile/src/FileUpload.vue";
 
 /** 资料 表单 */
 defineOptions({ name: 'IotInfoFormTree' })
@@ -77,11 +84,13 @@ const formData = ref({
   fileType: undefined,
   filePath: undefined,
   remark: undefined,
-  fileList: []
+  fileList: [],
+  folderTree: [],
+  folderJson: undefined,
 })
 const formRules = reactive({
   // classId: [{ required: true, message: '文件不能为空', trigger: 'blur' }],
-  filePath: [{ required: true, message: '文件不能为空', trigger: 'blur' }]
+  // filePath: [{ required: true, message: '文件不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
 const props = defineProps<{
@@ -138,12 +147,13 @@ const submitForm = async () => {
   // 提交请求
   formLoading.value = true
   try {
-    formData.value.fileList = formData.value.filePath
-    formData.value.filePath = ''
+    // formData.value.fileList = formData.value.filePath
+    // formData.value.filePath = ''
     const data = formData.value as unknown as IotInfoVO
     if (formType.value === 'create') {
       data.deviceId = props.deviceId
       // data.filePath = data.filePath.join(',')
+      formData.value.folderJson = JSON.stringify(formData.value.folderTree);
       await IotInfoApi.createIotInfo(data)
       message.success(t('common.createSuccess'))
     } else {
@@ -167,8 +177,28 @@ const resetForm = () => {
     filename: undefined,
     fileType: undefined,
     filePath: undefined,
-    remark: undefined
+    remark: undefined,
+    fileList: [],
+    folderTree: []
   }
   formRef.value?.resetFields()
 }
+
+const handleUploadSuccess = (data) => {
+  if (!Array.isArray(formData.value.folderTree)){
+    formData.value.folderTree = []
+  }
+  formData.value.folderTree.push(data.response.data);
+  console.log('777777777777777'+JSON.stringify(formData.value.folderTree))
+  message.success(`"${data.name}" 上传成功`);
+};
+
+const handleUploadError = (error) => {
+  console.error('上传失败', error);
+  message.error(`"${error.name}" 上传失败: ${error.error.message}`);
+};
+
+const handleUploadComplete = () => {
+  message.info('所有上传任务已处理完成');
+};
 </script>

+ 6 - 4
src/views/system/tree/index.vue

@@ -66,6 +66,8 @@
                 <Icon v-else-if="scope.row.fileType==='file'&&(scope.row.fileClassify==='doc'||scope.row.fileClassify==='docx')" icon="fa:file-word-o"  color="blue"/>
                 <Icon v-else-if="scope.row.fileType==='file'&&(scope.row.fileClassify==='xls'||scope.row.fileClassify==='xlsx')" icon="fa-solid:file-excel"  color="#107C41"/>
                 <Icon v-else-if="scope.row.fileType==='file'&&(scope.row.fileClassify==='txt')" icon="fa:file-text-o" />
+                <Icon v-else-if="scope.row.fileType==='file'&&(scope.row.fileClassify==='ppt'||scope.row.fileClassify==='pptx')" icon="fa-solid:file-powerpoint" color="#C43E1C" />
+                <Icon v-else icon="fa-solid:file-alt" />
                   {{scope.row.filename}}
                 </div>
               </template>
@@ -238,10 +240,10 @@ const handleDelete = async (id: number) => {
 }
 const formRef = ref()
 const openForm = (type: string, id?: number) => {
-  if (classType.value==='dept'){
-    message.error(t('common.deptChoose'))
-    return;
-  }
+  // if (classType.value==='dept'){
+  //   message.error(t('common.deptChoose'))
+  //   return;
+  // }
   if (!queryParams.classId) {
     message.error(t('common.leftNode'))
     return