需求:
我有一个项目叫做"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.json、node_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依赖声明,无ElButton、ElDialog等用法示例。
5. API / composables 模式
无 --- 无
src/composables/、src/api/或useXxx.ts等文件。
6.
vue3-treeselect是否已安装否 --- 无
package.json,全项目 grep 也无treeselect/vue3-treeselect匹配。
Excel 相关代码
无 --- 无
excel、xlsx、spreadsheet等相关文件或依赖。
实际检查到的路径汇总
类别 绝对路径 说明 工作区根目录 /Users/jimmy/Desktop/PROJECT/StarCollab空目录 package.json--- 不存在 Router --- 不存在 首页 / 功能栏 --- 不存在 类似功能页 --- 不存在 Composables 示例 --- 不存在 Excel 相关 --- 不存在 同级的
/Users/jimmy/Desktop/PROJECT/下其他项目(如xxx-web、wgp、lvshi-xcx等)不是 StarCollab,且在这些项目的package.json中也未发现element-plus或vue3-treeselect。
与父会话的关系
父会话 1 描述的是「XPXZ平台」要新增的 Excel 共享编辑 功能(空态/列表态、权限树形选择、重命名、删除等),但实现代码尚未出现在当前工作区。
建议下一步
- 若代码在 Git 远程或其他目录,请克隆/复制到
/Users/jimmy/Desktop/PROJECT/StarCollab,或提供真实项目路径后再探索。- 若这是全新项目,需先初始化 Vue3 + TS + Element Plus 脚手架,再按需求实现路由与 Excel 模块。
- 需要我基于需求起草目录结构与文件清单时,可切换到 Agent 模式(当前为 Ask/只读模式,无法创建文件)。
2、工作区是空的,我将从零搭建 Vue3 项目并实现 Excel 共享编辑功能。
bashcd /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 运行,通常有几种做法:
使用特殊的 Key(如 "//")作为注释(最通用,不会报错)。
在字段后面加上描述性后缀(非标准,不推荐)。
在文档中说明。
以下是为你添加了注释(采用兼容性最好的 // 键名方式)的版本,以及对每一项的中文解释:
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>
核心功能模块效果图展示:



