diff --git a/admin-frontend-vue/src/layout/AdminLayout.vue b/admin-frontend-vue/src/layout/AdminLayout.vue index 734f1f17..4d361049 100644 --- a/admin-frontend-vue/src/layout/AdminLayout.vue +++ b/admin-frontend-vue/src/layout/AdminLayout.vue @@ -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') diff --git a/admin-frontend-vue/src/router/index.ts b/admin-frontend-vue/src/router/index.ts index 758ee485..d20f84ce 100644 --- a/admin-frontend-vue/src/router/index.ts +++ b/admin-frontend-vue/src/router/index.ts @@ -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 || '/' diff --git a/admin-frontend-vue/src/router/meta.ts b/admin-frontend-vue/src/router/meta.ts new file mode 100644 index 00000000..b1304ec5 --- /dev/null +++ b/admin-frontend-vue/src/router/meta.ts @@ -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() + 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 +} diff --git a/admin-frontend-vue/src/types/vue-router.d.ts b/admin-frontend-vue/src/types/vue-router.d.ts new file mode 100644 index 00000000..060c45a5 --- /dev/null +++ b/admin-frontend-vue/src/types/vue-router.d.ts @@ -0,0 +1,10 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + /** 页面标题,驱动顶栏与 document.title。 */ + title?: string + /** 菜单 key(对应后端菜单节点 key);仅页面级鉴权使用。 */ + menuKey?: string + } +} diff --git a/admin-frontend-vue/tests/task-9.test.ts b/admin-frontend-vue/tests/task-9.test.ts new file mode 100644 index 00000000..a8d30830 --- /dev/null +++ b/admin-frontend-vue/tests/task-9.test.ts @@ -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), []) +})