fix(system-role): 修复角色资源树联动授权提交

This commit is contained in:
caozehui
2026-05-15 10:54:26 +08:00
parent 3a064eb09f
commit 8b34147868
3 changed files with 258 additions and 16 deletions

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import type { TreeInstance } from 'element-plus'; import type { TreeInstance } from 'element-plus';
import { menuTypeRecord } from '@/constants/business'; import { menuTypeRecord } from '@/constants/business';
import { fetchAssignRoleMenus, fetchGetRoleMenuIds } from '@/service/api'; import { fetchAssignRoleMenus, fetchGetRoleMenuIds } from '@/service/api';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { resolveRoleMenuSubmitIds } from './role-resource-tree';
defineOptions({ name: 'RoleResourcePanel' }); defineOptions({ name: 'RoleResourcePanel' });
@@ -26,6 +27,8 @@ const permissionLoading = ref(false);
const submitting = ref(false); const submitting = ref(false);
const filterKeyword = ref(''); const filterKeyword = ref('');
const checkedKeys = ref<string[]>([]); const checkedKeys = ref<string[]>([]);
const baselineMenuIds = ref<string[]>([]);
const dirtyMenuIds = ref<Set<string>>(new Set());
const disabled = computed(() => !props.role || props.role.status === 1); const disabled = computed(() => !props.role || props.role.status === 1);
const checkedCount = computed(() => checkedKeys.value.length); const checkedCount = computed(() => checkedKeys.value.length);
@@ -37,9 +40,24 @@ const treeProps = {
label: 'name' label: 'name'
} as const; } as const;
function applyCheckedKeys(keys: string[]) { function syncCheckedKeys() {
checkedKeys.value = [...keys]; checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
treeRef.value?.setCheckedKeys(keys); }
async function applyCheckedKeys(keys: string[]) {
checkedKeys.value = [];
await nextTick();
const tree = treeRef.value;
if (!tree) {
return;
}
tree.setCheckedKeys(keys);
syncCheckedKeys();
} }
function getTagType(type: Api.SystemManage.MenuType): UI.ThemeColor { function getTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
@@ -88,7 +106,9 @@ function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
async function loadRoleMenus() { async function loadRoleMenus() {
if (!props.role) { if (!props.role) {
applyCheckedKeys([]); baselineMenuIds.value = [];
dirtyMenuIds.value = new Set();
await applyCheckedKeys([]);
treeRef.value?.filter(filterKeyword.value); treeRef.value?.filter(filterKeyword.value);
return; return;
} }
@@ -100,19 +120,22 @@ async function loadRoleMenus() {
permissionLoading.value = false; permissionLoading.value = false;
if (error) { if (error) {
applyCheckedKeys([]); baselineMenuIds.value = [];
dirtyMenuIds.value = new Set();
await applyCheckedKeys([]);
treeRef.value?.filter(filterKeyword.value); treeRef.value?.filter(filterKeyword.value);
return; return;
} }
// Role-menu bindings are exact IDs from the backend, so tree echo must not baselineMenuIds.value = [...data];
// cascade parent checks down to unrelated descendants. dirtyMenuIds.value = new Set();
applyCheckedKeys(data); await applyCheckedKeys(data);
treeRef.value?.filter(filterKeyword.value); treeRef.value?.filter(filterKeyword.value);
} }
function handleCheck() { function handleCheck(data: Api.SystemManage.MenuSimple) {
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? []; dirtyMenuIds.value = new Set([...dirtyMenuIds.value, data.id]);
syncCheckedKeys();
} }
async function handleSave() { async function handleSave() {
@@ -120,7 +143,15 @@ async function handleSave() {
return; return;
} }
const menuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? []; const checkedMenuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
const halfCheckedMenuIds = (treeRef.value?.getHalfCheckedKeys() as string[]) ?? [];
const menuIds = resolveRoleMenuSubmitIds({
menuTree: props.menuTree,
baselineIds: baselineMenuIds.value,
dirtyIds: [...dirtyMenuIds.value],
checkedIds: checkedMenuIds,
halfCheckedIds: halfCheckedMenuIds
});
submitting.value = true; submitting.value = true;
@@ -135,7 +166,9 @@ async function handleSave() {
return; return;
} }
checkedKeys.value = [...menuIds]; baselineMenuIds.value = [...menuIds];
dirtyMenuIds.value = new Set();
syncCheckedKeys();
window.$message?.success($t('common.modifySuccess')); window.$message?.success($t('common.modifySuccess'));
emit('saved'); emit('saved');
@@ -155,9 +188,9 @@ watch(
watch( watch(
() => props.menuTree.length, () => props.menuTree.length,
value => { async value => {
if (value && props.role) { if (value && props.role) {
applyCheckedKeys(checkedKeys.value); await applyCheckedKeys(baselineMenuIds.value);
treeRef.value?.filter(filterKeyword.value); treeRef.value?.filter(filterKeyword.value);
} }
} }
@@ -207,7 +240,6 @@ watch(
ref="treeRef" ref="treeRef"
node-key="id" node-key="id"
show-checkbox show-checkbox
check-strictly
:default-expanded-keys="defaultExpandedKeys" :default-expanded-keys="defaultExpandedKeys"
:data="menuTree" :data="menuTree"
:props="treeProps" :props="treeProps"

View File

@@ -0,0 +1,132 @@
export type RoleResourceTreeNode = {
id: string;
children?: RoleResourceTreeNode[] | null;
};
type ResolveRoleMenuSubmitIdsInput = {
menuTree: RoleResourceTreeNode[];
baselineIds: string[];
dirtyIds: string[];
checkedIds: string[];
halfCheckedIds: string[];
};
type TreeIndex = {
orderedIds: string[];
parentById: Map<string, string | null>;
subtreeIdsById: Map<string, string[]>;
};
export function resolveRoleMenuSubmitIds(input: ResolveRoleMenuSubmitIdsInput) {
const baselineIds = normalizeIds(input.baselineIds);
if (!input.dirtyIds.length) {
return baselineIds;
}
const treeIndex = buildTreeIndex(input.menuTree);
const affectedIds = collectAffectedIds(treeIndex, baselineIds, normalizeIds(input.dirtyIds));
if (!affectedIds.size) {
return baselineIds;
}
const nextIdSet = new Set<string>();
baselineIds.forEach(id => {
if (!affectedIds.has(id)) {
nextIdSet.add(id);
}
});
normalizeIds([...input.checkedIds, ...input.halfCheckedIds]).forEach(id => {
if (affectedIds.has(id)) {
nextIdSet.add(id);
}
});
return sortIdsByTreeOrder(treeIndex.orderedIds, nextIdSet);
}
function normalizeIds(ids: string[]) {
return [...new Set(ids.map(id => String(id).trim()).filter(Boolean))];
}
function buildTreeIndex(nodes: RoleResourceTreeNode[]) {
const orderedIds: string[] = [];
const parentById = new Map<string, string | null>();
const subtreeIdsById = new Map<string, string[]>();
const walk = (items: RoleResourceTreeNode[], parentId: string | null) => {
items.forEach(item => {
orderedIds.push(item.id);
parentById.set(item.id, parentId);
const childIds = item.children?.length ? walk(item.children, item.id) : [];
subtreeIdsById.set(item.id, [item.id, ...childIds]);
});
return items.flatMap(item => subtreeIdsById.get(item.id) ?? [item.id]);
};
walk(nodes, null);
return {
orderedIds,
parentById,
subtreeIdsById
} satisfies TreeIndex;
}
function collectAffectedIds(treeIndex: TreeIndex, baselineIds: string[], dirtyIds: string[]) {
const affectedIds = new Set<string>();
const baselineIdSet = new Set(baselineIds);
dirtyIds.forEach(dirtyId => {
const subtreeIds = treeIndex.subtreeIdsById.get(dirtyId) ?? [dirtyId];
subtreeIds.forEach(id => affectedIds.add(id));
const ancestors = collectAncestorIds(treeIndex.parentById, dirtyId);
ancestors.forEach(ancestorId => {
if (!baselineIdSet.has(ancestorId)) {
return;
}
const ancestorSubtreeIds = treeIndex.subtreeIdsById.get(ancestorId) ?? [ancestorId];
ancestorSubtreeIds.forEach(id => affectedIds.add(id));
});
});
return affectedIds;
}
function collectAncestorIds(parentById: Map<string, string | null>, nodeId: string) {
const ancestorIds: string[] = [];
let currentId: string | null | undefined = nodeId;
while (currentId) {
ancestorIds.push(currentId);
currentId = parentById.get(currentId) ?? null;
}
return ancestorIds;
}
function sortIdsByTreeOrder(orderedIds: string[], idSet: Set<string>) {
const sortedIds: string[] = [];
orderedIds.forEach(id => {
if (idSet.has(id)) {
sortedIds.push(id);
idSet.delete(id);
}
});
idSet.forEach(id => {
sortedIds.push(id);
});
return sortedIds;
}

View File

@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveRoleMenuSubmitIds } from '../src/views/system/role/modules/role-resource-tree';
type MenuNode = {
id: string;
children?: MenuNode[];
};
const menuTree: MenuNode[] = [
{
id: 'personal',
children: [
{
id: 'weekly',
children: [{ id: 'weeklyDetail' }]
},
{
id: 'monthly',
children: [{ id: 'monthlyDetail' }]
}
]
},
{
id: 'infra',
children: [{ id: 'stateMachine' }, { id: 'cmd' }]
}
];
describe('resolveRoleMenuSubmitIds', () => {
it('keeps original ids when there is no user interaction', () => {
const result = resolveRoleMenuSubmitIds({
menuTree,
baselineIds: ['weekly', 'monthly'],
dirtyIds: [],
checkedIds: ['personal', 'weekly', 'monthly'],
halfCheckedIds: []
});
assert.deepEqual(result, ['weekly', 'monthly']);
});
it('preserves untouched branches when another branch changes', () => {
const result = resolveRoleMenuSubmitIds({
menuTree,
baselineIds: ['weekly', 'monthly'],
dirtyIds: ['stateMachine'],
checkedIds: ['personal', 'weekly', 'monthly', 'stateMachine'],
halfCheckedIds: ['infra']
});
assert.deepEqual(result, ['weekly', 'monthly', 'stateMachine']);
});
it('recomputes the whole dirty branch instead of expanding unrelated baseline ids', () => {
const result = resolveRoleMenuSubmitIds({
menuTree,
baselineIds: ['personal'],
dirtyIds: ['weekly'],
checkedIds: ['personal', 'weekly', 'weeklyDetail'],
halfCheckedIds: []
});
assert.deepEqual(result, ['personal', 'weekly', 'weeklyDetail']);
});
it('does not expand untouched sibling branches under the same ancestor', () => {
const result = resolveRoleMenuSubmitIds({
menuTree,
baselineIds: ['monthly'],
dirtyIds: ['weeklyDetail'],
checkedIds: ['personal', 'weekly', 'weeklyDetail', 'monthly', 'monthlyDetail'],
halfCheckedIds: []
});
assert.deepEqual(result, ['weeklyDetail', 'monthly']);
});
});