task-7(壳层/路由): 根路由跳转到首个可见页面,移除硬编码默认页
helpers.firstVisiblePath 深度优先返回首个可路由页面,无页面返回空串; store 增加 firstVisible;守卫在根路由/无权限时跳首个可见页,全部无页面则 停留根路由展示无菜单空态,删除 redirect:'/account/users' 硬编码。
This commit is contained in:
@@ -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 ''
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<AdminMenuNode> & { 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/)
|
||||
})
|
||||
Reference in New Issue
Block a user