From 6f0357e41c2ad8072541cfa9f46989755e3e84ef 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:13:19 +0800 Subject: [PATCH] =?UTF-8?q?task-2(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=86=BB=E7=BB=93=20/admin-vue/=20base=20=E4=B8=8E=20Histor?= =?UTF-8?q?y=20=E8=B7=AF=E7=94=B1=E5=A5=91=E7=BA=A6=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E4=BA=8B=E5=AE=9E=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/config/app.ts(APP_BASE_PATH/ensureAppBasePath/joinAdminPath), vite.config base、router createWebHistory、AdminLayout logo 路径统一消费常量, 禁止散落字面量;8 用例覆盖归一化/幂等/空段/单段/非法输入与字面量单源。 --- admin-frontend-vue/src/config/app.ts | 38 +++++++ admin-frontend-vue/src/layout/AdminLayout.vue | 102 ++++++++++++++++++ admin-frontend-vue/src/router/index.ts | 48 +++++++++ .../tests/admin-scaffold.test.ts | 31 ++++++ admin-frontend-vue/tests/task-2.test.ts | 91 ++++++++++++++++ admin-frontend-vue/vite.config.ts | 28 +++++ 6 files changed, 338 insertions(+) create mode 100644 admin-frontend-vue/src/config/app.ts create mode 100644 admin-frontend-vue/src/layout/AdminLayout.vue create mode 100644 admin-frontend-vue/src/router/index.ts create mode 100644 admin-frontend-vue/tests/admin-scaffold.test.ts create mode 100644 admin-frontend-vue/tests/task-2.test.ts create mode 100644 admin-frontend-vue/vite.config.ts diff --git a/admin-frontend-vue/src/config/app.ts b/admin-frontend-vue/src/config/app.ts new file mode 100644 index 00000000..4b0fa583 --- /dev/null +++ b/admin-frontend-vue/src/config/app.ts @@ -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('/')}` +} diff --git a/admin-frontend-vue/src/layout/AdminLayout.vue b/admin-frontend-vue/src/layout/AdminLayout.vue new file mode 100644 index 00000000..d88702b9 --- /dev/null +++ b/admin-frontend-vue/src/layout/AdminLayout.vue @@ -0,0 +1,102 @@ + + + + + + + + + + 运营管理控制台 + {{ pageTitle }} + + + + {{ session.user?.username || '当前用户' }} + {{ session.user?.role || '' }} + + 退出登录 + + + + + + + + + + + diff --git a/admin-frontend-vue/src/router/index.ts b/admin-frontend-vue/src/router/index.ts new file mode 100644 index 00000000..ff0024ee --- /dev/null +++ b/admin-frontend-vue/src/router/index.ts @@ -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 + + + diff --git a/admin-frontend-vue/tests/admin-scaffold.test.ts b/admin-frontend-vue/tests/admin-scaffold.test.ts new file mode 100644 index 00000000..11357168 --- /dev/null +++ b/admin-frontend-vue/tests/admin-scaffold.test.ts @@ -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/) +}) + diff --git a/admin-frontend-vue/tests/task-2.test.ts b/admin-frontend-vue/tests/task-2.test.ts new file mode 100644 index 00000000..b3663175 --- /dev/null +++ b/admin-frontend-vue/tests/task-2.test.ts @@ -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)}`, + ) +}) diff --git a/admin-frontend-vue/vite.config.ts b/admin-frontend-vue/vite.config.ts new file mode 100644 index 00000000..24d97c0c --- /dev/null +++ b/admin-frontend-vue/vite.config.ts @@ -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, + }, +})