diff --git a/src/views/system/role/modules/role-resource-panel.vue b/src/views/system/role/modules/role-resource-panel.vue index afb6e3c..5d596a0 100644 --- a/src/views/system/role/modules/role-resource-panel.vue +++ b/src/views/system/role/modules/role-resource-panel.vue @@ -4,7 +4,7 @@ import type { TreeInstance } from 'element-plus'; import { menuTypeRecord } from '@/constants/business'; import { fetchAssignRoleMenus, fetchGetRoleMenuIds } from '@/service/api'; import { $t } from '@/locales'; -import { resolveRoleMenuSubmitIds } from './role-resource-tree'; +import { normalizeRoleMenuCheckedIds, resolveRoleMenuSubmitIds } from './role-resource-tree'; defineOptions({ name: 'RoleResourcePanel' }); @@ -127,9 +127,14 @@ async function loadRoleMenus() { return; } - baselineMenuIds.value = [...data]; + const normalizedMenuIds = normalizeRoleMenuCheckedIds({ + menuTree: props.menuTree, + checkedIds: data + }); + + baselineMenuIds.value = normalizedMenuIds; dirtyMenuIds.value = new Set(); - await applyCheckedKeys(data); + await applyCheckedKeys(normalizedMenuIds); treeRef.value?.filter(filterKeyword.value); } @@ -144,13 +149,11 @@ async function handleSave() { } 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 + checkedIds: checkedMenuIds }); submitting.value = true; @@ -190,7 +193,13 @@ watch( () => props.menuTree.length, async value => { if (value && props.role) { - await applyCheckedKeys(baselineMenuIds.value); + const normalizedMenuIds = normalizeRoleMenuCheckedIds({ + menuTree: props.menuTree, + checkedIds: baselineMenuIds.value + }); + + baselineMenuIds.value = normalizedMenuIds; + await applyCheckedKeys(normalizedMenuIds); treeRef.value?.filter(filterKeyword.value); } } diff --git a/src/views/system/role/modules/role-resource-tree.ts b/src/views/system/role/modules/role-resource-tree.ts index 4985338..7199fcc 100644 --- a/src/views/system/role/modules/role-resource-tree.ts +++ b/src/views/system/role/modules/role-resource-tree.ts @@ -8,7 +8,11 @@ type ResolveRoleMenuSubmitIdsInput = { baselineIds: string[]; dirtyIds: string[]; checkedIds: string[]; - halfCheckedIds: string[]; +}; + +type NormalizeRoleMenuCheckedIdsInput = { + menuTree: RoleResourceTreeNode[]; + checkedIds: string[]; }; type TreeIndex = { @@ -17,6 +21,39 @@ type TreeIndex = { subtreeIdsById: Map; }; +export function normalizeRoleMenuCheckedIds(input: NormalizeRoleMenuCheckedIdsInput) { + const checkedIds = normalizeIds(input.checkedIds); + + if (!checkedIds.length) { + return checkedIds; + } + + const treeIndex = buildTreeIndex(input.menuTree); + const normalizedIdSet = new Set(checkedIds); + + treeIndex.orderedIds.forEach(id => { + if (!normalizedIdSet.has(id)) { + return; + } + + const descendantIds = (treeIndex.subtreeIdsById.get(id) ?? [id]).slice(1); + + if (!descendantIds.length) { + return; + } + + const selectedDescendantCount = descendantIds.reduce((count, descendantId) => { + return normalizedIdSet.has(descendantId) ? count + 1 : count; + }, 0); + + if (selectedDescendantCount > 0 && selectedDescendantCount < descendantIds.length) { + normalizedIdSet.delete(id); + } + }); + + return sortIdsByTreeOrder(treeIndex.orderedIds, normalizedIdSet); +} + export function resolveRoleMenuSubmitIds(input: ResolveRoleMenuSubmitIdsInput) { const baselineIds = normalizeIds(input.baselineIds); @@ -39,7 +76,8 @@ export function resolveRoleMenuSubmitIds(input: ResolveRoleMenuSubmitIdsInput) { } }); - normalizeIds([...input.checkedIds, ...input.halfCheckedIds]).forEach(id => { + // 半选父节点只用于树态展示,提交它会把整棵子树误当成完整授权。 + normalizeIds(input.checkedIds).forEach(id => { if (affectedIds.has(id)) { nextIdSet.add(id); } diff --git a/tests/role-resource-tree.test.ts b/tests/role-resource-tree.test.ts index 159a1d3..17091b2 100644 --- a/tests/role-resource-tree.test.ts +++ b/tests/role-resource-tree.test.ts @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { resolveRoleMenuSubmitIds } from '../src/views/system/role/modules/role-resource-tree'; +import * as roleResourceTree from '../src/views/system/role/modules/role-resource-tree'; type MenuNode = { id: string; children?: MenuNode[]; }; +type NormalizeRoleMenuCheckedIds = (input: { menuTree: MenuNode[]; checkedIds: string[] }) => string[]; + const menuTree: MenuNode[] = [ { id: 'personal', @@ -27,14 +29,18 @@ const menuTree: MenuNode[] = [ } ]; +const normalizeRoleMenuCheckedIds = (roleResourceTree as { normalizeRoleMenuCheckedIds?: NormalizeRoleMenuCheckedIds }) + .normalizeRoleMenuCheckedIds; + +const { resolveRoleMenuSubmitIds } = roleResourceTree; + 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: [] + checkedIds: ['personal', 'weekly', 'monthly'] }); assert.deepEqual(result, ['weekly', 'monthly']); @@ -45,8 +51,7 @@ describe('resolveRoleMenuSubmitIds', () => { menuTree, baselineIds: ['weekly', 'monthly'], dirtyIds: ['stateMachine'], - checkedIds: ['personal', 'weekly', 'monthly', 'stateMachine'], - halfCheckedIds: ['infra'] + checkedIds: ['personal', 'weekly', 'monthly', 'stateMachine'] }); assert.deepEqual(result, ['weekly', 'monthly', 'stateMachine']); @@ -57,8 +62,7 @@ describe('resolveRoleMenuSubmitIds', () => { menuTree, baselineIds: ['personal'], dirtyIds: ['weekly'], - checkedIds: ['personal', 'weekly', 'weeklyDetail'], - halfCheckedIds: [] + checkedIds: ['personal', 'weekly', 'weeklyDetail'] }); assert.deepEqual(result, ['personal', 'weekly', 'weeklyDetail']); @@ -69,10 +73,44 @@ describe('resolveRoleMenuSubmitIds', () => { menuTree, baselineIds: ['monthly'], dirtyIds: ['weeklyDetail'], - checkedIds: ['personal', 'weekly', 'weeklyDetail', 'monthly', 'monthlyDetail'], - halfCheckedIds: [] + checkedIds: ['personal', 'weekly', 'weeklyDetail', 'monthly', 'monthlyDetail'] }); assert.deepEqual(result, ['weeklyDetail', 'monthly']); }); + + it('does not submit half-checked parent ids when a fully authorized branch becomes partial', () => { + const result = resolveRoleMenuSubmitIds({ + menuTree, + baselineIds: ['personal'], + dirtyIds: ['monthlyDetail'], + checkedIds: ['weekly', 'weeklyDetail'] + }); + + assert.deepEqual(result, ['weekly', 'weeklyDetail']); + }); +}); + +describe('normalizeRoleMenuCheckedIds', () => { + it('removes partially covered parent ids before tree rendering', () => { + assert.equal(typeof normalizeRoleMenuCheckedIds, 'function'); + + const result = normalizeRoleMenuCheckedIds?.({ + menuTree, + checkedIds: ['personal', 'weekly', 'weeklyDetail'] + }); + + assert.deepEqual(result, ['weekly', 'weeklyDetail']); + }); + + it('keeps parent ids when the backend uses a parent-only full-branch representation', () => { + assert.equal(typeof normalizeRoleMenuCheckedIds, 'function'); + + const result = normalizeRoleMenuCheckedIds?.({ + menuTree, + checkedIds: ['personal'] + }); + + assert.deepEqual(result, ['personal']); + }); });