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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
test('admin build uses the standalone /admin-vue base path from single source', () => {
|
||||
const config = readFileSync(resolve('vite.config.ts'), 'utf8')
|
||||
const appConfig = readFileSync(resolve('src/config/app.ts'), 'utf8')
|
||||
assert.match(appConfig, /export const APP_BASE_PATH = '\/admin-vue\/'/)
|
||||
assert.match(config, /base:\s*APP_BASE_PATH/)
|
||||
assert.match(config, /outDir:\s*['"]dist['"]/)
|
||||
})
|
||||
|
||||
test('admin entry and first batch pages exist', () => {
|
||||
for (const file of [
|
||||
'index.html',
|
||||
'src/main.ts',
|
||||
'src/layout/AdminLayout.vue',
|
||||
'src/pages/account/UsersPage.vue',
|
||||
'src/pages/account/MenusPage.vue',
|
||||
'src/pages/account/GroupsPage.vue',
|
||||
]) {
|
||||
assert.equal(existsSync(resolve(file)), true, `${file} should exist`)
|
||||
}
|
||||
})
|
||||
|
||||
test('admin frontend does not depend on the client new_web_source', () => {
|
||||
const packageJson = readFileSync(resolve('package.json'), 'utf8')
|
||||
assert.doesNotMatch(packageJson, /new_web_source/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
APP_BASE_PATH,
|
||||
APP_HISTORY_MODE,
|
||||
ensureAppBasePath,
|
||||
joinAdminPath,
|
||||
} from '../src/config/app.ts'
|
||||
import { resolveWorkspaceRoot, walk } from '../src/config/workspace.ts'
|
||||
|
||||
const ROOT = resolveWorkspaceRoot()
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walk(ROOT)
|
||||
}
|
||||
|
||||
function literalBaseOccurrences(file: string): number {
|
||||
const content = readFileSync(join(ROOT, file), 'utf8')
|
||||
const matches = content.match(/['"]\/admin-vue\/['"]/g)
|
||||
return matches ? matches.length : 0
|
||||
}
|
||||
|
||||
test('test_task_002_history_base_contract_normal_primary_path', () => {
|
||||
// 正常主路径:冻结契约常量可被路由/构建直接消费。
|
||||
assert.equal(APP_BASE_PATH, '/admin-vue/')
|
||||
assert.equal(APP_HISTORY_MODE, 'history')
|
||||
assert.equal(joinAdminPath('account', 'users'), '/admin-vue/account/users')
|
||||
const router = readFileSync(join(ROOT, 'src/router/index.ts'), 'utf8')
|
||||
assert.match(router, /createWebHistory\(APP_BASE_PATH\)/)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_normal_variant_input', () => {
|
||||
// 正常变体:无尾斜杠/带尾斜杠/单段带斜杠的等价输入得到同一结果。
|
||||
assert.equal(ensureAppBasePath('/admin-vue'), APP_BASE_PATH)
|
||||
assert.equal(ensureAppBasePath('/admin-vue/'), APP_BASE_PATH)
|
||||
assert.equal(joinAdminPath('account/users'), joinAdminPath('account', 'users'))
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_normal_repeated_operation_is_idempotent', () => {
|
||||
// 正常重复:归一化与拼接幂等、不依赖可变状态。
|
||||
assert.equal(ensureAppBasePath(ensureAppBasePath('/admin-vue')), APP_BASE_PATH)
|
||||
assert.equal(joinAdminPath('account', 'users'), joinAdminPath('account', 'users'))
|
||||
const once = joinAdminPath('shop-center', 'list')
|
||||
const twice = joinAdminPath('shop-center', 'list')
|
||||
assert.equal(once, twice)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_boundary_empty_input', () => {
|
||||
// 边界空值:无段拼接回到基准根路径;空 base 被拒绝并带语义错误。
|
||||
assert.equal(joinAdminPath(), APP_BASE_PATH)
|
||||
assert.throws(() => ensureAppBasePath(''), /base 路径非法/)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_boundary_single_item', () => {
|
||||
// 边界单元素:单个路由段生成单一绝对地址。
|
||||
assert.equal(joinAdminPath('users'), '/admin-vue/users')
|
||||
assert.ok(APP_BASE_PATH.endsWith('/'))
|
||||
assert.ok(!APP_BASE_PATH.includes('//'))
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限:段内多余斜杠与首尾空白被收敛,不产生双斜杠或空格。
|
||||
const path = joinAdminPath(' /account/ ', ' users/ ')
|
||||
assert.equal(path, '/admin-vue/account/users')
|
||||
assert.equal((APP_BASE_PATH.match(/\//g) || []).length, 2, '基准路径只含一个目录段')
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_invalid_input_rejected', () => {
|
||||
// 异常输入:缺 / 前缀、含内部双斜杠、含 .. 越权段均被拒绝并给出消息。
|
||||
assert.throws(() => ensureAppBasePath('admin-vue'), /base 路径非法/)
|
||||
assert.throws(() => ensureAppBasePath('//admin-vue/'), /base 路径非法/)
|
||||
assert.throws(() => joinAdminPath('..', 'x'), /不允许出现 \.\./)
|
||||
assert.throws(() => joinAdminPath('/../etc'), /不允许出现 \.\./)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败:非法 base 值(undefined)返回可操作错误;base 字面量必须单一来源。
|
||||
assert.throws(() => ensureAppBasePath(undefined as unknown as string), (e: Error) => {
|
||||
return /base 路径非法/.test(e.message)
|
||||
})
|
||||
const offenders = sourceFiles()
|
||||
.map((file) => ({ file, count: literalBaseOccurrences(file) }))
|
||||
.filter(({ file, count }) => count > 0)
|
||||
assert.deepEqual(
|
||||
offenders.map((o) => o.file),
|
||||
['src/config/app.ts'],
|
||||
`'/admin-vue/' 字面量必须只出现在单一事实源 app.ts: ${JSON.stringify(offenders)}`,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { APP_BASE_PATH } from './src/config/app'
|
||||
|
||||
export default defineConfig({
|
||||
base: APP_BASE_PATH,
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:18080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user