fix(system-role): 修复角色资源树联动授权提交
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
132
src/views/system/role/modules/role-resource-tree.ts
Normal file
132
src/views/system/role/modules/role-resource-tree.ts
Normal 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;
|
||||||
|
}
|
||||||
78
tests/role-resource-tree.test.ts
Normal file
78
tests/role-resource-tree.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user