task-9(壳层/路由): 实现路由 meta 菜单 key 约定与 RouteMeta 类型增强
新增 src/types/vue-router.d.ts 模块增强(RouteMeta.title/menuKey),meta.ts 提供 metaMenuKey/命名校验,router 守卫改类型化访问去掉 as cast,AdminLayout 标题同步 去 cast;校验首批注册表 menuKey 合规。
This commit is contained in:
@@ -15,7 +15,7 @@ const collapsed = ref(false)
|
||||
const activePath = computed(() => route.path)
|
||||
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
|
||||
const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.path))
|
||||
const pageTitle = computed(() => pageTitleOf(route.meta.title as string | undefined))
|
||||
const pageTitle = computed(() => pageTitleOf(route.meta.title))
|
||||
const userVm = computed(() => topbarUserOf(session.user))
|
||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ router.beforeEach(async (to) => {
|
||||
if (to.path === '/') {
|
||||
return session.firstVisible || true
|
||||
}
|
||||
const menuKey = to.meta.menuKey as string | undefined
|
||||
const menuKey = to.meta.menuKey
|
||||
if (menuKey && !session.isSuperAdmin && !hasMenu(session.menuTree, menuKey)) {
|
||||
// 无权限页面:回退到首个可见页面;一个都没有则回根路由空态。
|
||||
return session.firstVisible || '/'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** 路由 meta 菜单 key 约定(任务 9):menuKey 对应后端菜单节点 key,仅作页面级鉴权。 */
|
||||
export const ADMIN_MENU_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
/** 从 route.meta 读取菜单 key;缺省/非法类型回退空串(无菜单约束)。 */
|
||||
export function metaMenuKey(meta: { menuKey?: unknown } | undefined): string {
|
||||
const value = meta?.menuKey
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/** 菜单 key 是否合规(小写下划线命名)。 */
|
||||
export function isValidMenuKey(key: string): boolean {
|
||||
return ADMIN_MENU_KEY_PATTERN.test(key)
|
||||
}
|
||||
|
||||
export interface MenuKeyCandidate {
|
||||
path?: string
|
||||
menuKey?: unknown
|
||||
}
|
||||
|
||||
/** 校验一批页面注册的 menuKey:重复与命名格式问题返回可操作清单。 */
|
||||
export function validateRouteMenuKeys(records: MenuKeyCandidate[]): string[] {
|
||||
const issues: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const record of records) {
|
||||
const key = metaMenuKey(record)
|
||||
if (!key) continue
|
||||
if (seen.has(key)) issues.push(`菜单 key 重复: ${key}`)
|
||||
seen.add(key)
|
||||
if (!isValidMenuKey(key)) {
|
||||
issues.push(`菜单 key 命名非法(须小写下划线): ${record.path || '(无路径)'} -> ${key}`)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/** 页面标题,驱动顶栏与 document.title。 */
|
||||
title?: string
|
||||
/** 菜单 key(对应后端菜单节点 key);仅页面级鉴权使用。 */
|
||||
menuKey?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
ADMIN_MENU_KEY_PATTERN,
|
||||
isValidMenuKey,
|
||||
metaMenuKey,
|
||||
validateRouteMenuKeys,
|
||||
} from '../src/router/meta.ts'
|
||||
import { adminPages } from '../src/router/routes.ts'
|
||||
|
||||
test('test_task_009_route_menu_key_meta_normal_primary_path', () => {
|
||||
// 正常主路径:合法 menuKey 被识别。
|
||||
assert.equal(metaMenuKey({ menuKey: 'admin_users' }), 'admin_users')
|
||||
assert.equal(isValidMenuKey('admin_users'), true)
|
||||
assert.equal(ADMIN_MENU_KEY_PATTERN.test('admin_users'), true)
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_normal_variant_input', () => {
|
||||
// 正常变体:另一合法 key(含数字)也能识别。
|
||||
assert.equal(isValidMenuKey('admin_group_manage'), true)
|
||||
assert.equal(isValidMenuKey('asin_center2'), true)
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_normal_repeated_operation_is_idempotent', () => {
|
||||
// 正常重复:读取与校验幂等。
|
||||
assert.equal(metaMenuKey({ menuKey: 'shop_list' }), metaMenuKey({ menuKey: 'shop_list' }))
|
||||
assert.equal(isValidMenuKey('shop_list'), isValidMenuKey('shop_list'))
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_boundary_empty_input', () => {
|
||||
// 边界空值:无 meta/无 menuKey 回退空串;空串 key 不参与命名约束。
|
||||
assert.equal(metaMenuKey(undefined), '')
|
||||
assert.equal(metaMenuKey({}), '')
|
||||
assert.equal(metaMenuKey({ menuKey: '' }), '')
|
||||
assert.deepEqual(validateRouteMenuKeys([]), [])
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_boundary_single_item', () => {
|
||||
// 边界单元素:单条带合法 key 的注册记录无校验问题。
|
||||
assert.deepEqual(validateRouteMenuKeys([{ path: 'a/b', menuKey: 'admin_x' }]), [])
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限/缺字段:重复 key、驼峰/连字符/数字开头 key 均被校验拒绝。
|
||||
const issues = validateRouteMenuKeys([
|
||||
{ path: 'a', menuKey: 'dup' },
|
||||
{ path: 'b', menuKey: 'dup' },
|
||||
{ path: 'c', menuKey: 'CamelKey' },
|
||||
{ path: 'd', menuKey: 'kebab-key' },
|
||||
{ path: 'e', menuKey: '1digit' },
|
||||
])
|
||||
assert.ok(issues.some((i) => i.includes('重复')))
|
||||
assert.ok(issues.some((i) => i.includes('命名非法')))
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_invalid_input_rejected', () => {
|
||||
// 异常输入:非字符串 menuKey 回退空串,不崩溃、不参与校验。
|
||||
assert.equal(metaMenuKey({ menuKey: 123 }), '')
|
||||
assert.equal(metaMenuKey({ menuKey: null }), '')
|
||||
assert.deepEqual(validateRouteMenuKeys([{ path: 'x', menuKey: 1 }, { path: 'y' }]), [])
|
||||
})
|
||||
|
||||
test('test_task_009_route_menu_key_meta_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败:RouteMeta 已增强类型;守卫使用类型化 meta;注册表 menuKey 全部合规。
|
||||
const dts = readSource('src/types/vue-router.d.ts')
|
||||
assert.match(dts, /interface RouteMeta/)
|
||||
assert.match(dts, /menuKey\?: string/)
|
||||
const router = readSource('src/router/index.ts')
|
||||
assert.equal(router.includes('as string | undefined'), false, 'menuKey 应为类型化访问,禁止 cast')
|
||||
assert.deepEqual(validateRouteMenuKeys(adminPages), [])
|
||||
})
|
||||
Reference in New Issue
Block a user