上传文件夹

这是一套基于 Vue 3 (Composition API) + Vue Router + Element Plus 实现的路由与页面设计方案。它完全满足了你在草图和原型图中所提出的业务逻辑(如:上传文件夹、正小数+字数限制校验、权限隐藏标签页、按钮联动禁用等)。

核心路由配置 (router/index.js)

这里采用嵌套路由(children)来实现三层页面的跳转:

  1. 首页(Dashboard/Home)

  2. 报表自动处理主页(功能中心)

  3. 子功能页(Excel在线操作 / 自有人员工时汇总)

TypeScript 复制代码
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/',
    name: 'Home',
    // 假设你的首页组件
    component: () => import('@/views/Home.vue'), 
  },
  {
    path: '/report-process',
    name: 'ReportProcess',
    // 第二张图:报表自动处理的功能中心主页
    component: () => import('@/views/report/Index.vue'),
    children: [
      {
        path: 'excel-online',
        name: 'ExcelOnline',
        component: () => import('@/views/report/ExcelOnline.vue'),
      },
      {
        path: 'staff-hours',
        name: 'StaffHoursSummary',
        // 第三张图:自有人员工时汇总页面
        component: () => import('@/views/report/StaffHoursSummary.vue'),
      }
    ]
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

页面代码实现

1. 报表自动处理功能中心 (views/report/Index.vue)

对应第二张图片:包含"Excel在线操作"和"自有人员工时汇总"两个卡片入口。

html 复制代码
<template>
  <!-- 如果在子路由页面,则渲染子路由内容;否则渲染功能中心主页 -->
  <router-view v-if="$route.path !== '/report-process'"></router-view>

  <div v-else class="report-container">
    <div class="header">
      <h2>⭐ BBZDCL</h2>
    </div>

    <div class="content-panel">
      <h3>功能中心</h3>
      <div class="card-grid">
        <!-- 卡片 1: Excel在线操作 -->
        <div class="menu-card" @click="$router.push('/report-process/excel-online')">
          <div class="icon-wrapper excel-icon">
            <el-icon><Document /></el-icon>
          </div>
          <h4>Excel在线操作</h4>
          <p>多人协作编辑 Excel 表格</p>
        </div>

        <!-- 卡片 2: ZYRYGSHZ -->
        <div class="menu-card" @click="$router.push('/report-process/staff-hours')">
          <div class="icon-wrapper staff-icon">
            <el-icon><DocumentCopy /></el-icon>
          </div>
          <h4>ZYRYGSHZ</h4>
          <p>XXXXXX</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { Document, DocumentCopy } from "@element-plus/icons-vue";
</script>

<style scoped>
.report-container {
  padding: 24px;
  background: #f5f7fa;
  min-height: 100vh;
}
.header h2 {
  margin-bottom: 30px;
}
.content-panel h3 {
  margin-bottom: 20px;
  color: #333;
}
.card-grid {
  display: flex;
  gap: 24px;
}
.menu-card {
  background: #fff;
  border-radius: 12px;
  padding: 30px;
  width: 280px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s;
  border: 1px solid #e4e7ed;
}
.menu-card:hover {
  transform: translateY(-5px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.icon-wrapper {
  width: 70px;
  height: 70px;
  border-radius: 16px;
  margin: 0 auto 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32px;
  color: #fff;
}
.excel-icon {
  background-color: #67c23a;
}
.staff-icon {
  background-color: #409eff;
}
.menu-card h4 {
  margin: 10px 0;
  font-size: 18px;
}
.menu-card p {
  color: #909399;
  font-size: 14px;
}
</style>

2. 重点:自有人员工时汇总页 (views/report/StaffHoursSummary.vue)

对应第三张图片(及草图说明)

  • 逻辑1:部门汇总 / 企划汇总标签页切换(企划汇总增加模拟角色权限控制)。

  • 逻辑2 :点击上传文件可选择两个文件夹

  • 逻辑3 :总合工作量占比输入框限制:最长50字 + 必须为正小数

  • 逻辑4 :导出按钮联动------只有成功上传文件输入占比正确时才可点击。

html 复制代码
<template>
  <div class="summary-container">
    <!-- 面包屑页头 -->
    <div class="page-header">
      <div class="left">
        <el-icon class="page-icon"><Document /></el-icon>
        <span class="title">BBZDCL</span>
      </div>
      <div class="right help-link">
        <el-icon><QuestionFilled /></el-icon> 帮助
      </div>
    </div>

    <!-- 主体卡片区域 -->
    <div class="main-card">
      <!-- 标签页切换 -->
      <el-tabs v-model="activeTab" class="custom-tabs">
        <el-tab-pane label="BMHZ" name="department"></el-tab-pane>
        <!-- 权限控制:模拟只有当前用户为特定用户/角色时才显示 -->
        <el-tab-pane v-if="currentUser.username === 'admin'" label="QHHZ" name="plan">
        </el-tab-pane>
      </el-tabs>

      <!-- 工具栏操作区 -->
      <div class="toolbar">
        <div class="left-actions">
          <!-- 上传文件夹(directory 允许选择文件夹) -->
          <el-upload
            action="#"
            multiple
            directory
            :auto-upload="false"
            :show-file-list="false"
            :on-change="handleFolderUpload"
          >
            <el-button type="primary" :icon="Upload">上传文件</el-button>
          </el-upload>

          <!-- 导出按钮:受上传状态和输入框校验联合控制 -->
          <el-button
            type="success"
            :icon="Download"
            :disabled="!isExportAvailable"
            class="export-btn"
          >
            导出 Excel 打包下载
          </el-button>
        </div>

        <!-- 占比输入框及校验逻辑 -->
        <div class="right-input">
          <span class="label-required">ZHGZLZB:</span>
          <el-input
            v-model="ratioInput"
            placeholder="输入框(字数限制50,要求正小数)"
            maxlength="50"
            show-word-limit
            :class="{ 'input-error': ratioError }"
            @input="validateRatio"
            style="width: 320px"
          />
          <div v-if="ratioError" class="error-msg">请输入有效的正小数(例如:0.25)</div>
        </div>
      </div>

      <!-- 数据表格 -->
      <el-table
        :data="tableData"
        border
        style="width: 100%; margin-top: 20px"
        empty-text="请点击「上传文件」选择文件夹"
      >
        <el-table-column prop="fileName" label="名称" min-width="250" />
        <el-table-column prop="status" label="状态" width="120" align="center">
          <template #default="scope">
            <el-tag :type="scope.row.status === '成功' ? 'success' : 'info'">{{
              scope.row.status
            }}</el-tag>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from "vue";
import { Document, QuestionFilled, Upload, Download } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";

// 模拟当前登录用户数据(改为非 'admin' 即可测试隐藏"QHHZ")
const currentUser = ref({ username: "admin" });

const activeTab = ref("department");
const ratioInput = ref("");
const ratioError = ref(false);
const tableData = ref([]); // 上传的文件列表数据

// 校验正小数的正则
const validateRatio = (val) => {
  if (!val) {
    ratioError.value = false;
    return;
  }
  // 正小数正则(不包括0和负数)
  const reg = /^(0|[1-9]\d*)(\.\d+)?$/;
  if (reg.test(val) && parseFloat(val) > 0) {
    ratioError.value = false;
  } else {
    ratioError.value = true;
  }
};

// 处理文件夹上传
const handleFolderUpload = (uploadFile) => {
  // 模拟读取文件夹下的文件并展示到表格中
  const rawFile = uploadFile.raw;
  const path = rawFile.webkitRelativePath || rawFile.name;

  // 避免重复添加
  if (tableData.value.some((item) => item.fileName === path)) return;

  tableData.value.push({
    fileName: path,
    status: "成功",
    recordCount: Math.floor(Math.random() * 100) + 10,
    reportCount: Math.floor(Math.random() * 10) + 1,
    total: (Math.random() * 200).toFixed(2),
  });

  ElMessage.success(`读取文件成功: ${rawFile.name}`);
};

// 核心联动逻辑:是否允许导出
const isExportAvailable = computed(() => {
  // 1. 必须有上传成功的文件
  const hasFiles = tableData.value.length > 0;
  // 2. 输入框不能为空且必须校验通过(无错误)
  const hasValidRatio = ratioInput.value && !ratioError.value;

  return hasFiles && hasValidRatio;
});
</script>

<style scoped>
.summary-container {
  background: #f5f7fa;
  min-height: 100vh;
  padding-bottom: 30px;
}
.page-header {
  background: #fff;
  padding: 14px 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #e4e7ed;
}
.page-header .left {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: bold;
}
.page-icon {
  color: #409eff;
  font-size: 20px;
}
.help-link {
  color: #409eff;
  cursor: pointer;
  font-size: 14px;
  display: flex;
  align-items: center;
  gap: 4px;
}

.main-card {
  background: #fff;
  margin: 20px;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}

.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-top: 20px;
  flex-wrap: wrap;
  gap: 16px;
}
.left-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.right-input {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
}
.label-required {
  font-weight: bold;
  font-size: 15px;
  white-space: nowrap;
}

/* 输入框标红框提示 */
:deep(.input-error .el-input__wrapper) {
  box-shadow: 0 0 0 1px #f56c6c inset !important;
}
.error-msg {
  position: absolute;
  bottom: -20px;
  right: 0;
  color: #f56c6c;
  font-size: 12px;
}
</style>

代码设计亮点说明

  1. 文件夹上传功能 (directory) :在 el-upload 上添加了原生的 directory 属性,点击"上传文件"按钮时,浏览器会自动弹出文件夹选择框而非单个文件选择框。

  2. 正小数与字数动态校验 :利用正则响应式监听输入,配合 Element Plus 的 maxlength 自动在右侧展示 0/50 字数限制。如果输入不合法(如输入负数、字母、或留空),输入框会立刻呈现高亮红框提示。

  3. 按钮状态联合联动 (computed) :采用计算属性 isExportAvailable 完美收拢业务逻辑。只有当 tableData 数组不为空(表示上传成功) ratioInput 输入合法时,导出打包下载按钮才会激活,逻辑清晰。


问题:

上传文件夹可以同时上传两个文件夹吗?或者可以上传文件夹套文件夹这样的吗?

在前端开发中,使用 Element Plus 的 directory(或原生 webkitdirectory)属性时,关于你提到的这两种复杂上传场景,实际的浏览器行为和底层逻辑是这样的:

1. 可以同时选择并上传"两个独立的文件夹"吗?

结论:原生文件选择弹窗不支持,但可以通过"拖拽"或"分批上传"来实现。

  • 常规点击上传(不支持多选文件夹) : 当你点击按钮触发系统的文件选择器时,即使你配置了 multiple在绝大多数操作系统(Windows / macOS)的标准弹窗里,你一次只能双击选中并"打开/上传"一个文件夹 。你无法按住 CtrlShift 同时选中两个平级的文件夹点击确定。

  • 完美的替代解决方案

    • 方案 A(拖拽上传) :如果你使用 Element Plus 的拖拽上传区域(drag 模式),用户可以在桌面同时选中两个文件夹,直接拖进上传区域,前端是可以同时接收到这两个文件夹内所有文件的。

    • 方案 B(分批多次点击):用户可以点击第一次,选择"文件夹 A"上传;然后再次点击,选择"文件夹 B"上传。我们在前端代码中通过一个数组把它们累加起来即可。

2. 可以上传"文件夹套文件夹(层级嵌套)"吗?

结论:完全可以!但正如你截图中的官方文档所说,数据会被"扁平化处理"。

当你选择了一个最外层的母文件夹,而这个文件夹里面还嵌套了子文件夹、孙文件夹时:

  1. 浏览器行为 :浏览器会递归地把这个母文件夹下所有层级里的文件全部读取出来。

  2. 数据的"扁平化":正如文档所述,Element 会把所有文件抽出来放进一个一维数组里传给你的后端或前端回调。

  3. 如何保留层级关系? 虽然数组是扁平的,但每一个文件对象上都包含一个关键属性:file.raw.webkitRelativePath(文件的相对路径)。 例如:

    • 母文件夹/文件1.xlsx

    • 母文件夹/子文件夹A/文件2.xlsx

    • 母文件夹/子文件夹A/孙文件夹B/文件3.xlsx

    你的后端(或前端)完全可以通过解析这个 webkitRelativePath 路径字符串,在服务器端或者页面上完美还原出原本的"嵌套树状结构"!

💡 针对你业务的落地建议

在写代码时,为了完美支持你原型图上的需求,建议这样处理:

TypeScript 复制代码
// 前端数组,用来接收多次上传或拖拽的多个文件夹文件
const allUploadedFiles = ref([]);

const handleFolderUpload = (uploadFile) => {
  const rawFile = uploadFile.raw;
  
  // 核心:通过 webkitRelativePath 拿到完整的层级路径
  console.log("当前文件相对路径:", rawFile.webkitRelativePath); 
  
  // 将文件追加到列表中,这样用户分两次上传两个文件夹也能完美兼容
  allUploadedFiles.value.push(rawFile);
  
  // 可以在这里通过解析路径,判断用户一共上传了多少个不同的顶级文件夹
};
  • 如果业务一定要用户一次性 交齐两个文件夹,建议页面上直接提示用户:"请将两个文件夹打包拖拽至此处,或分两次点击上传"

  • 如果需要区分不同的文件夹,在前端通过对 webkitRelativePath 字符串进行截取(取第一个 / 前的名字)就可以轻松将它们归类。

相关推荐
谙忆10241 小时前
响应式图片实战:srcset、sizes、picture 与 art direction 到底怎么用
前端
索西引擎4 小时前
【React】JSX:JavaScript 语法扩展的本质、编译机制与表达能力分析
前端·javascript·react.js
国科安芯11 小时前
航天电子模拟前端三大支柱:精密运放、高速运放与电压监控的协同设计方法——ASL8522S/ASL622S/ASL706S技术解析
前端·单片机·嵌入式硬件·fpga开发·架构·安全性测试
神明不懂浪漫12 小时前
【第四章】CSS(二)——文本外观属性与复合选择器
前端·css·经验分享·笔记
kyriewen12 小时前
别再用AI debug了——这5种bug越修越烂
前端·javascript·ai编程
星释13 小时前
鸿蒙智能体开发实战:39.鸿蒙壁纸大师 - 前端卡片显示与交互优化
前端·华为·交互·harmonyos·鸿蒙·火山引擎
顾昂_14 小时前
内存泄漏排查和 Chrome DevTools 使用教程
前端·javascript·面试
IT_陈寒15 小时前
Java线程池这个坑我算是踩明白了
前端·人工智能·后端
315356691315 小时前
Superpowers:GPT-5.6 时代下的流程毒瘤
前端·后端