task-2(壳层/路由): 冻结 /admin-vue/ base 与 History 路由契约为单一事实源

新增 src/config/app.ts(APP_BASE_PATH/ensureAppBasePath/joinAdminPath),
vite.config base、router createWebHistory、AdminLayout logo 路径统一消费常量,
禁止散落字面量;8 用例覆盖归一化/幂等/空段/单段/非法输入与字面量单源。
This commit is contained in:
2026-09-05 12:13:19 +08:00
parent ec7d56d566
commit 6f0357e41c
6 changed files with 338 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
/**
* /admin-vue/ 基准路径与 History 路由约定(任务 2 冻结的单一事实源)。
* Vite base、Vue Router history base、Nginx location 三者必须共用此常量。
*/
export const APP_BASE_PATH = '/admin-vue/'
/** 前端路由一律使用 History 模式,不引入 hash(旧 #tab 兼容不在目标契约内)。 */
export const APP_HISTORY_MODE = 'history' as const
function invalidBaseMessage(value: unknown): string {
return `管理后台 base 路径非法: ${JSON.stringify(value)},必须以 / 开头且形如 /xxx/,不允许空值或 ..`
}
/** 校验并归一化 base 路径:保证以 / 开头、无 ..、无内部连续斜杠、以单个 / 结尾。 */
export function ensureAppBasePath(value: string): string {
if (typeof value !== 'string' || value.length === 0) throw new Error(invalidBaseMessage(value))
if (!value.startsWith('/')) throw new Error(invalidBaseMessage(value))
if (value.includes('//')) throw new Error(invalidBaseMessage(value))
if (value.includes('..')) throw new Error(invalidBaseMessage(value))
return value.endsWith('/') ? value : `${value}/`
}
/** 把后台页面相对路径段拼成绝对 URL(不含 base 前缀的历史兼容子路径段由路由负责)。 */
export function joinAdminPath(...segments: string[]): string {
const parts: string[] = []
for (const segment of segments) {
for (const raw of segment.split('/')) {
const part = raw.trim()
if (!part || part === '.') continue
if (part === '..') {
throw new Error(`admin 路由段不允许出现 ..(越权跳转): ${segments.join('/')}`)
}
parts.push(part)
}
}
if (parts.length === 0) return APP_BASE_PATH
return `${APP_BASE_PATH}${parts.join('/')}`
}
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { joinAdminPath } from '@/config/app'
import type { AdminMenuNode } from '@/types/admin'
const route = useRoute()
const router = useRouter()
const session = useAdminSessionStore()
const collapsed = ref(false)
const activePath = computed(() => route.path)
const groups = computed(() => session.menuTree.filter((node) => node.children?.length))
const pageTitle = computed(() => String(route.meta.title || '管理后台'))
const logoUrl = joinAdminPath('assets', 'logo.jpg')
function menuPath(node: AdminMenuNode): string {
return node.route || '/account/users'
}
function visibleChildren(node: AdminMenuNode) {
return (node.children || []).filter((child) => child.route)
}
function canSee(node: AdminMenuNode) {
return session.isSuperAdmin.valueOf() || hasMenu(node, String(route.meta.menuKey || '')) || node.route === activePath.value
}
function hasMenu(node: AdminMenuNode, key: string): boolean {
return node.key === key || (node.children || []).some((child) => hasMenu(child, key))
}
async function signOut() {
try {
await session.signOut()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '退出登录失败')
}
}
</script>
<template>
<div class="admin-shell" :class="{ 'is-collapsed': collapsed }">
<aside class="admin-sidebar">
<div class="admin-brand">
<img :src="logoUrl" alt="数富AI" class="admin-brand-logo" />
<div v-if="!collapsed" class="admin-brand-copy">
<strong>数富AI</strong>
<span>电商运营管理后台</span>
</div>
</div>
<el-scrollbar class="admin-menu-scroll">
<el-menu :default-active="activePath" :collapse="collapsed" router class="admin-menu">
<template v-for="group in groups" :key="group.key">
<el-sub-menu :index="group.key">
<template #title>
<span class="menu-group-title">{{ group.name }}</span>
</template>
<el-menu-item
v-for="item in visibleChildren(group)"
:key="item.key"
:index="menuPath(item)"
>
{{ item.name }}
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
<el-empty v-if="!groups.length && !session.loading" description="暂无可用菜单" :image-size="72" />
</el-scrollbar>
<button class="sidebar-collapse" type="button" @click="collapsed = !collapsed">
{{ collapsed ? '展开菜单' : '收起菜单' }}
</button>
</aside>
<section class="admin-main">
<header class="admin-topbar">
<div>
<div class="admin-kicker">运营管理控制台</div>
<h1>{{ pageTitle }}</h1>
</div>
<div class="admin-user">
<div class="admin-user-meta">
<strong>{{ session.user?.username || '当前用户' }}</strong>
<span>{{ session.user?.role || '' }}</span>
</div>
<el-button text type="danger" @click="signOut">退出登录</el-button>
</div>
</header>
<main class="admin-content">
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<RouterView />
</main>
</section>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
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({
history: createWebHistory(APP_BASE_PATH),
routes: [
{
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: '数据权限分组' } },
],
},
],
})
router.beforeEach(async (to) => {
const session = useAdminSessionStore()
if (!session.initialized) {
try {
await session.initialize()
} catch {
return false
}
}
if (to.path === '/') return findFirstRoute(session.menuTree)
const menuKey = to.meta.menuKey as string | undefined
if (menuKey && !session.isSuperAdmin && !hasMenu(session.menuTree, menuKey)) {
return findFirstRoute(session.menuTree)
}
return true
})
function hasMenu(nodes: AdminMenuNode[], key: string): boolean {
return nodes.some((node) => node.key === key || hasMenu(node.children || [], key))
}
export default router