From 8e828c1204d92c8927816e3e1edc16b7424b4926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 12:18:16 +0800 Subject: [PATCH] =?UTF-8?q?task-7(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E6=A0=B9=E8=B7=AF=E7=94=B1=E8=B7=B3=E8=BD=AC=E5=88=B0?= =?UTF-8?q?=E9=A6=96=E4=B8=AA=E5=8F=AF=E8=A7=81=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E7=A1=AC=E7=BC=96=E7=A0=81=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit helpers.firstVisiblePath 深度优先返回首个可路由页面,无页面返回空串; store 增加 firstVisible;守卫在根路由/无权限时跳首个可见页,全部无页面则 停留根路由展示无菜单空态,删除 redirect:'/account/users' 硬编码。 --- admin-frontend-vue/src/router/helpers.ts | 14 ++++ admin-frontend-vue/src/router/index.ts | 13 ++-- .../src/stores/admin-session.ts | 43 +++++++++++ admin-frontend-vue/tests/task-7.test.ts | 77 +++++++++++++++++++ 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 admin-frontend-vue/src/router/helpers.ts create mode 100644 admin-frontend-vue/src/stores/admin-session.ts create mode 100644 admin-frontend-vue/tests/task-7.test.ts diff --git a/admin-frontend-vue/src/router/helpers.ts b/admin-frontend-vue/src/router/helpers.ts new file mode 100644 index 00000000..903d48e2 --- /dev/null +++ b/admin-frontend-vue/src/router/helpers.ts @@ -0,0 +1,14 @@ +import type { AdminMenuNode } from '../types/admin' + +/** + * 取后端菜单树中第一个可路由页面路径(深度优先、保序)。 + * 无任何可路由页面时返回空串,由调用方决定渲染空态或回根路由,不硬编码缺省页。 + */ +export function firstVisiblePath(nodes: AdminMenuNode[] | null | undefined): string { + for (const node of nodes || []) { + if (node.route) return node.route + const nested = firstVisiblePath(node.children || []) + if (nested) return nested + } + return '' +} diff --git a/admin-frontend-vue/src/router/index.ts b/admin-frontend-vue/src/router/index.ts index ff0024ee..5e250f67 100644 --- a/admin-frontend-vue/src/router/index.ts +++ b/admin-frontend-vue/src/router/index.ts @@ -2,7 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router' import type { AdminMenuNode } from '@/types/admin' import AdminLayout from '@/layout/AdminLayout.vue' import { useAdminSessionStore } from '@/stores/admin-session' -import { findFirstRoute } from './helpers' import { APP_BASE_PATH } from '@/config/app' const router = createRouter({ @@ -12,7 +11,6 @@ const router = createRouter({ path: '/', component: AdminLayout, children: [ - { path: '', redirect: '/account/users' }, { path: 'account/users', component: () => import('@/pages/account/UsersPage.vue'), meta: { menuKey: 'admin_users', title: '用户管理' } }, { path: 'account/menus', component: () => import('@/pages/account/MenusPage.vue'), meta: { menuKey: 'admin_columns', title: '菜单管理' } }, { path: 'account/groups', component: () => import('@/pages/account/GroupsPage.vue'), meta: { menuKey: 'admin_group_manage', title: '数据权限分组' } }, @@ -30,10 +28,14 @@ router.beforeEach(async (to) => { return false } } - if (to.path === '/') return findFirstRoute(session.menuTree) + // 根路由:有可见页面时跳到首个可见页;没有任何页面时停留根路由展示无菜单空态。 + if (to.path === '/') { + return session.firstVisible || true + } const menuKey = to.meta.menuKey as string | undefined if (menuKey && !session.isSuperAdmin && !hasMenu(session.menuTree, menuKey)) { - return findFirstRoute(session.menuTree) + // 无权限页面:回退到首个可见页面;一个都没有则回根路由空态。 + return session.firstVisible || '/' } return true }) @@ -43,6 +45,3 @@ function hasMenu(nodes: AdminMenuNode[], key: string): boolean { } export default router - - - diff --git a/admin-frontend-vue/src/stores/admin-session.ts b/admin-frontend-vue/src/stores/admin-session.ts new file mode 100644 index 00000000..f326fc50 --- /dev/null +++ b/admin-frontend-vue/src/stores/admin-session.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { fetchAdminMenuTree, fetchCurrentUser, logout } from '@/api/session' +import type { AdminMenuNode, AdminUser } from '@/types/admin' +import { firstVisiblePath } from '@/router/helpers' + +export const useAdminSessionStore = defineStore('admin-session', { + state: () => ({ + user: null as AdminUser | null, + menuTree: [] as AdminMenuNode[], + initialized: false, + loading: false, + error: '', + }), + getters: { + isSuperAdmin: (state) => state.user?.role === 'super_admin', + firstVisible: (state): string => { + return firstVisiblePath(state.menuTree) + }, + }, + actions: { + async initialize() { + if (this.initialized || this.loading) return + this.loading = true + this.error = '' + try { + this.user = await fetchCurrentUser() + this.menuTree = await fetchAdminMenuTree() + this.initialized = true + } catch (error) { + this.error = error instanceof Error ? error.message : '后台初始化失败' + throw error + } finally { + this.loading = false + } + }, + async signOut() { + await logout() + this.$reset() + window.location.assign('/login') + }, + }, +}) + diff --git a/admin-frontend-vue/tests/task-7.test.ts b/admin-frontend-vue/tests/task-7.test.ts new file mode 100644 index 00000000..b174f7a6 --- /dev/null +++ b/admin-frontend-vue/tests/task-7.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { firstVisiblePath } from '../src/router/helpers.ts' +import type { AdminMenuNode } from '../src/types/admin.ts' + +function node(partial: Partial & { key: string; name: string }): AdminMenuNode { + return { key: partial.key, name: partial.name, route: partial.route, children: partial.children } +} + +const NESTED: AdminMenuNode[] = [ + node({ key: 'a', name: '组A', children: [node({ key: 'a1', name: '无路由' })] }), + node({ key: 'b', name: '组B', children: [ + node({ key: 'b1', name: '用户', route: '/account/users' }), + node({ key: 'b2', name: '菜单', route: '/account/menus' }), + ] }), +] + +test('test_task_007_first_route_redirect_normal_primary_path', () => { + // 正常主路径:深度优先返回第一个可见页面。 + assert.equal(firstVisiblePath(NESTED), '/account/users') +}) + +test('test_task_007_first_route_redirect_normal_variant_input', () => { + // 正常变体:另一棵树返回其自身首个可见页,不依赖固定页。 + const tree: AdminMenuNode[] = [ + node({ key: 'x', name: '顶级', route: '/shop/list' }), + node({ key: 'y', name: '后置', route: '/tasks/list' }), + ] + assert.equal(firstVisiblePath(tree), '/shop/list') +}) + +test('test_task_007_first_route_redirect_normal_repeated_operation_is_idempotent', () => { + // 正常重复:结果稳定、不修改输入。 + const snapshot = JSON.stringify(NESTED) + assert.equal(firstVisiblePath(NESTED), firstVisiblePath(NESTED)) + assert.equal(JSON.stringify(NESTED), snapshot) +}) + +test('test_task_007_first_route_redirect_boundary_empty_input', () => { + // 边界空值:无任何菜单返回空串,不硬编码缺省页。 + assert.equal(firstVisiblePath([]), '') + assert.equal(firstVisiblePath(undefined), '') + assert.equal(firstVisiblePath(null), '') +}) + +test('test_task_007_first_route_redirect_boundary_single_item', () => { + // 边界单元素:仅一个顶层页面时返回它。 + assert.equal(firstVisiblePath([node({ key: 's', name: '单页', route: '/solo' })]), '/solo') +}) + +test('test_task_007_first_route_redirect_boundary_limit_or_missing_field', () => { + // 边界上限:只有无路由子项的分组不产出可见页。 + const onlyEmptyGroups: AdminMenuNode[] = [ + node({ key: 'g', name: '空组', children: [node({ key: 'c', name: '不可点' })] }), + ] + assert.equal(firstVisiblePath(onlyEmptyGroups), '') +}) + +test('test_task_007_first_route_redirect_invalid_input_rejected', () => { + // 异常输入:畸形节点被跳过并继续找下一个可见页。 + const withMalformed: AdminMenuNode[] = [ + { key: 'bad', name: '畸形', route: '' } as unknown as AdminMenuNode, + node({ key: 'ok', name: '可见', route: '/fine' }), + ] + assert.equal(firstVisiblePath(withMalformed), '/fine') +}) + +test('test_task_007_first_route_redirect_dependency_failure_returns_actionable_message', () => { + // 依赖失败:根跳转由会话首个可见页驱动,不再硬编码 account/users。 + const router = readSource('src/router/index.ts') + assert.equal(router.includes('redirect: \'/account/users\''), false, '禁止硬编码根跳转缺省页') + assert.equal(router.includes('firstVisible'), true, '守卫必须使用会话首个可见页') + const store = readSource('src/stores/admin-session.ts') + assert.match(store, /firstVisible:/) + assert.match(store, /firstVisiblePath/) +})