From 01d3cd1b9aca2be77dc035d0f9487bd90c931b7a Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 1 Oct 2026 00:35:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(batch-position):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=81=8C=E5=8A=A1=E5=BC=B9=E7=AA=97=E7=9A=84=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E5=91=98=E5=B7=A5=E6=A0=91=E6=94=B9=E7=94=A8=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 composables/useDeptEmployeeTree.ts(部门树 → 成员的单一实现,数据源 /API/FormAPI/DeptEmployeeSearch,与 EntityPicker(treeGroup) 同源):分页拉全 + 服务端部门树组装 + 统一成员标签,单部门模式返回单个分组节点。BatchPositionDialog 的公司页 / 部门列表页 / 部门详情页三个分支改为调用它,删除各自分页拉 UserRoleSearch 再前端拼树、以及 Get/UserRoleDepartment 的重复实现;工作组相关分支(非部门树)保持不动。顺带修正未分组节点 id 未计入 groupIds 导致勾选它会被当作成员 PK 提交的问题。 --- src/components/BatchPositionDialog.vue | 90 ++++---------- src/composables/useDeptEmployeeTree.ts | 159 +++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 69 deletions(-) create mode 100644 src/composables/useDeptEmployeeTree.ts diff --git a/src/components/BatchPositionDialog.vue b/src/components/BatchPositionDialog.vue index f255e62..fd901c7 100644 --- a/src/components/BatchPositionDialog.vue +++ b/src/components/BatchPositionDialog.vue @@ -4,7 +4,11 @@ * * 三个入口(部门详情 / 工作组详情 / 公司详情-员工)共用的批量职务操作: * - 多选职务(tree 视图:通用部门树 → 职务) - * - 多选员工(tree 视图:公司页=部门树分组员工 UserRoleSearch;部门/工作组页=各自成员树) + * - 多选员工(tree 视图): + * 公司页 / 部门列表页 / 部门详情页 → 部门树 → 成员,统一走 + * composables/useDeptEmployeeTree.ts(数据源 /API/FormAPI/DeptEmployeeSearch, + * 与表单里的 EntityPicker(treeGroup) 同源,避免"同功能两套代码") + * 工作组页 → 工作组树 → 成员(WorkgroupSearch + UserRoleWorkgroup) * - 批量增加 / 批量删除职务(后端 FormService.batch_positions,统一落 * UserRoleDepartmentPosition 子表,工作组/公司页解析到成员的"主部门关联") * @@ -16,6 +20,7 @@ import { ref, computed, watch, nextTick } from 'vue' import { ElMessage } from 'element-plus' import { formApi } from '@/api/form' import { get } from '@/api/request' +import { loadDeptEmployeeTree } from '@/composables/useDeptEmployeeTree' /** 批量目标表 */ type BatchTargetTable = @@ -151,7 +156,7 @@ async function fetchTreeData( } } -/** 分页拉全(UserRoleSearch 等按 offset+has_more 翻页;安全上限防失控) */ +/** 分页拉全(WorkgroupSearch / PositionSearch 等按 offset+has_more 翻页;安全上限防失控) */ const PAGE_SIZE = 20 const MAX_FETCH = 500 @@ -159,76 +164,23 @@ const MAX_FETCH = 500 async function loadEmployeeTree(): Promise { const ctx = props.context ?? {} selectedEmployeePks.value = new Set() - if (props.table === 'UserRole') { - // 公司页:UserRoleSearch 部门树 + 员工(user-role 对)按部门分组(翻页拉全) - let offset = 0 - let tree: unknown[] = [] - const allList: unknown[] = [] - let hasMore = true - while (hasMore) { - const params: Record = { keyword: '', tree: '1', offset } - if (ctx.CompanyUUID) params.CompanyUUID = ctx.CompanyUUID - const r = await fetchTreeData('/API/FormAPI/UserRoleSearch', params) - if (!tree.length) tree = r.tree - allList.push(...r.list) - hasMore = r.hasMore - offset += PAGE_SIZE - if (allList.length >= MAX_FETCH) hasMore = false - } - const leaves = allList - .map((r) => r as Record) - .map((r) => ({ - groupKey: r.DeptUUID ? String(r.DeptUUID) : '', - id: String(r.UserRoleUUID ?? ''), - // 角色覆盖名优先,空则角色真名(对齐员工列表约定) - label: `${String((r.RoleNameCover || r.RoleName) ?? '')}${r.UserName ? `(${String(r.UserName)})` : ''}` || String(r.UserRoleUUID ?? ''), - })) - .filter((l) => l.id) - const built = buildTree(tree as Array<{ uuid: string; name: string; parentUuid?: string }>, leaves) + if (props.table === 'UserRole' || props.table === 'Department') { + // 公司页 / 部门列表页:部门树 → 成员,取 UserRoleUUID(按"用户-角色对"去重) + // 数据源与表单选择器同源,见 composables/useDeptEmployeeTree.ts + const built = await loadDeptEmployeeTree({ + CompanyUUID: ctx.CompanyUUID, + DedupUserRole: true, + ValueField: 'UserRoleUUID', + }) employeeTree.value = built.tree employeeGroupIds.value = built.groupIds } else if (props.table === 'UserRoleDepartment') { - // 部门页:当前部门成员(UserRoleDepartment 行) - const params: Record = { DeptUUID: ctx.DeptUUID || '', CompanyUUID: ctx.CompanyUUID || '' } - const res = await get('/API/FormAPI/Get/UserRoleDepartment', params) - const list = Array.isArray(res) ? (res as unknown[]) : (res as Record)?.list || [] - const leaves = (list as Record[]) - .map((r) => ({ - groupKey: '', - id: String(r.UserRoleDepartmentUUID ?? ''), - // 角色覆盖名优先,空则角色真名(对齐员工列表约定) - label: `${String((r.RoleNameCover || r.UserRoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` || String(r.UserRoleDepartmentUUID ?? ''), - })) - .filter((l) => l.id) - const built = buildTree([], leaves) - employeeTree.value = [{ id: '__dept__', label: '本部门员工', children: built.tree }] - employeeGroupIds.value = new Set(['__dept__', ...built.groupIds]) - } else if (props.table === 'Department') { - // 部门列表页:以部门为树组织员工(UserRoleSearch 部门树分组,公司键取部门所属公司) - let offset = 0 - let tree: unknown[] = [] - const allList: unknown[] = [] - let hasMore = true - while (hasMore) { - const params: Record = { keyword: '', tree: '1', offset } - if (ctx.CompanyUUID) params.CompanyUUID = ctx.CompanyUUID - const r = await fetchTreeData('/API/FormAPI/UserRoleSearch', params) - if (!tree.length) tree = r.tree - allList.push(...r.list) - hasMore = r.hasMore - offset += PAGE_SIZE - if (allList.length >= MAX_FETCH) hasMore = false - } - const leaves = allList - .map((r) => r as Record) - .map((r) => ({ - groupKey: r.DeptUUID ? String(r.DeptUUID) : '', - id: String(r.UserRoleUUID ?? ''), - // 角色覆盖名优先,空则角色真名(对齐员工列表约定) - label: `${String((r.RoleNameCover || r.RoleName) ?? '')}${r.UserName ? `(${String(r.UserName)})` : ''}` || String(r.UserRoleUUID ?? ''), - })) - .filter((l) => l.id) - const built = buildTree(tree as Array<{ uuid: string; name: string; parentUuid?: string }>, leaves) + // 部门详情页:本部门成员,取 UserRoleDepartmentUUID(单个分组节点) + const built = await loadDeptEmployeeTree({ + CompanyUUID: ctx.CompanyUUID, + DeptUUID: ctx.DeptUUID, + ValueField: 'UserRoleDepartmentUUID', + }) employeeTree.value = built.tree employeeGroupIds.value = built.groupIds } else if (props.table === 'Workgroup') { diff --git a/src/composables/useDeptEmployeeTree.ts b/src/composables/useDeptEmployeeTree.ts new file mode 100644 index 0000000..35d4645 --- /dev/null +++ b/src/composables/useDeptEmployeeTree.ts @@ -0,0 +1,159 @@ +/** + * 部门树 → 成员 两级树数据源(el-tree 自绘场景的单一实现) + * + * 数据源 /API/FormAPI/DeptEmployeeSearch —— 与表单里的 EntityPicker(treeGroup) 同源: + * 一项 = 一条 UserRoleDepartment(部门 + 该用户在本公司的"用户-角色对"), + * 响应带 tree(部门树)、DeptEmployeeLabel("部门名 · 用户名")与 + * DeptUUID / UserName / RoleName / UserRoleUUID / UserRoleDepartmentUUID 等字段。 + * + * 为什么单独抽出:表单内"选人/选成员"请直接用 EntityPicker(config.treeGroup 已封装同一数据源); + * 而 el-tree 自绘场景(如批量调整职务弹窗)需要的是"树 + 勾选"形态,无法复用同一个组件, + * 但必须复用同一套数据源与节点组装逻辑 —— 否则就会出现"功能相同、两套代码"。 + * + * 对应后端单一来源配置:gvsds/apps/gerp/form_service.py 的 DEPT_TREE_GROUP / DEPT_EMPLOYEE_PICKER* + */ +import { get } from '@/api/request' + +/** 树节点(el-tree:node-key="id",props.label="label") */ +export interface DeptEmployeeTreeNode { + id: string + label: string + children?: DeptEmployeeTreeNode[] +} + +/** DeptEmployeeSearch 返回行(仅声明用到的字段) */ +interface DeptEmployeeRow { + DeptEmployeeLabel?: string + DeptName?: string + DeptUUID?: string + UserName?: string + RoleName?: string + UserRoleUUID?: string + UserRoleDepartmentUUID?: string +} + +/** 部门树节点原始结构(后端 tree 字段) */ +interface DeptTreeNodeRaw { + uuid: string + name?: string + parentUuid?: string +} + +export interface LoadDeptEmployeeTreeOptions { + /** 公司上下文(不传则后端回退当前角色公司) */ + CompanyUUID?: string + /** 只取某部门的成员(部门详情页):此时不请求部门树,返回单个分组节点 */ + DeptUUID?: string + /** 按"用户-角色对"去重(同一用户多部门只留一条,供只认 UserRoleUUID 的场景使用) */ + DedupUserRole?: boolean + /** 叶子取值列(默认 UserRoleUUID;部门详情页传 UserRoleDepartmentUUID) */ + ValueField?: 'UserRoleUUID' | 'UserRoleDepartmentUUID' + /** 单部门模式的组节点标题(默认"本部门员工") */ + SingleGroupTitle?: string +} + +/** 每页条数(与后端 Search 接口一致) */ +const PAGE_SIZE = 20 +/** 分页拉全的安全上限(防止异常数据把前端拖死) */ +const MAX_FETCH = 500 + +/** 成员叶子标签:角色名(用户名)——与成员列表口径一致;缺失时回退 DeptEmployeeLabel / 主键 */ +function memberLabel(row: DeptEmployeeRow, id: string): string { + const name = `${row.RoleName || ''}${row.UserName ? `(${row.UserName})` : ''}` + return name || row.DeptEmployeeLabel || id +} + +/** 响应拦截器已解包 {code,data}:兼容纯数组与 {list, tree, has_more} */ +function parsePage(res: unknown): { list: DeptEmployeeRow[]; tree: DeptTreeNodeRaw[]; hasMore: boolean } { + if (Array.isArray(res)) return { list: res as DeptEmployeeRow[], tree: [], hasMore: false } + const obj = (res ?? {}) as Record + return { + list: Array.isArray(obj.list) ? (obj.list as DeptEmployeeRow[]) : [], + tree: Array.isArray(obj.tree) ? (obj.tree as DeptTreeNodeRaw[]) : [], + hasMore: typeof obj.has_more === 'boolean' ? obj.has_more : false, + } +} + +/** + * 分页拉全部门树与成员,组装成 el-tree 数据(分组节点为部门,叶子为成员) + * + * @returns tree(树数据)与 groupIds(分组节点 id 集合,提交时用于过滤掉非成员节点) + */ +export async function loadDeptEmployeeTree( + options: LoadDeptEmployeeTreeOptions = {}, +): Promise<{ tree: DeptEmployeeTreeNode[]; groupIds: Set }> { + const singleDept = options.DeptUUID || '' + const rows: DeptEmployeeRow[] = [] + let rawTree: DeptTreeNodeRaw[] = [] + let offset = 0 + let hasMore = true + while (hasMore) { + const params: Record = { keyword: '', offset } + if (options.CompanyUUID) params.CompanyUUID = options.CompanyUUID + if (singleDept) params.DeptUUID = singleDept + if (options.DedupUserRole) params.Dedup = 'UserRole' + // 部门树:仅"全公司树"模式需要(单部门模式只需成员) + if (!singleDept) params.tree = '1' + const page = parsePage(await get('/API/FormAPI/DeptEmployeeSearch', params)) + if (!rawTree.length) rawTree = page.tree + rows.push(...page.list) + hasMore = page.hasMore + offset += PAGE_SIZE + if (rows.length >= MAX_FETCH) hasMore = false + } + + const valueField = options.ValueField || 'UserRoleUUID' + const leaves: Array<{ groupKey: string; id: string; label: string }> = [] + for (const row of rows) { + const id = String(row[valueField] ?? '') + if (!id) continue + leaves.push({ + groupKey: singleDept || String(row.DeptUUID ?? ''), + id, + label: memberLabel(row, id), + }) + } + + // 单部门模式:不展示全公司树,只给一个分组节点(与部门详情页语义一致) + if (singleDept) { + const groupId = '__dept__' + return { + tree: [{ + id: groupId, + label: options.SingleGroupTitle || '本部门员工', + children: leaves.map((l) => ({ id: l.id, label: l.label })), + }], + groupIds: new Set([groupId]), + } + } + + // 全公司模式:部门树 + 成员按 DeptUUID 归组,未挂部门的归入"未分组" + const nodeMap = new Map() + for (const n of rawTree) { + if (!n.uuid) continue + nodeMap.set(n.uuid, { id: n.uuid, label: n.name || n.uuid, children: [] }) + } + const roots: DeptEmployeeTreeNode[] = [] + for (const n of rawTree) { + const node = nodeMap.get(n.uuid) + if (!node) continue + if (n.parentUuid && nodeMap.has(n.parentUuid)) { + nodeMap.get(n.parentUuid)!.children!.push(node) + } else { + roots.push(node) + } + } + const ungrouped: DeptEmployeeTreeNode[] = [] + for (const leaf of leaves) { + const group = leaf.groupKey ? nodeMap.get(leaf.groupKey) : undefined + const node: DeptEmployeeTreeNode = { id: leaf.id, label: leaf.label } + if (group) group.children!.push(node) + else ungrouped.push(node) + } + const groupIds = new Set(nodeMap.keys()) + if (ungrouped.length) { + roots.push({ id: '__ungrouped__', label: '未分组', children: ungrouped }) + groupIds.add('__ungrouped__') + } + return { tree: roots, groupIds } +}