记录利用Cursor快速实现Excel共享编辑

需求:

我有一个项目叫做"XPXZ平台",现在有这么一个需求:从首页功能栏里点击"Excel 共享编辑"跳转页面,初始化界面是中间区域会大图展示"新建+"和上传Excel按钮,上下进行展示,是指在用户未新建过Excel和未上传过Excel的初始化进入的界面样子。如果有Excel的话,则会展示Excel的表格展示区域(上下栏布局,第一行也就是页面左上角展示为一个"上传Excel"按钮,高度固定50px就够,第二行为上传或新建过的Excel表格展示区域,高度可以随文件数量自动撑高,整体外围是虚线框包裹此区域),然后这个下方展示区域每行可以展示五个Excel表格(每个小块上方会展示Excel的图标,下方展示其文件名,右上角展示一列有三个小功能按钮,分别为:1、"权限分配"小图标,点击会跳转其界面,可以进行部门和人员的勾选从而进行权限分配,需要能够搜索查询,这里我想让你借鉴使用vue3-treeselect的结构树形插件帮我实现、2、"重命名"小图标,鼠标滑过悬浮展示输入框进行输入文件名进行更改、3、"删除"小图标,点击会提示是否确认删除警告框),然后首个展示则为新建块(上方为+加号图标,下方为文字"新建表格")。

项目规范

  • 使用 Vue3

  • 使用 TypeScript

  • 所有组件使用 setup script

  • API 使用 composables

  • UI 使用 Element Plus

  • 代码必须带中文注释

  • 不允许 any

利用 Cursor 实现起来会很快,我们的角色就跟 产品 / 需求 或者 项目经理似的,只需要在 Cursor Agents 窗口内提任务需求就可以了,它就能够快速理解需求并自动帮你实现,话不多说,先看下成果~

基本满足所提需求。


接下来咱们细看一下 Cursor 是如何一步一步实现的吧~

【 因为我只创建了一个 StarCollab 空项目文件夹,所以 Cursor 会从 0 到 1 实现 】

1、我先了解项目结构和现有代码规范,再实现「Excel 共享编辑」页面。

正在探索 StarCollab 项目结构与相关实现。

探索结论:工作区为空

/Users/jimmy/Desktop/PROJECT/StarCollab 目前只有空目录(创建于 2026-05-30),没有任何源码、package.jsonnode_modules 或配置文件。因此下面 6 项问题均无法在仓库内找到对应实现。


1. 项目结构(Vue3 / router / composables)

预期路径 状态
package.json 不存在
src/src/router/src/views/src/composables/ 不存在
vite.config.* / vue.config.* 不存在

无法确认是否采用 Vue3 + TypeScript + <script setup> + composables 等约定;这些仅出现在父会话的需求描述里,尚未落地为代码。


2. 首页功能栏与导航

预期路径 状态
路由入口(如 src/router/index.ts 不存在
首页(如 src/views/home/HomeView.vue 不存在
「Excel 共享编辑」跳转逻辑 不存在

父会话需求提到:从首页功能栏点击「Excel 共享编辑」进入新页面,但仓库内无首页、无路由、无功能栏组件。


3. 可参考的类似页面/功能

无 --- 工作区内没有任何 .vue.ts 页面或 feature 模块可供对照。


4. Element Plus 使用方式

无 --- 无 element-plus 依赖声明,无 ElButtonElDialog 等用法示例。


5. API / composables 模式

无 --- 无 src/composables/src/api/useXxx.ts 等文件。


6. vue3-treeselect 是否已安装

否 --- 无 package.json,全项目 grep 也无 treeselect / vue3-treeselect 匹配。


Excel 相关代码

无 --- 无 excelxlsxspreadsheet 等相关文件或依赖。


实际检查到的路径汇总

类别 绝对路径 说明
工作区根目录 /Users/jimmy/Desktop/PROJECT/StarCollab 空目录
package.json --- 不存在
Router --- 不存在
首页 / 功能栏 --- 不存在
类似功能页 --- 不存在
Composables 示例 --- 不存在
Excel 相关 --- 不存在

同级的 /Users/jimmy/Desktop/PROJECT/ 下其他项目(如 xxx-webwgplvshi-xcx 等)不是 StarCollab,且在这些项目的 package.json 中也未发现 element-plusvue3-treeselect


与父会话的关系

父会话 1 描述的是「XPXZ平台」要新增的 Excel 共享编辑 功能(空态/列表态、权限树形选择、重命名、删除等),但实现代码尚未出现在当前工作区。


建议下一步

  1. 若代码在 Git 远程或其他目录,请克隆/复制到 /Users/jimmy/Desktop/PROJECT/StarCollab,或提供真实项目路径后再探索。
  2. 若这是全新项目,需先初始化 Vue3 + TS + Element Plus 脚手架,再按需求实现路由与 Excel 模块。
  3. 需要我基于需求起草目录结构与文件清单时,可切换到 Agent 模式(当前为 Ask/只读模式,无法创建文件)。

2、工作区是空的,我将从零搭建 Vue3 项目并实现 Excel 共享编辑功能。

bash 复制代码
cd /Users/jimmy/Desktop/PROJECT/StarCollab && npm create vite@latest . -- --template vue-ts

结果我电脑上的 node 版本太低了

3、Node 版本较旧,我将手动创建项目文件并实现完整功能。


1、package.json

java 复制代码
{
  "name": "star-collab",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.1",
    "element-plus": "^2.9.0",
    "vue": "^3.5.13",
    "vue-router": "^4.5.0",
    "vue3-treeselect": "^0.1.10"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.6.2",
    "typescript": "^5.4.5",
    "vite": "^4.5.3",
    "vue-tsc": "^2.0.29"
  }
}

在 package.json 文件中,标准的 JSON 格式不支持普通的 // 或 /* */ 注释。为了让你的代码既能保留说明又不影响 npm 或 vite 运行,通常有几种做法:

  1. 使用特殊的 Key(如 "//")作为注释(最通用,不会报错)。

  2. 在字段后面加上描述性后缀(非标准,不推荐)。

  3. 在文档中说明

以下是为你添加了注释(采用兼容性最好的 // 键名方式)的版本,以及对每一项的中文解释:

1. 带有注释的 package.json 代码

你可以直接参考或部分复制到你的文件中:

bash 复制代码
{
  // 项目名称,npm包的唯一标识(私有项目不会发布,所以主要用于标识)
  "name": "star-collab",
  // 标记为私有项目,防止意外发布到npm仓库
  "private": true,
  // 项目版本号,遵循语义化版本规范(主版本号.次版本号.修订号)
  "version": "1.0.0",
  // 指定项目使用ES模块规范(import/export语法),而不是CommonJS规范(require/module.exports)
  "type": "module",
  // 项目可执行脚本命令,可通过 npm run <命令名> 执行
  "scripts": {
    // 启动开发服务器(基于Vite),支持热更新、本地预览开发效果
    "dev": "vite",
    // 构建生产环境包:先通过vue-tsc做TypeScript类型检查,再执行vite build打包
    "build": "vue-tsc && vite build",
    // 本地预览生产构建后的产物,模拟线上环境运行
    "preview": "vite preview"
  },
  // 生产环境依赖:项目上线运行时必须的依赖包
  "dependencies": {
    // Element Plus的图标库(Vue3版本),用于引入UI组件库的图标
    "@element-plus/icons-vue": "^2.3.1",
    // Element Plus UI组件库(Vue3版本),提供开箱即用的组件
    "element-plus": "^2.9.0",
    // Vue3框架,项目的核心视图框架
    "vue": "^3.5.13",
    // Vue Router,Vue的官方路由管理库,实现单页应用的页面跳转
    "vue-router": "^4.5.0",
    // Vue3树形选择组件(vue3-treeselect),用于实现树形结构的下拉选择
    "vue3-treeselect": "^0.1.10"
  },
  // 开发环境依赖:仅开发、构建阶段需要的工具/库,上线后不会被打包进产物
  "devDependencies": {
    // Vite的Vue插件,用于支持.vue单文件组件的编译
    "@vitejs/plugin-vue": "^4.6.2",
    // TypeScript,为项目提供类型检查和编译支持
    "typescript": "^5.4.5",
    // Vite,下一代前端构建工具,比传统Webpack更快的开发体验
    "vite": "^4.5.3",
    // Vue的TypeScript类型检查工具,构建前校验Vue组件的TS类型是否正确
    "vue-tsc": "^2.0.29"
  }
}

2、vite.config.ts

javascript 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
})

3、tsconfig.node.json

javascript 复制代码
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

4、tsconfig.json

javascript 复制代码
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

5、index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>XPXZ平台</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

6、.cursorignore

bash 复制代码
node_modules/
dist/
build/
.git/
.svn/
.idea/
.vscode/
.env
.env.local
.env.*.local
*.log
npm-debug.log*
*.tmp
*.temp
.DS_Store

7、src/vite-env.d.ts

javascript 复制代码
/// <reference types="vite/client" />

declare module 'vue3-treeselect' {
  import type { DefineComponent } from 'vue'
  const Treeselect: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
  export default Treeselect
}

8、src/main.ts

javascript 复制代码
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')

9、src/App.vue

javascript 复制代码
<template>
  <router-view />
</template>

<script setup lang="ts">
// 根组件:仅作为路由出口
</script>

<style>
/* 全局样式重置 */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body,
#app {
  width: 100%;
  height: 100%;
  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
  background-color: #f5f7fa;
  color: #303133;
}
</style>

10、src/views/home/HomeView.vue

javascript 复制代码
<template>
  <div class="home-view">
    <!-- 顶部导航栏 -->
    <header class="home-header">
      <div class="logo">
        <el-icon :size="28" color="#409eff"><Star /></el-icon>
        <span class="logo-text">XPXZ平台</span>
      </div>
    </header>

    <!-- 功能栏区域 -->
    <main class="home-main">
      <h2 class="section-title">功能中心</h2>
      <div class="feature-grid">
        <div
          v-for="feature in features"
          :key="feature.path"
          class="feature-card"
          @click="navigateTo(feature.path)"
        >
          <div class="feature-icon" :style="{ background: feature.color }">
            <el-icon :size="32" color="#fff">
              <component :is="feature.icon" />
            </el-icon>
          </div>
          <span class="feature-name">{{ feature.name }}</span>
          <span class="feature-desc">{{ feature.desc }}</span>
        </div>
      </div>
    </main>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import { Star, Document } from '@element-plus/icons-vue'
import type { Component } from 'vue'

/** 功能项配置 */
interface FeatureItem {
  name: string
  desc: string
  path: string
  icon: Component
  color: string
}

const router = useRouter()

/** 首页功能栏列表 */
const features: FeatureItem[] = [
  {
    name: 'Excel 共享编辑',
    desc: '多人协作编辑 Excel 表格',
    path: '/excel-share',
    icon: Document,
    color: 'linear-gradient(135deg, #67c23a, #529b2e)',
  },
  {
    name: 'Treeselect 测试',
    desc: '树形多选组件独立调试页',
    path: '/treeselect-test',
    icon: Document,
    color: 'linear-gradient(135deg, #409eff, #337ecc)',
  },
]

/** 跳转到对应功能页 */
function navigateTo(path: string): void {
  router.push(path)
}
</script>

<style scoped>
.home-view {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.home-header {
  height: 60px;
  background: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  display: flex;
  align-items: center;
  padding: 0 32px;
}

.logo {
  display: flex;
  align-items: center;
  gap: 10px;
}

.logo-text {
  font-size: 20px;
  font-weight: 600;
  color: #303133;
}

.home-main {
  flex: 1;
  padding: 40px 32px;
  max-width: 1200px;
  margin: 0 auto;
  width: 100%;
}

.section-title {
  font-size: 22px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 24px;
}

.feature-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 20px;
}

.feature-card {
  background: #fff;
  border-radius: 12px;
  padding: 28px 24px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  cursor: pointer;
  transition: all 0.25s ease;
  border: 1px solid #ebeef5;
}

.feature-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
  border-color: #409eff;
}

.feature-icon {
  width: 64px;
  height: 64px;
  border-radius: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.feature-name {
  font-size: 16px;
  font-weight: 600;
  color: #303133;
}

.feature-desc {
  font-size: 13px;
  color: #909399;
  text-align: center;
}
</style>

11、src/views/excel/ExcelShareView.vue

javascript 复制代码
<template>
  <div class="excel-share-view">
    <!-- 顶部导航 -->
    <header class="page-header">
      <div class="header-left">
        <el-button :icon="ArrowLeft" text @click="router.push('/')">返回首页</el-button>
        <h1 class="page-title">Excel 共享编辑</h1>
      </div>
    </header>

    <!-- 主内容区 -->
    <main v-loading="loading" class="page-main">
      <!-- 空态:无 Excel 文件 -->
      <ExcelEmptyState
        v-if="isEmpty"
        @create="handleCreate"
        @upload="handleUpload"
      />

      <!-- 列表态:已有 Excel 文件 -->
      <div v-else class="excel-list-container">
        <!-- 第一行:上传按钮(固定 50px 高度) -->
        <div class="upload-bar">
          <el-upload
            :show-file-list="false"
            :before-upload="handleUpload"
            accept=".xlsx,.xls"
          >
            <el-button type="primary" :icon="Upload">上传 Excel</el-button>
          </el-upload>
        </div>

        <!-- 第二行:Excel 表格展示区域(虚线框包裹) -->
        <div class="excel-grid-wrapper">
          <div class="excel-grid">
            <!-- 首个为新建块 -->
            <ExcelCard is-create @create="handleCreate" />

            <!-- Excel 文件卡片列表 -->
            <ExcelCard
              v-for="file in excelFiles"
              :key="file.id"
              :file="file"
              @permission="openPermissionDialog"
              @rename="handleRename"
              @delete="handleDelete"
            />
          </div>
        </div>
      </div>
    </main>

    <!-- 权限分配弹框 -->
    <ExcelPermissionDialog ref="permissionDialogRef" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, Upload } from '@element-plus/icons-vue'
import { useExcelShare } from '@/composables/useExcelShare'
import ExcelEmptyState from '@/components/excel/ExcelEmptyState.vue'
import ExcelCard from '@/components/excel/ExcelCard.vue'
import ExcelPermissionDialog from '@/components/excel/ExcelPermissionDialog.vue'

const router = useRouter()
const permissionDialogRef = ref<InstanceType<typeof ExcelPermissionDialog>>()

const {
  excelFiles,
  loading,
  isEmpty,
  handleCreate,
  handleUpload,
  handleRename,
  handleDelete,
} = useExcelShare()

/** 打开权限分配弹框 */
function openPermissionDialog(id: string): void {
  permissionDialogRef.value?.openDialog(id)
}
</script>

<style scoped>
.excel-share-view {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.page-header {
  height: 56px;
  background: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
  display: flex;
  align-items: center;
  padding: 0 24px;
}

.header-left {
  display: flex;
  align-items: center;
  gap: 12px;
}

.page-title {
  font-size: 18px;
  font-weight: 600;
  color: #303133;
}

.page-main {
  flex: 1;
  padding: 32px;
  max-width: 1200px;
  margin: 0 auto;
  width: 100%;
}

.excel-list-container {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* 上传栏:固定 50px 高度 */
.upload-bar {
  height: 50px;
  display: flex;
  align-items: center;
}

/* 虚线框包裹的表格展示区域 */
.excel-grid-wrapper {
  border: 2px dashed #dcdfe6;
  border-radius: 8px;
  padding: 20px;
  min-height: 180px;
}

/* 每行 5 个 Excel 卡片 */
.excel-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 16px;
}

@media (max-width: 1024px) {
  .excel-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (max-width: 640px) {
  .excel-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}
</style>

12、src/types/excel.ts

javascript 复制代码
/** 人员性质 */
export type PersonnelNature = '自有' | '外协'

/** 组织树节点类型 */
export type OrgNodeType = 'category' | 'department' | 'person'

/** Excel 文件项 */
export interface ExcelFileItem {
  /** 唯一标识 */
  id: string
  /** 文件名 */
  name: string
  /** 创建时间 */
  createdAt: string
  /** 已分配的人员 ID 列表 */
  personIds: string[]
  /** 已分配的部门 ID 列表 */
  departmentIds: string[]
}

/** 组织树节点 */
export interface OrgTreeNode {
  id: string
  label: string
  type: OrgNodeType
  children?: OrgTreeNode[]
  /** 是否默认展开(搜索过滤时使用) */
  isDefaultExpanded?: boolean
  /** 是否禁用选择(分类节点使用) */
  isDisabled?: boolean
  /** 人员姓名(person 类型) */
  name?: string
  /** 人员性质 */
  nature?: PersonnelNature
  /** 所属部门名称 */
  department?: string
  /** 所属公司 */
  company?: string
}

/** 已选人员信息(右侧表格展示) */
export interface SelectedPerson {
  id: string
  name: string
  nature: PersonnelNature
  department: string
  company: string
}

/** 已选部门信息(右侧列表展示) */
export interface SelectedDepartment {
  id: string
  name: string
  nature: PersonnelNature
}

/** 创建 Excel 请求参数 */
export interface CreateExcelParams {
  name: string
}

/** 重命名 Excel 请求参数 */
export interface RenameExcelParams {
  id: string
  name: string
}

/** 权限分配请求参数 */
export interface AssignPermissionParams {
  excelId: string
  personIds: string[]
  departmentIds: string[]
}

/** 权限分配数据 */
export interface PermissionData {
  personIds: string[]
  departmentIds: string[]
}

/** 上传 Excel 响应 */
export interface UploadExcelResponse {
  id: string
  name: string
}

13、src/router/index.ts

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/home/HomeView.vue'),
    meta: { title: '首页' },
  },
  {
    path: '/excel-share',
    name: 'ExcelShare',
    component: () => import('@/views/excel/ExcelShareView.vue'),
    meta: { title: 'Excel 共享编辑' },
  },
  {
    path: '/treeselect-test',
    name: 'TreeselectTest',
    component: () => import('@/views/dev/TreeselectTestView.vue'),
    meta: { title: 'Treeselect 测试' },
  },
]

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

/** 路由守卫:设置页面标题 */
router.beforeEach((to) => {
  const title = to.meta.title as string | undefined
  document.title = title ? `${title} - XPXZ平台` : 'XPXZ平台'
})

export default router

14、src/composables/useExcelApi.ts

javascript 复制代码
import { ref } from 'vue'
import type {
  ExcelFileItem,
  OrgTreeNode,
  CreateExcelParams,
  RenameExcelParams,
  AssignPermissionParams,
  UploadExcelResponse,
  PermissionData,
} from '@/types/excel'

/** 模拟组织树:一层为「自有 / 外协」,其下为部门,部门下为人员 */
const mockOrgTree: OrgTreeNode[] = [
  {
    id: 'cat-internal',
    label: '自有',
    type: 'category',
    children: [
      {
        id: 'dept-1-internal',
        label: '科技研发一部',
        type: 'department',
        nature: '自有',
        children: [
          {
            id: 'person-int-1',
            label: '张明',
            type: 'person',
            name: '张明',
            nature: '自有',
            department: '科技研发一部',
            company: 'XP科技',
          },
          {
            id: 'person-int-1b',
            label: '张磊',
            type: 'person',
            name: '张磊',
            nature: '自有',
            department: '科技研发一部',
            company: 'XP科技',
          },
          {
            id: 'person-int-2',
            label: '李华',
            type: 'person',
            name: '李华',
            nature: '自有',
            department: '科技研发一部',
            company: 'XP科技',
          },
        ],
      },
      {
        id: 'dept-2-internal',
        label: '科技研发二部',
        type: 'department',
        nature: '自有',
        children: [
          {
            id: 'person-int-3',
            label: '陈刚',
            type: 'person',
            name: '陈刚',
            nature: '自有',
            department: '科技研发二部',
            company: 'XP科技',
          },
          {
            id: 'person-int-3b',
            label: '张强',
            type: 'person',
            name: '张强',
            nature: '自有',
            department: '科技研发二部',
            company: 'XP科技',
          },
        ],
      },
      {
        id: 'dept-3-internal',
        label: '科技研发三部',
        type: 'department',
        nature: '自有',
        children: [
          {
            id: 'person-int-4',
            label: '张伟',
            type: 'person',
            name: '张伟',
            nature: '自有',
            department: '科技研发三部',
            company: 'XP科技',
          },
        ],
      },
      {
        id: 'dept-4-internal',
        label: '科技研发四部',
        type: 'department',
        nature: '自有',
        children: [
          {
            id: 'person-int-5',
            label: '张涛',
            type: 'person',
            name: '张涛',
            nature: '自有',
            department: '科技研发四部',
            company: 'XP科技',
          },
        ],
      },
    ],
  },
  {
    id: 'cat-external',
    label: '外协',
    type: 'category',
    children: [
      {
        id: 'dept-1-external',
        label: '科技研发一部',
        type: 'department',
        nature: '外协',
        children: [
          {
            id: 'person-ext-1',
            label: '王娇',
            type: 'person',
            name: '王娇',
            nature: '外协',
            department: '科技研发一部',
            company: '京北方',
          },
          {
            id: 'person-ext-1b',
            label: '张敏',
            type: 'person',
            name: '张敏',
            nature: '外协',
            department: '科技研发一部',
            company: '京北方',
          },
          {
            id: 'person-ext-2',
            label: '胡苏',
            type: 'person',
            name: '胡苏',
            nature: '外协',
            department: '科技研发一部',
            company: '中科软',
          },
          {
            id: 'person-ext-3',
            label: '沈昭昭',
            type: 'person',
            name: '沈昭昭',
            nature: '外协',
            department: '科技研发一部',
            company: '长亮科技',
          },
        ],
      },
      {
        id: 'dept-2-external',
        label: '科技研发二部',
        type: 'department',
        nature: '外协',
        children: [
          {
            id: 'person-ext-4',
            label: '张鹏',
            type: 'person',
            name: '张鹏',
            nature: '外协',
            department: '科技研发二部',
            company: '中科软',
          },
        ],
      },
      {
        id: 'dept-4-external',
        label: '科技研发四部',
        type: 'department',
        nature: '外协',
        children: [
          {
            id: 'person-ext-5',
            label: '张丽',
            type: 'person',
            name: '张丽',
            nature: '外协',
            department: '科技研发四部',
            company: '长亮科技',
          },
        ],
      },
      {
        id: 'dept-5-external',
        label: '科技研发五部',
        type: 'department',
        nature: '外协',
        children: [
          {
            id: 'person-ext-6',
            label: '张浩',
            type: 'person',
            name: '张浩',
            nature: '外协',
            department: '科技研发五部',
            company: '京北方',
          },
        ],
      },
    ],
  },
]

/** 模拟 Excel 文件列表(内存存储) */
const excelList = ref<ExcelFileItem[]>([])

/** 生成唯一 ID */
function generateId(): string {
  return `excel-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}

/** 模拟网络延迟 */
function delay(ms = 300): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
 * Excel 共享编辑 API Composable
 * 封装 Excel 文件的增删改查及权限分配接口
 */
export function useExcelApi() {
  /** 获取 Excel 文件列表 */
  async function fetchExcelList(): Promise<ExcelFileItem[]> {
    await delay()
    return [...excelList.value]
  }

  /** 新建空白 Excel 表格 */
  async function createExcel(params: CreateExcelParams): Promise<ExcelFileItem> {
    await delay()
    const item: ExcelFileItem = {
      id: generateId(),
      name: params.name,
      createdAt: new Date().toISOString(),
      personIds: [],
      departmentIds: [],
    }
    excelList.value.push(item)
    return item
  }

  /** 上传 Excel 文件 */
  async function uploadExcel(file: File): Promise<UploadExcelResponse> {
    await delay(500)
    const item: ExcelFileItem = {
      id: generateId(),
      name: file.name.replace(/\.(xlsx|xls)$/i, ''),
      createdAt: new Date().toISOString(),
      personIds: [],
      departmentIds: [],
    }
    excelList.value.push(item)
    return { id: item.id, name: item.name }
  }

  /** 重命名 Excel 文件 */
  async function renameExcel(params: RenameExcelParams): Promise<void> {
    await delay()
    const target = excelList.value.find((item) => item.id === params.id)
    if (target) {
      target.name = params.name
    }
  }

  /** 删除 Excel 文件 */
  async function deleteExcel(id: string): Promise<void> {
    await delay()
    const index = excelList.value.findIndex((item) => item.id === id)
    if (index !== -1) {
      excelList.value.splice(index, 1)
    }
  }

  /** 获取组织树(自有/外协 → 部门 → 人员) */
  async function fetchOrgTree(): Promise<OrgTreeNode[]> {
    await delay()
    return mockOrgTree
  }

  /** 获取指定 Excel 的已分配权限 */
  async function fetchExcelPermissions(excelId: string): Promise<PermissionData> {
    await delay()
    const target = excelList.value.find((item) => item.id === excelId)
    if (!target) {
      return { personIds: [], departmentIds: [] }
    }
    return {
      personIds: [...target.personIds],
      departmentIds: [...target.departmentIds],
    }
  }

  /** 分配权限 */
  async function assignPermissions(params: AssignPermissionParams): Promise<void> {
    await delay()
    const target = excelList.value.find((item) => item.id === params.excelId)
    if (target) {
      target.personIds = [...params.personIds]
      target.departmentIds = [...params.departmentIds]
    }
  }

  /** 根据 ID 获取 Excel 详情 */
  async function fetchExcelById(id: string): Promise<ExcelFileItem | null> {
    await delay()
    return excelList.value.find((item) => item.id === id) ?? null
  }

  return {
    fetchExcelList,
    createExcel,
    uploadExcel,
    renameExcel,
    deleteExcel,
    fetchOrgTree,
    fetchExcelPermissions,
    assignPermissions,
    fetchExcelById,
  }
}

/** 导出 mock 树供 composable 本地解析节点 */
export { mockOrgTree }

15、src/composables/useExcelPermissionDialog.ts

javascript 复制代码
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useExcelApi, mockOrgTree } from '@/composables/useExcelApi'
import type {
  OrgTreeNode,
  SelectedPerson,
  SelectedDepartment,
} from '@/types/excel'

/**
 * 从组织树中递归查找节点
 */
function findNodeById(nodes: OrgTreeNode[], id: string): OrgTreeNode | null {
  for (const node of nodes) {
    if (node.id === id) return node
    if (node.children) {
      const found = findNodeById(node.children, id)
      if (found) return found
    }
  }
  return null
}

/**
 * 查找人员节点的父部门 ID
 */
function findParentDepartmentId(personId: string): string | null {
  for (const category of mockOrgTree) {
    for (const dept of category.children ?? []) {
      if ((dept.children ?? []).some((person) => person.id === personId)) {
        return dept.id
      }
    }
  }
  return null
}

/**
 * 获取分类下所有部门 ID
 */
function getDepartmentIdsInCategory(categoryId: string): string[] {
  const category = findNodeById(mockOrgTree, categoryId)
  if (!category || category.type !== 'category') return []
  return (category.children ?? [])
    .filter((node) => node.type === 'department')
    .map((node) => node.id)
}

/**
 * 获取部门下所有人员 ID
 */
function getPersonIdsInDepartment(deptId: string): string[] {
  const dept = findNodeById(mockOrgTree, deptId)
  if (!dept || dept.type !== 'department') return []
  return (dept.children ?? [])
    .filter((node) => node.type === 'person')
    .map((node) => node.id)
}

function toSelectedPerson(node: OrgTreeNode): SelectedPerson | null {
  if (node.type !== 'person' || !node.name || !node.nature || !node.department || !node.company) {
    return null
  }
  return {
    id: node.id,
    name: node.name,
    nature: node.nature,
    department: node.department,
    company: node.company,
  }
}

/** rawIds 含部门 id,或该部门下人员已全部选中时,计入部门权限区 */
function shouldTreatAsDepartmentSelection(deptId: string, rawIds: string[]): boolean {
  if (rawIds.includes(deptId)) return true
  const allPersonIds = getPersonIdsInDepartment(deptId)
  return allPersonIds.length > 0 && allPersonIds.every((pid) => rawIds.includes(pid))
}

function toSelectedDepartment(node: OrgTreeNode): SelectedDepartment | null {
  if (node.type !== 'department' || !node.nature) {
    return null
  }
  return {
    id: node.id,
    name: node.label,
    nature: node.nature,
  }
}

/**
 * 归一化选中项
 * 规则:分类/部门勾选 → 右侧展示部门;仅勾选人员 → 右侧展示人员
 */
function normalizeSelection(rawIds: string[]): { personIds: string[]; departmentIds: string[] } {
  const departmentIds = new Set<string>()

  // 1. 分类勾选 → 其下所有部门
  for (const id of rawIds) {
    const node = findNodeById(mockOrgTree, id)
    if (node?.type === 'category') {
      getDepartmentIdsInCategory(id).forEach((deptId) => departmentIds.add(deptId))
    }
  }

  // 2. 部门直接勾选
  for (const id of rawIds) {
    const node = findNodeById(mockOrgTree, id)
    if (node?.type === 'department') {
      if (shouldTreatAsDepartmentSelection(id, rawIds)) {
        departmentIds.add(id)
      }
    }
  }

  // 2b. 仅分类勾选时可能只回传子级 id,提升为其下「整部门」权限(避免去掉部门 tag 后因残留人员 id 又把部门加回)
  const hasCategoryInRaw = rawIds.some(
    (id) => findNodeById(mockOrgTree, id)?.type === 'category'
  )
  if (hasCategoryInRaw) {
    for (const category of mockOrgTree) {
      for (const dept of category.children ?? []) {
        if (dept.type !== 'department' || departmentIds.has(dept.id)) continue
        if (shouldTreatAsDepartmentSelection(dept.id, rawIds)) {
          departmentIds.add(dept.id)
        }
      }
    }
  }

  // 3. 人员勾选:所属部门未被选为部门权限时才计入人员区
  const personIds = new Set<string>()
  for (const id of rawIds) {
    const node = findNodeById(mockOrgTree, id)
    if (node?.type !== 'person') continue

    const parentDeptId = findParentDepartmentId(id)
    if (parentDeptId && departmentIds.has(parentDeptId)) {
      continue
    }
    personIds.add(id)
  }

  return {
    personIds: [...personIds],
    departmentIds: [...departmentIds],
  }
}

/** 构建 treeselect 绑定值(BRANCH_PRIORITY:分类/部门优先展示为分支节点) */
function buildTreeselectValue(personIds: string[], departmentIds: string[]): string[] {
  const result: string[] = [...personIds]
  const remainingDepts = new Set(departmentIds)

  for (const category of mockOrgTree) {
    const categoryDeptIds = getDepartmentIdsInCategory(category.id)
    if (
      categoryDeptIds.length > 0 &&
      categoryDeptIds.every((id) => remainingDepts.has(id))
    ) {
      result.push(category.id)
      categoryDeptIds.forEach((id) => remainingDepts.delete(id))
    }
  }

  result.push(...remainingDepts)
  return result
}

/** 内置搜索字段(人员含 name/company,部门匹配 label) */
export const treeselectMatchKeys = ['label', 'name', 'company'] as const

/**
 * Excel 权限分配弹框逻辑 Composable
 */
export function useExcelPermissionDialog() {
  const { fetchOrgTree, fetchExcelPermissions, assignPermissions, fetchExcelById } =
    useExcelApi()

  const visible = ref(false)
  const excelId = ref('')
  const excelName = ref('')
  const orgTree = ref<OrgTreeNode[]>([])
  const selectedPersonIds = ref<string[]>([])
  const selectedDepartmentIds = ref<string[]>([])
  /** 仅右侧删除/清空时重建 treeselect(搜索不重建,与示例一一致) */
  const treeselectVersion = ref(0)
  /** treeselect 绑定值(与归一化后的选中状态解耦,避免重建时丢失) */
  const treeselectBindValue = ref<string[]>([])
  const loading = ref(false)
  const saving = ref(false)

  /** 将当前选中状态同步到 treeselect 绑定值 */
  function syncTreeselectBindValue(): void {
    treeselectBindValue.value = buildTreeselectValue(
      selectedPersonIds.value,
      selectedDepartmentIds.value
    )
  }

  /** 处理 treeselect 勾选/标签删除(与右侧联动) */
  function applyTreeselectChange(ids: string[]): void {
    const result = normalizeSelection(ids)
    selectedPersonIds.value = result.personIds
    selectedDepartmentIds.value = result.departmentIds
    syncTreeselectBindValue()
  }

  /** v-model 统一入口,确保标签叉号删除也会触发归一化 */
  const treeselectModel = computed({
    get: () => treeselectBindValue.value,
    set: (value: string | string[] | null | undefined) => {
      const ids = Array.isArray(value) ? value : value ? [value] : []
      applyTreeselectChange(ids)
    },
  })

  const treeselectKey = computed(() => `tree-${treeselectVersion.value}`)

  const selectedPersons = computed<SelectedPerson[]>(() => {
    return selectedPersonIds.value
      .map((id) => {
        const node = findNodeById(mockOrgTree, id)
        return node ? toSelectedPerson(node) : null
      })
      .filter((item): item is SelectedPerson => item !== null)
  })

  const selectedDepartments = computed<SelectedDepartment[]>(() => {
    return selectedDepartmentIds.value
      .map((id) => {
        const node = findNodeById(mockOrgTree, id)
        return node ? toSelectedDepartment(node) : null
      })
      .filter((item): item is SelectedDepartment => item !== null)
  })

  /** 刷新左侧 treeselect 勾选状态 */
  function refreshTreeselect(): void {
    treeselectVersion.value += 1
  }

  async function openDialog(id: string): Promise<void> {
    excelId.value = id
    visible.value = true
    loading.value = true
    selectedPersonIds.value = []
    selectedDepartmentIds.value = []
    treeselectBindValue.value = []
    orgTree.value = []
    treeselectVersion.value += 1

    try {
      const [excel, treeData, permissions] = await Promise.all([
        fetchExcelById(id),
        fetchOrgTree(),
        fetchExcelPermissions(id),
      ])

      if (!excel) {
        ElMessage.error('Excel 文件不存在')
        visible.value = false
        return
      }

      excelName.value = excel.name
      orgTree.value = treeData
      selectedPersonIds.value = [...permissions.personIds]
      selectedDepartmentIds.value = [...permissions.departmentIds]
      syncTreeselectBindValue()
    } catch {
      ElMessage.error('加载权限数据失败')
      visible.value = false
    } finally {
      loading.value = false
    }
  }

  function closeDialog(): void {
    visible.value = false
  }

  function clearSelectedPersons(): void {
    selectedPersonIds.value = []
    syncTreeselectBindValue()
    refreshTreeselect()
  }

  function clearSelectedDepartments(): void {
    selectedDepartmentIds.value = []
    syncTreeselectBindValue()
    refreshTreeselect()
  }

  function removePerson(id: string): void {
    selectedPersonIds.value = selectedPersonIds.value.filter((item) => item !== id)
    syncTreeselectBindValue()
    refreshTreeselect()
  }

  function removeDepartment(id: string): void {
    selectedDepartmentIds.value = selectedDepartmentIds.value.filter((item) => item !== id)
    syncTreeselectBindValue()
    refreshTreeselect()
  }

  async function handleConfirm(): Promise<void> {
    saving.value = true
    try {
      await assignPermissions({
        excelId: excelId.value,
        personIds: selectedPersonIds.value,
        departmentIds: selectedDepartmentIds.value,
      })
      ElMessage.success('权限分配保存成功')
      visible.value = false
    } catch {
      ElMessage.error('保存失败')
    } finally {
      saving.value = false
    }
  }

  return {
    visible,
    excelName,
    orgTree,
    treeselectBindValue,
    treeselectModel,
    applyTreeselectChange,
    treeselectMatchKeys,
    treeselectKey,
    selectedPersons,
    selectedDepartments,
    treeselectVersion,
    loading,
    saving,
    openDialog,
    closeDialog,
    clearSelectedPersons,
    clearSelectedDepartments,
    removePerson,
    removeDepartment,
    handleConfirm,
  }
}

16、src/composables/useExcelShare.ts

javascript 复制代码
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useExcelApi } from '@/composables/useExcelApi'
import type { ExcelFileItem } from '@/types/excel'

/**
 * Excel 共享编辑页面逻辑 Composable
 * 管理 Excel 列表状态及新建、上传、重命名、删除操作
 */
export function useExcelShare() {
  const { fetchExcelList, createExcel, uploadExcel, renameExcel, deleteExcel } =
    useExcelApi()

  /** Excel 文件列表 */
  const excelFiles = ref<ExcelFileItem[]>([])
  /** 加载状态 */
  const loading = ref(false)
  /** 是否为空态(无 Excel 文件) */
  const isEmpty = ref(true)

  /** 加载 Excel 列表 */
  async function loadExcelList(): Promise<void> {
    loading.value = true
    try {
      excelFiles.value = await fetchExcelList()
      isEmpty.value = excelFiles.value.length === 0
    } catch {
      ElMessage.error('加载 Excel 列表失败')
    } finally {
      loading.value = false
    }
  }

  /** 新建空白表格 */
  async function handleCreate(): Promise<void> {
    try {
      const count = excelFiles.value.length + 1
      await createExcel({ name: `新建表格${count}` })
      ElMessage.success('新建表格成功')
      await loadExcelList()
    } catch {
      ElMessage.error('新建表格失败')
    }
  }

  /** 上传 Excel 文件 */
  async function handleUpload(file: File): Promise<boolean> {
    const validTypes = [
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      'application/vnd.ms-excel',
    ]
    const validExtensions = /\.(xlsx|xls)$/i

    if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) {
      ElMessage.warning('请上传 .xlsx 或 .xls 格式的 Excel 文件')
      return false
    }

    try {
      await uploadExcel(file)
      ElMessage.success('上传成功')
      await loadExcelList()
    } catch {
      ElMessage.error('上传失败')
    }
    return false
  }

  /** 重命名 Excel 文件 */
  async function handleRename(id: string, newName: string): Promise<void> {
    const trimmed = newName.trim()
    if (!trimmed) {
      ElMessage.warning('文件名不能为空')
      return
    }
    try {
      await renameExcel({ id, name: trimmed })
      ElMessage.success('重命名成功')
      await loadExcelList()
    } catch {
      ElMessage.error('重命名失败')
    }
  }

  /** 删除 Excel 文件(带确认弹窗) */
  async function handleDelete(id: string, name: string): Promise<void> {
    try {
      await ElMessageBox.confirm(
        `确定要删除「${name}」吗?删除后无法恢复。`,
        '删除确认',
        {
          confirmButtonText: '确定删除',
          cancelButtonText: '取消',
          type: 'warning',
        }
      )
      await deleteExcel(id)
      ElMessage.success('删除成功')
      await loadExcelList()
    } catch {
      // 用户取消删除时不做处理
    }
  }

  onMounted(() => {
    loadExcelList()
  })

  return {
    excelFiles,
    loading,
    isEmpty,
    loadExcelList,
    handleCreate,
    handleUpload,
    handleRename,
    handleDelete,
  }
}

17、src/components/excel/ExcelCard.vue

javascript 复制代码
<template>
  <div
    class="excel-card"
    :class="{ 'is-create': isCreate }"
    @click="isCreate ? emit('create') : undefined"
  >
    <!-- 新建块 -->
    <template v-if="isCreate">
      <div class="card-body create-body">
        <el-icon :size="40" class="create-plus"><Plus /></el-icon>
        <span class="card-name">新建表格</span>
      </div>
    </template>

    <!-- Excel 文件块 -->
    <template v-else-if="file">
      <!-- 右上角操作按钮组 -->
      <div class="card-actions">
        <el-tooltip content="权限分配" placement="top">
          <el-button
            class="action-btn"
            :icon="User"
            circle
            size="small"
            @click.stop="emit('permission', file.id)"
          />
        </el-tooltip>
        <div
          class="rename-wrapper"
          @mouseenter="showRenameInput"
          @mouseleave="hideRenameInput"
        >
          <el-tooltip content="重命名" placement="top">
            <el-button
              class="action-btn"
              :icon="Edit"
              circle
              size="small"
              @click.stop
            />
          </el-tooltip>
        </div>
        <el-tooltip content="删除" placement="top">
          <el-button
            class="action-btn action-btn--danger"
            :icon="Delete"
            circle
            size="small"
            @click.stop="emit('delete', file.id, file.name)"
          />
        </el-tooltip>
      </div>

      <div class="card-body">
        <!-- Excel 图标 -->
        <div class="excel-icon">
          <svg viewBox="0 0 48 48" width="48" height="48">
            <rect x="4" y="4" width="40" height="40" rx="4" fill="#217346" />
            <text
              x="24"
              y="30"
              text-anchor="middle"
              fill="#fff"
              font-size="16"
              font-weight="bold"
            >
              X
            </text>
          </svg>
        </div>

        <!-- 文件名 / 悬停重命名输入框 -->
        <div
          v-if="isRenaming"
          class="rename-input"
          @click.stop
          @mouseenter="keepRenameInput"
          @mouseleave="hideRenameInput"
        >
          <el-input
            ref="renameInputRef"
            v-model="renameValue"
            size="small"
            placeholder="输入文件名"
            @blur="confirmRename"
            @keyup.enter="confirmRename"
          />
        </div>
        <span v-else class="card-name" :title="file.name">{{ file.name }}</span>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { Plus, User, Edit, Delete } from '@element-plus/icons-vue'
import type { ExcelFileItem } from '@/types/excel'
import type { ElInput } from 'element-plus'

const props = defineProps<{
  /** 是否为新建块 */
  isCreate?: boolean
  /** Excel 文件数据 */
  file?: ExcelFileItem
}>()

const emit = defineEmits<{
  create: []
  permission: [id: string]
  rename: [id: string, name: string]
  delete: [id: string, name: string]
}>()

/** 是否处于重命名状态 */
const isRenaming = ref(false)
/** 重命名输入值 */
const renameValue = ref('')
/** 重命名输入框引用 */
const renameInputRef = ref<InstanceType<typeof ElInput>>()
/** 延迟隐藏定时器 */
let hideTimer: ReturnType<typeof setTimeout> | null = null

/** 鼠标悬停重命名按钮时展示输入框 */
function showRenameInput(): void {
  if (!props.file) return
  if (hideTimer) {
    clearTimeout(hideTimer)
    hideTimer = null
  }
  renameValue.value = props.file.name
  isRenaming.value = true
  nextTick(() => {
    renameInputRef.value?.focus()
  })
}

/** 保持输入框显示(鼠标移入输入框区域时) */
function keepRenameInput(): void {
  if (hideTimer) {
    clearTimeout(hideTimer)
    hideTimer = null
  }
}

/** 鼠标离开时延迟隐藏输入框 */
function hideRenameInput(): void {
  hideTimer = setTimeout(() => {
    confirmRename()
  }, 200)
}

/** 确认重命名 */
function confirmRename(): void {
  if (!props.file || !isRenaming.value) return
  isRenaming.value = false
  const trimmed = renameValue.value.trim()
  if (trimmed && trimmed !== props.file.name) {
    emit('rename', props.file.id, trimmed)
  }
}
</script>

<style scoped>
.excel-card {
  position: relative;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  background: #fff;
  padding: 16px 12px;
  transition: all 0.2s ease;
  min-height: 140px;
}

.excel-card:hover {
  border-color: #409eff;
  box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}

.excel-card.is-create {
  cursor: pointer;
  border-style: dashed;
}

.excel-card.is-create:hover {
  border-color: #409eff;
  background: #ecf5ff;
}

.card-actions {
  position: absolute;
  top: 6px;
  right: 6px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.excel-card:hover .card-actions {
  opacity: 1;
}

.action-btn {
  width: 24px !important;
  height: 24px !important;
  padding: 0 !important;
}

.action-btn--danger:hover {
  color: #f56c6c;
  border-color: #f56c6c;
}

.card-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding-top: 8px;
}

.create-body {
  min-height: 100px;
}

.create-plus {
  color: #409eff;
}

.excel-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

.card-name {
  font-size: 13px;
  color: #606266;
  text-align: center;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.rename-input {
  width: 100%;
}
</style>

18、src/components/excel/ExcelEmptyState.vue

javascript 复制代码
<template>
  <div class="excel-empty">
    <!-- 新建按钮 -->
    <div class="empty-action create-action" @click="emit('create')">
      <div class="action-icon create-icon">
        <el-icon :size="48"><Plus /></el-icon>
      </div>
      <span class="action-label">新建表格</span>
    </div>

    <!-- 上传按钮 -->
    <el-upload
      :show-file-list="false"
      :before-upload="handleBeforeUpload"
      accept=".xlsx,.xls"
      class="empty-action upload-action"
    >
      <div class="action-icon upload-icon">
        <el-icon :size="48"><Upload /></el-icon>
      </div>
      <span class="action-label">上传 Excel</span>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { Plus, Upload } from '@element-plus/icons-vue'

const emit = defineEmits<{
  create: []
  upload: [file: File]
}>()

/** 拦截上传,交由父组件处理 */
function handleBeforeUpload(file: File): boolean {
  emit('upload', file)
  return false
}
</script>

<style scoped>
.excel-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 32px;
  min-height: 400px;
}

.empty-action {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  cursor: pointer;
  transition: transform 0.2s ease;
}

.empty-action:hover {
  transform: scale(1.05);
}

.action-icon {
  width: 120px;
  height: 120px;
  border-radius: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px dashed #dcdfe6;
  transition: border-color 0.2s ease;
}

.empty-action:hover .action-icon {
  border-color: #409eff;
}

.create-icon {
  color: #409eff;
}

.upload-icon {
  color: #67c23a;
}

.action-label {
  font-size: 16px;
  font-weight: 500;
  color: #606266;
}

.upload-action :deep(.el-upload) {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
</style>

19、src/components/excel/ExcelPermissionDialog.vue

javascript 复制代码
<template>
  <el-dialog
    v-model="visible"
    title="权限分配"
    width="960px"
    :close-on-click-modal="false"
    destroy-on-close
    class="permission-dialog"
    @close="closeDialog"
  >
    <div v-loading="loading" class="permission-body">
      <!-- 左侧:与示例一相同 --- 内置搜索 + 完整树 + 常开多选 -->
      <div class="panel-box panel-box--left">
        <div class="treeselect-panel">
          <Treeselect
            :key="treeselectKey"
            v-model="treeselectModel"
            :options="orgTree"
            :multiple="true"
            :always-open="true"
            :max-height="360"
            :searchable="true"
            :match-keys="[...treeselectMatchKeys]"
            :flatten-search-results="false"
            :clear-on-select="true"
            :clearable="false"
            :disable-branch-nodes="false"
            :before-clear-all="preventClear"
            placeholder="请输入姓名或部门名称"
            no-results-text="未找到匹配项"
            no-options-text="暂无可选项"
            class="permission-treeselect"
          />
        </div>
      </div>

      <!-- 右侧框 -->
      <div class="panel-box panel-box--right">
        <div class="excel-info">
          <svg viewBox="0 0 48 48" width="20" height="20" class="excel-info-icon">
            <rect x="4" y="4" width="40" height="40" rx="4" fill="#217346" />
            <text x="24" y="30" text-anchor="middle" fill="#fff" font-size="16" font-weight="bold">X</text>
          </svg>
          <span class="excel-info-text">{{ excelName }},权限分配至:</span>
        </div>

        <div class="selected-section">
          <div class="section-header">
            <span class="section-title">人员 ({{ selectedPersons.length }})</span>
            <el-button
              v-if="selectedPersons.length"
              link
              type="primary"
              @click="clearSelectedPersons"
            >
              清空已选
            </el-button>
          </div>
          <el-table
            :data="selectedPersons"
            border
            size="small"
            max-height="200"
            empty-text="暂无已选人员"
            class="person-table"
          >
            <el-table-column prop="name" label="姓名" min-width="70" label-class-name="col-no-border" class-name="col-no-border" />
            <el-table-column prop="nature" label="人员性质" min-width="70" label-class-name="col-no-border" class-name="col-no-border" />
            <el-table-column prop="department" label="部门" min-width="100" show-overflow-tooltip label-class-name="col-no-border" class-name="col-no-border" />
            <el-table-column prop="company" label="所属公司" min-width="80" show-overflow-tooltip label-class-name="col-company" class-name="col-company" />
            <el-table-column label="操作" width="60" align="center" label-class-name="col-action" class-name="col-action">
              <template #default="{ row }">
                <el-button link type="primary" @click="removePerson(row.id)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
        </div>

        <div class="selected-section dept-section">
          <div class="section-header">
            <span class="section-title">部门 ({{ selectedDepartments.length }})</span>
            <el-button
              v-if="selectedDepartments.length"
              link
              type="primary"
              @click="clearSelectedDepartments"
            >
              清空已选
            </el-button>
          </div>
          <div v-if="selectedDepartments.length" class="dept-list">
            <div v-for="dept in selectedDepartments" :key="dept.id" class="dept-item">
              <span class="dept-name">{{ dept.name }} - {{ dept.nature }}</span>
              <el-icon class="dept-remove" @click="removeDepartment(dept.id)">
                <Close />
              </el-icon>
            </div>
          </div>
          <div v-else class="dept-empty">暂无已选部门</div>
        </div>
      </div>
    </div>

    <div class="dialog-footer">
      <el-button @click="closeDialog">取消</el-button>
      <el-button type="primary" :loading="saving" @click="handleConfirm">确定</el-button>
    </div>
  </el-dialog>
</template>

<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import Treeselect from 'vue3-treeselect'
import 'vue3-treeselect/dist/vue3-treeselect.css'
import { useExcelPermissionDialog } from '@/composables/useExcelPermissionDialog'

const {
  visible,
  excelName,
  orgTree,
  treeselectModel,
  treeselectMatchKeys,
  treeselectKey,
  selectedPersons,
  selectedDepartments,
  loading,
  saving,
  openDialog,
  closeDialog,
  clearSelectedPersons,
  clearSelectedDepartments,
  removePerson,
  removeDepartment,
  handleConfirm,
} = useExcelPermissionDialog()

function preventClear(): boolean {
  return false
}

defineExpose({ openDialog })
</script>

<style scoped>
.permission-body {
  display: flex;
  gap: 16px;
  min-height: 420px;
}

.panel-box {
  flex: 1;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  padding: 16px;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.panel-box--left {
  flex: 0 0 42%;
  overflow: hidden;
}

.panel-box--right {
  flex: 1;
}

/* always-open 时菜单默认 absolute 会溢出 dialog,改为纵向 flex + static 定位 */
.treeselect-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 360px;
  overflow: hidden;
}

.permission-treeselect {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.permission-treeselect :deep(.vue-treeselect) {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  position: relative;
}

.permission-treeselect :deep(.vue-treeselect__control) {
  flex-shrink: 0;
}

.permission-treeselect :deep(.vue-treeselect__menu-container) {
  position: static !important;
  top: auto !important;
  bottom: auto !important;
  flex: 1;
  min-height: 0;
  overflow: hidden;
  transform: none !important;
}

.permission-treeselect :deep(.vue-treeselect__menu) {
  position: static !important;
  width: 100% !important;
  max-height: 100% !important;
  margin-top: 8px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  box-shadow: none;
  overflow-x: hidden;
  overflow-y: auto;
}

.permission-treeselect :deep(.vue-treeselect__menu-placeholder) {
  display: none;
}

.permission-treeselect :deep(.vue-treeselect__option) {
  font-size: 13px;
}

.excel-info {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 16px;
  flex-shrink: 0;
}

.excel-info-icon {
  flex-shrink: 0;
}

.excel-info-text {
  font-size: 14px;
  color: #606266;
}

.selected-section {
  display: flex;
  flex-direction: column;
}

.dept-section {
  margin-top: 16px;
  flex: 1;
}

.section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}

.section-title {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
}

.person-table {
  width: 100%;
}

.person-table :deep(th.col-no-border),
.person-table :deep(th.col-action) {
  background-color: #f5f7fa;
  border-right: none !important;
}

.person-table :deep(th.col-company) {
  background-color: #f5f7fa;
  border-right: 1px solid #ebeef5 !important;
}

.person-table :deep(td.col-no-border),
.person-table :deep(td.col-action) {
  border-right: none !important;
}

.person-table :deep(td.col-company) {
  border-right: 1px solid #ebeef5 !important;
}

.dept-list {
  border: 1px solid #ebeef5;
  border-radius: 4px;
  max-height: 140px;
  overflow-y: auto;
}

.dept-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border-bottom: 1px solid #f2f3f5;
  font-size: 13px;
  color: #606266;
}

.dept-item:last-child {
  border-bottom: none;
}

.dept-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.dept-remove {
  cursor: pointer;
  color: #909399;
  font-size: 14px;
  flex-shrink: 0;
  margin-left: 8px;
  transition: color 0.2s;
}

.dept-remove:hover {
  color: #f56c6c;
}

.dept-empty {
  padding: 20px;
  text-align: center;
  color: #c0c4cc;
  font-size: 13px;
  border: 1px dashed #e4e7ed;
  border-radius: 4px;
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 20px;
  padding-top: 4px;
}
</style>

<style>
.permission-dialog .el-dialog__body {
  padding-top: 12px;
  padding-bottom: 20px;
  overflow: visible;
}
</style>

核心功能模块效果图展示:

相关推荐
皮皮大人1 小时前
Vue 3 响应式内核完全解密:reactive & effect 与 Vue 2 Watcher 史诗对决
前端·vue.js
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-05-31
前端·人工智能·经验分享·搜索引擎·chatgpt·html
陆枫Larry1 小时前
CSS 中「深色 + 不透明度」vs 直接设浅色的区别
前端
Din1 小时前
使用AI从 27 秒到秒开:一次 Web 首屏加载优化实战
前端
leafyyuki1 小时前
两行 CSS 搞定筛选条行尾对齐,Element Plus 表单布局终极方案
前端
着迷不白1 小时前
六、Bash Shell 与进程管理
前端·chrome
A不落雨滴AI1 小时前
DKERP 客户端重构:30天从零到一的架构演进之路
前端
Xp021911031 小时前
知网研学、万方、WPS、大以论文四大排版工具横评,新用户免费排版等你领!
前端·css·html·生活·wps·论文排版
全栈技术负责人1 小时前
老项目新需求AI前端开发指南
前端·ai编程