refactor(batch-position): 批量职务弹窗的部门员工树改用统一数据源

新增 composables/useDeptEmployeeTree.ts(部门树 → 成员的单一实现,数据源 /API/FormAPI/DeptEmployeeSearch,与 EntityPicker(treeGroup) 同源):分页拉全 + 服务端部门树组装 + 统一成员标签,单部门模式返回单个分组节点。BatchPositionDialog 的公司页 / 部门列表页 / 部门详情页三个分支改为调用它,删除各自分页拉 UserRoleSearch 再前端拼树、以及 Get/UserRoleDepartment 的重复实现;工作组相关分支(非部门树)保持不动。顺带修正未分组节点 id 未计入 groupIds 导致勾选它会被当作成员 PK 提交的问题。
This commit is contained in:
TermiNexus committed 2026-10-01 00:35:20 +08:00
1 parent d83866542e
commit 01d3cd1b9a
2 files changed
+180 -69

No files matched your search

+21 -69
View File
@@ -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<void> {
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<string, unknown> = { 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<string, unknown>)
.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<string, unknown> = { DeptUUID: ctx.DeptUUID || '', CompanyUUID: ctx.CompanyUUID || '' }
const res = await get<unknown>('/API/FormAPI/Get/UserRoleDepartment', params)
const list = Array.isArray(res) ? (res as unknown[]) : (res as Record<string, unknown>)?.list || []
const leaves = (list as Record<string, unknown>[])
.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<string, unknown> = { 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<string, unknown>)
.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') {
+159
View File
@@ -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<string, unknown>
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<string> }> {
const singleDept = options.DeptUUID || ''
const rows: DeptEmployeeRow[] = []
let rawTree: DeptTreeNodeRaw[] = []
let offset = 0
let hasMore = true
while (hasMore) {
const params: Record<string, unknown> = { 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<unknown>('/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<string, DeptEmployeeTreeNode>()
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<string>(nodeMap.keys())
if (ungrouped.length) {
roots.push({ id: '__ungrouped__', label: '未分组', children: ungrouped })
groupIds.add('__ungrouped__')
}
return { tree: roots, groupIds }
}