chore: initial commit
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
Building2,
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import {
|
||||
clientNavigation,
|
||||
filterNavigationByPermissions,
|
||||
getFirstAccessiblePath,
|
||||
getNavigationLeaves,
|
||||
getNavigationParentKeys,
|
||||
openNavigationParentKeys,
|
||||
} from './clientNavigation.ts'
|
||||
|
||||
function collectNavigationNodes(nodes) {
|
||||
return nodes.flatMap((node) => [
|
||||
node,
|
||||
...(node.children ? collectNavigationNodes(node.children) : []),
|
||||
])
|
||||
}
|
||||
|
||||
test('keeps the confirmed personal entries at the top level', () => {
|
||||
assert.deepEqual(
|
||||
clientNavigation.filter((node) => node.path).map(({ title, path, permission }) => ({
|
||||
title,
|
||||
path,
|
||||
permission,
|
||||
})),
|
||||
[
|
||||
{ title: '业务总览', path: '/', permission: 'dashboard:view' },
|
||||
{ title: '我的信息', path: '/account/profile', permission: 'account:profile:view' },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps the confirmed navigation groups and leaf order', () => {
|
||||
assert.deepEqual(
|
||||
clientNavigation.map((node) => ({
|
||||
title: node.title,
|
||||
children: node.children?.map(({ title, path, permission }) => ({
|
||||
title,
|
||||
path,
|
||||
permission,
|
||||
})),
|
||||
})),
|
||||
[
|
||||
{ title: '业务总览', children: undefined },
|
||||
{ title: '我的信息', children: undefined },
|
||||
{
|
||||
title: '头程业务',
|
||||
children: [
|
||||
{
|
||||
title: '头程订单',
|
||||
path: '/orders/first-leg',
|
||||
permission: 'order:first-leg:list',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '企业管理',
|
||||
children: [
|
||||
{
|
||||
title: '企业资料',
|
||||
path: '/company/profile',
|
||||
permission: 'company:profile:view',
|
||||
},
|
||||
{
|
||||
title: '企业成员',
|
||||
path: '/organization/members',
|
||||
permission: 'company:member:list',
|
||||
},
|
||||
{
|
||||
title: '角色管理',
|
||||
path: '/organization/roles',
|
||||
permission: 'company:role:list',
|
||||
},
|
||||
{
|
||||
title: '菜单权限',
|
||||
path: '/organization/permissions',
|
||||
permission: 'company:permission:list',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('provides confirmed icons and unique keys recursively', () => {
|
||||
const expectedIcons = new Map([
|
||||
['dashboard', LayoutGrid],
|
||||
['account-profile', CircleUserRound],
|
||||
['first-leg', Truck],
|
||||
['first-leg-orders', ClipboardList],
|
||||
['company', Settings],
|
||||
['company-profile', Building2],
|
||||
['company-members', Users],
|
||||
['company-roles', ShieldCheck],
|
||||
['company-permissions', KeyRound],
|
||||
])
|
||||
const nodes = collectNavigationNodes(clientNavigation)
|
||||
|
||||
for (const node of nodes) {
|
||||
assert.equal(node.icon, expectedIcons.get(node.key), `${node.key} has the confirmed icon`)
|
||||
}
|
||||
assert.equal(new Set(nodes.map((node) => node.key)).size, nodes.length)
|
||||
})
|
||||
|
||||
test('flattens leaves with unique paths and non-empty permissions', () => {
|
||||
const leaves = getNavigationLeaves(clientNavigation)
|
||||
const paths = leaves.map((leaf) => leaf.path)
|
||||
|
||||
assert.deepEqual(paths, [
|
||||
'/',
|
||||
'/account/profile',
|
||||
'/orders/first-leg',
|
||||
'/company/profile',
|
||||
'/organization/members',
|
||||
'/organization/roles',
|
||||
'/organization/permissions',
|
||||
])
|
||||
assert.equal(new Set(paths).size, paths.length)
|
||||
assert.ok(leaves.every((leaf) => leaf.permission.trim().length > 0))
|
||||
assert.equal(new Set(leaves.map((leaf) => leaf.permission)).size, leaves.length)
|
||||
})
|
||||
|
||||
test('filters inaccessible leaves and empty navigation groups', () => {
|
||||
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
|
||||
|
||||
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/account/profile', '/orders/first-leg'])
|
||||
assert.deepEqual(visible.map((item) => item.title), ['我的信息', '头程业务'])
|
||||
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
|
||||
assert.equal(getFirstAccessiblePath([]), '/forbidden')
|
||||
})
|
||||
|
||||
test('flattens a synthetic three-level navigation tree recursively', () => {
|
||||
const nestedLeaf = {
|
||||
key: 'nested-leaf',
|
||||
title: '嵌套叶子',
|
||||
icon: ClipboardList,
|
||||
path: '/nested/leaf',
|
||||
permission: 'nested:leaf:view',
|
||||
}
|
||||
const syntheticTree = [
|
||||
{
|
||||
key: 'level-one',
|
||||
title: '一级分组',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{
|
||||
key: 'level-two',
|
||||
title: '二级分组',
|
||||
icon: Truck,
|
||||
children: [nestedLeaf],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
assert.deepEqual(getNavigationLeaves(syntheticTree), [nestedLeaf])
|
||||
})
|
||||
|
||||
test('returns navigation parent keys from outermost to innermost', () => {
|
||||
const syntheticTree = [
|
||||
{
|
||||
key: 'level-one',
|
||||
title: '一级分组',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{
|
||||
key: 'level-two',
|
||||
title: '二级分组',
|
||||
icon: Truck,
|
||||
children: [
|
||||
{
|
||||
key: 'nested-leaf',
|
||||
title: '嵌套叶子',
|
||||
icon: ClipboardList,
|
||||
path: '/nested/leaf',
|
||||
permission: 'nested:leaf:view',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/'), [])
|
||||
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/first-leg'), ['first-leg'])
|
||||
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/organization/members'), ['company'])
|
||||
assert.deepEqual(getNavigationParentKeys(syntheticTree, '/nested/leaf'), ['level-one', 'level-two'])
|
||||
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), [])
|
||||
})
|
||||
|
||||
test('opens the company parent group for a company route', () => {
|
||||
const openedKeys = []
|
||||
|
||||
openNavigationParentKeys('/organization/members', clientNavigation, (key) => openedKeys.push(key))
|
||||
|
||||
assert.deepEqual(openedKeys, ['company'])
|
||||
})
|
||||
|
||||
test('opens nested parent groups from outermost to innermost', () => {
|
||||
const syntheticTree = [
|
||||
{
|
||||
key: 'level-one',
|
||||
title: '一级分组',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{
|
||||
key: 'level-two',
|
||||
title: '二级分组',
|
||||
icon: Truck,
|
||||
children: [
|
||||
{
|
||||
key: 'nested-leaf',
|
||||
title: '嵌套叶子',
|
||||
icon: ClipboardList,
|
||||
path: '/nested/leaf',
|
||||
permission: 'nested:leaf:view',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const openedKeys = []
|
||||
|
||||
openNavigationParentKeys('/nested/leaf', syntheticTree, (key) => openedKeys.push(key))
|
||||
|
||||
assert.deepEqual(openedKeys, ['level-one', 'level-two'])
|
||||
})
|
||||
|
||||
test('does not open a group when the navigation path does not exist', () => {
|
||||
const openedKeys = []
|
||||
|
||||
openNavigationParentKeys('/not-found', clientNavigation, (key) => openedKeys.push(key))
|
||||
|
||||
assert.deepEqual(openedKeys, [])
|
||||
})
|
||||
|
||||
test('enforces mutually exclusive leaf and non-empty group node types', () => {
|
||||
const testDirectory = mkdtempSync(join(fileURLToPath(new URL('.', import.meta.url)), '.client-navigation-types-'))
|
||||
const fixturePath = join(testDirectory, 'fixture.ts')
|
||||
const compilerPath = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
|
||||
|
||||
try {
|
||||
writeFileSync(fixturePath, `
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
getNavigationLeaves,
|
||||
type ClientNavigationGroup,
|
||||
type ClientNavigationLeaf,
|
||||
type ClientNavigationNode,
|
||||
} from '../clientNavigation.ts'
|
||||
|
||||
declare const icon: Component
|
||||
|
||||
const leaf: ClientNavigationLeaf = {
|
||||
key: 'leaf',
|
||||
title: 'Leaf',
|
||||
icon,
|
||||
path: '/leaf',
|
||||
permission: 'leaf:view',
|
||||
}
|
||||
const group: ClientNavigationGroup = {
|
||||
key: 'group',
|
||||
title: 'Group',
|
||||
icon,
|
||||
children: [leaf],
|
||||
}
|
||||
const nodes: readonly ClientNavigationNode[] = [leaf, group]
|
||||
getNavigationLeaves(nodes)
|
||||
|
||||
// @ts-expect-error groups require at least one child
|
||||
const emptyGroup: ClientNavigationGroup = { key: 'empty', title: 'Empty', icon, children: [] }
|
||||
// @ts-expect-error groups cannot be route leaves
|
||||
const linkedGroup: ClientNavigationGroup = { key: 'linked', title: 'Linked', icon, children: [leaf], path: '/linked' }
|
||||
// @ts-expect-error leaves cannot contain child nodes
|
||||
const parentLeaf: ClientNavigationLeaf = { key: 'parent', title: 'Parent', icon, path: '/parent', permission: 'parent:view', children: [leaf] }
|
||||
|
||||
void emptyGroup
|
||||
void linkedGroup
|
||||
void parentLeaf
|
||||
`)
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
compilerPath,
|
||||
'--noEmit',
|
||||
'--strict',
|
||||
'--skipLibCheck',
|
||||
'--target',
|
||||
'ES2022',
|
||||
'--module',
|
||||
'ESNext',
|
||||
'--moduleResolution',
|
||||
'Bundler',
|
||||
'--allowImportingTsExtensions',
|
||||
fixturePath,
|
||||
], { encoding: 'utf8' })
|
||||
|
||||
assert.equal(result.status, 0, result.stdout || result.stderr)
|
||||
} finally {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps the dashboard route permission synchronized with navigation', () => {
|
||||
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||
const dashboardRoute = routerSource.match(/\{ path: '', name: 'dashboard'.*?\}/)?.[0]
|
||||
|
||||
assert.ok(dashboardRoute)
|
||||
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
Building2,
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
export interface ClientNavigationBaseNode {
|
||||
key: string
|
||||
title: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export interface ClientNavigationLeaf extends ClientNavigationBaseNode {
|
||||
path: string
|
||||
permission: string
|
||||
children?: never
|
||||
}
|
||||
|
||||
export interface ClientNavigationGroup extends ClientNavigationBaseNode {
|
||||
path?: never
|
||||
permission?: never
|
||||
children: readonly [ClientNavigationNode, ...ClientNavigationNode[]]
|
||||
}
|
||||
|
||||
export type ClientNavigationNode = ClientNavigationLeaf | ClientNavigationGroup
|
||||
|
||||
export const clientNavigation: ClientNavigationNode[] = [
|
||||
{ key: 'dashboard', title: '业务总览', icon: LayoutGrid, path: '/', permission: 'dashboard:view' },
|
||||
{ key: 'account-profile', title: '我的信息', icon: CircleUserRound, path: '/account/profile', permission: 'account:profile:view' },
|
||||
{
|
||||
key: 'first-leg',
|
||||
title: '头程业务',
|
||||
icon: Truck,
|
||||
children: [
|
||||
{ key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'order:first-leg:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
title: '企业管理',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ key: 'company-profile', title: '企业资料', icon: Building2, path: '/company/profile', permission: 'company:profile:view' },
|
||||
{ key: 'company-members', title: '企业成员', icon: Users, path: '/organization/members', permission: 'company:member:list' },
|
||||
{ key: 'company-roles', title: '角色管理', icon: ShieldCheck, path: '/organization/roles', permission: 'company:role:list' },
|
||||
{ key: 'company-permissions', title: '菜单权限', icon: KeyRound, path: '/organization/permissions', permission: 'company:permission:list' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function isNavigationGroup(node: ClientNavigationNode): node is ClientNavigationGroup {
|
||||
return 'children' in node && node.children !== undefined
|
||||
}
|
||||
|
||||
export function getNavigationLeaves(nodes: readonly ClientNavigationNode[]): ClientNavigationLeaf[] {
|
||||
return nodes.flatMap((node) => {
|
||||
if (isNavigationGroup(node)) return getNavigationLeaves(node.children)
|
||||
return [node]
|
||||
})
|
||||
}
|
||||
|
||||
export function filterNavigationByPermissions(
|
||||
nodes: readonly ClientNavigationNode[],
|
||||
permissions: readonly string[],
|
||||
): ClientNavigationNode[] {
|
||||
const allowed = new Set(permissions)
|
||||
const result: ClientNavigationNode[] = []
|
||||
for (const node of nodes) {
|
||||
if (!isNavigationGroup(node)) {
|
||||
if (allowed.has(node.permission)) result.push(node)
|
||||
continue
|
||||
}
|
||||
const children = filterNavigationByPermissions(node.children, permissions)
|
||||
if (children.length > 0) result.push({ ...node, children: children as [ClientNavigationNode, ...ClientNavigationNode[]] })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getFirstAccessiblePath(permissions: readonly string[]) {
|
||||
return getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path ?? '/forbidden'
|
||||
}
|
||||
|
||||
function findNavigationParentKeys(
|
||||
nodes: readonly ClientNavigationNode[],
|
||||
path: string,
|
||||
parentKeys: readonly string[],
|
||||
): string[] | undefined {
|
||||
for (const node of nodes) {
|
||||
if (isNavigationGroup(node)) {
|
||||
const result = findNavigationParentKeys(node.children, path, [...parentKeys, node.key])
|
||||
if (result) return result
|
||||
} else if (node.path === path) {
|
||||
return [...parentKeys]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNavigationParentKeys(nodes: readonly ClientNavigationNode[], path: string): string[] {
|
||||
return findNavigationParentKeys(nodes, path, []) ?? []
|
||||
}
|
||||
|
||||
export function openNavigationParentKeys(
|
||||
path: string,
|
||||
nodes: readonly ClientNavigationNode[],
|
||||
open: (key: string) => void,
|
||||
) {
|
||||
for (const key of getNavigationParentKeys(nodes, path)) {
|
||||
open(key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user