Files
docs/superpowers/plans/2026-07-27-admin-permission-tree-sync.md
T

14 KiB

Admin Permission Tree Sync Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make permission management and role authorization use the same grouped, recursive static permission hierarchy as the admin sidebar while preserving all existing local interactions.

Architecture: Add one pure permission-tree utility that builds arbitrary-depth trees, preserves ancestors during filtering, sorts siblings, and safely traverses descendants. Keep the sidebar and permission data independent, then verify their menu permission codes agree in tests; both views consume the utility, while Pinia remains the static data source and no Java API contract is invented.

Tech Stack: Vue 3, TypeScript, Pinia, Element Plus, Vitest, Vue Test Utils, Vite


Task 1: Lock the recursive tree contract with tests

Files:

  • Create: apps/admin-web/src/utils/permissionTree.test.ts

  • Create: apps/admin-web/src/utils/permissionTree.ts

  • Step 1: Write failing tests for recursive structure and sibling sorting

Create fixtures with a directory, menu, and button in deliberately unsorted input order. Import the wished-for buildPermissionTree function and assert that it returns all three levels and sorts each sibling group by sort.

import { describe, expect, it } from 'vitest'
import type { Permission } from '@/types/admin'
import { buildPermissionTree } from '@/utils/permissionTree'

const permissions: Permission[] = [
  { id: 'action-b', parentId: 'menu', name: '操作 B', code: 'demo:b', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
  { id: 'menu', parentId: 'directory', name: '页面', code: 'demo:list', type: '菜单', path: '/demo', method: '-', status: '启用', sort: 10 },
  { id: 'directory', parentId: null, name: '目录', code: 'demo', type: '目录', path: '', method: '-', status: '启用', sort: 10 },
  { id: 'action-a', parentId: 'menu', name: '操作 A', code: 'demo:a', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
]

it('builds arbitrary-depth trees and sorts siblings', () => {
  const tree = buildPermissionTree(permissions)
  expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a', 'action-b'])
})
  • Step 2: Run the focused test and verify RED

Run: pnpm test -- src/utils/permissionTree.test.ts

Expected: FAIL because @/utils/permissionTree does not exist.

  • Step 3: Add failing tests for ancestor-preserving search and cycle-safe descendants

Add assertions that a matcher for demo:a retains directory -> menu -> action-a, and that getDescendantIds returns each reachable child once even when malformed fixture data includes a cycle.

import { buildPermissionTree, getDescendantIds } from '@/utils/permissionTree'

it('keeps the ancestor chain when a deep descendant matches', () => {
  const tree = buildPermissionTree(permissions, (item) => item.code === 'demo:a')
  expect(tree.map((node) => node.id)).toEqual(['directory'])
  expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a'])
})

it('collects descendants once when malformed data contains a cycle', () => {
  const malformed = [
    ...permissions,
    { id: 'cycle-a', parentId: 'cycle-b', name: 'A', code: 'cycle:a', type: '目录', path: '', method: '-', status: '启用', sort: 1 },
    { id: 'cycle-b', parentId: 'cycle-a', name: 'B', code: 'cycle:b', type: '目录', path: '', method: '-', status: '启用', sort: 2 },
  ] satisfies Permission[]

  expect(getDescendantIds(malformed, 'cycle-a')).toEqual(['cycle-b'])
})
  • Step 4: Implement the minimal recursive utility

Create permissionTree.ts with a PermissionTreeNode type, a sorted parent index, path-local cycle protection for tree construction, ancestor-preserving matching, and breadth-first descendant traversal whose visited set starts with the selected node.

import type { Permission } from '@/types/admin'

export type PermissionTreeNode = Permission & { children?: PermissionTreeNode[] }
export type PermissionMatcher = (permission: Permission) => boolean

function groupByParent(permissions: Permission[]) {
  const childrenByParent = new Map<string | null, Permission[]>()
  permissions.forEach((permission) => {
    const siblings = childrenByParent.get(permission.parentId) ?? []
    siblings.push(permission)
    childrenByParent.set(permission.parentId, siblings)
  })
  childrenByParent.forEach((siblings) => siblings.sort((left, right) => left.sort - right.sort))
  return childrenByParent
}

export function buildPermissionTree(permissions: Permission[], matches: PermissionMatcher = () => true): PermissionTreeNode[] {
  const childrenByParent = groupByParent(permissions)
  const buildNode = (permission: Permission, ancestors: Set<string>): PermissionTreeNode | null => {
    if (ancestors.has(permission.id)) return null
    const nextAncestors = new Set(ancestors).add(permission.id)
    const children = (childrenByParent.get(permission.id) ?? [])
      .map((child) => buildNode(child, nextAncestors))
      .filter((child): child is PermissionTreeNode => child !== null)
    if (!matches(permission) && children.length === 0) return null
    return { ...permission, ...(children.length ? { children } : {}) }
  }
  return (childrenByParent.get(null) ?? [])
    .map((permission) => buildNode(permission, new Set()))
    .filter((node): node is PermissionTreeNode => node !== null)
}

export function getDescendantIds(permissions: Permission[], permissionId: string): string[] {
  const childrenByParent = groupByParent(permissions)
  const visited = new Set([permissionId])
  const descendants: string[] = []
  const queue = [...(childrenByParent.get(permissionId) ?? [])]
  while (queue.length) {
    const current = queue.shift()
    if (!current || visited.has(current.id)) continue
    visited.add(current.id)
    descendants.push(current.id)
    queue.push(...(childrenByParent.get(current.id) ?? []))
  }
  return descendants
}
  • Step 5: Run the focused test and verify GREEN

Run: pnpm test -- src/utils/permissionTree.test.ts

Expected: all permission-tree utility tests pass with no warnings.

Task 2: Restructure the static permission and role fixtures

Files:

  • Modify: apps/admin-web/src/stores/adminData.ts

  • Create: apps/admin-web/src/stores/adminData.test.ts

  • Step 1: Write failing tests for the grouped hierarchy

Activate a fresh Pinia instance, read useAdminDataStore(), build its tree, and assert the roots are 工作台, 业务管理, and 系统管理; assert 业务管理 owns the customer/order pages and 系统管理 owns the three system pages.

import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAdminDataStore } from '@/stores/adminData'
import { buildPermissionTree } from '@/utils/permissionTree'

beforeEach(() => setActivePinia(createPinia()))

it('groups page permissions under business and system directories', () => {
  const tree = buildPermissionTree(useAdminDataStore().permissions)
  expect(tree.map((node) => node.name)).toEqual(['工作台', '业务管理', '系统管理'])
  expect(tree[1]?.children?.map((node) => node.name)).toEqual(['客户管理', '头程订单'])
  expect(tree[2]?.children?.map((node) => node.name)).toEqual(['内部用户', '角色管理', '菜单权限'])
})
  • Step 2: Write a failing navigation consistency test

Compare all 菜单 permission codes with getNavigationLeaves(adminNavigation) and assert both sets are equal, proving visible routes and static permission resources cannot silently drift.

it('keeps menu permission codes aligned with navigation leaves', () => {
  const store = useAdminDataStore()
  const menuCodes = store.permissions.filter((item) => item.type === '菜单').map((item) => item.code).sort()
  const navigationCodes = getNavigationLeaves(adminNavigation).map((item) => item.permission).sort()
  expect(menuCodes).toEqual(navigationCodes)
})
  • Step 3: Run store tests and verify RED

Run: pnpm test -- src/stores/adminData.test.ts

Expected: FAIL because current permissions expose five flat roots and contain redundant view nodes.

  • Step 4: Replace the permission fixtures

Use stable IDs and the confirmed hierarchy:

P001 工作台 (菜单)
P010 业务管理 (目录)
  P100 客户管理 (菜单)
    P102 新增客户 (按钮)
    P103 审核客户 (按钮)
  P200 头程订单 (菜单)
    P202 创建订单 (按钮)
    P203 更新状态 (按钮)
P020 系统管理 (目录)
  P300 内部用户 (菜单)
    P302 维护用户 (按钮)
  P400 角色管理 (菜单)
    P402 角色授权 (按钮)
  P500 菜单权限 (菜单)
    P502 维护权限 (按钮)

Menu nodes keep their real route paths and route permission codes. Directory and button nodes use an empty path and method: '-'; do not add 接口 fixtures or /api/... paths before the Java contract exists.

  • Step 5: Update all five role permission ID lists

Keep R001 mapped to all resources. Include grouping directory IDs for the other roles and grant only the confirmed menu/action resources described by the design document.

R002: P001, P010, P100, P103, P200, P203
R003: P001, P010, P200, P202, P203
R004: P001, P010, P200
R005: P001, P010, P100, P102, P200
  • Step 6: Run store and full tests and verify GREEN

Run: pnpm test -- src/stores/adminData.test.ts

Expected: grouped hierarchy and navigation consistency tests pass.

Run: pnpm test

Expected: all existing navigation/sidebar tests and new tests pass.

Task 3: Integrate the recursive utility into both views

Files:

  • Modify: apps/admin-web/src/views/PermissionsView.vue

  • Modify: apps/admin-web/src/views/RolesView.vue

  • Read before UI edits: C:/Users/BY/.codex/skills/impeccable/reference/audit.md

  • Read before UI edits: C:/Users/BY/.codex/skills/impeccable/reference/craft-floor.md

  • Step 1: Replace the permission page's local two-level builder

Import buildPermissionTree and getDescendantIds. Compute treeRows with buildPermissionTree(store.permissions, matches) so filters preserve full ancestor chains. Compute a blocked ID set from form.id plus all descendants and exclude those resources from parentOptions.

const treeRows = computed(() => buildPermissionTree(store.permissions, matches))
const blockedParentIds = computed(() => new Set(form.id ? [form.id, ...getDescendantIds(store.permissions, form.id)] : []))
const parentOptions = computed(() => store.permissions.filter((item) =>
  ['目录', '菜单'].includes(item.type) && !blockedParentIds.value.has(item.id),
))
  • Step 2: Update permission-page copy for static API scope

Keep all existing interactions, but change copy that implies interface resources are already active. The table description should say API permissions will be added after the service contract is fixed, the fourth metric should show 0 interface permissions as reserved, and path labels/placeholders should distinguish routes from future interfaces without fabricating endpoints.

  • Step 3: Replace the role page's local two-level builder

Import buildPermissionTree, map its recursive nodes to the Element Plus node shape through one recursive mapper, and keep default-expand-all, checked keys, half-checked keys, and save behavior unchanged.

function toRolePermissionNode(node: PermissionTreeNode): RolePermissionTreeNode {
  return {
    id: node.id,
    label: node.name,
    code: node.code,
    ...(node.children?.length ? { children: node.children.map(toRolePermissionNode) } : {}),
  }
}

const permissionTree = computed(() => buildPermissionTree(store.permissions).map(toRolePermissionNode))
  • Step 4: Correct role permission summaries

Count 菜单 items only as menus and count 按钮 plus future 接口 items as operations. Do not count 目录 nodes as menu entries.

const menuCount = store.permissions.filter((item) => row.permissionIds.includes(item.id) && item.type === '菜单').length
const actionCount = store.permissions.filter((item) => row.permissionIds.includes(item.id) && ['按钮', '接口'].includes(item.type)).length
  • Step 5: Run tests and type checking

Run: pnpm test

Expected: all tests pass.

Run: pnpm typecheck

Expected: Vue and TypeScript checks exit successfully with no errors.

Task 4: Verify production and real interactions

Files:

  • Verify: apps/admin-web/src/views/PermissionsView.vue

  • Verify: apps/admin-web/src/views/RolesView.vue

  • Step 1: Run the production build

Run: pnpm build

Expected: vue-tsc --noEmit and vite build both exit with code 0; output stays within apps/admin-web/dist.

  • Step 2: Start or reuse the admin development server

Run: pnpm dev

Expected: Vite serves the admin application on an available local port, normally http://localhost:5173.

  • Step 3: Perform one bounded browser inspection pass

At desktop width, inspect /system/permissions and verify three roots, deep search ancestor preservation, editing, adding a child, and exclusion of self/descendants in the parent selector. Open /system/roles, configure a role, and verify the grouped authorization tree, checked/half-checked state, and corrected summary.

At 390px width, verify both pages have no incoherent overlap, drawers remain operable, the tree hierarchy is readable, and table horizontal scrolling remains available.

  • Step 4: Run Impeccable detector once

Run the detector command documented by the loaded Impeccable audit reference against the modified admin UI. Fix only actionable findings caused by this change in one batch.

  • Step 5: Perform at most one confirmation pass

Recheck only the affected routes and any fixed findings. Stop after this pass per the bounded Impeccable verification rule.

  • Step 6: Record final verification evidence

Report exact test counts, type-check/build exit status, browser paths and viewport sizes, and any remaining risk. State explicitly that src/api/http.ts remains only the transport/auth wrapper and domain APIs are deferred until the Java contract is known.

Git commits are intentionally omitted: C:/Users/BY/Desktop/code/chongchong-global is not a Git repository, so worktrees, commits, merges, and pull requests are unavailable.