chore: initial commit

This commit is contained in:
2026-07-30 17:59:43 +08:00
commit dd0d365b03
73 changed files with 9898 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
export interface PlatformJwtClaims {
user_id?: string | number
username?: string
app_code?: string
roles?: string[]
permissions?: string[]
tenant_id?: string | number
membership_id?: string | number
exp?: number
}
function decodeBase64Url(value: string): string {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
return atob(padded)
}
export function decodeJwtClaims(token: string): PlatformJwtClaims {
const payload = token.split('.')[1]
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
try {
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
} catch {
throw new Error('登录响应中的 accessToken 无法解析')
}
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { Permission } from '@/types/admin'
import { buildPermissionTree, getDescendantIds, summarizePermissionSelection } 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 },
]
describe('permissionTree', () => {
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'])
})
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?.map((node) => node.id)).toEqual(['menu'])
expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a'])
})
it('stops building a branch when malformed data repeats an ancestor id', () => {
const malformed = [
...permissions,
{ id: 'loop', parentId: null, name: '循环目录', code: 'loop', type: '目录', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'loop', parentId: 'loop', name: '循环下级', code: 'loop:child', type: '目录', path: '', method: '-', status: '启用', sort: 10 },
] satisfies Permission[]
const tree = buildPermissionTree(malformed)
expect(tree.find((node) => node.code === 'loop')).not.toHaveProperty('children')
})
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'])
})
it('counts page menus and operations without treating directories as menus', () => {
expect(summarizePermissionSelection(permissions, ['directory', 'menu', 'action-a'])).toEqual({
menuCount: 1,
actionCount: 1,
})
})
})
+75
View File
@@ -0,0 +1,75 @@
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)
function 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
}
export function summarizePermissionSelection(permissions: Permission[], selectedIds: string[]) {
const selected = new Set(selectedIds)
const selectedPermissions = permissions.filter((permission) => selected.has(permission.id))
return {
menuCount: selectedPermissions.filter((permission) => permission.type === '菜单').length,
actionCount: selectedPermissions.filter((permission) => ['按钮', '接口'].includes(permission.type)).length,
}
}