317 lines
14 KiB
Markdown
317 lines
14 KiB
Markdown
# 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`.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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:
|
|
|
|
```text
|
|
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.
|
|
|
|
```ts
|
|
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`.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|