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:
2026-09-05 12:20:14 +08:00
parent 02e0dc20b8
commit ab7255a55b
5 changed files with 118 additions and 2 deletions
@@ -15,7 +15,7 @@ const collapsed = ref(false)
const activePath = computed(() => route.path) const activePath = computed(() => route.path)
const menuEntries = computed(() => toSidebarEntries(session.menuTree)) const menuEntries = computed(() => toSidebarEntries(session.menuTree))
const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.path)) 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 userVm = computed(() => topbarUserOf(session.user))
const logoUrl = joinAdminPath('assets', 'logo.jpg') const logoUrl = joinAdminPath('assets', 'logo.jpg')
+1 -1
View File
@@ -29,7 +29,7 @@ router.beforeEach(async (to) => {
if (to.path === '/') { if (to.path === '/') {
return session.firstVisible || true 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)) { if (menuKey && !session.isSuperAdmin && !hasMenu(session.menuTree, menuKey)) {
// 无权限页面:回退到首个可见页面;一个都没有则回根路由空态。 // 无权限页面:回退到首个可见页面;一个都没有则回根路由空态。
return session.firstVisible || '/' return session.firstVisible || '/'
+34
View File
@@ -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
View File
@@ -0,0 +1,10 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
/** 页面标题,驱动顶栏与 document.title。 */
title?: string
/** 菜单 key(对应后端菜单节点 key);仅页面级鉴权使用。 */
menuKey?: string
}
}
+72
View File
@@ -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), [])
})