目录
1、核心单文件⭐️
src/components/permission/OrgPermissionDialog.vue
2、测试页
src/views/dev/PermissionTestView.vue
3、Excel 薄封装
src/components/excel/ExcelPermissionDialog.vue
4、类型定义
src/types/excel.ts
基于之前的需求:
记录利用Cursor快速实现Excel共享编辑-CSDN博客
将项目里的树形结构逻辑及右侧人员和部门都提取到一个".vue"文件里面,并配置一个单独路由页面,页面里只有两个按钮,一个是“新增权限分配”按钮,一个是“回显权限分配”按钮,点击分别弹出对应的弹框,左边按钮为了测试分配权限功能,右边按钮主要是测试后端返回数据时前端进行回显,代码逻辑最好基于"vue3-treeselect"基本功能来实现,然后我是一个Vue3小白使用者,别写的太复杂了。
中间再给我加一个“回显本地权限分配”按钮,点击可以回显出来我第一个按钮新增的权限数据。
在测试页增加第三个按钮,并在“新增权限分配”确认后将数据保存到本地供回显。
正在查找项目中树形结构与人员/部门权限相关逻辑。
正在创建自包含的权限分配组件与测试页,并简化 Excel 中的引用;
结论: 树形选人、右侧人员/部门展示已收拢到单个组件 OrgPermissionDialog.vue;测试页 /permission-test 提供「新增」与「回显」两个按钮;Excel 权限仍可用,逻辑复用同一组件。
文件结构
| 文件 | 作用 |
|---|---|
| 核心单文件:左侧 |
| 测试页:两个按钮 + 引用上面的弹框 |
| Excel 薄封装(约 50 行),内部仍调用 |
已删除 useExcelPermissionDialog.ts,避免逻辑分散。数据大屏小标题标头已改为 40px。
怎么用(小白向)
npm run dev- 首页 → 权限分配测试,或打开
/permission-test - 新增权限分配:空数据打开,自己勾选测试
- 回显权限分配:模拟后端返回:
- 人员:
person-int-1、person-ext-2 - 部门:
dept-2-internal
打开后看左侧树和右侧列表是否一致
- 人员:
对接真实后端时
回显只需在 open() 里传入接口返回的两个数组:
dialogRef.value?.open({ resourceText: '你的文档名', personIds: 接口返回的人员id数组, departmentIds: 接口返回的部门id数组, })点「确定」会触发 @confirm,参数同样是 { personIds, departmentIds },再发给保存接口即可。
核心逻辑都在 OrgPermissionDialog.vue 里,用中文注释标了 open()、treeModel(树勾选)和右侧列表的关系,便于对照阅读。
结论: 已在中间加入「回显本地权限分配」按钮;只有通过「新增权限分配」点「确定」后的数据才会写入本地,第三个按钮用来回显这份数据。
使用顺序
- 点 新增权限分配 → 勾选人员/部门 → 点 确定(会提示「已保存到本地」)
- 点 回显本地权限分配 → 弹框会按上一步保存的内容回显
若还没做过第 1 步就点第 2 个按钮,会提示:请先用「新增权限分配」勾选并确定。
和另外两个按钮的区别
| 按钮 | 数据来源 |
|---|---|
新增权限分配 | 空数据,确定后写入本地 |
回显本地权限分配 | 本页内存里上次「新增」确定的数据 |
回显权限分配 | 写死的模拟后端数据(与新增无关) |
页面下方在本地有数据时会显示一行橙色提示,方便确认是否已保存。刷新页面后本地数据会清空(仅当前会话有效);若需要刷新后仍保留,可以说一声再加 localStorage。
1、核心单文件⭐️
src/components/permission/OrgPermissionDialog.vue
子组件:左侧 vue3-treeselect + 右侧人员表/部门列表 + 勾选联动逻辑
取消勾选不生效,很可能与
@deselect里同步调用refreshTree()有关——它在 v-model 更新前就重挂载了组件。正在查看当前代码并修复。结论: 取消勾选不生效,是上轮修复里
@deselect调用时机不对导致的——它在 v-model 更新之前就refreshTree()重挂载了组件,用旧选中值把取消操作覆盖掉了。已修复。原因
vue3-treeselect 的事件顺序是:
1. 用户点击取消勾选
2. 触发 @deselect ← 之前的 handleTreeDeselect 在这里 refreshTree()
3. 触发 update:modelValue ← applyTreeChange 在这里才更新 treeBindIds
refreshTree()会立刻用:key重挂载树组件,此时treeBindIds还是旧值(仍包含「张磊」),新组件按旧数据渲染,取消勾选等于被撤销。修复
- 移除
@deselect监听,避免在 v-model 更新前 remount。- 把「搜索态下取消勾选后恢复层级」的逻辑挪到
applyTreeChange里,在 v-model 更新完成后再nextTick刷新:function applyTreeChange(rawIds: string[]): void { // ...先更新 personIds / departmentIds / treeBindIds if (isDeselect) { nextTick(() => { refreshTree() // 此时 treeBindIds 已是取消后的新值 treeSearchQuery.value = '' }) } }
<template> <!-- 组织权限分配弹窗组件:左侧组织树多选,右侧展示已选人员/部门 --> <el-dialog v-model="visible" :title="dialogTitle" :close-on-click-modal="false" destroy-on-close @close="handleClose" > <div> <!-- 左侧:组织树选择面板 --> <div> <div> <!-- 组织多选树:支持部门/人员选择、搜索、自动去重合并 --> <Treeselect :key="treeKey" v-model="treeModel" :options="orgTree" :multiple="true" :always-open="true" :max-height="360" :searchable="true" :match-keys="['label', 'name', 'company']" :flatten-search-results="false" :clear-on-select="true" :clearable="false" :disable-branch-nodes="false" :before-clear-all="() => false" placeholder="请输入姓名或部门名称" no-results-text="未找到匹配项" no-options-text="暂无可选项" @search-change="handleSearchChange" /> </div> </div> <!-- 右侧:已选人员/部门展示面板 --> <div> <!-- 资源名称展示 --> <div> <span>{ { resourceText }},权限分配至:</span> </div> <!-- 已选人员列表 --> <div> <div> <span>人员 ({ { selectedPersons.length }})</span> <el-button v-if="selectedPersons.length" link type="primary" @click="clearPersons"> 清空已选 </el-button> </div> <el-table :data="selectedPersons" border size="small" max-height="200" empty-text="暂无已选人员" > <el-table-column prop="name" label="姓名" min-width="70" /> <el-table-column prop="nature" label="人员性质" min-width="70" /> <el-table-column prop="department" label="部门" min-width="100" show-overflow-tooltip /> <el-table-column prop="company" label="所属公司" min-width="80" show-overflow-tooltip /> <el-table-column label="操作" align="center"> <template #default="{ row }"> <el-button link type="primary" @click="removePerson(row.id)">删除</el-button> </template> </el-table-column> </el-table> </div> <!-- 已选部门列表 --> <div> <div> <span>部门 ({ { selectedDepartments.length }})</span> <el-button v-if="selectedDepartments.length" link type="primary" @click="clearDepartments" > 清空已选 </el-button> </div> <div v-if="selectedDepartments.length"> <div v-for="dept in selectedDepartments" :key="dept.id"> <span>{ { dept.name }} - { { dept.nature }}</span> <el-icon @click="removeDepartment(dept.id)"><Close /></el-icon> </div> </div> <div v-else>暂无已选部门</div> </div> </div> </div> <!-- 弹窗底部操作按钮 --> <div> <el-button @click="handleClose">取消</el-button> <el-button type="primary" :loading="saving" @click="handleConfirm">确定</el-button> </div> </el-dialog> </template> <script setup lang="ts"> /** * @description 组织权限分配弹框组件 * 组织权限分配弹框(单文件组件) * 功能:通过vue3-treeselect组织树选择人员/部门,自动合并为【部门全选 + 单独人员】 * 支持多选,且自动处理级联逻辑(全选部门则视为选择部门而非散落的人员)。 * 右侧分类实时展示已选的“人员”和“部门”。 * 支持双向同步,删除右侧项会同步更新左侧树勾选状态。 * 父组件调用:ref.value.open() 打开;回显传入 personIds、departmentIds 即可 * 输出:最终选中的人员ID数组、部门ID数组 */ import { ref, computed, nextTick } from 'vue' import { Close } from '@element-plus/icons-vue' import Treeselect from 'vue3-treeselect' import 'vue3-treeselect/dist/vue3-treeselect.css' import { mockOrgTree } from '@/composables/useExcelApi' import type { OrgTreeNode, SelectedPerson, SelectedDepartment, PermissionUserItem } from '@/types/excel' /** 打开弹窗时的配置项类型 */ export interface PermissionOpenOptions { title?: string // 弹窗标题 resourceText?: string // 资源名称描述(如:文档A,权限分配至:) personIds?: string[] // 回显人员ID departmentIds?: string[] // 回显部门ID } // 事件定义:确认选择时向外抛出最终选中的用户列表与部门 ID const emit = defineEmits<{ confirm: [payload: { userList: PermissionUserItem[]; departmentIds: string[] }] }>() // ==================== 基础响应式状态 ==================== const visible = ref(false) // 弹窗显隐 const dialogTitle = ref('权限分配') // 弹窗标题 const resourceText = ref('测试资源') // 资源名称文本 const saving = ref(false) // 确定按钮加载状态 const orgTree = ref<OrgTreeNode[]>([...mockOrgTree]) // 组织树数据源 // 内部维护的扁平化数据 const personIds = ref<string[]>([]) // 最终选中的人员ID(去重后) const departmentIds = ref<string[]>([]) // 最终选中的部门ID(去重后) const treeBindIds = ref<string[]>([]) // 绑定到树组件的ID(包含分类/部门/人员) const treeKey = ref(0) // 树组件刷新key(强制重新渲染) const treeSearchQuery = ref('') // 树组件当前搜索词(用于搜索态下取消勾选时恢复层级) // ==================== 核心逻辑:树节点工具方法 搜索与递归 ==================== /** * 递归查找指定ID的树节点 * @param id 节点ID * @returns 节点对象 / null */ function findNode(id: string): OrgTreeNode | null { function walk(nodes: OrgTreeNode[]): OrgTreeNode | null { for (const n of nodes) { if (n.id === id) return n if (n.children) { const found = walk(n.children) if (found) return found } } return null } return walk(mockOrgTree) } /** * 获取一个部门下所有人员ID * @param deptId 部门ID * @returns 人员ID数组 */ function getDeptPersonIds(deptId: string): string[] { const dept = findNode(deptId) if (!dept?.children) return [] return dept.children.filter((n) => n.type === 'person').map((n) => n.id) } /** * 获取一个分类下所有部门ID * @param categoryId 分类ID * @returns 部门ID数组 */ function getCategoryDeptIds(categoryId: string): string[] { const cat = findNode(categoryId) if (!cat?.children) return [] return cat.children.filter((n) => n.type === 'department').map((n) => n.id) } /** * 查找人员所属的父部门ID * @param personId 人员ID * @returns 部门ID / null */ function findPersonParentDeptId(personId: string): string | null { for (const cat of mockOrgTree) { for (const dept of cat.children ?? []) { if ((dept.children ?? []).some((p) => p.id === personId)) return dept.id } } return null } /** * 查找人员所属的第一层分类 ID(即 flag:自有 '01' / 外协 '02') */ function findPersonCategoryId(personId: string): string | null { for (const cat of mockOrgTree) { for (const dept of cat.children ?? []) { if ((dept.children ?? []).some((p) => p.id === personId)) return cat.id } } return null } /** * 将已选人员 ID 组装为后端需要的 userList 格式 */ function buildUserList(ids: strin