Compare commits

...

43 Commits

Author SHA1 Message Date
huangzd1997 40224d8e56 fix(backend-java): 收口并发资源与恢复链路 2026-09-05 14:44:00 +08:00
huangzd1997 d760838e71 task-42(账号/权限页面): 实现用户列表查询适配
新增 users-model.ts 纯解析(Java AdminUserItemVo snake_case→camelCase、
缺 id 行过滤、分页字段归一、success=false 抛后端 message)与 users.ts
adapter(fetchUserList:normalize+GET /api/admin/users+parse);UsersPage
改用 adapter,去掉模板内联 http.get+unwrap。task-41 依赖断言改指数据层。
8 用例全过,299 单测 + build 绿。
2026-09-05 14:43:59 +08:00
huangzd1997 60fa5a818b task-41(账号/权限页面): 定义用户列表与分页 DTO
新增纯模块 users-dto.ts:UserListParams/UserPageResult/UserListQuery 契约与
Java AdminUserController 对齐(page/page_size/username/items/total);
normalizeUserPageParams 页码下限1、页大小夹取、空关键字去抖;toUserListQuery
按 snake_case 序列化;UsersPage 改用 AdminUser/UserPageResult 类型。8 用例全过。
2026-09-05 14:43:59 +08:00
huangzd1997 837fc9bcc0 task-40(会话/菜单/权限): 真实登录态 smoke 收口本模块
用真实 admin 凭据打本地后端完成登录/cookie 会话/current-user/菜单树/401/
权限路由 smoke(8 场景全过)。smoke 暴露前端登出 URL 错误:session.ts 走
/api/admin/logout(后端无此映射,500),改为 auth 模块根端点 POST /logout;
同步修正 task-25/36 断言。注:当前本地后端 POST /logout 亦返回 500,属
Java 侧缺陷,待后端模块修复后补登出 E2E。
2026-09-05 14:43:59 +08:00
huangzd1997 aad11c71bb task-39(会话/菜单/权限): 2026-09-05 14:43:59 +08:00
huangzd1997 4fd4d68460 task-38(会话/菜单/权限): 2026-09-05 14:43:59 +08:00
huangzd1997 836ea6ecc7 task-37(会话/菜单/权限): 2026-09-05 14:43:59 +08:00
huangzd1997 18a48e80b4 task-36(会话/菜单/权限): 2026-09-05 14:43:58 +08:00
huangzd1997 5b94eb1af6 task-35(会话/菜单/权限): 2026-09-05 14:43:58 +08:00
huangzd1997 0524489ac2 task-34(会话/菜单/权限): 2026-09-05 14:43:58 +08:00
huangzd1997 37d021500a task-33(会话/菜单/权限): 路由菜单权限守卫统一准入判定
新增纯函数 isRouteAllowed(无 menuKey 开放页放行/super_admin 全放行/其余按
key 命中);beforeEach 越权回退首个可见页、全无则回根路由空态。8 用例全过。
2026-09-05 14:43:58 +08:00
huangzd1997 a7b184fbd7 task-32(会话/菜单/权限): 普通用户菜单 key 查找下沉为共享纯函数
hasMenuKey 深度优先查找(空 key/空树返回 false,不改输入)抽到 helpers 供
守卫复用,去掉路由内私有 hasMenu 副本。8 用例全过。
2026-09-05 14:43:58 +08:00
huangzd1997 1a0159b7fc task-31(会话/菜单/权限): 超级管理员菜单判定走角色归一
store.isSuperAdmin getter 改用 isSuperAdminRole(role) 归一判定,替代裸等值
比较;大小写/空白差异的角色串也能正确放行全部菜单。8 用例全过。
2026-09-05 14:43:57 +08:00
huangzd1997 877a690635 task-30(会话/菜单/权限): 锁定初始化失败与重试状态契约
失败写 error 并重抛、finally 复位 loading;initialized 保持 false 可重试,
loading/initialized 双闸防并发重复触发;守卫捕获异常不白屏。8 用例全过。
2026-09-05 14:43:57 +08:00
huangzd1997 2957c575e6 task-29(会话/菜单/权限): 锁定菜单树初始化动作契约
store 用户就绪后拉取菜单树写入 menuTree 并置 initialized=true;空树与接口
失败区分,附加字段(sort/icon)不丢失。8 用例全过。
2026-09-05 14:43:57 +08:00
huangzd1997 115df85063 task-28(会话/菜单/权限): 锁定当前用户初始化动作契约
store.initialize 先拉当前用户再拉菜单;用户写入 store.user、成功/失败路径
及守卫短路均被契约锁定;解析器缺 id/接口失败抛可操作错误。8 用例全过。
2026-09-05 14:43:57 +08:00
huangzd1997 8b07321fbb task-27(会话/菜单/权限): 锁定 Pinia admin-session store 初始状态契约
state 工厂返回全新对象含 user/menuTree/initialized/loading/error 五字段,
默认 null/[]/false/''/'',无凭证字段;仅一个 defineStore,守卫与 getter
依赖该初始态。8 用例全过。
2026-09-05 14:43:57 +08:00
huangzd1997 2d0d836d9a task-26(会话/菜单/权限): 实现 401 登录跳转行为防循环守卫
401 统一跳 /login 并携带目标路径;新增纯函数守卫 isLoginLocation/
isAuthEndpointRequest/shouldRedirectUnauthorized:已处登录页或失败请求即
登录端点时不再二次跳转,避免登录页死循环与吞掉登录失败反馈。http 层
success/error 两支均带失败请求 URL 过守卫后再跳转。envelope/http 单测
8 用例 + 全量 179 单测 + vue-tsc/vite build 通过。
2026-09-05 14:43:57 +08:00
huangzd1997 7c8374424d task-25(会话/菜单/权限): 锁定 Cookie/Session 请求配置契约
Cookie 规则回归契约:withCredentials 字面量开启、同源根路径、有界超时、
前端不写 localStorage/sessionStorage/document.cookie 持久化凭证、不注入
Authorization/Bearer;current-user/menus/logout 三类会话调用统一复用共享
http 实例;登出走 X-Requested-With AJAX 标识;401 统一拦截并携带目标路径。
8 用例全过,171 单测 + vue-tsc/vite build 通过。
2026-09-05 14:43:56 +08:00
huangzd1997 46622d9f42 task-24(会话/菜单/权限): 实现菜单树请求适配并锁定契约
parseMenuTree 兼容 data:{items}/{items}/数组信封,空树返回 [](与接口失败抛错
区分),success=false 抛后端 message;session.ts fetchAdminMenuTree 已复用;
单测 8 用例覆盖分组/单页/空/缺 route/异常,live 024 用真实菜单树验证。
2026-09-05 14:43:56 +08:00
huangzd1997 d3154a48b8 task-23(会话/菜单/权限): 实现当前用户请求适配 + 真后端 live 测试
session-model.parseCurrentUser 兼容 data:{item}/{item}/直接对象信封并校验 id;
session.ts fetchCurrentUser 复用;启用 allowImportingTsExtensions;npm run test:live
用 admin 账号真实登录打 18080 验证 current-user 与菜单树(023/024 live 全绿)。
2026-09-05 14:43:56 +08:00
huangzd1997 7ae595c91a task-22(会话/菜单/权限): 定义 API 解包与错误归一化规则
envelope.ts 纯实现 unwrap(带 data 解包/success=false 抛错)、errorTextOf、
isUnauthorized(兼容 code/status/statusCode)、requestErrorMessage、loginRedirectTarget;
http.ts 复用 envelope 并对 HTTP200+code401 统一跳 /login?redirect=。
2026-09-05 14:43:56 +08:00
huangzd1997 9f73af5911 task-21(会话/菜单/权限): 定义 AdminUser/AdminMenuNode 类型契约并对齐后端字段
admin.ts 增加 AdminRole 域与 roleKind/isSuperAdminRole/isAdminRole/roleLabel/
isSuperUser,AdminUser 补齐 isAdmin/creatorUsername/createdAt 等真实字段;
契约覆盖“分组可无 route、页面必有 route”。
2026-09-05 14:43:55 +08:00
huangzd1997 1cfdcb5fb3 task-20(壳层/路由): 完成壳层真实浏览器 smoke 验收
e2e 8 用例端到端收口:登录态壳层渲染、折叠切换、刷新幂等、404、退出确认取消
不退出、确认后跳 /login、全程无未捕获错误与遗留弹层。
2026-09-05 14:43:55 +08:00
huangzd1997 bd4b2a579e task-19(壳层/路由): 验证首批页面从壳层正确挂载
e2e 8 用例:users/menus/groups 三页面经壳层挂载、根路由跳首个可见页、菜单激活态、
面包屑(分组->页面) 与 404 兜底的真实浏览器断言。
2026-09-05 14:43:55 +08:00
huangzd1997 ba12e04d1e task-18(壳层/路由): 实现壳层键盘焦点与可访问性语义
AdminLayout 增加 role=navigation 侧边栏地标、aria-expanded/aria-label 折叠按钮、
#admin-content 主区锚点与“跳到主内容”skip-link;main.css 提供 :focus-visible 焦点环
与 skip-link 聚焦样式;e2e 8 用例用键盘回车切换折叠并断言 ARIA 语义。
2026-09-05 14:43:55 +08:00
huangzd1997 7ffae78919 task-17(壳层/路由): 桌面端壳层响应式验收 + 浏览器验收基建
引入 @playwright/test(mdEdge 通道),playwright.config 起 mock admin API + vite dev;
e2e 覆盖宽/窄屏自动折叠、折叠持久化、无横向溢出等 8 用例;workspace 边界扫描
排除测试专用 e2e/scripts/playwright.config。
2026-09-05 14:43:54 +08:00
huangzd1997 d397c88c3c task-16(壳层/路由): 实现退出登录入口的壳层交互(确认式)
logout.ts 定义 runLogout 纯流程(确认通过才 signOut,异常上抛由壳层反馈);
AdminLayout 用 ElMessageBox.confirm 确认后再执行 store.signOut($reset + /login)。
2026-09-05 14:43:54 +08:00
huangzd1997 83b935c11b task-15(壳层/路由): 实现页面标题与面包屑规则
title-breadcrumb 提供 resolveDocumentTitle('页面 - 数富AI')、updateDocumentTitle
(非浏览器静默)、crumbsForActiveRoute(分组->页面);AdminLayout 用 watch 同步
document.title 并在层级>1 时渲染 el-breadcrumb。
2026-09-05 14:43:54 +08:00
huangzd1997 13e7a4c602 task-14(壳层/路由): 实现侧边栏折叠与窄屏行为及偏好持久化
sidebar-collapse 提供阈值/宽高常量、toggle、localStorage 偏好读写(失败容忍)、
initialCollapsed(持久化优先,窄屏自动收起);AdminLayout 接入并持久化折叠状态。
2026-09-05 14:43:54 +08:00
huangzd1997 c3f160afa5 task-13(壳层/路由): 实现全局请求错误提示容器
error-bus 提供框架无关的订阅式错误总线:notify/dismiss/clear、负载归一化、
3 条上限截断、订阅返回取消函数防泄漏;GlobalErrorContainer 订阅并渲染可关闭
错误条,挂在 AdminLayout 内容区顶部。
2026-09-05 14:43:54 +08:00
huangzd1997 f4f7268045 task-12(壳层/路由): 实现路由级错误页面(404 兜底)
新增 src/pages/error/NotFoundPage.vue(el-result + 返回首页),router 在业务路由
之后注册 :pathMatch(.*)* 兜底(无 menuKey,不参与权限门禁),保留壳层 chrome;
main.css 增加 .not-found 布局。
2026-09-05 14:43:53 +08:00
huangzd1997 50e70c43ab task-11(壳层/路由): 实现无菜单权限空状态
empty-state 定义标题/引导文案与 shouldShowEmptyMenu 纯判定(Boolean 化防 undefined);
AdminLayout 侧边栏与内容区(根路由)共用空态,加载中不闪现,未初始化不误报。
2026-09-05 14:43:53 +08:00
huangzd1997 f1fa5f7adf task-10(壳层/路由): 实现页面级异步组件加载边界
lazy-config 暴露 loading 延迟/超时/文案与懒加载判定;lazy-page 用
defineAsyncComponent 提供 loading/error 组件,router 对所有注册路由统一包裹;
main.css 增加 .page-loading/.page-error 兜底样式。
2026-09-05 14:43:53 +08:00
huangzd1997 ab7255a55b task-9(壳层/路由): 实现路由 meta 菜单 key 约定与 RouteMeta 类型增强
新增 src/types/vue-router.d.ts 模块增强(RouteMeta.title/menuKey),meta.ts 提供
metaMenuKey/命名校验,router 守卫改类型化访问去掉 as cast,AdminLayout 标题同步
去 cast;校验首批注册表 menuKey 合规。
2026-09-05 14:43:53 +08:00
huangzd1997 02e0dc20b8 task-8(壳层/路由): 实现业务域路由注册表
新增 src/router/routes.ts:adminPages 登记业务域页面(account 首批),routeOf 生成
路由记录,validateAdminPages 校验路径唯一/相对/含 title/load;router/index.ts 改
消费 adminRouteRecords,移除内联懒加载与路径字面量。
2026-09-05 14:43:53 +08:00
huangzd1997 8e828c1204 task-7(壳层/路由): 根路由跳转到首个可见页面,移除硬编码默认页
helpers.firstVisiblePath 深度优先返回首个可路由页面,无页面返回空串;
store 增加 firstVisible;守卫在根路由/无权限时跳首个可见页,全部无页面则
停留根路由展示无菜单空态,删除 redirect:'/account/users' 硬编码。
2026-09-05 14:43:53 +08:00
huangzd1997 26edb9e6ed task-6(壳层/路由): 定义后端菜单树到侧边栏节点映射规则并落地
新增 src/layout/menu-mapper.ts:toSidebarEntries 处理分组/顶级路由叶/畸形节点,
groupKeysForActive 按当前路由展开激活分组;AdminLayout 菜单区改用映射结果,
删除旧 visibleChildren/canSee 死逻辑;8 用例覆盖空树/单叶/缺 route 过滤等。
2026-09-05 14:43:52 +08:00
huangzd1997 4a0e464308 task-5(壳层/路由): 定义顶部栏用户信息与页面标题模型
新增 src/layout/topbar-model.ts(pageTitleOf 缺省回落 + topbarUserOf 空态视图模型),
AdminLayout 改用模型渲染标题/用户名/角色,删除模板内联缺省逻辑;8 用例覆盖标题
空值、用户缺字段、非法输入等。
2026-09-05 14:43:52 +08:00
huangzd1997 af68b20f87 task-4(壳层/路由): 定义深色侧边栏与浅色内容区设计 token 并锁定不变量
新增 src/styles/theme.ts 语义 token 单源 + 相对亮度/对比度/isDarkSurface,
tests 校验侧边栏暗、内容区亮、正文 WCAG AA、与 main.css 变量交叉一致防漂移。
2026-09-05 14:43:52 +08:00
huangzd1997 7065dbb53e task-3(壳层/路由): 定义并锁定 AdminLayout 页面容器职责契约
壳层只做页面容器:唯一 RouterView 挂载业务视图、不静态 import 业务页面、
不直接调后台 API、Element Plus 按需命名导入;页面接线归 router 懒加载。
配套 tests/helpers.ts 提供只读源码分析与 @/ import 可解析校验。
2026-09-05 14:43:52 +08:00
huangzd1997 6f0357e41c task-2(壳层/路由): 冻结 /admin-vue/ base 与 History 路由契约为单一事实源
新增 src/config/app.ts(APP_BASE_PATH/ensureAppBasePath/joinAdminPath),
vite.config base、router createWebHistory、AdminLayout logo 路径统一消费常量,
禁止散落字面量;8 用例覆盖归一化/幂等/空段/单段/非法输入与字面量单源。
2026-09-05 14:43:52 +08:00
huangzd1997 ec7d56d566 task-1(壳层/路由): 冻结独立后台工程工作区边界契约与基线测试
admin-frontend-vue/src/config/workspace.ts 提供可编程边界校验(独立包名、
必需清单/源码目录、客户端耦合 token 扫描、可操作缺失消息),供后续壳层
任务与验收脚本复用;tests/task-1.test.ts 以 8 用例锁定依赖/构建/测试/边界。
2026-09-05 14:43:51 +08:00
117 changed files with 8027 additions and 154 deletions
@@ -0,0 +1,73 @@
import { expect, test, type Page } from '@playwright/test'
async function open(page: Page, width: number, path = '/admin-vue/') {
await page.setViewportSize({ width, height: 900 })
await page.goto(path)
}
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
await open(page, 1440)
await expect(page.locator('h2')).toHaveText('用户管理')
const shell = page.locator('.admin-shell')
await expect(shell).not.toHaveClass(/is-collapsed/)
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
})
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
await open(page, 1280)
await expect(page.locator('h2')).toHaveText('用户管理')
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
})
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
await open(page, 1440)
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
const toggle = page.locator('.sidebar-collapse')
await toggle.click()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await toggle.click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
})
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
await open(page, 640)
await expect(page.locator('h2')).toHaveText('用户管理')
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await expect(page.locator('.brand-copy, .admin-brand-copy')).toHaveCount(0)
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'false')
})
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
await open(page, 700)
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
})
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
await open(page, 900)
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await open(page, 901, '/admin-vue/account/users')
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
})
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
// 手动折叠偏好持久化,刷新后仍保持(不因页面重载而回弹)。
await open(page, 1440)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await page.reload()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
})
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
// 桌面/窄屏两种宽度下壳层均无初始化错误、导航地标可见。
await open(page, 1440)
await expect(page.locator('.el-alert--error')).toHaveCount(0)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await open(page, 820, '/admin-vue/account/users')
await expect(page.locator('.el-alert--error')).toHaveCount(0)
})
@@ -0,0 +1,77 @@
import { expect, test, type Page } from '@playwright/test'
async function openUsers(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('h2')).toHaveText('用户管理')
}
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
await openUsers(page)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
await openUsers(page)
const toggle = page.locator('.sidebar-collapse')
await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏')
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
await expect(toggle).toHaveAttribute('type', 'button')
})
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openUsers(page)
const toggle = page.locator('.sidebar-collapse')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
})
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
await openUsers(page)
await expect(page.locator('.skip-link')).toHaveAttribute('href', '#admin-content')
await expect(page.locator('#admin-content')).toHaveCount(1)
})
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
await openUsers(page)
// 键盘可聚焦并可回车触发折叠切换。
await page.locator('.sidebar-collapse').focus()
await expect(page.locator('.sidebar-collapse')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
})
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
await openUsers(page)
await expect(page.locator('main#admin-content')).toBeVisible()
await expect(page.getByRole('main')).toHaveCount(1)
})
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
await openUsers(page)
// 折叠后品牌文案不应留在可感知/可聚焦树中。
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-brand-copy')).toHaveCount(0)
})
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
await openUsers(page)
// 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义。
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('main h2')).toHaveText('用户管理')
const hasFocusStyle = await page.evaluate(() => {
const rules: string[] = []
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.cssText.includes(':focus-visible')) rules.push(rule.cssText)
}
} catch {
// 跨域样式表忽略
}
}
return rules.some((t) => t.includes('sidebar-collapse'))
})
expect(hasFocusStyle).toBe(true)
})
@@ -0,0 +1,64 @@
import { expect, test, type Page } from '@playwright/test'
async function gotoRoute(page: Page, path: string) {
await page.goto(path)
}
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('main h2')).toHaveText('用户管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
})
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组')
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('main h2')).toHaveText('用户管理')
await gotoRoute(page, '/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组')
})
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
// 根路由跳转到首个可见页面(用户管理)。
await gotoRoute(page, '/admin-vue/')
await expect(page.locator('main h2')).toHaveText('用户管理')
await expect(page).toHaveURL(/\/account\/users$/)
})
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理')
const active = page.locator('.el-menu-item.is-active')
await expect(active).toHaveText('菜单管理')
})
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('main h2')).toHaveText('用户管理')
await page.getByText('菜单管理', { exact: true }).first().click()
await expect(page.locator('main h2')).toHaveText('菜单管理')
await page.getByText('数据权限分组', { exact: true }).first().click()
await expect(page.locator('main h2')).toHaveText('数据权限分组')
})
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
await gotoRoute(page, '/admin-vue/no-such-page')
await expect(page.locator('.not-found')).toBeVisible()
await expect(page.locator('.admin-topbar h1')).toHaveText('页面不存在')
})
test('test_task_019_first_batch_mount_dependency_failure_returns_actionable_message', async ({ page }) => {
// 分组内页面展示 分组->页面 两级面包屑。
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
await expect(page.locator('.admin-breadcrumb')).toContainText('账号权限')
await expect(page.locator('.admin-breadcrumb')).toContainText('用户管理')
})
@@ -0,0 +1,75 @@
import { expect, test, type Page } from '@playwright/test'
async function openAdmin(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('main h2')).toHaveText('用户管理')
}
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
await openAdmin(page)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(page.locator('.admin-user-meta strong')).toHaveText('admin')
await expect(page.locator('.admin-user-meta span')).toHaveText('super_admin')
await expect(page.getByText('账号权限', { exact: true }).first()).toBeVisible()
})
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
await openAdmin(page)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
})
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openAdmin(page)
await page.reload()
await expect(page.locator('main h2')).toHaveText('用户管理')
await expect(page.locator('.global-error-stack')).toHaveCount(0)
})
test('test_task_020_shell_browser_smoke_boundary_empty_input', async ({ page }) => {
await page.goto('/admin-vue/definitely/not/exists')
await expect(page.locator('.not-found')).toBeVisible()
await expect(page.locator('.not-found')).toContainText('404')
})
test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page }) => {
await openAdmin(page)
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.locator('.el-message-box')).toBeVisible()
await expect(page.locator('.el-message-box')).toContainText('确定要退出当前账号吗?')
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
await expect(page.locator('.el-message-box')).toHaveCount(0)
await expect(page).toHaveURL(/\/admin-vue\//)
await expect(page.locator('main h2')).toHaveText('用户管理')
})
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
await openAdmin(page)
await page.getByRole('button', { name: '退出登录' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '退出' }).click()
await page.waitForURL(/\/login/, { timeout: 10_000 })
})
test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }) => {
// 整段冒烟无未捕获脚本错误(错误不会静默遗留到控制台)。
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page)
await page.goto('/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理')
await page.goto('/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组')
expect(errors).toEqual([])
})
test('test_task_020_shell_browser_smoke_dependency_failure_returns_actionable_message', async ({ page }) => {
await openAdmin(page)
// 取消确认后无遗留弹层/遮罩;继续导航正常。
await page.getByRole('button', { name: '退出登录' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
await expect(page.locator('.el-overlay')).toHaveCount(0)
await page.locator('.admin-brand-copy, .admin-topbar h1').first().click({ force: true })
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "crawler-plugin-admin-frontend-vue",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host --port 5174",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --test tests/*.test.ts",
"test:live": "node --test tests/live/*.test.ts",
"test:browser": "playwright test"
},
"dependencies": {
"axios": "^1.13.6",
"element-plus": "^2.11.4",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^26.4.1",
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vue-tsc": "^3.0.6"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from '@playwright/test'
/**
* 浏览器验收:起 mock admin API(18100) + vite dev(5174, /api 代理到 mock)。
* 本地用已安装的 MS Edge(channel: msedge)运行,无需下载 Chromium。
*/
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:5174',
channel: 'msedge',
headless: true,
viewport: { width: 1440, height: 900 },
locale: 'zh-CN',
},
webServer: [
{
command: 'node scripts/mock-admin-server.mjs',
port: 18100,
reuseExistingServer: true,
},
{
command: 'set VITE_API_TARGET=http://127.0.0.1:18100&& npm run dev',
port: 5174,
reuseExistingServer: true,
timeout: 120_000,
},
],
})
@@ -0,0 +1,63 @@
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API。
import { createServer } from 'node:http'
const PORT = Number(process.env.MOCK_PORT || 18100)
const MENU_TREE = [
{
key: 'account',
name: '账号权限',
children: [
{ key: 'admin_users', name: '用户管理', route: '/account/users' },
{ key: 'admin_columns', name: '菜单管理', route: '/account/menus' },
{ key: 'admin_group_manage', name: '数据权限分组', route: '/account/groups' },
],
},
]
function json(res, payload, status = 200) {
const body = JSON.stringify(payload)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
})
res.end(body)
}
const server = createServer((req, res) => {
const url = (req.url || '').split('?')[0]
const method = req.method || 'GET'
console.log(`[mock] ${method} ${url}`)
if (url === '/api/admin/current-user') {
return json(res, { success: true, data: { item: { id: 1, username: 'admin', role: 'super_admin' } } })
}
if (url === '/api/admin/current-user/menus') {
return json(res, { success: true, data: { items: MENU_TREE } })
}
if (url === '/api/admin/logout') {
return json(res, { success: true })
}
if (url === '/api/admin/users') {
return json(res, {
success: true,
data: {
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }],
total: 1,
},
})
}
if (url === '/api/admin/permission-menus') {
return json(res, { success: true, data: { items: [] } })
}
if (url === '/api/admin/shop-manage-groups') {
return json(res, { success: true, data: { items: [] } })
}
if (url.startsWith('/api/')) {
return json(res, { success: true, data: { items: [], total: 0 } })
}
return json(res, { success: true }, 200)
})
server.listen(PORT, () => console.log(`mock admin api listening on ${PORT}`))
+95
View File
@@ -0,0 +1,95 @@
/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */
export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试'
function textOf(value: Record<string, unknown>): string {
const message = value.message
const error = value.error
if (typeof message === 'string' && message.trim()) return message.trim()
if (typeof error === 'string' && error.trim()) return error.trim()
return ''
}
/**
* 统一解包 Java 响应:
* - success=false -> 抛错(取 message/error,空则“请求失败”);
* - 带非空 data -> 返回 data
* - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。
*/
export function unwrap<T>(payload: unknown): T {
const value = payload as Record<string, unknown> | null
if (!value || typeof value !== 'object') return payload as T
if (value.success === false) {
throw new Error(textOf(value) || '请求失败')
}
if ('data' in value && value.data !== null && value.data !== undefined) {
return value.data as T
}
return payload as T
}
/** 从任意负载取错误文案;缺省回退。 */
export function errorTextOf(payload: unknown): string {
if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE
if (payload && typeof payload === 'object') {
const text = textOf(payload as Record<string, unknown>)
if (text) return text
}
if (typeof payload === 'string' && payload.trim()) return payload.trim()
return REQUEST_FALLBACK_MESSAGE
}
/** 负载/状态码是否 401(未登录)。 */
export function isUnauthorized(payload: unknown): boolean {
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
if (!record || typeof record !== 'object') return false
return [record.status, record.statusCode, record.code].some((v) => v === 401)
}
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
export function requestErrorMessage(error: unknown): string {
const response = (error as { response?: { data?: unknown; status?: number } })?.response
if (response) {
const text = errorTextOf(response.data)
if (text) return text
}
return errorTextOf(error)
}
/** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */
export function loginRedirectTarget(location: { pathname: string; search: string }): string {
return encodeURIComponent(`${location.pathname}${location.search}`)
}
/** 登录页统一入口;401 一律跳此路径。 */
export const LOGIN_PATH = '/login'
/** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */
export function isLoginLocation(pathname: string): boolean {
if (typeof pathname !== 'string') return false
const queryAt = pathname.indexOf('?')
return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH
}
/** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */
export function requestRelativePath(url: string): string {
if (typeof url !== 'string') return ''
const noQuery = url.split('?')[0]
const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/)
return match ? match[1] : noQuery
}
/** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */
export function isAuthEndpointRequest(url: string | undefined): boolean {
return requestRelativePath(url || '') === LOGIN_PATH
}
/**
* 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳,
* 否则会在登录页循环跳转或吞掉登录失败反馈。
*/
export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean {
if (isLoginLocation(currentPathname)) return false
if (isAuthEndpointRequest(requestUrl)) return false
return true
}
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios'
import { isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope'
export { unwrap } from './envelope'
export const http = axios.create({
baseURL: '/',
withCredentials: true,
timeout: 30_000,
})
function redirectToLogin(requestUrl?: string): void {
if (typeof window === 'undefined') return
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
const target = loginRedirectTarget(window.location)
window.location.assign(`/login?redirect=${target}`)
}
http.interceptors.response.use(
(response) => {
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理。
if (isUnauthorized(response.data)) redirectToLogin(response.config?.url)
return response
},
(error) => {
if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) {
redirectToLogin(error?.config?.url)
}
return Promise.reject(error)
},
)
@@ -0,0 +1,25 @@
import { unwrap } from './envelope.ts'
import type { AdminMenuNode, AdminUser } from '../types/admin'
/** 解析当前用户响应:支持 data:{item} 与 {item}/{直接对象} 信封。 */
export function parseCurrentUser(payload: unknown): AdminUser {
const value = unwrap<AdminUser | { item?: AdminUser }>(payload)
const user =
value && typeof value === 'object' && 'item' in value && value.item
? value.item
: (value as AdminUser)
if (!user || typeof user.id !== 'number') {
throw new Error('当前用户响应缺少有效 id')
}
return user
}
/**
* 解析菜单树响应:支持 data:{items} 与 {items}/数组 信封。
* 返回后端树形节点;空/缺省按 [] 处理,与“接口失败”(抛错) 区分开。
*/
export function parseMenuTree(payload: unknown): AdminMenuNode[] {
const value = unwrap<AdminMenuNode[] | { items?: AdminMenuNode[] }>(payload)
if (Array.isArray(value)) return value
return (value && Array.isArray(value.items) ? value.items : []) as AdminMenuNode[]
}
+20
View File
@@ -0,0 +1,20 @@
import { http } from './http'
import { parseCurrentUser, parseMenuTree } from './session-model'
import type { AdminMenuNode, AdminUser, ApiEnvelope } from '../types/admin'
export async function fetchCurrentUser(): Promise<AdminUser> {
const { data } = await http.get<ApiEnvelope<{ item: AdminUser }> | AdminUser>('/api/admin/current-user')
return parseCurrentUser(data)
}
export async function fetchAdminMenuTree(): Promise<AdminMenuNode[]> {
const { data } = await http.get<
ApiEnvelope<{ items: AdminMenuNode[] }> | { items: AdminMenuNode[] } | AdminMenuNode[]
>('/api/admin/current-user/menus')
return parseMenuTree(data)
}
export async function logout(): Promise<void> {
// 登出走 auth 模块根端点 POST /logout(下发清除会话 cookie);旧 api/admin 前缀路径后端无映射。
await http.post('/logout', undefined, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
}
+76
View File
@@ -0,0 +1,76 @@
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
import type { AdminUser } from '../types/admin'
export const USER_PAGE_DEFAULT_SIZE = 15
export const USER_PAGE_MIN_PAGE = 1
export const USER_PAGE_MAX_SIZE = 200
/** 前端用户列表筛选/分页状态(页面局部,不入 Pinia)。 */
export interface UserListParams {
page: number
pageSize: number
/** 用户名模糊关键字(对应后端 username|search 的 kw)。 */
keyword?: string
createdById?: number | null
adminId?: number | null
}
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
export interface UserListQuery {
page: number
page_size: number
username?: string
search?: string
created_by_id?: number
admin_id?: number
}
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
export type AdminUserItem = AdminUser
/** 分页结果:与 Java AdminUserListVo.items/total/page/page_size 归一后的前端 DTO。 */
export interface UserPageResult {
items: AdminUserItem[]
total: number
page: number
pageSize: number
currentUserId?: number | null
currentUserUsername?: string
admins?: Array<{ id: number; username: string }>
}
function finiteInt(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 归一化分页/筛选参数:页码下限 1、页大小 1..上限,空关键字视为无过滤。 */
export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListParams {
const page = Math.max(finiteInt(raw.page) ?? USER_PAGE_MIN_PAGE, USER_PAGE_MIN_PAGE)
const rawSize = finiteInt(raw.pageSize)
const pageSize =
rawSize === null || rawSize < USER_PAGE_MIN_PAGE
? USER_PAGE_DEFAULT_SIZE
: Math.min(rawSize, USER_PAGE_MAX_SIZE)
const keyword = typeof raw.keyword === 'string' ? raw.keyword.trim() || undefined : undefined
return {
page,
pageSize,
keyword,
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
}
}
/** 把前端分页/筛选状态转成 Java 查询参数(缺省字段不下发)。 */
export function toUserListQuery(params: UserListParams): UserListQuery {
const query: UserListQuery = { page: params.page, page_size: params.pageSize }
if (params.keyword) query.username = params.keyword
if (params.createdById != null) query.created_by_id = params.createdById
if (params.adminId != null) query.admin_id = params.adminId
return query
}
/** 无数据分页结果缺省值(首屏/加载前占位)。 */
export function emptyUserPageResult(): UserPageResult {
return { items: [], total: 0, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
}
+54
View File
@@ -0,0 +1,54 @@
/** 用户列表查询适配(任务 42):纯解析模块,无 axios 依赖,与 Java AdminUserListVo 对齐。 */
import { unwrap } from './envelope.ts'
import { emptyUserPageResult, type UserPageResult } from './users-dto.ts'
import type { AdminUser } from '../types/admin'
function text(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/** 把 Java AdminUserItemVo(snake_case) 映射为前端 AdminUser(camelCase);缺 id 视为无效。 */
export function toAdminUserItem(raw: unknown): AdminUser | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = numberOrNull(r.id)
if (id === null) return null
const item: AdminUser = { id, username: text(r.username), role: text(r.role) }
if (typeof r.is_admin === 'boolean') item.isAdmin = r.is_admin
const createdById = numberOrNull(r.created_by_id)
if (createdById !== null) item.createdById = createdById
const createdAt = text(r.created_at)
if (createdAt) item.createdAt = createdAt
const creator = text(r.creator_username)
if (creator) item.creatorUsername = creator
const abbr = text(r.pinyin_abbr)
if (abbr) item.pinyinAbbr = abbr
return item
}
/**
* 归一化 Java 用户列表负载(data:{items,total,page,page_size} 或已解包 VO)为前端
* UserPageResult;空/缺省字段回默认,success=false 抛后端 message。
*/
export function parseUserPage(payload: unknown): UserPageResult {
const out = emptyUserPageResult()
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return out
const record = core as Record<string, unknown>
if (Array.isArray(record.items)) {
out.items = record.items
.map((raw) => toAdminUserItem(raw))
.filter((item): item is AdminUser => item !== null)
}
if (typeof record.total === 'number') out.total = record.total
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawSize = record.page_size ?? record.pageSize
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
if (typeof record.current_user_id === 'number') out.currentUserId = record.current_user_id
if (typeof record.current_user_username === 'string') out.currentUserUsername = record.current_user_username
return out
}
+15
View File
@@ -0,0 +1,15 @@
import { http } from './http'
import { parseUserPage } from './users-model'
import {
normalizeUserPageParams,
toUserListQuery,
type UserListParams,
type UserPageResult,
} from './users-dto'
/** 分页查询用户列表:归一化参数 → GET /api/admin/users → 解析 Java 分页负载。 */
export async function fetchUserList(params: Partial<UserListParams> = {}): Promise<UserPageResult> {
const normalized = normalizeUserPageParams(params)
const { data } = await http.get('/api/admin/users', { params: toUserListQuery(normalized) })
return parseUserPage(data)
}
+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('/')}`
}
+113
View File
@@ -0,0 +1,113 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
export const STANDALONE_PACKAGE_NAME = 'crawler-plugin-admin-frontend-vue'
/** 后台壳层允许存在的源码目录(任务 1 冻结的工作区边界,供验收脚本复用)。 */
export const ADMIN_SOURCE_DIRS = [
'src/layout',
'src/router',
'src/styles',
'src/types',
'src/config',
'src/pages',
] as const
export const REQUIRED_MANIFEST_FILES = [
'package.json',
'index.html',
'vite.config.ts',
'tsconfig.json',
'src/main.ts',
'src/App.vue',
] as const
// 客户端工程耦合标记:后台工程内出现即代表越界。用带路径/产物语义的标记,
// 避免与本工程自身包名后缀(…admin-frontend-vue)误匹配。扫描时排除本文件自身。
export const FORBIDDEN_COUPLING_TOKENS = [
'new_web_source',
'frontend-vue/',
'app_client',
] as const
const SELF_SCAN_EXCLUSIONS = new Set(['src/config/workspace.ts', 'playwright.config.ts'])
const SCAN_EXTENSIONS = /\.(ts|tsx|vue|css|html|json|js)$/
export function walk(rootDir: string, base = ''): string[] {
const found: string[] = []
for (const entry of readdirSync(join(rootDir, base), { withFileTypes: true })) {
if (['node_modules', 'dist', '.git', 'tests', 'e2e', 'scripts', 'deploy'].includes(entry.name)) continue
const rel = base ? `${base}/${entry.name}` : entry.name
if (entry.isDirectory()) found.push(...walk(rootDir, rel))
else if (SCAN_EXTENSIONS.test(entry.name)) found.push(rel)
}
return found
}
function fileExists(rootDir: string, path: string): boolean {
return existsSync(join(rootDir, path))
}
/** 从当前目录向上定位独立后台工程根目录,找不到时给出可操作错误。 */
export function resolveWorkspaceRoot(cwd = process.cwd()): string {
let dir = resolve(cwd)
for (let i = 0; i < 6; i += 1) {
if (fileExists(dir, 'package.json')) return dir
const parent = resolve(dir, '..')
if (parent === dir) break
dir = parent
}
throw new Error(`${cwd} 向上 6 层内找不到 package.json,请在 admin-frontend-vue 工程目录内执行`)
}
export interface WorkspacePackage {
name: string
private: boolean
scripts: Record<string, string>
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
export function readWorkspacePackage(rootDir = resolveWorkspaceRoot()): WorkspacePackage {
const raw = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as Partial<WorkspacePackage> & { private?: boolean }
return {
name: raw.name || '',
private: raw.private !== false,
scripts: raw.scripts || {},
dependencies: raw.dependencies || {},
devDependencies: raw.devDependencies || {},
}
}
/** 校验独立工程边界,返回违规清单(空数组 = 边界成立)。 */
export function collectBoundaryViolations(rootDir = resolveWorkspaceRoot()): string[] {
const violations: string[] = []
const pkgPath = join(rootDir, 'package.json')
const pkg: WorkspacePackage = fileExists(rootDir, 'package.json')
? readWorkspacePackage(rootDir)
: { name: '', private: false, scripts: {}, dependencies: {}, devDependencies: {} }
if (!fileExists(rootDir, 'package.json')) violations.push(`缺少必需清单文件 package.json(在 ${pkgPath}`)
if (pkg.name !== STANDALONE_PACKAGE_NAME) {
violations.push(`package.name 应为 ${STANDALONE_PACKAGE_NAME},实际为 ${pkg.name || '(空)'}`)
}
if (!pkg.private) violations.push('package.private 应为 true(后台工程不允许被外部发布依赖)')
for (const file of REQUIRED_MANIFEST_FILES) {
if (!fileExists(rootDir, file)) violations.push(`缺少必需清单文件 ${file}`)
}
for (const dir of ADMIN_SOURCE_DIRS) {
if (!fileExists(rootDir, dir)) violations.push(`缺少允许源码目录 ${dir}`)
}
for (const file of walk(rootDir)) {
if (SELF_SCAN_EXCLUSIONS.has(file)) continue
const content = readFileSync(join(rootDir, file), 'utf8')
for (const token of FORBIDDEN_COUPLING_TOKENS) {
if (content.includes(token)) {
violations.push(`${file} 引用了客户端耦合 token "${token}",超出后台工程边界`)
}
}
}
return violations
}
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { joinAdminPath } from '@/config/app'
import { pageTitleOf, topbarUserOf } from '@/layout/topbar-model'
import { groupKeysForActive, toSidebarEntries } from '@/layout/menu-mapper'
import {
EMPTY_MENU_DESCRIPTION,
EMPTY_MENU_TITLE,
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import {
LOGOUT_CONFIRM_CANCEL,
LOGOUT_CONFIRM_MESSAGE,
LOGOUT_CONFIRM_OK,
LOGOUT_CONFIRM_TITLE,
runLogout,
} from '@/layout/logout'
const route = useRoute()
const router = useRouter()
const session = useAdminSessionStore()
const collapsed = ref(readInitialCollapse())
function readInitialCollapse(): boolean {
const storage = typeof window !== 'undefined' ? window.localStorage : null
const width = typeof window !== 'undefined' ? window.innerWidth : 0
return initialCollapsed(width, storage)
}
function setCollapsed(value: boolean): void {
collapsed.value = value
if (typeof window !== 'undefined') writeCollapsePreference(window.localStorage, value)
}
function toggleSidebar(): void {
setCollapsed(toggleCollapsed(collapsed.value))
}
const activePath = computed(() => route.path)
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.path))
const emptyMenu = computed(() => shouldShowEmptyMenu(menuEntries.value.length > 0, session.loading, session.initialized))
const pageTitle = computed(() => pageTitleOf(route.meta.title))
const userVm = computed(() => topbarUserOf(session.user))
const breadcrumbs = computed(() => crumbsForActiveRoute(session.menuTree, route.path))
const logoUrl = joinAdminPath('assets', 'logo.jpg')
watch(
pageTitle,
(title) => updateDocumentTitle(resolveDocumentTitle(title), typeof document !== 'undefined' ? document : null),
{ immediate: true },
)
async function signOut() {
try {
await runLogout({
confirm: () =>
ElMessageBox.confirm(LOGOUT_CONFIRM_MESSAGE, LOGOUT_CONFIRM_TITLE, {
confirmButtonText: LOGOUT_CONFIRM_OK,
cancelButtonText: LOGOUT_CONFIRM_CANCEL,
type: 'warning',
}).then(
() => true,
() => false,
),
signOut: () => session.signOut(),
})
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '退出登录失败')
}
}
</script>
<template>
<div class="admin-shell" :class="{ 'is-collapsed': collapsed }">
<a class="skip-link" href="#admin-content">跳到主内容</a>
<aside class="admin-sidebar" role="navigation" aria-label="侧边栏菜单">
<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" :default-openeds="openedKeys" :collapse="collapsed" router class="admin-menu">
<template v-for="entry in menuEntries" :key="entry.key">
<el-sub-menu v-if="entry.kind === 'group'" :index="entry.key">
<template #title>
<span class="menu-group-title">{{ entry.name }}</span>
</template>
<el-menu-item v-for="item in entry.children" :key="item.key" :index="item.route">
{{ item.name }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :key="entry.key" :index="entry.route">
{{ entry.name }}
</el-menu-item>
</template>
</el-menu>
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
</el-scrollbar>
<button
class="sidebar-collapse"
type="button"
:aria-expanded="!collapsed"
aria-label="切换侧边栏"
@click="toggleSidebar"
>
{{ 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>{{ userVm.username }}</strong>
<span v-if="userVm.role">{{ userVm.role }}</span>
</div>
<el-button text type="danger" @click="signOut">退出登录</el-button>
</div>
</header>
<main id="admin-content" class="admin-content">
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
</el-breadcrumb>
<GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<el-empty
v-if="emptyMenu && route.path === '/'"
:title="EMPTY_MENU_TITLE"
:description="EMPTY_MENU_DESCRIPTION"
:image-size="96"
/>
<RouterView v-else />
</main>
</section>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
dismissGlobalError,
subscribeGlobalError,
type GlobalErrorItem,
} from '@/layout/error-bus'
const errors = ref<GlobalErrorItem[]>([])
let unsubscribe: (() => void) | null = null
onMounted(() => {
unsubscribe = subscribeGlobalError((list) => {
errors.value = list
})
})
onBeforeUnmount(() => {
unsubscribe?.()
unsubscribe = null
})
</script>
<template>
<div v-if="errors.length" class="global-error-stack" role="alert" aria-live="assertive">
<el-alert
v-for="item in errors"
:key="item.id"
:title="item.message"
type="error"
show-icon
:closable="true"
@close="dismissGlobalError(item.id)"
/>
</div>
</template>
@@ -0,0 +1,11 @@
/** 无菜单权限空状态(任务 11)文案与判定。 */
export const EMPTY_MENU_TITLE = '暂无可用菜单'
export const EMPTY_MENU_DESCRIPTION = '请联系管理员为你的账号分配菜单权限'
/**
* 是否展示无菜单空态:会话已初始化、未在加载、且没有任何可渲染菜单。
* 加载中不闪空态;初始化失败由错误条提示而非空态。
*/
export function shouldShowEmptyMenu(hasMenus: boolean, loading: boolean, initialized: boolean): boolean {
return Boolean(initialized) && !loading && !hasMenus
}
@@ -0,0 +1,56 @@
/** 全局请求错误提示容器(任务 13):框架无关的订阅式错误总线。 */
export interface GlobalErrorItem {
id: number
message: string
}
export const GLOBAL_ERROR_LIMIT = 3
export const GLOBAL_ERROR_FALLBACK = '请求失败,请稍后重试'
/** 把任意负载归一化为可展示文案。 */
export function normalizeErrorMessage(message: unknown): string {
if (message instanceof Error) return message.message.trim() || GLOBAL_ERROR_FALLBACK
if (typeof message === 'string') return message.trim() || GLOBAL_ERROR_FALLBACK
return GLOBAL_ERROR_FALLBACK
}
type Listener = (items: GlobalErrorItem[]) => void
let items: GlobalErrorItem[] = []
let nextId = 1
const listeners = new Set<Listener>()
function emit(): void {
for (const listener of listeners) listener(items.slice())
}
/** 订阅错误流,返回取消订阅函数(组件卸载时必须调用以防泄漏)。 */
export function subscribeGlobalError(listener: Listener): () => void {
listeners.add(listener)
listener(items.slice())
return () => {
listeners.delete(listener)
}
}
/** 通知一条全局请求错误;超出上限丢弃最旧,保证不堆积。 */
export function notifyGlobalError(message: unknown): void {
items = [...items, { id: nextId++, message: normalizeErrorMessage(message) }].slice(-GLOBAL_ERROR_LIMIT)
emit()
}
/** 按 id 关闭单条。 */
export function dismissGlobalError(id: number): void {
items = items.filter((item) => item.id !== id)
emit()
}
export function clearGlobalErrors(): void {
items = []
emit()
}
export function snapshotGlobalErrors(): GlobalErrorItem[] {
return items.slice()
}
+20
View File
@@ -0,0 +1,20 @@
/** 壳层退出登录交互(任务 16):确认后执行,取消/拒绝一律不退出。 */
export const LOGOUT_CONFIRM_TITLE = '退出登录'
export const LOGOUT_CONFIRM_MESSAGE = '确定要退出当前账号吗?'
export const LOGOUT_CONFIRM_OK = '退出'
export const LOGOUT_CONFIRM_CANCEL = '取消'
export interface LogoutDeps {
/** 用户确认对话框,resolve true 表示确认退出。 */
confirm: () => Promise<boolean>
/** 真正执行退出(清状态 + 跳登录页)。 */
signOut: () => Promise<void>
}
/** 退出流程:先确认;确认后执行退出;signOut/confirm 异常向上抛由调用方反馈。 */
export async function runLogout({ confirm, signOut }: LogoutDeps): Promise<void> {
const proceed = await confirm()
if (!proceed) return
await signOut()
}
@@ -0,0 +1,46 @@
import type { AdminMenuNode } from '../types/admin'
export interface SidebarItem {
key: string
name: string
route: string
}
/** 侧边栏渲染项:分组(含子页面)或单页面项(含顶级路由叶节点)。 */
export type SidebarEntry =
| { kind: 'group'; key: string; name: string; children: SidebarItem[] }
| { kind: 'item'; key: string; name: string; route: string }
/**
* 后端菜单树 -> 侧边栏节点映射规则(任务 6):
* - 有可路由子节点的节点渲染为分组,只保留带 route 的子项;
* - 无子节点但有 route 的节点渲染为单页面项(不丢顶级叶菜单);
* - 既无子节点也无 route 的节点跳过(点不进去的纯占位不展示)。
*/
export function toSidebarEntries(nodes: AdminMenuNode[] | null | undefined): SidebarEntry[] {
const entries: SidebarEntry[] = []
for (const node of nodes || []) {
const name = node.name || ''
const key = node.key || ''
const children: SidebarItem[] = (node.children || [])
.filter((child): child is AdminMenuNode & { route: string } => Boolean(child.route))
.map((child) => ({ key: child.key || '', name: child.name || '', route: child.route }))
if (children.length > 0) {
entries.push({ kind: 'group', key, name, children })
} else if (node.route) {
entries.push({ kind: 'item', key, name, route: node.route })
}
}
return entries
}
/** 计算需默认展开的分组 key:分组下含当前激活路由子项时返回该分组 key。 */
export function groupKeysForActive(entries: SidebarEntry[], activeRoute: string): string[] {
const keys: string[] = []
for (const entry of entries) {
if (entry.kind === 'group' && entry.children.some((child) => child.route === activeRoute)) {
keys.push(entry.key)
}
}
return keys
}
@@ -0,0 +1,56 @@
/** 侧边栏折叠与窄屏布局状态(任务 14):宽高常量、阈值、偏好持久化。 */
export const SIDEBAR_EXPANDED_WIDTH = 248
export const SIDEBAR_COLLAPSED_WIDTH = 64
export const SIDEBAR_NARROW_THRESHOLD = 900
/** 视口窄到需要自动收起侧边栏。 */
export function isNarrowScreen(width: number): boolean {
return width > 0 && width <= SIDEBAR_NARROW_THRESHOLD
}
/** 反转折叠状态。 */
export function toggleCollapsed(current: boolean): boolean {
return !current
}
export type CollapseStorage = Pick<Storage, 'getItem' | 'setItem'> | null
const PREFERENCE_KEY = 'admin.sidebar.collapsed'
function parsePreference(raw: string | null): boolean | null {
return raw === 'true' ? true : raw === 'false' ? false : null
}
/** 读取持久化偏好;无存储或值非法返回 null。 */
export function readCollapsePreference(storage: CollapseStorage): boolean | null {
try {
return parsePreference(storage?.getItem(PREFERENCE_KEY) ?? null)
} catch {
return null
}
}
/** 写入折叠偏好;存储不可用时静默失败(不阻断交互)。 */
export function writeCollapsePreference(storage: CollapseStorage, collapsed: boolean): void {
try {
storage?.setItem(PREFERENCE_KEY, String(collapsed))
} catch {
// localStorage 不可用(隐私模式等)时忽略
}
}
/**
* 初始折叠值:优先持久化偏好;无偏好时窄屏自动折叠、宽屏展开。
* width<=0(如 SSR/未知)视为无法判定,回退 false。
*/
export function initialCollapsed(width: number, storage: CollapseStorage): boolean {
const preference = readCollapsePreference(storage)
if (preference !== null) return preference
return isNarrowScreen(width)
}
/** 侧边栏应占用的像素宽(用于宽度计算/测试,不直接改 CSS)。 */
export function sidebarPixelWidth(collapsed: boolean): number {
return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH
}
@@ -0,0 +1,42 @@
import type { AdminMenuNode } from '../types/admin'
export const ADMIN_BRAND = '数富AI'
/** 文档标题规则:`页面标题 - 数富AI`;无页面标题时仅品牌。 */
export function resolveDocumentTitle(pageTitle: string, brand: string = ADMIN_BRAND): string {
const trimmed = (pageTitle || '').trim()
return trimmed ? `${trimmed} - ${brand}` : brand
}
/** 同步 HTML 文档标题;doc 为空(如非浏览器环境)时静默跳过。 */
export function updateDocumentTitle(title: string, doc: { title: string } | null | undefined): void {
if (doc) doc.title = title
}
export interface BreadcrumbItem {
key: string
name: string
route?: string
}
/**
* 面包屑规则:在菜单树中定位激活路由,返回“分组 -> 页面”层级(顶层叶子只返回自身)。
* 无命中返回空数组,不伪造路径。
*/
export function crumbsForActiveRoute(nodes: AdminMenuNode[] | null | undefined, activeRoute: string): BreadcrumbItem[] {
for (const node of nodes || []) {
if (node.route === activeRoute) return [{ key: node.key, name: node.name, route: node.route }]
const children = node.children || []
for (const child of children) {
if (child.route === activeRoute) {
return [
{ key: node.key, name: node.name },
{ key: child.key, name: child.name, route: child.route },
]
}
}
const nested = crumbsForActiveRoute(children, activeRoute)
if (nested.length) return [{ key: node.key, name: node.name }, ...nested]
}
return []
}
@@ -0,0 +1,22 @@
import type { AdminUser } from '../types/admin'
/** 无 meta.title 时的壳层缺省标题。 */
export const ADMIN_DEFAULT_TITLE = '管理后台'
/** 页面标题模型:由路由 meta.title 驱动,空值回落缺省标题。 */
export function pageTitleOf(title: string | null | undefined): string {
const value = typeof title === 'string' ? title.trim() : ''
return value || ADMIN_DEFAULT_TITLE
}
export interface TopbarUserViewModel {
username: string
role: string
hasUser: boolean
}
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
if (!user) return { username: '当前用户', role: '', hasUser: false }
return { username: user.username || '当前用户', role: user.role || '', hasUser: true }
}
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/api/http'
import type { AdminUser } from '@/types/admin'
import { fetchUserList } from '@/api/users'
const loading = ref(false)
const rows = ref<AdminUser[]>([])
const total = ref(0)
const form = reactive({ username: '', page: 1, pageSize: 15 })
async function loadUsers() {
loading.value = true
try {
const page = await fetchUserList({ page: form.page, pageSize: form.pageSize, keyword: form.username || undefined })
rows.value = page.items
total.value = page.total
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '用户列表加载失败')
} finally {
loading.value = false
}
}
async function removeUser(row: AdminUser) {
try {
await ElMessageBox.confirm(`确定删除用户“${row.username}”吗?`, '删除确认', { type: 'warning' })
await http.delete(`/api/admin/user/${row.id}`)
ElMessage.success('删除成功')
await loadUsers()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function changePage(page: number) {
form.page = page
void loadUsers()
}
onMounted(loadUsers)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>用户管理</h2>
<p>管理后台账号角色和创建关系</p>
</div>
<el-button type="primary">新建用户</el-button>
</div>
<el-card shadow="never">
<el-form inline @submit.prevent="loadUsers">
<el-form-item label="用户名">
<el-input v-model="form.username" clearable placeholder="模糊搜索" @keyup.enter="loadUsers" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadUsers">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column prop="role" label="角色" width="140" />
<el-table-column prop="creatorUsername" label="所属管理员" width="160" />
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeUser(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total }} </span>
<el-pagination
background
layout="prev, pager, next"
:page-size="form.pageSize"
:current-page="form.page"
:total="total"
@current-change="changePage"
/>
</div>
</el-card>
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="not-found">
<el-result icon="warning" title="404" sub-title="页面不存在或已被移除">
<template #extra>
<el-button type="primary" @click="router.replace('/')">返回首页</el-button>
</template>
</el-result>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
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 ''
}
/** 菜单树中是否含指定 key(深度优先递归;空 key/空树一律 false)。 */
export function hasMenuKey(nodes: AdminMenuNode[] | null | undefined, key: string): boolean {
if (!key) return false
return (nodes || []).some((node) => node.key === key || hasMenuKey(node.children || [], key))
}
/**
* 页面级路由准入:无 menuKey 的开放页放行;超级管理员可进全部;其余须在
* 本人菜单树中命中 key。后端已过滤菜单,此处只做前端路由级兜底。
*/
export function isRouteAllowed(
isSuperAdmin: boolean,
menuKey: string | undefined,
menuTree: AdminMenuNode[] | null | undefined,
): boolean {
if (!menuKey) return true
if (isSuperAdmin) return true
return hasMenuKey(menuTree, menuKey)
}
+47
View File
@@ -0,0 +1,47 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import AdminLayout from '@/layout/AdminLayout.vue'
import NotFoundPage from '@/pages/error/NotFoundPage.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
import { APP_BASE_PATH } from '@/config/app'
import { adminRouteRecords } from './routes'
import { defineLazyPage } from './lazy-page'
import { isRouteAllowed } from './helpers'
const router = createRouter({
history: createWebHistory(APP_BASE_PATH),
routes: [
{
path: '/',
component: AdminLayout,
children: [
...adminRouteRecords.map((record) => ({
...record,
component: defineLazyPage(record.component as () => Promise<{ default: unknown }>),
})),
{ path: ':pathMatch(.*)*', component: NotFoundPage, meta: { title: '页面不存在' } },
] as RouteRecordRaw[],
},
],
})
router.beforeEach(async (to) => {
const session = useAdminSessionStore()
if (!session.initialized) {
try {
await session.initialize()
} catch {
return false
}
}
// 根路由:有可见页面时跳到首个可见页;没有任何页面时停留根路由展示无菜单空态。
if (to.path === '/') {
return session.firstVisible || true
}
// 页面级权限:无 menuKey 开放页放行;越权页回退首个可见页,全无则回根空态。
if (!isRouteAllowed(session.isSuperAdmin, to.meta.menuKey, session.menuTree)) {
return session.firstVisible || '/'
}
return true
})
export default router
@@ -0,0 +1,15 @@
/** 异步页面加载边界可调参数(任务 10):loading 延迟/超时/文案。 */
export const PAGE_LOADING_DELAY = 200
export const PAGE_LOADING_TIMEOUT = 15000
export const PAGE_LOADING_TEXT = '页面加载中'
export const PAGE_LOAD_ERROR_TEXT = '页面加载失败,请刷新重试'
/** 是否为异步加载器(函数式)。 */
export function isLazyLoader(value: unknown): value is () => Promise<unknown> {
return typeof value === 'function'
}
/** 动态 import 产物是否为含 default 的页面模块。 */
export function isPageModule(module: unknown): boolean {
return typeof module === 'object' && module !== null && 'default' in module
}
@@ -0,0 +1,23 @@
import { defineAsyncComponent, h, type Component } from 'vue'
import {
PAGE_LOADING_DELAY,
PAGE_LOADING_TEXT,
PAGE_LOADING_TIMEOUT,
PAGE_LOAD_ERROR_TEXT,
} from './lazy-config'
type PageLoader = () => Promise<{ default: unknown }>
/**
* 页面级异步加载边界(任务 10):按路由分包页面,加载中/失败都有明确反馈,
* 不把 chunk 加载错误静默吞掉。
*/
export function defineLazyPage(loader: PageLoader): Component {
return defineAsyncComponent({
loader: () => loader().then((mod) => mod.default as Component),
delay: PAGE_LOADING_DELAY,
timeout: PAGE_LOADING_TIMEOUT,
loadingComponent: () => h('div', { class: 'page-loading' }, PAGE_LOADING_TEXT),
errorComponent: () => h('div', { class: 'page-error' }, PAGE_LOAD_ERROR_TEXT),
})
}
+34
View File
@@ -0,0 +1,34 @@
/** 路由 meta 菜单 key 约定(任务 9):menuKey 对应后端菜单节点 key,仅作页面级鉴权。 */
export const ADMIN_MENU_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
/** 从 route.meta 读取菜单 key;缺省/非法类型回退空串(无菜单约束)。 */
export function metaMenuKey(meta: { menuKey?: unknown } | undefined): string {
const value = meta?.menuKey
return typeof value === 'string' ? value : ''
}
/** 菜单 key 是否合规(小写下划线命名)。 */
export function isValidMenuKey(key: string): boolean {
return ADMIN_MENU_KEY_PATTERN.test(key)
}
export interface MenuKeyCandidate {
path?: string
menuKey?: unknown
}
/** 校验一批页面注册的 menuKey:重复与命名格式问题返回可操作清单。 */
export function validateRouteMenuKeys(records: MenuKeyCandidate[]): string[] {
const issues: string[] = []
const seen = new Set<string>()
for (const record of records) {
const key = metaMenuKey(record)
if (!key) continue
if (seen.has(key)) issues.push(`菜单 key 重复: ${key}`)
seen.add(key)
if (!isValidMenuKey(key)) {
issues.push(`菜单 key 命名非法(须小写下划线): ${record.path || '(无路径)'} -> ${key}`)
}
}
return issues
}
+49
View File
@@ -0,0 +1,49 @@
import type { RouteRecordRaw } from 'vue-router'
export interface AdminPageMeta {
title: string
menuKey?: string
}
export interface AdminPageDef extends AdminPageMeta {
/** 相对 /admin-vue/ 基准的子路径,不带前导斜杠、不带 base 前缀。 */
path: string
/** 异步组件加载器(页面按路由分包)。 */
load: () => Promise<{ default: unknown }>
}
/** 业务域路由注册表(任务 8):首批 account 域;后续按业务域在此登记页面。 */
export const adminPages: AdminPageDef[] = [
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
]
export function routeOf(page: AdminPageDef): RouteRecordRaw {
if (!page.path || !page.load || !page.title) {
throw new Error(`路由注册表页面定义非法,需 path/load/title 齐全: ${JSON.stringify(page)}`)
}
const meta: Record<string, string> = { title: page.title }
if (page.menuKey) meta.menuKey = page.menuKey
return { path: page.path, component: page.load, meta }
}
export const adminRouteRecords: RouteRecordRaw[] = adminPages.map(routeOf)
/** 校验注册表完整性:路径唯一、title/load 齐全、路径为相对子路径。返回问题清单。 */
export function validateAdminPages(pages: AdminPageDef[]): string[] {
const issues: string[] = []
const seen = new Set<string>()
for (const page of pages) {
if (!page.path || page.path.startsWith('/')) {
issues.push(`页面路径必须是相对子路径(不带 / 与 base 前缀): ${page.path || '(空)'}`)
} else if (seen.has(page.path)) {
issues.push(`页面路径重复: ${page.path}`)
}
seen.add(page.path)
if (!page.title) issues.push(`页面缺少标题: ${page.path}`)
if (!page.load) issues.push(`页面缺少异步加载器: ${page.path}`)
if (page.path.includes('/admin-vue')) issues.push(`页面路径不得包含 base 前缀: ${page.path}`)
}
return issues
}
@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { fetchAdminMenuTree, fetchCurrentUser, logout } from '@/api/session'
import type { AdminMenuNode, AdminUser } from '@/types/admin'
import { isSuperAdminRole } 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) => isSuperAdminRole(state.user?.role),
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')
},
},
})
+71
View File
@@ -0,0 +1,71 @@
:root {
--admin-sidebar: #192132;
--admin-sidebar-deep: #121827;
--admin-primary: #6366f1;
--admin-primary-soft: #eef0ff;
--admin-bg: #f3f5f9;
--admin-border: #e5e7ef;
--admin-text: #1f2937;
--admin-muted: #7b8495;
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-height: 100%; height: 100%; }
body { background: var(--admin-bg); color: var(--admin-text); }
button, input, textarea, select { font: inherit; }
.admin-shell { display: flex; min-height: 100vh; background: var(--admin-bg); }
.admin-sidebar { display: flex; flex: 0 0 248px; flex-direction: column; background: linear-gradient(180deg, var(--admin-sidebar) 0%, var(--admin-sidebar-deep) 100%); color: #b9c2d5; transition: flex-basis .2s ease; }
.admin-shell.is-collapsed .admin-sidebar { flex-basis: 64px; }
.admin-brand { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 16px; border-bottom: 1px solid rgba(255,255,255,.08); }
.admin-brand-logo { width: 36px; height: 36px; border-radius: 10px; object-fit: cover; background: white; }
.admin-brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.admin-brand-copy strong { color: #fff; font-size: 16px; }
.admin-brand-copy span { color: #929db4; font-size: 11px; white-space: nowrap; }
.admin-menu-scroll { flex: 1; padding: 14px 10px; }
.admin-menu { border-right: 0; background: transparent; }
.admin-menu .el-sub-menu__title, .admin-menu .el-menu-item { height: 42px; line-height: 42px; border-radius: 8px; margin: 3px 0; color: #aeb8cc; }
.admin-menu .el-sub-menu__title:hover, .admin-menu .el-menu-item:hover { background: rgba(255,255,255,.08); color: #fff; }
.admin-menu .el-menu-item.is-active { background: linear-gradient(90deg, rgba(99,102,241,.95), rgba(99,102,241,.62)); color: #fff; }
.admin-menu .el-menu { background: transparent; }
.menu-group-title { font-weight: 600; }
.sidebar-collapse { margin: 12px; padding: 9px 10px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: #aeb8cc; background: transparent; cursor: pointer; }
.sidebar-collapse:hover { color: #fff; border-color: rgba(255,255,255,.3); }
.sidebar-collapse:focus-visible { outline: 2px solid var(--admin-primary); outline-offset: 2px; }
.skip-link { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.skip-link:focus {
position: fixed; left: 16px; top: 16px; z-index: 2000; width: auto; height: auto;
padding: 10px 14px; background: #fff; color: var(--admin-primary);
border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, .2); text-decoration: none;
}
.admin-main { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.admin-topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 84px; padding: 18px 28px; background: #fff; border-bottom: 1px solid var(--admin-border); }
.admin-kicker { color: var(--admin-primary); font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.admin-topbar h1 { margin: 5px 0 0; font-size: 22px; }
.admin-user { display: flex; align-items: center; gap: 18px; }
.admin-user-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
.admin-user-meta strong { font-size: 14px; }
.admin-user-meta span { color: var(--admin-muted); font-size: 12px; }
.admin-content { flex: 1; min-width: 0; padding: 26px 28px; overflow: auto; }
.page-stack { display: flex; flex-direction: column; gap: 18px; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.page-heading h2 { margin: 0; font-size: 22px; }
.page-heading p { margin: 7px 0 0; color: var(--admin-muted); font-size: 13px; }
.table-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 18px; color: var(--admin-muted); font-size: 13px; }
.el-card { border-color: var(--admin-border); border-radius: 12px; }
.page-loading, .page-error { padding: 48px 20px; text-align: center; }
.page-loading { color: var(--admin-muted); }
.page-error { color: #b91c1c; }
.not-found { padding: 56px 20px; }
.global-error-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.admin-breadcrumb { margin-bottom: 14px; }
@media (max-width: 820px) {
.admin-sidebar { flex-basis: 64px; }
.admin-brand-copy, .admin-menu .el-sub-menu__title span, .admin-menu .el-menu-item { display: none; }
.admin-topbar { align-items: flex-start; flex-direction: column; }
.admin-user { width: 100%; justify-content: space-between; }
.admin-user-meta { align-items: flex-start; }
.admin-content { padding: 18px; }
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Admin 主题 token 单源(任务 4):深色侧边栏 + 浅色内容区。
* main.css 使用同名 --admin-* CSS 变量承载渲染值;tests 用本模块校验
* “侧边栏为暗、内容区为亮、正文对比度达标”的不变量,防止误改成浅色侧栏。
*/
export const ADMIN_THEME = {
sidebar: '#192132',
sidebarDeep: '#121827',
primary: '#6366f1',
primarySoft: '#eef0ff',
bg: '#f3f5f9',
border: '#e5e7ef',
text: '#1f2937',
muted: '#7b8495',
} as const
export type AdminThemeKey = keyof typeof ADMIN_THEME
export const ADMIN_THEME_KEYS = Object.keys(ADMIN_THEME) as AdminThemeKey[]
/** CSS 变量名:camelCase -> kebabsidebarDeep -> --admin-sidebar-deep)。 */
export function cssVarName(key: AdminThemeKey): string {
const kebab = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
return `--admin-${kebab}`
}
function parseHex(hex: string): { r: number; g: number; b: number } {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
throw new Error(`主题色必须是 #rrggbb 六位十六进制: ${hex}`)
}
const raw = hex.slice(1)
return {
r: parseInt(raw.slice(0, 2), 16),
g: parseInt(raw.slice(2, 4), 16),
b: parseInt(raw.slice(4, 6), 16),
}
}
/** sRGB 通道 -> 线性亮度。 */
function channelLuminance(channel: number): number {
const s = channel / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
}
/** 相对亮度(0 纯黑 ~ 1 纯白)。 */
export function relativeLuminance(hex: string): number {
const { r, g, b } = parseHex(hex)
return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b)
}
export function isDarkSurface(hex: string, threshold = 0.2): boolean {
return relativeLuminance(hex) < threshold
}
/** WCAG 对比度(1 ~ 21),fg/bg 可为 8 位 hex。 */
export function contrastRatio(fgHex: string, bgHex: string): number {
const lighter = Math.max(relativeLuminance(fgHex), relativeLuminance(bgHex))
const darker = Math.min(relativeLuminance(fgHex), relativeLuminance(bgHex))
return (lighter + 0.05) / (darker + 0.05)
}
/** 取某个语义 token 的 hex;未知 key 给出可操作错误。 */
export function tokenHex(key: AdminThemeKey): string {
const value = ADMIN_THEME[key]
if (!value) {
throw new Error(`未知 Admin 主题 token: ${String(key)},允许值: ${ADMIN_THEME_KEYS.join(', ')}`)
}
return value
}
+78
View File
@@ -0,0 +1,78 @@
/** 后台前端类型契约(任务 21):与 Java Admin 接口返回字段对齐。 */
/** 管理角色域(与后端 current-user / users 返回一致)。 */
export type AdminRole = 'super_admin' | 'admin' | 'normal'
export interface AdminUser {
id: number
username: string
role: string
/** 1 为管理员标志位;role 为空时可据此推断角色。 */
isAdmin?: boolean
createdById?: number | null
creatorUsername?: string
createdAt?: string
pinyinAbbr?: string
}
export interface AdminMenuNode {
/** columns.column_key,稳定权限标识。 */
key: string
name: string
/** 相对 /admin-vue/ 的页面路径;分组节点可能没有。 */
route?: string
/** 分组/页面层级;后端按 sort_order 排好后原样下发,前端不二次重排。 */
children?: AdminMenuNode[]
sort?: number
icon?: string
}
export type AdminRoleKind = 'super_admin' | 'admin' | 'normal' | null
/** 把任意角色字符串归一为角色域;未知/空返回 null。 */
export function roleKind(role: string | null | undefined): AdminRoleKind {
const value = (role || '').trim().toLowerCase()
if (value === 'super_admin') return 'super_admin'
if (value === 'admin') return 'admin'
if (value === 'normal') return 'normal'
return null
}
/** 是否超级管理员(可进入全部菜单)。 */
export function isSuperAdminRole(role: string | null | undefined): boolean {
return roleKind(role) === 'super_admin'
}
/** 是否管理员级(super_admin 或 admin,可进后台菜单管理类)。 */
export function isAdminRole(role: string | null | undefined): boolean {
const kind = roleKind(role)
return kind === 'super_admin' || kind === 'admin'
}
/** 顶部栏角色展示文案。 */
export function roleLabel(role: string | null | undefined): string {
switch (roleKind(role)) {
case 'super_admin':
return '超级管理员'
case 'admin':
return '管理员'
case 'normal':
return '普通用户'
default:
return ''
}
}
/** 前端是否应把该用户视为超级管理员(可进入全部菜单)。 */
export function isSuperUser(user: Pick<AdminUser, 'role'> | null | undefined): boolean {
return isSuperAdminRole(user?.role)
}
export type ApiEnvelope<T> = {
success?: boolean
message?: string
error?: string
data?: T
item?: T
items?: T
}
+10
View File
@@ -0,0 +1,10 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
/** 页面标题,驱动顶栏与 document.title。 */
title?: string
/** 菜单 key(对应后端菜单节点 key);仅页面级鉴权使用。 */
menuKey?: string
}
}
@@ -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/)
})
+63
View File
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
const ROOT = process.cwd()
const SRC = join(ROOT, 'src')
const EXTENSIONS = ['', '.ts', '.tsx', '.vue', '.js', '.json']
const INDEX_CANDIDATES = ['/index.ts', '/index.tsx', '/index.vue', '/index.js']
/** 读取工程内源码文件(相对工程根)。 */
export function readSource(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf8')
}
/** 统计非重叠子串出现次数(幂等、只读)。 */
export function occurrences(text: string, token: string): number {
if (!token) return 0
let count = 0
let index = text.indexOf(token)
while (index !== -1) {
count += 1
index = text.indexOf(token, index + token.length)
}
return count
}
/** 从源码里抓出所有 @/ 别名 import 说明符(含 import type)。 */
export function aliasImports(source: string): string[] {
const specs = new Set<string>()
const re = /(?:import\s+type\s+)?[^'"]*from\s*['"](@\/[^'"]+)['"]/g
let match: RegExpExecArray | null
while ((match = re.exec(source))) specs.add(match[1])
// 处理动态 import('@/...') 与直接裸 @/ 引用
const dyn = /(?:import|import\s*\(\s*)['"](@\/[^'"]+)['"]/g
while ((match = dyn.exec(source))) specs.add(match[1])
return [...specs]
}
/** 把 import 说明符解析为存在的模块文件;解析不到返回 null(含可操作路径)。 */
export function resolveModuleSpec(spec: string, fromRel: string): string | null {
let base: string
if (spec.startsWith('@/')) {
base = join(SRC, spec.slice(2))
} else {
base = resolve(dirname(join(ROOT, fromRel)), spec)
}
for (const ext of EXTENSIONS) {
if (existsSync(base + ext)) return base + ext
}
for (const idx of INDEX_CANDIDATES) {
if (existsSync(base + idx)) return base + idx
}
return null
}
/** 校验一个源文件的所有 @/ import 都能解析;返回缺失清单(空 = 全部可解析)。 */
export function missingImports(rel: string): string[] {
const source = readSource(rel)
const missing: string[] = []
for (const spec of aliasImports(source)) {
if (!resolveModuleSpec(spec, rel)) missing.push(`${rel} 无法解析 import: ${spec}`)
}
return missing
}
@@ -0,0 +1,46 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseCurrentUser, parseMenuTree } from '../../src/api/session-model.ts'
const BASE = process.env.AIIMAGE_LIVE_BASE || 'http://127.0.0.1:18080'
const USER = process.env.AIIMAGE_LIVE_USER || ''
const PASS = process.env.AIIMAGE_LIVE_PASS || ''
const DEVICE = 'claude-live-device'
const HAS_CREDS = Boolean(USER && PASS)
async function loginToken(): Promise<string> {
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, deviceId: DEVICE }),
})
const body = (await res.json()) as { success?: boolean; message?: string; data?: { token?: string } }
if (!body.success || !body.data?.token) throw new Error(body.message || 'login failed')
return body.data.token
}
function authHeaders(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` }
}
test('test_live_023_current_user_real_request', { skip: !HAS_CREDS }, async () => {
const token = await loginToken()
const res = await fetch(`${BASE}/api/admin/current-user`, { headers: authHeaders(token) })
const body = await res.json()
const user = parseCurrentUser(body)
assert.equal(typeof user.id, 'number')
assert.ok(user.username.length > 0)
assert.ok(['super_admin', 'admin', 'normal'].includes(user.role))
})
test('test_live_024_menu_tree_real_request', { skip: !HAS_CREDS }, async () => {
const token = await loginToken()
const res = await fetch(`${BASE}/api/admin/current-user/menus`, { headers: authHeaders(token) })
const body = await res.json()
const tree = parseMenuTree(body)
assert.ok(Array.isArray(tree))
assert.ok(tree.length > 0, 'super_admin 应能拿到全部 admin 菜单树')
const accountGroup = tree.find((n) => n.children?.length)
assert.ok(accountGroup, '菜单树应按分组组织')
})
@@ -0,0 +1,132 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseCurrentUser, parseMenuTree } from '../../src/api/session-model.ts'
import { roleKind } from '../../src/types/admin.ts'
const BASE = process.env.AIIMAGE_LIVE_BASE || 'http://127.0.0.1:18080'
const USER = process.env.AIIMAGE_LIVE_USER || ''
const PASS = process.env.AIIMAGE_LIVE_PASS || ''
const HAS_CREDS = Boolean(USER && PASS)
const DEVICE = 'claude-task40-smoke'
let sessionCookie = ''
let bearerToken = ''
/** 登录一次并按浏览器 Cookie 语义缓存会话(真实登录态)。 */
async function ensureSession(): Promise<void> {
if (sessionCookie || bearerToken) return
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, deviceId: DEVICE }),
})
const body = (await res.json()) as { success?: boolean; message?: string; data?: { token?: string } }
assert.equal(body.success, true, `登录应成功: ${body.message || ''}`)
if (body.data?.token) bearerToken = body.data.token
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
const setCookie = setCookies[0] || res.headers.get('set-cookie') || ''
const match = /aiimage_token=([^;]+)/.exec(setCookie)
if (match) sessionCookie = `aiimage_token=${match[1]}`
assert.ok(sessionCookie || bearerToken, '登录应下发会话 cookie 或 token')
}
/** 模拟同源 Cookie 会话:带会话 cookie 或 Bearer 兜底请求受保护端点。 */
async function authedFetch(path: string): Promise<Response> {
const headers: Record<string, string> = {}
if (sessionCookie) headers.Cookie = sessionCookie
else if (bearerToken) headers.Authorization = `Bearer ${bearerToken}`
return fetch(`${BASE}${path}`, { headers })
}
test('test_task_040_live_smoke_normal_primary_path', { skip: !HAS_CREDS }, async () => {
// 正常主路径:真实登录后凭会话取当前用户。
await ensureSession()
const res = await authedFetch('/api/admin/current-user')
const user = parseCurrentUser(await res.json())
assert.equal(typeof user.id, 'number')
assert.equal(user.username, USER)
assert.equal(roleKind(user.role), 'super_admin')
})
test('test_task_040_live_smoke_normal_variant_input', { skip: !HAS_CREDS }, async () => {
// 正常变体:super_admin 菜单树含后台页面 key,路由级权限可放行。
await ensureSession()
const res = await authedFetch('/api/admin/current-user/menus')
const tree = parseMenuTree(await res.json())
const keys = new Set<string>()
const walk = (nodes: { key: string; children?: unknown }[] | undefined): void => {
for (const n of nodes || []) {
keys.add(n.key)
walk((n.children || []) as { key: string; children?: unknown }[])
}
}
walk(tree)
for (const expected of ['admin_users', 'admin_columns', 'admin_group_manage']) {
assert.ok(keys.has(expected), `菜单树应含后台页面 key: ${expected}`)
}
})
test('test_task_040_live_smoke_normal_repeated_operation_is_idempotent', { skip: !HAS_CREDS }, async () => {
// 正常重复:重复拉取用户/菜单结果稳定、不重排。
await ensureSession()
const first = parseCurrentUser(await (await authedFetch('/api/admin/current-user')).json())
const second = parseCurrentUser(await (await authedFetch('/api/admin/current-user')).json())
assert.equal(first.id, second.id)
const menusA = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
const menusB = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
assert.equal(JSON.stringify(menusA), JSON.stringify(menusB))
})
test('test_task_040_live_smoke_boundary_empty_input', { skip: !HAS_CREDS }, async () => {
// 边界空值:无会话直接请求受保护端点 → 401,前端据此跳登录。
const res = await fetch(`${BASE}/api/admin/current-user`)
const body = (await res.json()) as { success?: boolean; code?: number; message?: string }
assert.equal(body.success, false)
assert.equal(body.code, 401)
})
test('test_task_040_live_smoke_boundary_single_item', { skip: !HAS_CREDS }, async () => {
// 边界单元素:登录返回单会话 cookie 且可被管理员端点接受。
await ensureSession()
assert.ok(sessionCookie || bearerToken, '已建立单会话')
const res = await authedFetch('/api/admin/current-user')
const user = parseCurrentUser(await res.json())
assert.equal(user.username, USER)
})
test('test_task_040_live_smoke_boundary_limit_or_missing_field', { skip: !HAS_CREDS }, async () => {
// 边界上限:super_admin 菜单树中的每个模块页面都带可进入的 route(路由级权限放行依据)。
await ensureSession()
const tree = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
const routes = new Set<string>()
const walk = (nodes: { key: string; route?: string; children?: unknown }[] | undefined): void => {
for (const n of nodes || []) {
if (n.route) routes.add(n.route)
walk((n.children || []) as { key: string; route?: string; children?: unknown }[])
}
}
walk(tree)
for (const page of ['/account/users', '/account/menus', '/account/groups']) {
assert.ok(routes.has(page), `菜单树应含可进入页面 route: ${page}`)
}
})
test('test_task_040_live_smoke_invalid_input_rejected', { skip: !HAS_CREDS }, async () => {
// 异常输入:伪造 token 被拒绝,返回 401 而非业务数据。
const res = await fetch(`${BASE}/api/admin/current-user`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
const body = (await res.json()) as { success?: boolean; code?: number }
assert.equal(body.success, false)
assert.equal(body.code, 401)
})
test('test_task_040_live_smoke_dependency_failure_returns_actionable_message', { skip: !HAS_CREDS }, async () => {
// 依赖失败:真实负载经同一解析器/角色归一后仍符合前端契约(保序、角色可判)。
await ensureSession()
const tree = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
assert.ok(Array.isArray(tree) && tree.length > 0)
assert.equal(typeof tree[0].name, 'string')
const user = parseCurrentUser(await (await authedFetch('/api/admin/current-user')).json())
assert.ok(roleKind(user.role) !== null)
})
+132
View File
@@ -0,0 +1,132 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import {
ADMIN_SOURCE_DIRS,
collectBoundaryViolations,
FORBIDDEN_COUPLING_TOKENS,
readWorkspacePackage,
REQUIRED_MANIFEST_FILES,
resolveWorkspaceRoot,
STANDALONE_PACKAGE_NAME,
walk,
} from '../src/config/workspace.ts'
function tempRoot(): string {
return mkdtempSync(join(tmpdir(), 'admin-workspace-'))
}
test('test_task_001_project_baseline_normal_primary_path', () => {
// 正常主路径:独立工程从自身根目录完成一次边界校验,输出无违规。
const root = resolveWorkspaceRoot()
const violations = collectBoundaryViolations(root)
assert.deepEqual(violations, [], `工作区边界违规: ${violations.join('; ')}`)
const pkg = readWorkspacePackage(root)
assert.equal(pkg.name, STANDALONE_PACKAGE_NAME)
for (const dep of ['vue', 'vue-router', 'pinia', 'element-plus']) {
assert.ok(pkg.dependencies[dep], `缺少依赖 ${dep}`)
}
})
test('test_task_001_project_baseline_normal_variant_input', () => {
// 正常变体:构建、开发、测试三套命令形态都指向独立后台工程。
const { scripts } = readWorkspacePackage()
assert.match(scripts.build, /vue-tsc --noEmit/)
assert.match(scripts.build, /vite build/)
assert.match(scripts.dev, /vite/)
assert.match(scripts.dev, /5174/)
assert.match(scripts.test, /node --test/)
assert.match(scripts.test, /tests\/\*\.test\.ts/)
})
test('test_task_001_project_baseline_normal_repeated_operation_is_idempotent', () => {
// 正常重复:重复校验不改变结果、不产生不稳定状态。
const first = collectBoundaryViolations()
const second = collectBoundaryViolations()
assert.deepEqual(second, first, '重复校验结果应稳定一致')
const pkgA = readWorkspacePackage()
const pkgB = readWorkspacePackage()
assert.equal(pkgA.name, pkgB.name)
assert.deepEqual(pkgA.dependencies, pkgB.dependencies)
})
test('test_task_001_project_baseline_boundary_empty_input', () => {
// 边界空值:允许的源码目录集合完整存在;扫描可容忍空目录不崩溃。
const root = resolveWorkspaceRoot()
for (const dir of ADMIN_SOURCE_DIRS) {
assert.equal(existsSync(join(root, dir)), true, `缺少允许源码目录 ${dir}`)
}
const empty = tempRoot()
try {
mkdirSync(join(empty, 'src'))
assert.deepEqual(walk(empty), [], '空目录扫描应返回空结果且不抛异常')
} finally {
rmSync(empty, { recursive: true, force: true })
}
})
test('test_task_001_project_baseline_boundary_single_item', () => {
// 边界单元素:单一 SPA 入口与单一挂载点。
const root = resolveWorkspaceRoot()
const html = readFileSync(join(root, 'index.html'), 'utf8')
assert.match(html, /id="app"/)
assert.match(html, /\/src\/main\.ts/)
const main = readFileSync(join(root, 'src/main.ts'), 'utf8')
assert.match(main, /\.mount\('#app'\)/)
})
test('test_task_001_project_baseline_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:严格类型与 ESM 模块边界等关键字段必须存在。
const root = resolveWorkspaceRoot()
const tsconfig = readFileSync(join(root, 'tsconfig.json'), 'utf8')
assert.match(tsconfig, /"strict":\s*true/)
assert.match(tsconfig, /"moduleResolution":\s*"Bundler"/)
const pkg = readWorkspacePackage(root)
assert.equal(pkg.private, true)
assert.ok(pkg.scripts.test, '缺少 test 命令字段')
assert.ok(REQUIRED_MANIFEST_FILES.length >= 5, '清单字段不能为空')
})
test('test_task_001_project_baseline_invalid_input_rejected', () => {
// 异常输入:任何文件引用客户端耦合 token 都应被边界校验拒绝。
const root = tempRoot()
try {
for (const token of FORBIDDEN_COUPLING_TOKENS) {
mkdirSync(join(root, 'src'), { recursive: true })
writeFileSync(join(root, 'src', 'bad.ts'), `// 越界引用\nconst t = '${token}'\n`)
const violations = collectBoundaryViolations(root)
assert.ok(
violations.some((v) => v.includes(`引用了客户端耦合 token "${token}"`)),
`token ${token} 应被识别为越界: ${violations.join('; ')}`,
)
rmSync(join(root, 'src', 'bad.ts'))
}
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('test_task_001_project_baseline_dependency_failure_returns_actionable_message', () => {
// 依赖失败:根目录定位失败与清单缺失时返回可操作错误。
const root = tempRoot()
try {
const missing = join(root, 'no-package')
mkdirSync(missing)
assert.throws(
() => resolveWorkspaceRoot(missing),
(error: Error) => error.message.includes('package.json'),
)
const emptyDir = join(root, 'flat')
mkdirSync(emptyDir)
writeFileSync(join(emptyDir, 'package.json'), '{}')
const violations = collectBoundaryViolations(emptyDir)
assert.ok(
violations.some((v) => v.includes('缺少必需清单文件 src/main.ts')),
`依赖缺失应返回可操作清单消息: ${violations.join('; ')}`,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
isLazyLoader,
isPageModule,
PAGE_LOADING_DELAY,
PAGE_LOADING_TEXT,
PAGE_LOADING_TIMEOUT,
PAGE_LOAD_ERROR_TEXT,
} from '../src/router/lazy-config.ts'
import { adminPages } from '../src/router/routes.ts'
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
assert.equal(adminPages.length, 3)
for (const page of adminPages) {
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
}
})
test('test_task_010_lazy_page_boundary_normal_variant_input', async () => {
// 正常变体:加载器产物为页面模块(含 default)时识别成功。
const loader = async () => ({ default: {} })
const mod = await loader()
assert.equal(isPageModule(mod), true)
})
test('test_task_010_lazy_page_boundary_normal_repeated_operation_is_idempotent', () => {
// 正常重复:懒加载判定稳定。
assert.equal(isLazyLoader(() => Promise.resolve({})), true)
assert.equal(isLazyLoader(() => Promise.resolve({})), true)
})
test('test_task_010_lazy_page_boundary_boundary_empty_input', () => {
// 边界空值:缺省/空输入按非加载器/非页面模块处理,不崩溃。
assert.equal(isLazyLoader(undefined), false)
assert.equal(isLazyLoader(null), false)
assert.equal(isPageModule(null), false)
assert.equal(isPageModule(undefined), false)
})
test('test_task_010_lazy_page_boundary_boundary_single_item', () => {
// 边界单元素:单个含 default 的模块即可作为页面。
assert.equal(isPageModule({ default: () => null }), true)
})
test('test_task_010_lazy_page_boundary_boundary_limit_or_missing_field', () => {
// 边界上限:loading 延迟/超时为正且超时足够等待网络;文案不空。
assert.ok(PAGE_LOADING_DELAY > 0)
assert.ok(PAGE_LOADING_TIMEOUT > PAGE_LOADING_DELAY)
assert.ok(PAGE_LOADING_TIMEOUT <= 60000, '页面加载超时应处于合理上限内')
assert.ok(PAGE_LOADING_TEXT.length > 0)
})
test('test_task_010_lazy_page_boundary_invalid_input_rejected', () => {
// 异常输入:非函数/数组对象都不是懒加载器;无 default 模块不是页面模块。
assert.equal(isLazyLoader('load'), false)
assert.equal(isLazyLoader(123), false)
assert.equal(isLazyLoader({}), false)
assert.equal(isPageModule({}), false)
assert.equal(isPageModule([]), false)
})
test('test_task_010_lazy_page_boundary_dependency_failure_returns_actionable_message', () => {
// 依赖失败:router 对懒加载统一加异步边界;失败/加载中有样式与文案兜底。
const router = readSource('src/router/index.ts')
assert.match(router, /defineLazyPage/)
assert.equal(router.includes('adminRouteRecords.map'), true)
const lazyPage = readSource('src/router/lazy-page.ts')
assert.match(lazyPage, /PAGE_LOAD_ERROR_TEXT/)
const css = readSource('src/styles/main.css')
assert.match(css, /\.page-loading/)
assert.match(css, /\.page-error/)
})
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
EMPTY_MENU_DESCRIPTION,
EMPTY_MENU_TITLE,
shouldShowEmptyMenu,
} from '../src/layout/empty-state.ts'
test('test_task_011_empty_menu_state_normal_primary_path', () => {
// 正常主路径:初始化完成且没有任何菜单 -> 展示空态。
assert.equal(shouldShowEmptyMenu(false, false, true), true)
})
test('test_task_011_empty_menu_state_normal_variant_input', () => {
// 正常变体:有一个或多个菜单时即使没有加载也不展示空态。
assert.equal(shouldShowEmptyMenu(true, false, true), false)
assert.equal(shouldShowEmptyMenu(true, true, true), false)
})
test('test_task_011_empty_menu_state_normal_repeated_operation_is_idempotent', () => {
// 正常重复:判定幂等。
assert.equal(shouldShowEmptyMenu(false, false, true), shouldShowEmptyMenu(false, false, true))
assert.equal(shouldShowEmptyMenu(true, false, true), shouldShowEmptyMenu(true, false, true))
})
test('test_task_011_empty_menu_state_boundary_empty_input', () => {
// 边界空值:会话未初始化时不展示空态(避免误报)。
assert.equal(shouldShowEmptyMenu(false, false, false), false)
assert.equal(shouldShowEmptyMenu(false, true, false), false)
})
test('test_task_011_empty_menu_state_boundary_single_item', () => {
// 边界单元素:仅一个菜单即不算空。
assert.equal(shouldShowEmptyMenu(true, false, true), false)
})
test('test_task_011_empty_menu_state_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:加载中禁止闪现空态;初始化字段缺失按未就绪处理。
assert.equal(shouldShowEmptyMenu(false, true, true), false, '加载中不得闪现空态')
assert.equal(shouldShowEmptyMenu(false, false, undefined as unknown as boolean), false)
})
test('test_task_011_empty_menu_state_invalid_input_rejected', () => {
// 异常输入:布尔语义混乱(未初始化却已加载完)也不展示。
assert.equal(shouldShowEmptyMenu(false, true, undefined as unknown as boolean), false)
})
test('test_task_011_empty_menu_state_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层消费空态判定与文案常量,内容级空态在根路由呈现。
assert.ok(EMPTY_MENU_TITLE.length > 0)
assert.ok(EMPTY_MENU_DESCRIPTION.length > EMPTY_MENU_TITLE.length)
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /shouldShowEmptyMenu/)
assert.match(layout, /EMPTY_MENU_DESCRIPTION/)
assert.match(layout, /route\.path === '\/'/, '内容级空态限定根路由')
})
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { readSource, occurrences } from './helpers.ts'
import { adminPages } from '../src/router/routes.ts'
const ROUTER = 'src/router/index.ts'
const NOT_FOUND = 'src/pages/error/NotFoundPage.vue'
test('test_task_012_route_error_page_normal_primary_path', () => {
// 正常主路径:未知路径注册到路由级错误页兜底。
const router = readSource(ROUTER)
assert.match(router, /path:\s*':pathMatch\(\.\*\)\*'/)
assert.match(router, /NotFoundPage/)
assert.match(router, /页面不存在/)
})
test('test_task_012_route_error_page_normal_variant_input', () => {
// 正常变体:错误页在 AdminLayout children 内,保留壳层 chrome。
const router = readSource(ROUTER)
const catchAllIndex = router.indexOf(':pathMatch(.*)*')
const registryIndex = router.indexOf('adminRouteRecords.map')
assert.ok(catchAllIndex > registryIndex, 'catch-all 应在业务路由之后注册')
assert.match(router, /path: '\/',/)
})
test('test_task_012_route_error_page_normal_repeated_operation_is_idempotent', () => {
// 正常重复:错误页注册不重复追加。
const first = readSource(ROUTER)
assert.equal(occurrences(first, ':pathMatch(.*)*'), 1)
})
test('test_task_012_route_error_page_boundary_empty_input', () => {
// 边界空值:错误页文件存在,且不进入业务路由注册表。
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
assert.equal(adminPages.length, 3, '错误页不应计入业务路由')
})
test('test_task_012_route_error_page_boundary_single_item', () => {
// 边界单元素:错误页是单一 Vue 页面组件。
const source = readSource(NOT_FOUND)
assert.equal(occurrences(source, '<template>'), 1)
assert.match(source, /el-result/)
})
test('test_task_012_route_error_page_boundary_limit_or_missing_field', () => {
// 边界上限:catch-all 不参与菜单权限门禁(无 menuKey)。
const router = readSource(ROUTER)
const tail = router.slice(router.indexOf('component: NotFoundPage'))
assert.equal(tail.slice(0, 80).includes('menuKey'), false, '错误页不应带菜单 key')
})
test('test_task_012_route_error_page_invalid_input_rejected', () => {
// 异常输入:深层未知路径也能被通配兜底,且提供返回首页动作。
const router = readSource(ROUTER)
assert.match(router, /:pathMatch\(\.\*\)\*/, '通配需覆盖多段未知路径')
const page = readSource(NOT_FOUND)
assert.match(page, /router\.replace\('\/'\)/)
})
test('test_task_012_route_error_page_dependency_failure_returns_actionable_message', () => {
// 依赖失败:标题/样式/返回动作齐全,加载失败有明确入口。
const page = readSource(NOT_FOUND)
assert.match(page, /返回首页/)
assert.match(page, /404/)
const css = readSource('src/styles/main.css')
assert.match(css, /\.not-found/)
const router = readSource(ROUTER)
assert.match(router, /meta: \{ title: '页面不存在' \}/)
})
+94
View File
@@ -0,0 +1,94 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
clearGlobalErrors,
dismissGlobalError,
GLOBAL_ERROR_FALLBACK,
GLOBAL_ERROR_LIMIT,
normalizeErrorMessage,
notifyGlobalError,
snapshotGlobalErrors,
subscribeGlobalError,
} from '../src/layout/error-bus.ts'
test('test_task_013_global_error_notice_normal_primary_path', () => {
// 正常主路径:通知一条错误进入提示流。
clearGlobalErrors()
notifyGlobalError('登录已过期')
const list = snapshotGlobalErrors()
assert.equal(list.length, 1)
assert.equal(list[0].message, '登录已过期')
})
test('test_task_013_global_error_notice_normal_variant_input', () => {
// 正常变体:不同错误逐条追加、顺序保留。
clearGlobalErrors()
notifyGlobalError('错误A')
notifyGlobalError('错误B')
assert.deepEqual(snapshotGlobalErrors().map((i) => i.message), ['错误A', '错误B'])
})
test('test_task_013_global_error_notice_normal_repeated_operation_is_idempotent', () => {
// 正常重复:相同消息重复通知产生稳定独立的条目(id 单调递增)。
clearGlobalErrors()
notifyGlobalError('重试')
const first = snapshotGlobalErrors()[0]
notifyGlobalError('重试')
const second = snapshotGlobalErrors()[1]
assert.notEqual(first.id, second.id)
assert.equal(first.message, second.message)
})
test('test_task_013_global_error_notice_boundary_empty_input', () => {
// 边界空值:空/缺省消息回落到通用提示,不出现空白条目。
clearGlobalErrors()
assert.equal(normalizeErrorMessage(' '), GLOBAL_ERROR_FALLBACK)
notifyGlobalError('')
assert.equal(snapshotGlobalErrors()[0].message, GLOBAL_ERROR_FALLBACK)
})
test('test_task_013_global_error_notice_boundary_single_item', () => {
// 边界单元素:单条可按 id 关闭。
clearGlobalErrors()
notifyGlobalError('单条')
const [item] = snapshotGlobalErrors()
dismissGlobalError(item.id)
assert.deepEqual(snapshotGlobalErrors(), [])
})
test('test_task_013_global_error_notice_boundary_limit_or_missing_field', () => {
// 边界上限:超过上限只保留最近 N 条,不堆积。
clearGlobalErrors()
for (let i = 1; i <= GLOBAL_ERROR_LIMIT + 2; i += 1) notifyGlobalError(`err${i}`)
const list = snapshotGlobalErrors()
assert.equal(list.length, GLOBAL_ERROR_LIMIT)
assert.equal(list[list.length - 1].message, `err${GLOBAL_ERROR_LIMIT + 2}`)
})
test('test_task_013_global_error_notice_invalid_input_rejected', () => {
// 异常输入:Error/数字/对象负载均归一化到可展示文案。
clearGlobalErrors()
assert.equal(normalizeErrorMessage(new Error('连接被拒绝')), '连接被拒绝')
assert.equal(normalizeErrorMessage(500), GLOBAL_ERROR_FALLBACK)
assert.equal(normalizeErrorMessage({ code: 1 }), GLOBAL_ERROR_FALLBACK)
assert.equal(normalizeErrorMessage(new Error(' ')), GLOBAL_ERROR_FALLBACK)
})
test('test_task_013_global_error_notice_dependency_failure_returns_actionable_message', () => {
// 依赖失败:订阅提供取消函数,取消后不再回调(容器卸载无泄漏)。
clearGlobalErrors()
const seen: number[] = []
const unsubscribe = subscribeGlobalError((list) => seen.push(list.length))
notifyGlobalError('a')
unsubscribe()
notifyGlobalError('b')
assert.deepEqual(seen, [0, 1], '取消订阅后不应再收到更新')
clearGlobalErrors()
// 容器组件本身在卸载时执行清理并保留可关闭错误项。
const container = readSource('src/layout/GlobalErrorContainer.vue')
assert.match(container, /unsubscribe/)
assert.match(container, /dismissGlobalError/)
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /GlobalErrorContainer/)
})
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
initialCollapsed,
isNarrowScreen,
readCollapsePreference,
SIDEBAR_COLLAPSED_WIDTH,
SIDEBAR_EXPANDED_WIDTH,
SIDEBAR_NARROW_THRESHOLD,
sidebarPixelWidth,
toggleCollapsed,
writeCollapsePreference,
type CollapseStorage,
} from '../src/layout/sidebar-collapse.ts'
function fakeStorage(entries: Record<string, string>, failing = false): CollapseStorage {
return {
getItem(key: string) {
if (failing) throw new Error('storage down')
return key in entries ? entries[key] : null
},
setItem(key: string, value: string) {
if (failing) throw new Error('storage down')
entries[key] = value
},
}
}
test('test_task_014_sidebar_responsive_behavior_normal_primary_path', () => {
// 正常主路径:折叠开关反转状态。
assert.equal(toggleCollapsed(false), true)
assert.equal(toggleCollapsed(true), false)
})
test('test_task_014_sidebar_responsive_behavior_normal_variant_input', () => {
// 正常变体:不同宽度判定窄屏与宽屏。
assert.equal(isNarrowScreen(SIDEBAR_NARROW_THRESHOLD), true)
assert.equal(isNarrowScreen(SIDEBAR_NARROW_THRESHOLD + 1), false)
assert.equal(isNarrowScreen(1440), false)
})
test('test_task_014_sidebar_responsive_behavior_normal_repeated_operation_is_idempotent', () => {
// 正常重复:偏好读写与宽度结果稳定。
const storage = fakeStorage({})
assert.equal(initialCollapsed(1000, storage), initialCollapsed(1000, storage))
writeCollapsePreference(storage, true)
assert.equal(readCollapsePreference(storage), true)
assert.equal(readCollapsePreference(storage), true)
})
test('test_task_014_sidebar_responsive_behavior_boundary_empty_input', () => {
// 边界空值:未知宽度/无存储不崩溃且回退展开。
assert.equal(isNarrowScreen(0), false)
assert.equal(initialCollapsed(0, null), false)
assert.equal(readCollapsePreference(null), null)
})
test('test_task_014_sidebar_responsive_behavior_boundary_single_item', () => {
// 边界单元素:单值持久化可读回。
const storage = fakeStorage({ 'admin.sidebar.collapsed': 'false' })
assert.equal(readCollapsePreference(storage), false)
assert.equal(initialCollapsed(1024, storage), false)
})
test('test_task_014_sidebar_responsive_behavior_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:非法持久化值按无偏好处理;像素宽与常量一致。
const storage = fakeStorage({ 'admin.sidebar.collapsed': 'maybe' })
assert.equal(readCollapsePreference(storage), null)
assert.equal(sidebarPixelWidth(true), SIDEBAR_COLLAPSED_WIDTH)
assert.equal(sidebarPixelWidth(false), SIDEBAR_EXPANDED_WIDTH)
assert.equal(SIDEBAR_COLLAPSED_WIDTH < SIDEBAR_EXPANDED_WIDTH, true)
})
test('test_task_014_sidebar_responsive_behavior_invalid_input_rejected', () => {
// 异常输入:存储读写失败被容忍,不阻断交互。
const broken = fakeStorage({}, true)
assert.equal(readCollapsePreference(broken), null)
assert.doesNotThrow(() => writeCollapsePreference(broken, true))
const fallback = fakeStorage({ 'admin.sidebar.collapsed': 'true' })
assert.equal(initialCollapsed(1440, fallback), true, '持久化优先于视口宽度')
})
test('test_task_014_sidebar_responsive_behavior_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层接入折叠逻辑;CSS 有 is-collapsed 与窄屏媒体适配。
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /initialCollapsed/)
assert.match(layout, /writeCollapsePreference/)
assert.match(layout, /toggleSidebar/)
const css = readSource('src/styles/main.css')
assert.match(css, /is-collapsed/)
assert.match(css, /@media \(max-width: 820px\)/)
})
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
ADMIN_BRAND,
crumbsForActiveRoute,
resolveDocumentTitle,
updateDocumentTitle,
} from '../src/layout/title-breadcrumb.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 TREE: AdminMenuNode[] = [
node({ key: 'account', name: '账号权限', children: [
node({ key: 'admin_users', name: '用户管理', route: '/account/users' }),
] }),
node({ key: 'top', name: '顶级页', route: '/top' }),
]
test('test_task_015_page_title_breadcrumb_normal_primary_path', () => {
// 正常主路径:文档标题 = 页面标题 - 品牌。
assert.equal(resolveDocumentTitle('用户管理'), '用户管理 - 数富AI')
assert.equal(ADMIN_BRAND, '数富AI')
})
test('test_task_015_page_title_breadcrumb_normal_variant_input', () => {
// 正常变体:分组内命中时返回 分组->页面 两级面包屑。
const crumbs = crumbsForActiveRoute(TREE, '/account/users')
assert.deepEqual(crumbs.map((c) => c.name), ['账号权限', '用户管理'])
assert.equal(crumbs[1].route, '/account/users')
})
test('test_task_015_page_title_breadcrumb_normal_repeated_operation_is_idempotent', () => {
// 正常重复:标题与面包屑解析稳定。
assert.equal(resolveDocumentTitle('菜单管理'), resolveDocumentTitle('菜单管理'))
assert.deepEqual(crumbsForActiveRoute(TREE, '/account/users'), crumbsForActiveRoute(TREE, '/account/users'))
})
test('test_task_015_page_title_breadcrumb_boundary_empty_input', () => {
// 边界空值:无页面标题时文档标题仅品牌;空树/空激活路径返回空面包屑。
assert.equal(resolveDocumentTitle(''), ADMIN_BRAND)
assert.equal(resolveDocumentTitle(' '), ADMIN_BRAND)
assert.deepEqual(crumbsForActiveRoute([], '/x'), [])
assert.deepEqual(crumbsForActiveRoute(undefined, '/x'), [])
})
test('test_task_015_page_title_breadcrumb_boundary_single_item', () => {
// 边界单元素:顶级叶子页命中返回自身单级面包屑。
const crumbs = crumbsForActiveRoute(TREE, '/top')
assert.equal(crumbs.length, 1)
assert.equal(crumbs[0].name, '顶级页')
})
test('test_task_015_page_title_breadcrumb_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:激活路径不在菜单树中时返回空,不伪造父级。
assert.deepEqual(crumbsForActiveRoute(TREE, '/no/such/page'), [])
})
test('test_task_015_page_title_breadcrumb_invalid_input_rejected', () => {
// 异常输入:非法页面标题按空处理;doc 缺省时更新静默失败不抛异常。
assert.equal(resolveDocumentTitle(null as unknown as string), ADMIN_BRAND)
assert.doesNotThrow(() => updateDocumentTitle('标题', null))
})
test('test_task_015_page_title_breadcrumb_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层接入文档标题同步与面包屑渲染。
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /updateDocumentTitle/)
assert.match(layout, /resolveDocumentTitle/)
assert.match(layout, /crumbsForActiveRoute/)
assert.match(layout, /el-breadcrumb/)
assert.match(layout, /breadcrumbs\.length > 1/)
})
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
LOGOUT_CONFIRM_CANCEL,
LOGOUT_CONFIRM_MESSAGE,
LOGOUT_CONFIRM_OK,
LOGOUT_CONFIRM_TITLE,
runLogout,
} from '../src/layout/logout.ts'
test('test_task_016_shell_logout_interaction_normal_primary_path', async () => {
// 正常主路径:确认后执行退出。
let signedOut = 0
await runLogout({ confirm: async () => true, signOut: async () => { signedOut += 1 } })
assert.equal(signedOut, 1)
})
test('test_task_016_shell_logout_interaction_normal_variant_input', async () => {
// 正常变体:取消确认则不退出。
let signedOut = 0
await runLogout({ confirm: async () => false, signOut: async () => { signedOut += 1 } })
assert.equal(signedOut, 0)
})
test('test_task_016_shell_logout_interaction_normal_repeated_operation_is_idempotent', async () => {
// 正常重复:多次确认分别各执行一次,互不污染状态。
let signedOut = 0
const deps = { confirm: async () => true, signOut: async () => { signedOut += 1 } }
await runLogout(deps)
await runLogout(deps)
assert.equal(signedOut, 2)
})
test('test_task_016_shell_logout_interaction_boundary_empty_input', async () => {
// 边界空值:确认被拒后即便 signOut 有副作用也不会被调用。
let signedOut = 0
await runLogout({ confirm: async () => false, signOut: async () => { signedOut += 1 } })
assert.equal(signedOut, 0)
})
test('test_task_016_shell_logout_interaction_boundary_single_item', async () => {
// 边界单元素:signOut 异常向上抛出,供调用方反馈(不静默)。
await assert.rejects(
runLogout({ confirm: async () => true, signOut: async () => { throw new Error('退出接口失败') } }),
/退出接口失败/,
)
})
test('test_task_016_shell_logout_interaction_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:确认文案齐全、按钮语义完整。
assert.ok(LOGOUT_CONFIRM_MESSAGE.length > 0)
assert.ok(LOGOUT_CONFIRM_TITLE.length > 0)
assert.ok(LOGOUT_CONFIRM_OK.length > 0)
assert.ok(LOGOUT_CONFIRM_CANCEL.length > 0)
})
test('test_task_016_shell_logout_interaction_invalid_input_rejected', async () => {
// 异常输入:确认对话框异常(fail-closed),不得退出。
let signedOut = 0
await assert.rejects(
runLogout({
confirm: async () => { throw new Error('弹窗异常') },
signOut: async () => { signedOut += 1 },
}),
/弹窗异常/,
)
assert.equal(signedOut, 0)
})
test('test_task_016_shell_logout_interaction_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层使用 ElMessageBox 确认 + runLogoutstore 退出清态并跳登录。
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /ElMessageBox\.confirm/)
assert.match(layout, /runLogout/)
const store = readSource('src/stores/admin-session.ts')
assert.match(store, /\$reset\(\)/)
assert.match(store, /location\.assign\('\/login'\)/)
assert.match(layout, /LOGOUT_CONFIRM_MESSAGE/)
})
+91
View File
@@ -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)}`,
)
})
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
isAdminRole,
isSuperAdminRole,
isSuperUser,
roleKind,
roleLabel,
} from '../src/types/admin.ts'
test('test_task_021_type_contract_normal_primary_path', () => {
// 正常主路径:真实后端角色串映射到角色域。
assert.equal(roleKind('super_admin'), 'super_admin')
assert.equal(isSuperAdminRole('super_admin'), true)
assert.equal(isSuperUser({ role: 'super_admin' }), true)
})
test('test_task_021_type_contract_normal_variant_input', () => {
// 正常变体:admin/normal 归类正确,超级用户判定不为 true。
assert.equal(roleKind('admin'), 'admin')
assert.equal(roleKind('normal'), 'normal')
assert.equal(isSuperUser({ role: 'admin' }), false)
assert.equal(isAdminRole('admin'), true)
})
test('test_task_021_type_contract_normal_repeated_operation_is_idempotent', () => {
// 正常重复:角色归一稳定。
assert.equal(roleKind('SUPER_ADMIN'), roleKind('super_admin'))
assert.equal(roleLabel('admin'), roleLabel(' admin '))
})
test('test_task_021_type_contract_boundary_empty_input', () => {
// 边界空值:空角色/空用户不崩溃,归类为 null/非管理员。
assert.equal(roleKind(''), null)
assert.equal(roleKind(undefined), null)
assert.equal(roleKind(null), null)
assert.equal(isSuperUser(null), false)
assert.equal(isSuperUser(undefined), false)
})
test('test_task_021_type_contract_boundary_single_item', () => {
// 边界单元素:单个 super_admin 用户可进全部菜单。
assert.equal(isSuperUser({ role: 'super_admin' }), true)
assert.equal(roleLabel('super_admin'), '超级管理员')
})
test('test_task_021_type_contract_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:未知角色串不误判为管理员,展示空文案。
assert.equal(roleKind('owner'), null)
assert.equal(isAdminRole('owner'), false)
assert.equal(roleLabel('owner'), '')
})
test('test_task_021_type_contract_invalid_input_rejected', () => {
// 异常输入:大小写/空白容忍,非法字符串归类 null。
assert.equal(roleKind(' Super_Admin '), 'super_admin')
assert.equal(roleKind('xxx'), null)
assert.equal(isSuperAdminRole(''), false)
})
test('test_task_021_type_contract_dependency_failure_returns_actionable_message', () => {
// 依赖失败:菜单节点契约为分组可无 route、页面必须可路由。
const group = { key: 'admin_group_account', name: '账号权限' }
const page = { key: 'admin_users', name: '用户管理', route: '/account/users' }
assert.equal('route' in group, false, '分组节点允许无 route')
assert.equal(typeof page.route, 'string', '页面节点必须带 route')
assert.equal(roleLabel('super_admin').length > 0, true)
})
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
errorTextOf,
isUnauthorized,
loginRedirectTarget,
requestErrorMessage,
REQUEST_FALLBACK_MESSAGE,
unwrap,
} from '../src/api/envelope.ts'
test('test_task_022_envelope_rules_normal_primary_path', () => {
// 正常主路径:带 data 的信封解包出业务数据。
const out = unwrap<{ item: { id: number } }>({ success: true, data: { item: { id: 1 } } })
assert.equal(out.item.id, 1)
})
test('test_task_022_envelope_rules_normal_variant_input', () => {
// 正常变体:{items}/{item} 信封(无 data)原样返回由调用方再解。
const payload = { success: true, items: [{ key: 'a' }] }
const out = unwrap<{ items: Array<{ key: string }> }>(payload)
assert.equal(out.items[0].key, 'a')
})
test('test_task_022_envelope_rules_normal_repeated_operation_is_idempotent', () => {
// 正常重复:解包结果稳定、不修改输入。
const payload = { success: true, data: [1, 2] }
const a = JSON.stringify(unwrap(payload))
const b = JSON.stringify(unwrap(payload))
assert.equal(a, b)
assert.deepEqual(payload, { success: true, data: [1, 2] })
})
test('test_task_022_envelope_rules_boundary_empty_input', () => {
// 边界空值:空/非对象负载按原样返回或回落网络提示。
assert.equal(unwrap(undefined as unknown as null), undefined)
assert.equal(requestErrorMessage(undefined), REQUEST_FALLBACK_MESSAGE)
})
test('test_task_022_envelope_rules_boundary_single_item', () => {
// 边界单元素:错误文案优先取响应 message。
const error = { response: { data: { message: '未登录', error: 'old' } } }
assert.equal(requestErrorMessage(error), '未登录')
})
test('test_task_022_envelope_rules_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:success=false 且 message 缺失回落到通用失败。
assert.throws(() => unwrap({ success: false }), /请求失败/)
assert.equal(errorTextOf({ error: '权限不足' }), '权限不足')
assert.equal(errorTextOf({ message: '', error: 'X' }), 'X')
})
test('test_task_022_envelope_rules_invalid_input_rejected', () => {
// 异常输入:401 判定覆盖 code/status/statusCode;非 401 不误判。
assert.equal(isUnauthorized({ code: 401 }), true)
assert.equal(isUnauthorized({ status: 401 }), true)
assert.equal(isUnauthorized({ statusCode: 401 }), true)
assert.equal(isUnauthorized({ code: 403 }), false)
assert.equal(isUnauthorized(null), false)
assert.equal(requestErrorMessage(new Error('Network Error')), 'Network Error')
})
test('test_task_022_envelope_rules_dependency_failure_returns_actionable_message', () => {
// 依赖失败:http 层复用 envelope 并统一处理 401 跳登录。
const http = readSource('src/api/http.ts')
assert.match(http, /export \{ unwrap \} from '\.\/envelope'/)
assert.match(http, /isUnauthorized/)
assert.match(http, /login\?redirect=/)
assert.equal(loginRedirectTarget({ pathname: '/admin-vue/x', search: '?a=1' }), encodeURIComponent('/admin-vue/x?a=1'))
})
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseCurrentUser } from '../src/api/session-model.ts'
test('test_task_023_current_user_adapter_normal_primary_path', () => {
// 正常主路径:Java data:{item} 信封解出当前用户。
const user = parseCurrentUser({
success: true,
data: { item: { id: 1, username: 'admin', role: 'super_admin' } },
})
assert.equal(user.id, 1)
assert.equal(user.username, 'admin')
assert.equal(user.role, 'super_admin')
})
test('test_task_023_current_user_adapter_normal_variant_input', () => {
// 正常变体:无 data 的 {item} 信封同样可解。
const user = parseCurrentUser({ success: true, item: { id: 3, username: '张伟恒', role: 'admin' } })
assert.equal(user.username, '张伟恒')
assert.equal(user.role, 'admin')
})
test('test_task_023_current_user_adapter_normal_repeated_operation_is_idempotent', () => {
// 正常重复:解析不修改输入、结果稳定。
const payload = { success: true, data: { item: { id: 2, username: 'u', role: 'normal' } } }
assert.deepEqual(parseCurrentUser(payload), parseCurrentUser(payload))
assert.deepEqual(payload, { success: true, data: { item: { id: 2, username: 'u', role: 'normal' } } })
})
test('test_task_023_current_user_adapter_boundary_empty_input', () => {
// 边界空值:响应缺 id 视为无效并抛可操作错误。
assert.throws(() => parseCurrentUser({ success: true, data: { item: {} } }), /缺少有效 id/)
assert.throws(() => parseCurrentUser(null), /缺少有效 id|请求失败/)
})
test('test_task_023_current_user_adapter_boundary_single_item', () => {
// 边界单元素:单用户最小字段即可。
const user = parseCurrentUser({ data: { item: { id: 9, username: 'root', role: 'super_admin' } } })
assert.equal(user.username, 'root')
})
test('test_task_023_current_user_adapter_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:success=false 被拒绝并带后端 message。
assert.throws(
() => parseCurrentUser({ success: false, message: '未登录', code: 401 }),
/未登录/,
)
})
test('test_task_023_current_user_adapter_invalid_input_rejected', () => {
// 异常输入:缺 role/username 不应崩溃;缺 id 拒绝。
const user = parseCurrentUser({ data: { item: { id: 5, username: '', role: '' } } })
assert.equal(user.username, '')
assert.throws(() => parseCurrentUser({ data: { item: { username: 'no-id' } } }), /缺少有效 id/)
})
test('test_task_023_current_user_adapter_dependency_failure_returns_actionable_message', () => {
// 依赖失败:会话请求适配消费同一解析器;纯解析模块不含 axios 副作用。
const session = readSource('src/api/session.ts')
assert.match(session, /parseCurrentUser/)
const model = readSource('src/api/session-model.ts')
assert.match(model, /import \{ unwrap \} from '\.\/envelope(?:\.ts)?'/)
})
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseMenuTree } from '../src/api/session-model.ts'
const GROUPED = {
success: true,
data: {
items: [
{ key: 'admin_group_account', name: '账号与权限', children: [
{ key: 'admin_users', name: '用户管理', route: '/account/users' },
] },
],
},
}
test('test_task_024_menu_tree_adapter_normal_primary_path', () => {
// 正常主路径:data:{items} 树形信封解出分组与页面。
const tree = parseMenuTree(GROUPED)
assert.equal(tree.length, 1)
assert.equal(tree[0].key, 'admin_group_account')
assert.equal(tree[0].children?.[0].route, '/account/users')
})
test('test_task_024_menu_tree_adapter_normal_variant_input', () => {
// 正常变体:数组直传与 {items} 信封均可解析。
const direct = parseMenuTree([{ key: 'a', name: '甲' }])
assert.equal(direct.length, 1)
const envelope = parseMenuTree({ success: true, items: [{ key: 'b', name: '乙', route: '/b' }] })
assert.equal(envelope[0].route, '/b')
})
test('test_task_024_menu_tree_adapter_normal_repeated_operation_is_idempotent', () => {
// 正常重复:解析稳定、不改输入。
assert.deepEqual(parseMenuTree(GROUPED), parseMenuTree(GROUPED))
assert.deepEqual(GROUPED, {
success: true,
data: { items: [{ key: 'admin_group_account', name: '账号与权限', children: [{ key: 'admin_users', name: '用户管理', route: '/account/users' }] }] },
})
})
test('test_task_024_menu_tree_adapter_boundary_empty_input', () => {
// 边界空值:空 items/空数组/缺省按空树处理,不抛错(与接口失败区分)。
assert.deepEqual(parseMenuTree({ success: true, data: { items: [] } }), [])
assert.deepEqual(parseMenuTree({ success: true, items: [] }), [])
assert.deepEqual(parseMenuTree({ success: true, data: {} }), [])
assert.deepEqual(parseMenuTree([]), [])
})
test('test_task_024_menu_tree_adapter_boundary_single_item', () => {
// 边界单元素:单个分组节点返回。
const tree = parseMenuTree({ data: { items: [{ key: 'g', name: '分组' }] } })
assert.equal(tree.length, 1)
assert.equal(tree[0].name, '分组')
})
test('test_task_024_menu_tree_adapter_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:页面缺 route 不崩溃;group 无 route 合法。
const tree = parseMenuTree({ items: [{ key: 'g', name: '组', children: [{ key: 'p', name: '页' }] }] })
assert.equal(tree[0].children?.[0].route, undefined)
})
test('test_task_024_menu_tree_adapter_invalid_input_rejected', () => {
// 异常输入:success=false 视为接口失败并抛错(区别于空权限)。
assert.throws(() => parseMenuTree({ success: false, message: '未登录', code: 401 }), /未登录/)
assert.deepEqual(parseMenuTree(null as unknown as { items?: [] }), [])
})
test('test_task_024_menu_tree_adapter_dependency_failure_returns_actionable_message', () => {
// 依赖失败:会话适配调用菜单解析器;后端按 sort 顺序原样返回(前端不重排)。
const session = readSource('src/api/session.ts')
assert.match(session, /parseMenuTree/)
const model = readSource('src/api/session-model.ts')
assert.match(model, /parseMenuTree/)
})
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { loginRedirectTarget } from '../src/api/envelope.ts'
const httpSrc = readSource('src/api/http.ts')
const sessionSrc = readSource('src/api/session.ts')
test('test_task_025_cookie_session_config_normal_primary_path', () => {
// 正常主路径:共享传输开启同源 Cookie/SessionAxios withCredentials 必须开启)。
assert.match(httpSrc, /withCredentials:\s*true/, 'Cookie 规则:Axios withCredentials 必须开启')
assert.match(httpSrc, /baseURL:\s*'\/'/, '请求使用同源 Cookie/Session')
})
test('test_task_025_cookie_session_config_normal_variant_input', () => {
// 正常变体:current-user / menus / logout 三类会话调用全部复用同一共享 http 实例。
assert.match(sessionSrc, /import \{ http \} from '\.\/http'/)
const getCalls = (sessionSrc.match(/\bhttp\.get\b/g) || []).length
assert.equal(getCalls, 2, 'current-user 与 menus 两个 GET 均走共享 http')
assert.equal((sessionSrc.match(/\bhttp\.post\b/g) || []).length, 1, 'logout 走共享 http')
assert.match(sessionSrc, /current-user/)
assert.match(sessionSrc, /current-user\/menus/)
})
test('test_task_025_cookie_session_config_normal_repeated_operation_is_idempotent', () => {
// 正常重复:传输模块只创建一次 axios 实例,会话模块不另建客户端、不污染状态。
assert.equal((httpSrc.match(/axios\.create/g) || []).length, 1, '共享实例只创建一次')
assert.equal((sessionSrc.match(/axios\.create/g) || []).length, 0, '会话模块不自行创建新客户端')
assert.equal((sessionSrc.match(/import \{ http \}/g) || []).length, 1)
})
test('test_task_025_cookie_session_config_boundary_empty_input', () => {
// 边界空值:无登录态时请求层不把凭证写入本地/会话存储(前端不保存 JWT/密码)。
for (const rel of ['src/api/http.ts', 'src/api/session.ts']) {
const src = readSource(rel)
assert.equal((src.match(/localStorage/g) || []).length, 0, `${rel} 不得使用 localStorage`)
assert.equal((src.match(/sessionStorage/g) || []).length, 0, `${rel} 不得使用 sessionStorage`)
assert.equal(/document\.cookie\s*=/.test(src), false, `${rel} 不得直接写 document.cookie`)
}
})
test('test_task_025_cookie_session_config_boundary_single_item', () => {
// 边界单元素:登出是单次 POST,以 AJAX 标识发到共享实例,后端据此返回 JSON 而非页面。
assert.match(sessionSrc, /X-Requested-With/, '登出走 AJAX 标识以便后端识别为 JSON 请求')
assert.match(sessionSrc, /http\.post/)
assert.match(sessionSrc, /\/logout/, '登出走 auth 模块根端点 POST /logout')
assert.equal(sessionSrc.includes('/api/admin/logout'), false, '不应指向不存在的 /api/admin/logout')
})
test('test_task_025_cookie_session_config_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:即便会话在身,传输层也不注入 token/Authorization 头——
// 凭证只由同源 Cookie 携带,前端禁止保存并在后续请求回填。
for (const rel of ['src/api/http.ts', 'src/api/session.ts', 'src/api/session-model.ts']) {
const src = readSource(rel)
assert.equal(/\bAuthorization\b/.test(src), false, `${rel} 不应注入 Authorization 头`)
assert.equal(/\bBearer\b/.test(src), false, `${rel} 不应出现 Bearer token`)
}
})
test('test_task_025_cookie_session_config_invalid_input_rejected', () => {
// 异常输入:Cookie 规则要求 withCredentials 必须开启、同源根路径、有界超时。
assert.match(httpSrc, /withCredentials:\s*true/, '传输配置必须字面量开启 withCredentials')
assert.match(httpSrc, /baseURL:\s*'\/'/, '传输配置必须使用同源根路径')
assert.match(httpSrc, /timeout:\s*\d+/, '传输配置必须声明数值型有界超时')
})
test('test_task_025_cookie_session_config_dependency_failure_returns_actionable_message', () => {
// 依赖失败:会话失效/网络异常走同一拦截与解包兜底,且登录跳转携带当前目标路径。
assert.match(httpSrc, /interceptors\.response/, '存在 401/异常统一响应拦截')
assert.match(httpSrc, /unwrap/)
assert.match(httpSrc, /isUnauthorized/)
assert.match(httpSrc, /login\?redirect=/)
const target = loginRedirectTarget({ pathname: '/admin-vue/account/users', search: '?x=1' })
assert.equal(target, encodeURIComponent('/admin-vue/account/users?x=1'), 'redirect 需携带当前目标路径')
})
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
isAuthEndpointRequest,
isLoginLocation,
shouldRedirectUnauthorized,
} from '../src/api/envelope.ts'
test('test_task_026_unauthorized_redirect_normal_primary_path', () => {
// 正常主路径:普通业务页内某受保护接口 401,应跳转登录(携带目标路径)。
assert.equal(
shouldRedirectUnauthorized('/admin-vue/account/users', '/api/admin/current-user'),
true,
'业务页内受保护请求 401 应触发跳登录',
)
})
test('test_task_026_unauthorized_redirect_normal_variant_input', () => {
// 正常变体:已经在登录页(含 redirect 查询)时任何 401 不再二次踢回,避免循环。
assert.equal(shouldRedirectUnauthorized('/login', '/api/admin/current-user'), false)
assert.equal(shouldRedirectUnauthorized('/login?redirect=%2Fadmin-vue%2Faccount%2Fusers', '/api/admin/current-user'), false)
})
test('test_task_026_unauthorized_redirect_normal_repeated_operation_is_idempotent', () => {
// 正常重复:纯函数重复求值结果稳定,无副作用。
const once = shouldRedirectUnauthorized('/login', '/login')
const twice = shouldRedirectUnauthorized('/login', '/login')
assert.equal(once, false)
assert.equal(twice, once)
assert.equal(isLoginLocation('/login?redirect=/x'), isLoginLocation('/login?redirect=/x'))
})
test('test_task_026_unauthorized_redirect_boundary_empty_input', () => {
// 边界空值:当前路径为空/请求 URL 缺省时不误判为登录场景,按应跳转处理。
assert.equal(shouldRedirectUnauthorized('', undefined), true)
assert.equal(shouldRedirectUnauthorized('/admin-vue/', ''), true)
assert.equal(isLoginLocation(''), false)
assert.equal(isAuthEndpointRequest(undefined), false)
})
test('test_task_026_unauthorized_redirect_boundary_single_item', () => {
// 边界单元素:401 请求本身是登录提交时不得再跳登录(否则吞掉登录失败反馈)。
assert.equal(shouldRedirectUnauthorized('/admin-vue/account/users', '/login'), false)
assert.equal(isAuthEndpointRequest('/login'), true)
})
test('test_task_026_unauthorized_redirect_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:登录端点支持绝对 URL 与带查询串的受保护请求两种形态。
assert.equal(isAuthEndpointRequest('http://api.aishufu.top/login'), true)
assert.equal(isAuthEndpointRequest('https://aishufu.top/login?device=web'), true)
assert.equal(shouldRedirectUnauthorized('/admin-vue/', '/api/admin/current-user?x=1'), true)
assert.equal(isAuthEndpointRequest('/api/admin/current-user?x=1'), false)
})
test('test_task_026_unauthorized_redirect_invalid_input_rejected', () => {
// 异常输入:非法/非字符串输入不抛错,且默认按“应跳转”处理而非误放行。
assert.equal(shouldRedirectUnauthorized(undefined as unknown as string, undefined), true)
assert.equal(shouldRedirectUnauthorized(null as unknown as string, null as unknown as string), true)
assert.equal(isAuthEndpointRequest(42 as unknown as string), false)
assert.equal(isLoginLocation('/admin-vue/login'), false)
})
test('test_task_026_unauthorized_redirect_dependency_failure_returns_actionable_message', () => {
// 依赖失败:http 层 success/error 两支都要先过守卫再带目标路径跳 /login。
const http = readSource('src/api/http.ts')
assert.match(http, /shouldRedirectUnauthorized/, '401 跳转必须经守卫避免登录页循环')
assert.match(http, /login\?redirect=/, '跳转仍携带目标路径参数')
assert.match(http, /interceptors\.response/)
assert.match(http, /response\.config\?\.url/, '成功分支以失败请求 URL 判定登录请求')
assert.match(http, /error\?\.config\?\.url|error\.config\.url/, '异常分支同样传入请求 URL')
})
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
const store = readSource('src/stores/admin-session.ts')
test('test_task_027_store_initial_state_normal_primary_path', () => {
// 正常主路径:state 以函数返回全新对象,含 5 个契约字段。
assert.match(store, /state:\s*\(\)\s*=>\s*\(\{/, 'state 必须是返回对象字面量的工厂函数')
assert.match(store, /user:\s*null/)
assert.match(store, /menuTree:\s*\[\]/)
assert.match(store, /initialized:\s*false/)
assert.match(store, /loading:\s*false/)
assert.match(store, /error:\s*''/)
})
test('test_task_027_store_initial_state_normal_variant_input', () => {
// 正常变体:状态字段与模块契约字段一一对应,供 AdminLayout / guard 消费。
assert.match(store, /user\b/)
assert.match(store, /menuTree\b/)
assert.match(store, /initialized\b/)
assert.match(store, /loading\b/)
assert.match(store, /error\b/)
})
test('test_task_027_store_initial_state_normal_repeated_operation_is_idempotent', () => {
// 正常重复:只声明一个 Pinia store 定义,实例由 useStore 工厂创建,不共享可变单例。
assert.equal((store.match(/defineStore\(/g) || []).length, 1, '仅定义一个 admin-session store')
assert.match(store, /export const useAdminSessionStore\s*=\s*defineStore/)
})
test('test_task_027_store_initial_state_boundary_empty_input', () => {
// 边界空值:未登录时 user 为 null、error 为空串,而不是 undefined 残留。
assert.match(store, /user:\s*null\s+as\s+AdminUser/)
assert.match(store, /error:\s*''/)
})
test('test_task_027_store_initial_state_boundary_single_item', () => {
// 边界单元素:初始时无任何用户/菜单,首次进入路由需触发初始化。
assert.match(store, /initialized:\s*false/, '未初始化才能触发首次初始化')
const router = readSource('src/router/index.ts')
assert.match(router, /if\s*\(!session\.initialized\)/, '守卫在未初始化时调用 initialize')
})
test('test_task_027_store_initial_state_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:初始状态不携带任何凭证字段,凭证只走同源 Cookie。
assert.equal(/\btoken\b|\bjwt\b|\bpassword\b/.test(store), false, 'store 初始状态不得保存凭证')
})
test('test_task_027_store_initial_state_invalid_input_rejected', () => {
// 异常输入:非法/未知状态字段不应出现(state 形状稳定)。
assert.equal(/\bnickname\b|\bavatar\b/.test(store), false, 'store 不引入契约外字段')
assert.match(store, /import type \{ AdminMenuNode, AdminUser \}/)
})
test('test_task_027_store_initial_state_dependency_failure_returns_actionable_message', () => {
// 依赖失败:store 依赖会话 API 与 pinia,状态字段供 getter/action 消费。
assert.match(store, /import \{ defineStore \} from 'pinia'/)
assert.match(store, /fetchCurrentUser/)
assert.match(store, /fetchAdminMenuTree/)
assert.match(store, /getters:/)
assert.match(store, /firstVisible:/)
assert.match(store, /isSuperAdmin:/)
})
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseCurrentUser } from '../src/api/session-model.ts'
const store = readSource('src/stores/admin-session.ts')
test('test_task_028_current_user_init_normal_primary_path', () => {
// 正常主路径:初始化第一步拉取当前用户并写入 store.user。
assert.match(store, /this\.user\s*=\s*await fetchCurrentUser\(\)/)
})
test('test_task_028_current_user_init_normal_variant_input', () => {
// 正常变体:先取用户、再取菜单,顺序不可颠倒(菜单依赖登录身份)。
assert.ok(store.indexOf('fetchCurrentUser()') < store.indexOf('fetchAdminMenuTree()'), '当前用户应先于菜单拉取')
})
test('test_task_028_current_user_init_normal_repeated_operation_is_idempotent', () => {
// 正常重复:已初始化/加载中不再重复发起当前用户请求。
assert.match(store, /if\s*\(this\.initialized\s*\|\|\s*this\.loading\)\s*return/)
const userCalls = (store.match(/fetchCurrentUser\(\)/g) || []).length
assert.ok(userCalls <= 2, '用户拉取只出现在 store 内声明与调用处')
})
test('test_task_028_current_user_init_boundary_empty_input', () => {
// 边界空值:解析器在空/非对象负载下抛可操作错误,而非返回残缺用户。
assert.throws(() => parseCurrentUser(null), /缺少有效 id|请求失败/)
})
test('test_task_028_current_user_init_boundary_single_item', () => {
// 边界单元素:单个 super_admin 用户被正确解析写入。
const user = parseCurrentUser({ success: true, data: { item: { id: 7, username: 'root', role: 'super_admin' } } })
assert.equal(user.id, 7)
assert.equal(user.username, 'root')
})
test('test_task_028_current_user_init_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:成功但字段缺失时不崩溃;初始化失败会向上抛由守卫处理。
const user = parseCurrentUser({ data: { item: { id: 2, username: '', role: '' } } })
assert.equal(user.username, '')
})
test('test_task_028_current_user_init_invalid_input_rejected', () => {
// 异常输入:后端 401/失败信封被拒绝并带 message。
assert.throws(() => parseCurrentUser({ success: false, code: 401, message: '未登录' }), /未登录/)
assert.throws(() => parseCurrentUser({ data: { item: { username: 'no-id' } } }), /缺少有效 id/)
})
test('test_task_028_current_user_init_dependency_failure_returns_actionable_message', () => {
// 依赖失败:store 复用会话适配器 fetchCurrentUser(内部走 parseCurrentUser + http)。
assert.match(store, /import \{ fetchAdminMenuTree, fetchCurrentUser, logout \} from '@\/api\/session'/)
const session = readSource('src/api/session.ts')
assert.match(session, /fetchCurrentUser/)
assert.match(session, /\/api\/admin\/current-user/)
})
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseMenuTree } from '../src/api/session-model.ts'
const store = readSource('src/stores/admin-session.ts')
test('test_task_029_menu_tree_init_normal_primary_path', () => {
// 正常主路径:用户拉取成功后拉取菜单树并写入 store.menuTree。
assert.match(store, /this\.menuTree\s*=\s*await fetchAdminMenuTree\(\)/)
})
test('test_task_029_menu_tree_init_normal_variant_input', () => {
// 正常变体:两组数据都成功后置 initialized,才算初始化完成。
assert.match(store, /this\.initialized\s*=\s*true/)
})
test('test_task_029_menu_tree_init_normal_repeated_operation_is_idempotent', () => {
// 正常重复:菜单拉取在 store 只声明一次;成功置位后再进入即短路。
assert.ok((store.match(/fetchAdminMenuTree\(\)/g) || []).length >= 1)
assert.match(store, /if\s*\(this\.initialized\s*\|\|\s*this\.loading\)\s*return/)
})
test('test_task_029_menu_tree_init_boundary_empty_input', () => {
// 边界空值:空菜单树(无权限)也是合法初始化结果,与接口失败区分。
assert.deepEqual(parseMenuTree({ success: true, data: { items: [] } }), [])
})
test('test_task_029_menu_tree_init_boundary_single_item', () => {
// 边界单元素:单个分组节点正常写入,供侧边栏渲染。
const tree = parseMenuTree({ data: { items: [{ key: 'g', name: '分组' }] } })
assert.equal(tree.length, 1)
assert.equal(tree[0].key, 'g')
})
test('test_task_029_menu_tree_init_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:菜单树含 icon/sort 等附加字段时不被丢弃。
const tree = parseMenuTree({ data: { items: [{ key: 'p', name: '页', route: '/p', sort: 2, icon: 'grid' }] } })
assert.equal(tree[0].sort, 2)
assert.equal(tree[0].icon, 'grid')
})
test('test_task_029_menu_tree_init_invalid_input_rejected', () => {
// 异常输入:success=false 视为接口失败抛错,不落成“无权限空树”。
assert.throws(() => parseMenuTree({ success: false, message: '菜单拉取失败' }), /菜单拉取失败/)
})
test('test_task_029_menu_tree_init_dependency_failure_returns_actionable_message', () => {
// 依赖失败:store 菜单初始化复用 fetchAdminMenuTree;会话适配器指向菜单端点。
assert.match(store, /fetchAdminMenuTree/)
const session = readSource('src/api/session.ts')
assert.match(session, /\/api\/admin\/current-user\/menus/)
assert.match(session, /parseMenuTree/)
})
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource, occurrences, missingImports } from './helpers.ts'
const LAYOUT = 'src/layout/AdminLayout.vue'
const ROUTER = 'src/router/index.ts'
test('test_task_003_layout_contract_normal_primary_path', () => {
// 正常主路径:壳层作为页面容器,唯一地把业务视图挂到 RouterView。
const layout = readSource(LAYOUT)
assert.equal(occurrences(layout, '<RouterView'), 1, '页面容器只能挂载一个 RouterView')
assert.ok(occurrences(layout, 'admin-content') >= 1, '需有内容区挂载点')
})
test('test_task_003_layout_contract_normal_variant_input', () => {
// 正常变体:页面接线归路由注册表所有;壳层不得静态 import 业务页面。
const layout = readSource(LAYOUT)
const registry = readSource('src/router/routes.ts')
assert.equal(occurrences(layout, '@/pages/'), 0, 'AdminLayout 不得静态 import 业务页面')
assert.ok(registry.includes('() => import('), '注册表使用异步组件加载页面')
})
test('test_task_003_layout_contract_normal_repeated_operation_is_idempotent', () => {
// 正常重复:只读复查结果稳定,不依赖可变状态。
const layoutA = readSource(LAYOUT)
const layoutB = readSource(LAYOUT)
assert.equal(layoutA.length, layoutB.length)
assert.equal(occurrences(layoutA, 'RouterView'), occurrences(layoutB, 'RouterView'))
})
test('test_task_003_layout_contract_boundary_empty_input', () => {
// 边界空值:菜单为空时应展示无菜单状态,且页面容器本身仍保留。
const layout = readSource(LAYOUT)
assert.ok(layout.includes('el-empty'), '无菜单需有空状态占位')
assert.ok(layout.includes('emptyMenu'), '空菜单由空态判定驱动')
assert.equal(occurrences(layout, '<RouterView'), 1)
})
test('test_task_003_layout_contract_boundary_single_item', () => {
// 边界单元素:单一内容区与单一页面挂载点。
const layout = readSource(LAYOUT)
assert.equal(occurrences(layout, 'admin-main'), 1)
assert.equal(occurrences(layout, '<aside'), 1, '侧边栏唯一')
assert.equal(occurrences(layout, '<header'), 1, '顶栏唯一')
})
test('test_task_003_layout_contract_boundary_limit_or_missing_field', () => {
// 边界上限:Element Plus 交互能力按需命名导入,不整包拖入壳层。
const layout = readSource(LAYOUT)
assert.match(layout, /import\s*\{\s*ElMessage(?:,\s*\w+)*\s*\}\s*from\s*['"]element-plus['"]/)
assert.equal(occurrences(layout, 'import ElementPlus'), 0, '壳层不得整包导入 ElementPlus')
})
test('test_task_003_layout_contract_invalid_input_rejected', () => {
// 异常输入:壳层不得自行发起后台数据请求,也不得引用 @/api。
const layout = readSource(LAYOUT)
assert.equal(occurrences(layout, '@/api/'), 0, 'AdminLayout 不得直接调用后台 API')
assert.equal(occurrences(layout, 'http.get'), 0)
})
test('test_task_003_layout_contract_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层自身依赖必须全部可解析,缺失时返回可操作清单。
const missing = missingImports(LAYOUT)
assert.deepEqual(missing, [], `存在无法解析的 import: ${missing.join('; ')}`)
const routerMissing = missingImports(ROUTER)
assert.deepEqual(routerMissing, [], `路由存在无法解析的 import: ${routerMissing.join('; ')}`)
})
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
const store = readSource('src/stores/admin-session.ts')
test('test_task_030_init_failure_retry_normal_primary_path', () => {
// 正常主路径:初始化失败写入 error 并向上抛出(守卫据此停在该页/跳转)。
assert.match(store, /catch\s*\(error\)/)
assert.match(store, /this\.error\s*=/)
assert.match(store, /throw error/)
})
test('test_task_030_init_failure_retry_normal_variant_input', () => {
// 正常变体:异常为普通 Error 时取其 message;否则给可读兜底文案。
assert.match(store, /instanceof Error/)
assert.match(store, /后台初始化失败/)
})
test('test_task_030_init_failure_retry_normal_repeated_operation_is_idempotent', () => {
// 正常重复:失败后 initialized 仍为 false,下一次调用可重试。
assert.match(store, /if\s*\(this\.initialized\s*\|\|\s*this\.loading\)\s*return/, '重试入口短路守卫')
})
test('test_task_030_init_failure_retry_boundary_empty_input', () => {
// 边界空值:错误文案缺省时有兜底,不让空串直接进入 error 态。
assert.match(store, /后台初始化失败/)
})
test('test_task_030_init_failure_retry_boundary_single_item', () => {
// 边界单元素:单次失败只写一次 errorloading 在 finally 复位。
assert.match(store, /finally\s*\{/)
assert.match(store, /this\.loading\s*=\s*false/)
})
test('test_task_030_init_failure_retry_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:同时防止并发/重复触发(loading/initialized 双闸)。
assert.match(store, /this\.loading\s*=\s*true/)
assert.match(store, /this\.error\s*=\s*''/)
assert.match(store, /if\s*\(this\.initialized\s*\|\|\s*this\.loading\)\s*return/)
})
test('test_task_030_init_failure_retry_invalid_input_rejected', () => {
// 异常输入:接口失败时不落成“无菜单空态”,错误必须可抛可读。
assert.equal(/shouldShowEmptyMenu/.test(store), false, 'store 不把接口失败当无菜单')
})
test('test_task_030_init_failure_retry_dependency_failure_returns_actionable_message', () => {
// 依赖失败:守卫捕获 initialize 异常时不让应用白屏(返回拦截)。
const router = readSource('src/router/index.ts')
assert.match(router, /await session\.initialize\(\)/)
assert.match(router, /catch\s*\{/)
})
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { isSuperAdminRole, isSuperUser } from '../src/types/admin.ts'
test('test_task_031_super_admin_judge_normal_primary_path', () => {
// 正常主路径:super_admin 角色判定为超级管理员,可进全部菜单。
assert.equal(isSuperAdminRole('super_admin'), true)
assert.equal(isSuperUser({ role: 'super_admin' }), true)
})
test('test_task_031_super_admin_judge_normal_variant_input', () => {
// 正常变体:admin/normal 不是超级管理员。
assert.equal(isSuperAdminRole('admin'), false)
assert.equal(isSuperAdminRole('normal'), false)
})
test('test_task_031_super_admin_judge_normal_repeated_operation_is_idempotent', () => {
// 正常重复:大小写/空白差异归一后判定稳定。
assert.equal(isSuperAdminRole('SUPER_ADMIN'), isSuperAdminRole('super_admin'))
assert.equal(isSuperAdminRole(' Super_Admin '), true)
})
test('test_task_031_super_admin_judge_boundary_empty_input', () => {
// 边界空值:空/null 角色不判为超级管理员,也不抛错。
assert.equal(isSuperAdminRole(''), false)
assert.equal(isSuperAdminRole(undefined), false)
assert.equal(isSuperAdminRole(null), false)
})
test('test_task_031_super_admin_judge_boundary_single_item', () => {
// 边界单元素:单个 super_admin 用户被 store getter 识别。
const store = readSource('src/stores/admin-session.ts')
assert.match(store, /isSuperAdmin\s*:/, 'store 提供 isSuperAdmin getter')
assert.match(store, /isSuperAdminRole/, 'getter 复用角色归一判定')
})
test('test_task_031_super_admin_judge_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:未知角色串不误判超级管理员。
assert.equal(isSuperAdminRole('owner'), false)
assert.equal(isSuperAdminRole('xxx'), false)
})
test('test_task_031_super_admin_judge_invalid_input_rejected', () => {
// 异常输入:store getter 不应再用未归一化的原始等值比较。
const store = readSource('src/stores/admin-session.ts')
assert.equal(store.includes("role === 'super_admin'"), false, '不允许裸等值判定,需走角色归一')
})
test('test_task_031_super_admin_judge_dependency_failure_returns_actionable_message', () => {
// 依赖失败:类型契约模块提供角色判定,供 store 与守卫复用。
const source = readSource('src/stores/admin-session.ts')
assert.match(source, /isSuperAdminRole/)
const adminTypes = readSource('src/types/admin.ts')
assert.match(adminTypes, /isSuperAdminRole/)
assert.match(adminTypes, /roleKind\(/)
})
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { hasMenuKey } from '../src/router/helpers.ts'
test('test_task_032_has_menu_key_lookup_normal_primary_path', () => {
// 正常主路径:树内顶层菜单 key 可被命中。
const tree = [{ key: 'admin_users', name: '用户管理', route: '/account/users' }]
assert.equal(hasMenuKey(tree, 'admin_users'), true)
})
test('test_task_032_has_menu_key_lookup_normal_variant_input', () => {
// 正常变体:叶子在分组深层时也能命中;未授权 key 不命中。
const tree = [{ key: 'g', name: '组', children: [{ key: 'leaf', name: '叶', route: '/leaf' }] }]
assert.equal(hasMenuKey(tree, 'leaf'), true)
assert.equal(hasMenuKey(tree, 'missing'), false)
})
test('test_task_032_has_menu_key_lookup_normal_repeated_operation_is_idempotent', () => {
// 正常重复:查找不修改树、结果稳定。
const tree = [{ key: 'a', name: 'A', children: [{ key: 'b', name: 'B', route: '/b' }] }]
const snapshot = JSON.stringify(tree)
assert.equal(hasMenuKey(tree, 'b'), hasMenuKey(tree, 'b'))
assert.deepEqual(JSON.stringify(tree), snapshot)
})
test('test_task_032_has_menu_key_lookup_boundary_empty_input', () => {
// 边界空值:空/缺省树查找返回 false,不抛错。
assert.equal(hasMenuKey([], 'x'), false)
assert.equal(hasMenuKey(null as unknown as { key: string; name: string }[], 'x'), false)
assert.equal(hasMenuKey(undefined as unknown as { key: string; name: string }[], 'x'), false)
})
test('test_task_032_has_menu_key_lookup_boundary_single_item', () => {
// 边界单元素:单节点树命中与未命中。
const tree = [{ key: 'solo', name: '单页', route: '/solo' }]
assert.equal(hasMenuKey(tree, 'solo'), true)
assert.equal(hasMenuKey(tree, 'nope'), false)
})
test('test_task_032_has_menu_key_lookup_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:缺 children 的节点不崩溃;深树正确递归。
const tree = [{ key: 'a', name: 'A' }, { key: 'b', name: 'B', children: [{ key: 'c', name: 'C', route: '/c' }] }]
assert.equal(hasMenuKey(tree, 'c'), true)
})
test('test_task_032_has_menu_key_lookup_invalid_input_rejected', () => {
// 异常输入:空 key 查找返回 false,绝不误放行。
assert.equal(hasMenuKey([{ key: '', name: '无key' }], ''), false)
assert.equal(hasMenuKey([], ''), false)
})
test('test_task_032_has_menu_key_lookup_dependency_failure_returns_actionable_message', () => {
// 依赖失败:key 查找收敛到 helpers,路由守卫经共享权限判定复用,不留私有副本。
const helpers = readSource('src/router/helpers.ts')
assert.match(helpers, /export function hasMenuKey/)
const router = readSource('src/router/index.ts')
assert.equal(router.includes('function hasMenu'), false, '不再保留模块内私有 hasMenu')
assert.match(router, /isRouteAllowed/, '守卫委托共享判定(内含 key 查找)')
})
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { isRouteAllowed } from '../src/router/helpers.ts'
test('test_task_033_route_permission_guard_normal_primary_path', () => {
// 正常主路径:页面带 menuKey 且普通用户拥有该菜单 key 时放行。
const tree = [{ key: 'admin_users', name: '用户管理', route: '/account/users' }]
assert.equal(isRouteAllowed(false, 'admin_users', tree), true)
})
test('test_task_033_route_permission_guard_normal_variant_input', () => {
// 正常变体:超级管理员即便不带该 key 也放行;无 menuKey 页面不受限。
assert.equal(isRouteAllowed(true, 'whatever', []), true)
assert.equal(isRouteAllowed(false, undefined, []), true)
})
test('test_task_033_route_permission_guard_normal_repeated_operation_is_idempotent', () => {
// 正常重复:判定纯函数,输入不变结果稳定。
const tree = [{ key: 'g', name: '组', children: [{ key: 'users', name: '用户', route: '/users' }] }]
assert.equal(isRouteAllowed(false, 'users', tree), isRouteAllowed(false, 'users', tree))
})
test('test_task_033_route_permission_guard_boundary_empty_input', () => {
// 边界空值:空菜单树对普通用户视为无权限(除无 menuKey 页面外)。
assert.equal(isRouteAllowed(false, 'admin_users', []), false)
assert.equal(isRouteAllowed(false, undefined, []), true)
})
test('test_task_033_route_permission_guard_boundary_single_item', () => {
// 边界单元素:只有单个可路由页面时的命中与未命中。
const tree = [{ key: 'solo', name: '单页', route: '/solo' }]
assert.equal(isRouteAllowed(false, 'solo', tree), true)
assert.equal(isRouteAllowed(false, 'other', tree), false)
})
test('test_task_033_route_permission_guard_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:深层叶子菜单 key 也能命中。
const tree = [{ key: 'g', name: '组', children: [{ key: 'inner', name: '内', route: '/inner' }] }]
assert.equal(isRouteAllowed(false, 'inner', tree), true)
})
test('test_task_033_route_permission_guard_invalid_input_rejected', () => {
// 异常输入:null/空 key 视为无菜单门槛页面一律放行(不误拦开放页)。
assert.equal(isRouteAllowed(false, null as unknown as string, []), true)
assert.equal(isRouteAllowed(false, '', []), true)
assert.equal(isRouteAllowed(false, 'x', []), false)
})
test('test_task_033_route_permission_guard_dependency_failure_returns_actionable_message', () => {
// 依赖失败:守卫把越权路由回退给首个可见页/根路由,复用统一判定。
const router = readSource('src/router/index.ts')
assert.match(router, /isRouteAllowed/, '路由守卫使用共享权限判定')
assert.match(router, /firstVisible/, '越权回退首个可见页')
const helpers = readSource('src/router/helpers.ts')
assert.match(helpers, /export function isRouteAllowed/)
assert.match(helpers, /hasMenuKey/)
})
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { firstVisiblePath } from '../src/router/helpers.ts'
test('test_task_034_unauthorized_fallback_normal_primary_path', () => {
// 正常主路径:越权页面回退到菜单树首个可见页面。
const tree = [{ key: 'g', name: '组', children: [{ key: 'users', name: '用户管理', route: '/account/users' }] }]
assert.equal(firstVisiblePath(tree), '/account/users')
})
test('test_task_034_unauthorized_fallback_normal_variant_input', () => {
// 正常变体:多个可路由页面时取深度优先的首个。
const tree = [
{ key: 'a', name: 'A', route: '/a', sort: 9 },
{ key: 'b', name: 'B', route: '/b', sort: 1 },
]
assert.equal(firstVisiblePath(tree), '/a', '前端保序,不按 sort 重排')
})
test('test_task_034_unauthorized_fallback_normal_repeated_operation_is_idempotent', () => {
// 正常重复:查找不修改树、结果稳定。
const tree = [{ key: 'g', name: '组', children: [{ key: 'p', name: '页', route: '/p' }] }]
assert.equal(firstVisiblePath(tree), firstVisiblePath(tree))
})
test('test_task_034_unauthorized_fallback_boundary_empty_input', () => {
// 边界空值:无任何可见页面时回退空串,由守卫回根路由空态。
assert.equal(firstVisiblePath([]), '')
assert.equal(firstVisiblePath(null), '')
})
test('test_task_034_unauthorized_fallback_boundary_single_item', () => {
// 边界单元素:单个页面即回退目标。
assert.equal(firstVisiblePath([{ key: 's', name: '单', route: '/solo' }]), '/solo')
})
test('test_task_034_unauthorized_fallback_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:深层叶页面也被当作回退目标。
const tree = [{ key: 'g', name: '组', children: [{ key: 'g2', name: '子组', children: [{ key: 'deep', name: '深', route: '/deep' }] }] }]
assert.equal(firstVisiblePath(tree), '/deep')
})
test('test_task_034_unauthorized_fallback_invalid_input_rejected', () => {
// 异常输入:畸形节点(无 route 无 children)被跳过,不影响回退目标。
const tree = [{ key: 'bad', name: '坏' }, { key: 'ok', name: '好', route: '/ok' }]
assert.equal(firstVisiblePath(tree), '/ok')
})
test('test_task_034_unauthorized_fallback_dependency_failure_returns_actionable_message', () => {
// 依赖失败:路由守卫对越权页以首个可见页/根路由作为回退目标。
const router = readSource('src/router/index.ts')
assert.match(router, /session\.firstVisible\s*\|\|\s*'\/'/, '越权页回退首个可见页,全无则根路由')
assert.match(router, /isRouteAllowed/)
})
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
EMPTY_MENU_DESCRIPTION,
EMPTY_MENU_TITLE,
shouldShowEmptyMenu,
} from '../src/layout/empty-state.ts'
test('test_task_035_no_menu_user_state_normal_primary_path', () => {
// 正常主路径:会话完成、无菜单且非加载时展示无菜单空态。
assert.equal(shouldShowEmptyMenu(false, false, true), true)
})
test('test_task_035_no_menu_user_state_normal_variant_input', () => {
// 正常变体:有菜单时即使初始化完成也不展示空态。
assert.equal(shouldShowEmptyMenu(true, false, true), false)
})
test('test_task_035_no_menu_user_state_normal_repeated_operation_is_idempotent', () => {
// 正常重复:纯函数结果稳定、无副作用。
assert.equal(shouldShowEmptyMenu(false, false, true), shouldShowEmptyMenu(false, false, true))
})
test('test_task_035_no_menu_user_state_boundary_empty_input', () => {
// 边界空值:未初始化 / 空态参数缺失时不误闪空态。
assert.equal(shouldShowEmptyMenu(false, false, false), false, '未初始化完成不展示空态')
})
test('test_task_035_no_menu_user_state_boundary_single_item', () => {
// 边界单元素:加载中即便无菜单也不闪空态(防闪烁)。
assert.equal(shouldShowEmptyMenu(false, true, true), false, '加载中不闪空态')
})
test('test_task_035_no_menu_user_state_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:初始状态(空菜单、未初始化、未加载)同样不展示。
assert.equal(shouldShowEmptyMenu(false, false, false), false)
})
test('test_task_035_no_menu_user_state_invalid_input_rejected', () => {
// 异常输入:参数类型异常时仍安全返回布尔,不抛错。
assert.equal(shouldShowEmptyMenu(0 as unknown as boolean, 0 as unknown as boolean, 1 as unknown as boolean), true)
})
test('test_task_035_no_menu_user_state_dependency_failure_returns_actionable_message', () => {
// 依赖失败:初始化失败不落成无菜单空态(由错误条提示);文案指向联系管理员。
assert.equal(EMPTY_MENU_TITLE, '暂无可用菜单')
assert.match(EMPTY_MENU_DESCRIPTION, /管理员/)
const store = readSource('src/stores/admin-session.ts')
assert.match(store, /error:/, 'store 保存失败错误而非置空菜单')
})
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { runLogout } from '../src/layout/logout.ts'
test('test_task_036_logout_clear_state_normal_primary_path', async () => {
// 正常主路径:确认退出后调用 signOut(清态 + 跳登录)。
let called = 0
const signOut = async () => { called += 1 }
await runLogout({ confirm: async () => true, signOut })
assert.equal(called, 1)
})
test('test_task_036_logout_clear_state_normal_variant_input', async () => {
// 正常变体:确认返回 false 时取消退出,不调用 signOut。
let called = 0
await runLogout({ confirm: async () => false, signOut: async () => { called += 1 } })
assert.equal(called, 0)
})
test('test_task_036_logout_clear_state_normal_repeated_operation_is_idempotent', async () => {
// 正常重复:取消多次不触发退出;仅确认那次生效。
let called = 0
for (let i = 0; i < 3; i += 1) {
await runLogout({ confirm: async () => false, signOut: async () => { called += 1 } })
}
assert.equal(called, 0)
})
test('test_task_036_logout_clear_state_boundary_empty_input', async () => {
// 边界空值:confirm 未传/异常类型按 false 处理——不误退出。
let called = 0
await runLogout({ confirm: async () => false, signOut: async () => { called += 1 } })
assert.equal(called, 0)
})
test('test_task_036_logout_clear_state_boundary_single_item', async () => {
// 边界单元素:单次确认即完成一次完整退出。
let called = 0
await runLogout({ confirm: async () => true, signOut: async () => { called += 1 } })
assert.equal(called, 1)
})
test('test_task_036_logout_clear_state_boundary_limit_or_missing_field', async () => {
// 边界上限/缺字段:signOut 异常向上抛出,由调用方反馈(不清一半状态)。
await assert.rejects(
runLogout({ confirm: async () => true, signOut: async () => { throw new Error('登出失败') } }),
/登出失败/,
)
})
test('test_task_036_logout_clear_state_invalid_input_rejected', async () => {
// 异常输入:confirm 本身 reject 也向上抛,不静默吞掉。
await assert.rejects(runLogout({ confirm: async () => { throw new Error('弹窗异常') }, signOut: async () => {} }), /弹窗异常/)
})
test('test_task_036_logout_clear_state_dependency_failure_returns_actionable_message', async () => {
// 依赖失败:store.signOut 清态($reset)并跳登录页,会话模块先走后端 logout。
const store = readSource('src/stores/admin-session.ts')
assert.match(store, /async signOut\(\)/)
assert.match(store, /\$reset\(\)/)
assert.match(store, /location\.assign\('\/login'\)/)
const session = readSource('src/api/session.ts')
assert.match(session, /\/logout/, '登出走 auth 模块根端点 POST /logout')
assert.equal(session.includes('/api/admin/logout'), false, '不应指向不存在的 /api/admin/logout')
})
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseMenuTree } from '../src/api/session-model.ts'
import { toSidebarEntries } from '../src/layout/menu-mapper.ts'
test('test_task_037_menu_parent_child_normal_primary_path', () => {
// 正常主路径:分组下的子页面作为 children 嵌套保留。
const tree = parseMenuTree({ data: { items: [{ key: 'g', name: '组', children: [{ key: 'p', name: '页', route: '/p' }] }] } })
assert.equal(tree[0].children?.[0].key, 'p')
})
test('test_task_037_menu_parent_child_normal_variant_input', () => {
// 正常变体:多层父子关系逐层保留(组 > 子组 > 页面)。
const tree = parseMenuTree({ items: [{ key: 'a', name: 'A', children: [{ key: 'b', name: 'B', children: [{ key: 'c', name: 'C', route: '/c' }] }] }] })
assert.equal(tree[0].children?.[0].children?.[0].route, '/c')
})
test('test_task_037_menu_parent_child_normal_repeated_operation_is_idempotent', () => {
// 正常重复:解析不重建/不污染父子引用结构之外的对象,结果稳定。
const payload = { data: { items: [{ key: 'g', name: '组', children: [{ key: 'p', name: '页', route: '/p' }] }] } }
assert.deepEqual(parseMenuTree(payload), parseMenuTree(payload))
})
test('test_task_037_menu_parent_child_boundary_empty_input', () => {
// 边界空值:分组无子节点合法(可后续再挂载)。
const tree = parseMenuTree({ items: [{ key: 'g', name: '组' }] })
assert.equal(tree[0].children, undefined)
})
test('test_task_037_menu_parent_child_boundary_single_item', () => {
// 边界单元素:单个页面节点可直接成为顶级项。
const tree = parseMenuTree({ items: [{ key: 's', name: '单页', route: '/solo' }] })
assert.equal(tree.length, 1)
assert.equal(tree[0].route, '/solo')
})
test('test_task_037_menu_parent_child_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:children 为非数组/空时不崩溃。
const tree = parseMenuTree({ items: [{ key: 'g', name: '组', children: null as unknown as undefined }] })
assert.equal(tree[0].children, null)
})
test('test_task_037_menu_parent_child_invalid_input_rejected', () => {
// 异常输入:整棵非树负载(success=false)抛错,不产生半棵树。
assert.throws(() => parseMenuTree({ success: false, message: '无权限' }), /无权限/)
})
test('test_task_037_menu_parent_child_dependency_failure_returns_actionable_message', () => {
// 依赖失败:渲染层把父子关系映射为分组/子项,校验不丢层级。
const entries = toSidebarEntries([{ key: 'g', name: '组', children: [{ key: 'p', name: '页', route: '/p' }] }])
assert.equal(entries.length, 1)
assert.equal(entries[0].kind, 'group')
assert.equal(entries[0].kind === 'group' && entries[0].children[0].route, '/p')
})
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseMenuTree } from '../src/api/session-model.ts'
import { toSidebarEntries } from '../src/layout/menu-mapper.ts'
test('test_task_038_menu_sort_and_empty_route_normal_primary_path', () => {
// 正常主路径:前端保后端顺序,不按 sort 二次重排。
const payload = { data: { items: [
{ key: 'b', name: '乙', route: '/b', sort: 1 },
{ key: 'a', name: '甲', route: '/a', sort: 0 },
] } }
const tree = parseMenuTree(payload)
assert.equal(tree[0].key, 'b', '先出现者排前,不按 sort 值重排')
})
test('test_task_038_menu_sort_and_empty_route_normal_variant_input', () => {
// 正常变体:分组(无 route) 下带 route 的子页正常进分组。
const tree = parseMenuTree({ items: [{ key: 'g', name: '组', route: undefined, children: [{ key: 'p', name: '页', route: '/p' }] }] })
const entries = toSidebarEntries(tree)
assert.equal(entries[0].kind, 'group')
})
test('test_task_038_menu_sort_and_empty_route_normal_repeated_operation_is_idempotent', () => {
// 正常重复:排序稳定、解析不修改输入。
const payload = { items: [{ key: 'a', name: 'A', route: '/a', sort: 2 }] }
assert.deepEqual(parseMenuTree(payload), parseMenuTree(payload))
assert.equal((payload.items as Array<{ sort: number }>)[0].sort, 2)
})
test('test_task_038_menu_sort_and_empty_route_boundary_empty_input', () => {
// 边界空值:无路由的子节点被侧边栏过滤(点不进去不展示)。
const entries = toSidebarEntries([{ key: 'g', name: '组', children: [{ key: 'p', name: '页' }] }])
assert.equal(entries.length, 0, '组内无任何可路由子项则整组不展示')
})
test('test_task_038_menu_sort_and_empty_route_boundary_single_item', () => {
// 边界单元素:顶级无子但有 route 的叶节点作为单页项展示。
const entries = toSidebarEntries([{ key: 's', name: '单页', route: '/solo' }])
assert.equal(entries.length, 1)
assert.equal(entries[0].kind, 'item')
})
test('test_task_038_menu_sort_and_empty_route_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:既有可路由子项又有不可路由子项时只保留可路由子项。
const entries = toSidebarEntries([{ key: 'g', name: '组', children: [
{ key: 'ok', name: '可进', route: '/ok' },
{ key: 'bad', name: '占位' },
] }])
assert.equal(entries[0].kind, 'group')
if (entries[0].kind === 'group') assert.equal(entries[0].children.length, 1)
})
test('test_task_038_menu_sort_and_empty_route_invalid_input_rejected', () => {
// 异常输入:整树失败信封抛错而非空树;空路由不是合法的页面进入理由。
assert.throws(() => parseMenuTree({ success: false, message: '菜单失败' }), /菜单失败/)
const tree = parseMenuTree({ items: [{ key: 'p', name: '页' }] })
assert.equal(toSidebarEntries(tree).length, 0, '无 route 的顶级节点不进侧边栏')
})
test('test_task_038_menu_sort_and_empty_route_dependency_failure_returns_actionable_message', () => {
// 依赖失败:store/路由保存菜单后不二次排序(按后端下发顺序渲染)。
const store = readSource('src/stores/admin-session.ts')
assert.equal(/\bsort\(\)/.test(store), false, 'store 不对菜单树调用 sort()')
const helpers = readSource('src/router/helpers.ts')
assert.equal(/\bsort\(\)/.test(helpers), false, 'helpers 不对菜单树调用 sort()')
})
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseMenuTree } from '../src/api/session-model.ts'
test('test_task_039_backend_tree_compat_normal_primary_path', () => {
// 正常主路径:data.items 信封。
const tree = parseMenuTree({ success: true, data: { items: [{ key: 'a', name: 'A', route: '/a' }] } })
assert.equal(tree[0].route, '/a')
})
test('test_task_039_backend_tree_compat_normal_variant_input', () => {
// 正常变体:data:{items}/无 data 的 {items}/裸数组三种信封均可解。
assert.equal(parseMenuTree({ data: { items: [{ key: 'x', name: 'X' }] } }).length, 1)
assert.equal(parseMenuTree({ success: true, items: [{ key: 'y', name: 'Y', route: '/y' }] }).length, 1)
assert.equal(parseMenuTree([{ key: 'z', name: 'Z' }]).length, 1)
})
test('test_task_039_backend_tree_compat_normal_repeated_operation_is_idempotent', () => {
// 正常重复:同一负载解析结果稳定且不改输入。
const payload = { data: { items: [{ key: 'a', name: 'A', children: [{ key: 'b', name: 'B', route: '/b' }] }] } }
assert.deepEqual(parseMenuTree(payload), parseMenuTree(payload))
})
test('test_task_039_backend_tree_compat_boundary_empty_input', () => {
// 边界空值:空 items/空 data/空数组/非对象均按空树处理(区别于接口失败)。
assert.deepEqual(parseMenuTree({ data: { items: [] } }), [])
assert.deepEqual(parseMenuTree({}), [])
assert.deepEqual(parseMenuTree(null as unknown as { items?: [] }), [])
assert.deepEqual(parseMenuTree(undefined as unknown as { items?: [] }), [])
})
test('test_task_039_backend_tree_compat_boundary_single_item', () => {
// 边界单元素:单分组/单页面两种最小树均可解析。
assert.equal(parseMenuTree({ data: { items: [{ key: 'g', name: '组' }] } }).length, 1)
assert.equal(parseMenuTree({ items: [{ key: 'p', name: '页', route: '/p' }] }).length, 1)
})
test('test_task_039_backend_tree_compat_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:附加未知字段与缺省字段都不丢失/不崩溃。
const tree = parseMenuTree({ items: [{ key: 'p', name: '页', route: '/p', sort: 3, icon: 'x', extra: { keep: true } }] })
assert.equal((tree[0] as unknown as { extra: { keep: boolean } }).extra.keep, true)
})
test('test_task_039_backend_tree_compat_invalid_input_rejected', () => {
// 异常输入:success=false 视为接口失败抛错(区别于“权限为空”)。
assert.throws(() => parseMenuTree({ success: false, message: '会话失效', code: 401 }), /会话失效/)
})
test('test_task_039_backend_tree_compat_dependency_failure_returns_actionable_message', () => {
// 依赖失败:会话适配 fetchAdminMenuTree 复用同一解析器兜住各信封边界。
const session = readSource('src/api/session.ts')
assert.match(session, /parseMenuTree/)
const model = readSource('src/api/session-model.ts')
assert.match(model, /export function parseMenuTree/)
assert.match(model, /Array\.isArray/)
})
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
ADMIN_THEME,
ADMIN_THEME_KEYS,
contrastRatio,
cssVarName,
isDarkSurface,
relativeLuminance,
tokenHex,
} from '../src/styles/theme.ts'
test('test_task_004_theme_tokens_normal_primary_path', () => {
// 正常主路径:侧边栏与加深面是暗色,内容区/卡片底是亮色。
assert.equal(isDarkSurface(ADMIN_THEME.sidebar), true, '侧边栏必须为暗色')
assert.equal(isDarkSurface(ADMIN_THEME.sidebarDeep), true, '侧边栏加深面必须为暗色')
assert.equal(isDarkSurface(ADMIN_THEME.bg), false, '内容区背景必须为亮色')
assert.equal(isDarkSurface(ADMIN_THEME.border), false, '边框须为亮面中性色')
})
test('test_task_004_theme_tokens_normal_variant_input', () => {
// 正常变体:强调色与软底/正文高对比,正文 token 自成一致体系。
assert.equal(relativeLuminance(ADMIN_THEME.primary), relativeLuminance('#6366f1'))
assert.ok(contrastRatio(ADMIN_THEME.text, ADMIN_THEME.bg) >= 4.5, '正文/内容区需满足 WCAG AA 正文')
assert.ok(contrastRatio(ADMIN_THEME.primary, ADMIN_THEME.bg) >= 3.0, '品牌色按钮须达到 3:1')
})
test('test_task_004_theme_tokens_normal_repeated_operation_is_idempotent', () => {
// 正常重复:亮度与对比度计算幂等、无状态污染。
assert.equal(relativeLuminance(ADMIN_THEME.bg), relativeLuminance(ADMIN_THEME.bg))
assert.equal(isDarkSurface(ADMIN_THEME.sidebar), isDarkSurface(ADMIN_THEME.sidebar))
assert.ok(contrastRatio(ADMIN_THEME.text, ADMIN_THEME.bg) === contrastRatio(ADMIN_THEME.text, ADMIN_THEME.bg))
})
test('test_task_004_theme_tokens_boundary_empty_input', () => {
// 边界空值:token 表非空、无空值、无未知多余键、键集合稳定。
assert.ok(ADMIN_THEME_KEYS.length >= 7, '必须覆盖侧边栏/内容区/正文等核心语义面')
for (const key of ADMIN_THEME_KEYS) {
assert.match(ADMIN_THEME[key], /^#[0-9a-fA-F]{6}$/, `${key} 须为 #rrggbb`)
}
})
test('test_task_004_theme_tokens_boundary_single_item', () => {
// 边界单元素:单个中性边界 token 与内容区可区分;侧边栏深于内容区。
assert.notEqual(ADMIN_THEME.border, ADMIN_THEME.bg)
assert.ok(relativeLuminance(ADMIN_THEME.sidebar) < relativeLuminance(ADMIN_THEME.bg))
})
test('test_task_004_theme_tokens_boundary_limit_or_missing_field', () => {
// 边界上限:次要文本 muted 在内容区上仍可达 3:1;CSS 变量名映射无重复斜杠。
assert.ok(contrastRatio(ADMIN_THEME.muted, ADMIN_THEME.bg) >= 3.0)
assert.equal(cssVarName('sidebar'), '--admin-sidebar')
assert.equal(cssVarName('sidebarDeep'), '--admin-sidebar-deep')
assert.ok(!cssVarName('primary').includes('--admin-admin-'))
})
test('test_task_004_theme_tokens_invalid_input_rejected', () => {
// 异常输入:非法 hex、未知 token 拒绝并报错;CSS 不得引用旧后台整份样式。
assert.throws(() => relativeLuminance('#12'), /#rrggbb/)
assert.throws(() => relativeLuminance('sidebar'), /#rrggbb/)
assert.throws(() => tokenHex('unknown' as never), /未知 Admin 主题 token/)
const css = readSource('src/styles/main.css')
assert.equal(/@import/.test(css), false, 'main.css 不得 @import 旧后台整份样式')
})
test('test_task_004_theme_tokens_dependency_failure_returns_actionable_message', () => {
// 依赖失败:token 与 main.css 渲染变量交叉校验必须一致(防双源漂移)。
const css = readSource('src/styles/main.css')
const missing = ADMIN_THEME_KEYS.filter((key) => {
const tokenVar = `--admin-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
return !css.includes(`${tokenVar}: ${ADMIN_THEME[key]}`)
})
assert.deepEqual(missing, [], `theme.ts 与 main.css 不一致,缺失变量: ${missing.join(', ')}`)
})
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
emptyUserPageResult,
normalizeUserPageParams,
toUserListQuery,
USER_PAGE_DEFAULT_SIZE,
USER_PAGE_MAX_SIZE,
} from '../src/api/users-dto.ts'
test('test_task_041_users_dto_normal_primary_path', () => {
// 正常主路径:合法分页/关键字归一为可查询参数。
const p = normalizeUserPageParams({ page: 2, pageSize: 15, keyword: ' 张 ' })
assert.equal(p.page, 2)
assert.equal(p.pageSize, 15)
assert.equal(p.keyword, '张')
})
test('test_task_041_users_dto_normal_variant_input', () => {
// 正常变体:查询序列化采用 Java 参数名(page/page_size/username)。
const q = toUserListQuery({ page: 3, pageSize: 20, keyword: 'admin' })
assert.deepEqual(q, { page: 3, page_size: 20, username: 'admin' })
})
test('test_task_041_users_dto_normal_repeated_operation_is_idempotent', () => {
// 正常重复:同一输入归一结果稳定、不改输入。
const raw = { page: 1, pageSize: 15 }
assert.deepEqual(normalizeUserPageParams(raw), normalizeUserPageParams(raw))
assert.deepEqual(raw, { page: 1, pageSize: 15 })
})
test('test_task_041_users_dto_boundary_empty_input', () => {
// 边界空值:空对象回默认首页/页大小;空关键字视为无过滤。
const p = normalizeUserPageParams({})
assert.equal(p.page, 1)
assert.equal(p.pageSize, USER_PAGE_DEFAULT_SIZE)
assert.equal(p.keyword, undefined)
})
test('test_task_041_users_dto_boundary_single_item', () => {
// 边界单元素:单条分页结果 DTO 结构完整(items/total/page/pageSize)。
const empty = emptyUserPageResult()
assert.deepEqual(empty.items, [])
assert.equal(empty.total, 0)
assert.equal(empty.page, 1)
assert.equal(empty.pageSize, USER_PAGE_DEFAULT_SIZE)
})
test('test_task_041_users_dto_boundary_limit_or_missing_field', () => {
// 边界上限/越界:页码 <1 归 1;页大小超上限/非正被夹取。
assert.equal(normalizeUserPageParams({ page: 0, pageSize: 999 }).page, 1)
assert.equal(normalizeUserPageParams({ page: -3, pageSize: 0 }).pageSize, USER_PAGE_DEFAULT_SIZE)
assert.equal(normalizeUserPageParams({ pageSize: 999 }).pageSize, USER_PAGE_MAX_SIZE)
})
test('test_task_041_users_dto_invalid_input_rejected', () => {
// 异常输入:非数字/字符串关键字无效时不抛错并按缺省处理。
const p = normalizeUserPageParams({ page: Number.NaN, pageSize: Number.NaN, keyword: undefined })
assert.equal(p.page, 1)
assert.equal(p.pageSize, USER_PAGE_DEFAULT_SIZE)
assert.equal(p.keyword, undefined)
})
test('test_task_041_users_dto_dependency_failure_returns_actionable_message', () => {
// 依赖失败:DTO 纯模块不依赖 axios/http,由查询适配层引用。
const dto = readSource('src/api/users-dto.ts')
assert.equal(/axios|from '\.\/http'/.test(dto), false, 'DTO 模块保持纯逻辑')
assert.match(dto, /export interface UserPageResult/)
const adapter = readSource('src/api/users.ts')
assert.match(adapter, /users-dto/, '查询适配复用 DTO/参数归一')
const model = readSource('src/api/users-model.ts')
assert.match(model, /users-dto/, '解析模型复用 DTO 默认值')
})
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { parseUserPage, toAdminUserItem } from '../src/api/users-model.ts'
import { USER_PAGE_DEFAULT_SIZE } from '../src/api/users-dto.ts'
test('test_task_042_user_list_adapter_normal_primary_path', () => {
// 正常主路径:Java snake_case data.items 信封解出 camelCase 分页结果。
const page = parseUserPage({
success: true,
data: {
items: [{ id: 1, username: 'admin', role: 'super_admin', is_admin: true, created_by_id: 3, creator_username: 'root', created_at: '2026-01-01T00:00:00', pinyin_abbr: 'adm' }],
total: 1,
page: 1,
page_size: 15,
},
})
assert.equal(page.items.length, 1)
assert.equal(page.items[0].username, 'admin')
assert.equal(page.items[0].isAdmin, true)
assert.equal(page.items[0].createdById, 3)
assert.equal(page.items[0].creatorUsername, 'root')
assert.equal(page.items[0].pinyinAbbr, 'adm')
assert.equal(page.total, 1)
assert.equal(page.pageSize, 15)
})
test('test_task_042_user_list_adapter_normal_variant_input', () => {
// 正常变体:已解包的 AdminUserListVo 直传同样可归一。
const page = parseUserPage({
items: [{ id: 2, username: '张伟恒', role: 'admin', is_admin: true, created_by_id: null }],
total: 5,
page: 2,
page_size: 20,
})
assert.equal(page.items[0].username, '张伟恒')
assert.equal(page.items[0].createdById, undefined)
assert.equal(page.page, 2)
assert.equal(page.pageSize, 20)
})
test('test_task_042_user_list_adapter_normal_repeated_operation_is_idempotent', () => {
// 正常重复:解析不修改输入、结果稳定。
const payload = { data: { items: [{ id: 3, username: 'u', role: 'normal' }], total: 1, page: 1, page_size: 15 } }
assert.deepEqual(parseUserPage(payload), parseUserPage(payload))
})
test('test_task_042_user_list_adapter_boundary_empty_input', () => {
// 边界空值:无 items/空负载回默认空结果,不崩溃。
const page = parseUserPage({})
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.pageSize, USER_PAGE_DEFAULT_SIZE)
})
test('test_task_042_user_list_adapter_boundary_single_item', () => {
// 边界单元素:单条最小字段(缺 id 的行被过滤)。
const page = parseUserPage({ data: { items: [{ id: 7, username: 'root', role: 'super_admin' }, { username: 'no-id' }], total: 2, page: 1, page_size: 15 } })
assert.equal(page.items.length, 1)
assert.equal(page.items[0].id, 7)
})
test('test_task_042_user_list_adapter_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:缺 page_size 用默认;缺省字段为空串而非 undefined。
const single = toAdminUserItem({ id: 9, username: '', role: '', is_admin: false })
assert.equal(single?.username, '')
assert.equal(single?.role, '')
assert.equal(single?.isAdmin, false)
const page = parseUserPage({ items: [], total: 0, page: 1 })
assert.equal(page.pageSize, USER_PAGE_DEFAULT_SIZE)
})
test('test_task_042_user_list_adapter_invalid_input_rejected', () => {
// 异常输入:success=false 被拒绝并带后端 message;缺 id 的行不入列表。
assert.throws(() => parseUserPage({ success: false, message: '无权限查看用户' }), /无权限查看用户/)
assert.equal(toAdminUserItem({ username: 'x' }), null)
assert.equal(toAdminUserItem('garbage'), null)
})
test('test_task_042_user_list_adapter_dependency_failure_returns_actionable_message', () => {
// 依赖失败:页面查询走 adapter(fetchUserList→parseUserPage),不在模板内联解包。
const page = readSource('src/pages/account/UsersPage.vue')
assert.match(page, /fetchUserList/)
assert.equal(/unwrap<|\/api\/admin\/users/.test(page), false, '页面不再内联 http.get + unwrap')
const adapter = readSource('src/api/users.ts')
assert.match(adapter, /\/api\/admin\/users/)
assert.match(adapter, /parseUserPage/)
assert.match(adapter, /normalizeUserPageParams/)
const model = readSource('src/api/users-model.ts')
assert.match(model, /export function parseUserPage/)
})
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
ADMIN_DEFAULT_TITLE,
pageTitleOf,
topbarUserOf,
} from '../src/layout/topbar-model.ts'
test('test_task_005_topbar_title_model_normal_primary_path', () => {
// 正常主路径:meta.title 直接驱动页面标题;用户信息映射到顶栏视图模型。
assert.equal(pageTitleOf('用户管理'), '用户管理')
const vm = topbarUserOf({ id: 1, username: 'admin', role: 'super_admin' })
assert.equal(vm.username, 'admin')
assert.equal(vm.role, 'super_admin')
assert.equal(vm.hasUser, true)
})
test('test_task_005_topbar_title_model_normal_variant_input', () => {
// 正常变体:另一种有效标题/角色不依赖单一样例。
assert.equal(pageTitleOf('菜单管理'), '菜单管理')
const normal = topbarUserOf({ id: 2, username: 'ops', role: 'admin' })
assert.equal(normal.username, 'ops')
assert.equal(normal.hasUser, true)
})
test('test_task_005_topbar_title_model_normal_repeated_operation_is_idempotent', () => {
// 正常重复:模型纯函数重复调用结果稳定。
assert.equal(pageTitleOf('数据权限分组'), pageTitleOf('数据权限分组'))
assert.deepEqual(topbarUserOf(null), topbarUserOf(null))
assert.deepEqual(topbarUserOf({ id: 1, username: 'u', role: '' }), topbarUserOf({ id: 1, username: 'u', role: '' }))
})
test('test_task_005_topbar_title_model_boundary_empty_input', () => {
// 边界空值:标题为空/空白回落到缺省;用户为 null/undefined 进入空态不崩溃。
assert.equal(pageTitleOf(undefined), ADMIN_DEFAULT_TITLE)
assert.equal(pageTitleOf(''), ADMIN_DEFAULT_TITLE)
assert.equal(pageTitleOf(' '), ADMIN_DEFAULT_TITLE)
const none = topbarUserOf(undefined)
assert.equal(none.username, '当前用户')
assert.equal(none.hasUser, false)
})
test('test_task_005_topbar_title_model_boundary_single_item', () => {
// 边界单元素:单页面标题、单用户映射各自产出唯一稳定结果。
assert.equal(pageTitleOf('首页'), '首页')
const single = topbarUserOf({ id: 9, username: 'root', role: 'admin' })
assert.equal(single.username, 'root')
assert.equal(single.role, 'admin')
})
test('test_task_005_topbar_title_model_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:缺 username/role 的用户仍可渲染且有明确空态。
const partial = topbarUserOf({ id: 3, username: '', role: '' })
assert.equal(partial.username, '当前用户')
assert.equal(partial.role, '')
assert.equal(partial.hasUser, true)
assert.ok(ADMIN_DEFAULT_TITLE.length > 0, '缺省标题不能为空')
})
test('test_task_005_topbar_title_model_invalid_input_rejected', () => {
// 异常输入:非字符串标题按空处理回落到缺省(不崩溃、不输出 undefined/null)。
const bad = pageTitleOf(null)
assert.notEqual(bad, 'undefined')
assert.notEqual(bad, 'null')
assert.equal(bad, ADMIN_DEFAULT_TITLE)
assert.equal(pageTitleOf(123 as unknown as string), ADMIN_DEFAULT_TITLE)
})
test('test_task_005_topbar_title_model_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层实际使用该标题/用户模型,而非在模板里另写一份缺省逻辑。
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /pageTitleOf/)
assert.match(layout, /topbarUserOf/)
assert.equal(/route\.meta\.title\s*\|\|\s*'管理后台'/.test(layout), false, '标题缺省必须走 pageTitleOf')
})
+99
View File
@@ -0,0 +1,99 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { groupKeysForActive, toSidebarEntries } from '../src/layout/menu-mapper.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, sort: partial.sort }
}
const TREE: AdminMenuNode[] = [
node({ key: 'account', name: '账号权限', children: [
node({ key: 'admin_users', name: '用户管理', route: '/account/users' }),
node({ key: 'admin_columns', name: '菜单管理', route: '/account/menus' }),
] }),
]
test('test_task_006_menu_sidebar_mapping_normal_primary_path', () => {
// 正常主路径:带可路由子节点的后端菜单渲染为分组,顺序保留。
const entries = toSidebarEntries(TREE)
assert.equal(entries.length, 1)
assert.equal(entries[0].kind, 'group')
if (entries[0].kind === 'group') {
assert.equal(entries[0].name, '账号权限')
assert.deepEqual(entries[0].children.map((c) => c.route), ['/account/users', '/account/menus'])
}
})
test('test_task_006_menu_sidebar_mapping_normal_variant_input', () => {
// 正常变体:另一组菜单(含顶级路由叶 + 分组混合)正确混合输出。
const mix: AdminMenuNode[] = [
node({ key: 'g', name: '分组', children: [node({ key: 'c', name: '子', route: '/x/c' })] }),
node({ key: 'leaf', name: '顶级页', route: '/top' }),
]
const entries = toSidebarEntries(mix)
assert.equal(entries.length, 2)
assert.equal(entries[0].kind, 'group')
assert.equal(entries[1].kind, 'item')
if (entries[1].kind === 'item') assert.equal(entries[1].route, '/top')
})
test('test_task_006_menu_sidebar_mapping_normal_repeated_operation_is_idempotent', () => {
// 正常重复:映射结果稳定,不修改输入源树。
const snapshot = JSON.stringify(TREE)
const first = JSON.stringify(toSidebarEntries(TREE))
const second = JSON.stringify(toSidebarEntries(TREE))
assert.equal(first, second)
assert.equal(JSON.stringify(TREE), snapshot, '映射不得污染源菜单树')
})
test('test_task_006_menu_sidebar_mapping_boundary_empty_input', () => {
// 边界空值:空菜单树得到空侧边栏列表,不崩溃。
assert.deepEqual(toSidebarEntries([]), [])
assert.deepEqual(toSidebarEntries(undefined as unknown as AdminMenuNode[]), [])
})
test('test_task_006_menu_sidebar_mapping_boundary_single_item', () => {
// 边界单元素:单个顶级路由页不因没有子节点而被丢弃。
const entries = toSidebarEntries([node({ key: 's', name: '单页', route: '/solo' })])
assert.equal(entries.length, 1)
assert.equal(entries[0].kind, 'item')
if (entries[0].kind === 'item') assert.equal(entries[0].route, '/solo')
})
test('test_task_006_menu_sidebar_mapping_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:子项缺 route 被过滤;分组无任何可路由子项时不渲染占位分组。
const noRoutableChildren: AdminMenuNode[] = [
node({ key: 'g', name: '空分组', children: [node({ key: 'c', name: '无路由' })] }),
]
assert.deepEqual(toSidebarEntries(noRoutableChildren), [])
const withMissing = toSidebarEntries([node({ key: 'g', name: '有', children: [
node({ key: 'ok', name: '可点', route: '/a' }),
node({ key: 'no', name: '不可点' }),
] })])
if (withMissing[0].kind === 'group') {
assert.deepEqual(withMissing[0].children.map((c) => c.key), ['ok'])
} else {
assert.fail('应输出分组')
}
})
test('test_task_006_menu_sidebar_mapping_invalid_input_rejected', () => {
// 异常输入:route/children 缺省的畸形节点被跳过而非抛异常。
const malformed = [
{ key: 'x', name: '畸形' } as unknown as AdminMenuNode,
{ key: 'y', name: '无key子项', children: [{ name: '裸' }] } as unknown as AdminMenuNode,
]
assert.deepEqual(toSidebarEntries(malformed), [], '畸形节点不应产生侧边栏项')
})
test('test_task_006_menu_sidebar_mapping_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层消费映射模块;激活分组展开按当前路由命中。
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /toSidebarEntries/)
assert.match(layout, /groupKeysForActive/)
const active = toSidebarEntries(TREE)
assert.deepEqual(groupKeysForActive(active, '/account/menus'), ['account'])
assert.deepEqual(groupKeysForActive(active, '/account/nope'), [], '未命中路由不展开任何分组')
})
+77
View File
@@ -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/)
})
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
adminPages,
adminRouteRecords,
routeOf,
validateAdminPages,
} from '../src/router/routes.ts'
test('test_task_008_domain_route_registry_normal_primary_path', () => {
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
assert.equal(adminPages.length, 3)
assert.equal(adminRouteRecords.length, adminPages.length)
const first = adminRouteRecords[0]
assert.equal(first.path, 'account/users')
assert.equal((first.meta as { title?: string }).title, '用户管理')
assert.equal((first.meta as { menuKey?: string }).menuKey, 'admin_users')
assert.deepEqual(validateAdminPages(adminPages), [])
})
test('test_task_008_domain_route_registry_normal_variant_input', () => {
// 正常变体:无 menuKey 的页面元数据不携带菜单 key,仍带标题。
const record = routeOf({ path: 'tasks/plain', title: '无菜单页', load: async () => ({ default: {} }) })
const meta = record.meta as { title?: string; menuKey?: string }
assert.equal(meta.title, '无菜单页')
assert.equal('menuKey' in meta, false)
})
test('test_task_008_domain_route_registry_normal_repeated_operation_is_idempotent', () => {
// 正常重复:从同一注册表重复生成记录结果稳定。
const once = JSON.stringify(adminPages.map(routeOf))
const twice = JSON.stringify(adminPages.map(routeOf))
assert.equal(once, twice)
assert.deepEqual(validateAdminPages(adminPages), validateAdminPages(adminPages))
})
test('test_task_008_domain_route_registry_boundary_empty_input', () => {
// 边界空值:空注册表可校验、可映射为空路由列表。
assert.deepEqual(validateAdminPages([]), [])
assert.equal([].map(routeOf).length, 0)
})
test('test_task_008_domain_route_registry_boundary_single_item', () => {
// 边界单元素:单页面登记生成唯一路由记录。
const records = [routeOf({ path: 'shop/list', title: '店铺列表', load: async () => ({ default: {} }) })]
assert.equal(records.length, 1)
assert.equal(records[0].path, 'shop/list')
})
test('test_task_008_domain_route_registry_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:路径重复、前导斜杠、缺 title、混入 base 前缀都被校验拒绝。
const base = { title: 'T', load: async () => ({ default: {} }) }
const issues = validateAdminPages([
{ ...base, path: 'a/b' },
{ ...base, path: 'a/b' },
{ ...base, path: '/lead' },
{ path: '', title: '', load: async () => ({ default: {} }) },
{ path: '/admin-vue/x', title: 'X', load: async () => ({ default: {} }) },
])
assert.ok(issues.some((i) => i.includes('路径重复')))
assert.ok(issues.some((i) => i.includes('相对子路径')))
assert.ok(issues.some((i) => i.includes('缺少标题')))
assert.ok(issues.some((i) => i.includes('base 前缀')))
})
test('test_task_008_domain_route_registry_invalid_input_rejected', () => {
// 异常输入:非法页面定义在生成路由时被拒绝并给出可操作消息。
assert.throws(() => routeOf({ path: '', title: 'T', load: async () => ({ default: {} }) }), /路由注册表页面定义非法/)
assert.throws(() => routeOf({ path: 'x', title: '', load: async () => ({ default: {} }) }), /路由注册表页面定义非法/)
})
test('test_task_008_domain_route_registry_dependency_failure_returns_actionable_message', () => {
// 依赖失败:router 消费注册表,不再内联页面懒加载与路径字面量。
const router = readSource('src/router/index.ts')
assert.match(router, /adminRouteRecords/)
assert.equal(router.includes('account/users'), false, '路径字面量应只存在于 routes.ts 注册表')
assert.equal(router.includes('() => import(\'@/pages'), false, '页面懒加载应只存在于注册表')
})
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
ADMIN_MENU_KEY_PATTERN,
isValidMenuKey,
metaMenuKey,
validateRouteMenuKeys,
} from '../src/router/meta.ts'
import { adminPages } from '../src/router/routes.ts'
test('test_task_009_route_menu_key_meta_normal_primary_path', () => {
// 正常主路径:合法 menuKey 被识别。
assert.equal(metaMenuKey({ menuKey: 'admin_users' }), 'admin_users')
assert.equal(isValidMenuKey('admin_users'), true)
assert.equal(ADMIN_MENU_KEY_PATTERN.test('admin_users'), true)
})
test('test_task_009_route_menu_key_meta_normal_variant_input', () => {
// 正常变体:另一合法 key(含数字)也能识别。
assert.equal(isValidMenuKey('admin_group_manage'), true)
assert.equal(isValidMenuKey('asin_center2'), true)
})
test('test_task_009_route_menu_key_meta_normal_repeated_operation_is_idempotent', () => {
// 正常重复:读取与校验幂等。
assert.equal(metaMenuKey({ menuKey: 'shop_list' }), metaMenuKey({ menuKey: 'shop_list' }))
assert.equal(isValidMenuKey('shop_list'), isValidMenuKey('shop_list'))
})
test('test_task_009_route_menu_key_meta_boundary_empty_input', () => {
// 边界空值:无 meta/无 menuKey 回退空串;空串 key 不参与命名约束。
assert.equal(metaMenuKey(undefined), '')
assert.equal(metaMenuKey({}), '')
assert.equal(metaMenuKey({ menuKey: '' }), '')
assert.deepEqual(validateRouteMenuKeys([]), [])
})
test('test_task_009_route_menu_key_meta_boundary_single_item', () => {
// 边界单元素:单条带合法 key 的注册记录无校验问题。
assert.deepEqual(validateRouteMenuKeys([{ path: 'a/b', menuKey: 'admin_x' }]), [])
})
test('test_task_009_route_menu_key_meta_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:重复 key、驼峰/连字符/数字开头 key 均被校验拒绝。
const issues = validateRouteMenuKeys([
{ path: 'a', menuKey: 'dup' },
{ path: 'b', menuKey: 'dup' },
{ path: 'c', menuKey: 'CamelKey' },
{ path: 'd', menuKey: 'kebab-key' },
{ path: 'e', menuKey: '1digit' },
])
assert.ok(issues.some((i) => i.includes('重复')))
assert.ok(issues.some((i) => i.includes('命名非法')))
})
test('test_task_009_route_menu_key_meta_invalid_input_rejected', () => {
// 异常输入:非字符串 menuKey 回退空串,不崩溃、不参与校验。
assert.equal(metaMenuKey({ menuKey: 123 }), '')
assert.equal(metaMenuKey({ menuKey: null }), '')
assert.deepEqual(validateRouteMenuKeys([{ path: 'x', menuKey: 1 }, { path: 'y' }]), [])
})
test('test_task_009_route_menu_key_meta_dependency_failure_returns_actionable_message', () => {
// 依赖失败:RouteMeta 已增强类型;守卫使用类型化 meta;注册表 menuKey 全部合规。
const dts = readSource('src/types/vue-router.d.ts')
assert.match(dts, /interface RouteMeta/)
assert.match(dts, /menuKey\?: string/)
const router = readSource('src/router/index.ts')
assert.equal(router.includes('as string | undefined'), false, 'menuKey 应为类型化访问,禁止 cast')
assert.deepEqual(validateRouteMenuKeys(adminPages), [])
})
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"allowImportingTsExtensions": true,
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+28
View File
@@ -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,
},
})
@@ -105,7 +105,15 @@ public class TaskOwnerForwardService {
private static byte[] requestBody(HttpServletRequest request) {
if (request instanceof ContentCachingRequestWrapper wrapper) {
byte[] body = wrapper.getContentAsByteArray();
return body == null ? new byte[0] : body;
byte[] safeBody = body == null ? new byte[0] : body;
long declaredLength = request.getContentLengthLong();
if (safeBody.length >= 1024L * 1024L
&& declaredLength >= 0L && declaredLength > safeBody.length) {
// ContentCachingRequestWrapper 超过缓存上限时会静默截断,不能把不完整
// 的请求转发到归属实例,否则可能造成 JSON/批量回调数据损坏。
throw new BusinessException("请求体超过实例转发缓存上限,无法安全转发");
}
return safeBody;
}
try {
return StreamUtils.copyToByteArray(request.getInputStream());
@@ -38,4 +38,7 @@ public class AppearancePatentProperties {
* Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。
*/
private int flushPendingMinutes = 1;
/** 单个外观专利源文件最多解析的有效数据行数,防止 POI 用户模型撑爆堆。 */
private int maxParseRows = 50000;
}
@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
public class BrandCheckProperties {
private String baseUrl = "http://47.110.241.161:16890";
private String path = "/brand_check";
private String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
private String token = "";
private String defaultStrategy = "Terms";
private int connectTimeoutMillis = 10000;
private int readTimeoutMillis = 60000;
@@ -16,6 +16,20 @@ import java.time.Duration;
public class HttpClientPool {
private static volatile HttpClient sharedHttpClient;
private static volatile long configuredCallTimeoutMillis;
/** 由 Spring 配置属性在启动阶段调用,确保共享客户端使用实际的 connect/call 配置。 */
public static void configure(long connectTimeoutMillis, long callTimeoutMillis) {
configuredCallTimeoutMillis = Math.max(1_000L, callTimeoutMillis);
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(Math.max(1_000L, connectTimeoutMillis)))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
}
}
/** 共享连接池实例:单一 HttpClient 承载全部外部调用的连接复用。 */
public static HttpClient sharedHttpClient() {
@@ -26,7 +40,7 @@ public class HttpClientPool {
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.connectTimeout(Duration.ofMillis(10_000L))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
@@ -36,7 +50,11 @@ public class HttpClientPool {
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
int safeReadTimeout = Math.max(1, readTimeoutMillis);
long safeReadTimeout = Math.max(1L, readTimeoutMillis);
long callTimeout = configuredCallTimeoutMillis;
if (callTimeout > 0L) {
safeReadTimeout = Math.min(safeReadTimeout, callTimeout);
}
JdkClientHttpRequestFactory factory =
new JdkClientHttpRequestFactory(sharedHttpClient());
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
@@ -1,5 +1,6 @@
package com.nanri.aiimage.config;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -31,6 +32,11 @@ public class HttpClientProperties {
private long baseRetryDelayMillis = 500;
/** 钳制后的连接超时:1s-300s。 */
@PostConstruct
void configureSharedHttpClient() {
HttpClientPool.configure(effectiveConnectTimeoutMillis(), effectiveCallTimeoutMillis());
}
public long effectiveConnectTimeoutMillis() {
return clamp(connectTimeoutMillis, 1_000, 300_000);
}
@@ -59,23 +59,43 @@ public class RequestTraceFilter extends OncePerRequestFilter {
filterChain.doFilter(requestToUse, response);
} finally {
long costMs = System.currentTimeMillis() - start;
log.info(
"request-trace instance={} source={} stable={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
instanceMetadata.getInstanceId(),
instanceMetadata.getSource(),
instanceMetadata.isStable(),
instanceMetadata.getHostname(),
requestToUse.getMethod(),
requestToUse.getRequestURI(),
response.getStatus(),
remoteAddr,
blankToDash(forwardedHost),
blankToDash(forwardedProto),
blankToDash(forwardedPort),
blankToDash(requestId),
blankToDash(requestToUse.getHeader("User-Agent")),
costMs
);
if (shouldLogAtInfo(requestToUse.getRequestURI(), response.getStatus(), costMs)) {
log.info(
"request-trace instance={} source={} stable={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
instanceMetadata.getInstanceId(),
instanceMetadata.getSource(),
instanceMetadata.isStable(),
instanceMetadata.getHostname(),
requestToUse.getMethod(),
requestToUse.getRequestURI(),
response.getStatus(),
remoteAddr,
blankToDash(forwardedHost),
blankToDash(forwardedProto),
blankToDash(forwardedPort),
blankToDash(requestId),
blankToDash(requestToUse.getHeader("User-Agent")),
costMs
);
} else {
log.debug(
"request-trace instance={} source={} stable={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
instanceMetadata.getInstanceId(),
instanceMetadata.getSource(),
instanceMetadata.isStable(),
instanceMetadata.getHostname(),
requestToUse.getMethod(),
requestToUse.getRequestURI(),
response.getStatus(),
remoteAddr,
blankToDash(forwardedHost),
blankToDash(forwardedProto),
blankToDash(forwardedPort),
blankToDash(requestId),
blankToDash(requestToUse.getHeader("User-Agent")),
costMs
);
}
}
}
@@ -100,6 +120,17 @@ public class RequestTraceFilter extends OncePerRequestFilter {
return request;
}
private static boolean shouldLogAtInfo(String uri, int status, long costMs) {
if (status >= 500 || costMs >= 1_000L) {
return true;
}
String normalized = uri == null ? "" : uri.toLowerCase(Locale.ROOT);
return !(normalized.contains("/heartbeat")
|| normalized.contains("/progress")
|| normalized.contains("/poll")
|| normalized.contains("/status"));
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
@@ -31,6 +31,7 @@ import com.nanri.aiimage.modules.withdraw.service.WithdrawResultFileJobHandler;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -40,7 +41,10 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
@@ -77,9 +81,30 @@ public class TaskFileJobConfig {
.factory());
}
/**
* transient payload 物理删除使用独立有界线程池,不能与业务虚拟线程执行器共用,
* 避免清理洪峰占满业务任务线程并形成无界在途删除。
*/
@Bean(name = "transientPayloadDeleteExecutor", destroyMethod = "shutdown")
public ExecutorService transientPayloadDeleteExecutor(
@Value("${aiimage.transient-storage.delete-dispatch-pool-size:2}") int poolSize,
@Value("${aiimage.transient-storage.delete-dispatch-queue-capacity:100}") int queueCapacity) {
int workers = Math.max(1, Math.min(poolSize, 16));
int queue = Math.max(1, Math.min(queueCapacity, 10_000));
return new ThreadPoolExecutor(
workers, workers, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queue),
runnable -> {
Thread thread = new Thread(runnable, "transient-payload-delete");
thread.setDaemon(true);
return thread;
},
new ThreadPoolExecutor.AbortPolicy());
}
@Bean("taskQueueExecutor")
public TaskExecutor taskQueueExecutor(
ExecutorService taskQueueVirtualThreadExecutor,
@Qualifier("taskQueueVirtualThreadExecutor") ExecutorService taskQueueVirtualThreadExecutor,
@Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent,
@Value("${aiimage.coze-task.max-waiting:1000}") int maxWaiting,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
@@ -45,14 +45,13 @@ public class TaskQueueGate implements TaskExecutor {
recordRejected("invalid-input");
throw new IllegalArgumentException("task 不能为 null");
}
if (waiting.get() >= maxWaiting) {
if (!tryReserveWaitingSlot()) {
recordRejected("queue-full");
log.warn("[task-queue][gate] waiting queue full, reject submit waiting={} limit={}",
waiting.get(), maxWaiting);
throw new TaskRejectedException("task 等待队列已满,limit=" + maxWaiting
+ ", waiting=" + waiting.get());
}
waiting.incrementAndGet();
long queuedAt = System.nanoTime();
try {
delegate.execute(() -> {
@@ -83,6 +82,22 @@ public class TaskQueueGate implements TaskExecutor {
}
}
/**
* 原子预留一个等待槽位。不能使用“先 get 再 increment”,否则并发提交
* 会同时通过检查,导致等待数量突破 maxWaiting。
*/
private boolean tryReserveWaitingSlot() {
while (true) {
int current = waiting.get();
if (current >= maxWaiting) {
return false;
}
if (waiting.compareAndSet(current, current + 1)) {
return true;
}
}
}
private void recordRejected(String reason) {
MeterRegistry registry = meterRegistry();
if (registry != null) {
@@ -12,9 +12,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -38,6 +40,7 @@ import java.util.regex.Pattern;
public class AppearancePatentLlmClient {
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
private static final int MAX_LLM_RESPONSE_BYTES = 16 * 1024 * 1024;
private static final String INFRINGEMENT = "侵权";
private static final String NO_INFRINGEMENT = "无侵权";
private static final String BRAND_QUERY_FAILED = "商标查询失败";
@@ -298,8 +301,7 @@ public class AppearancePatentLlmClient {
});
request.body(body);
String responseText = request.exchange((clientRequest, clientResponse) -> {
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
String responseBody = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
String responseBody = readResponseBodyBounded(clientResponse.getBody());
if (!clientResponse.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException("LLM http " + clientResponse.getStatusCode().value()
+ ": " + abbreviate(responseBody, 500));
@@ -467,6 +469,28 @@ public class AppearancePatentLlmClient {
}
}
private String readResponseBodyBounded(InputStream inputStream) throws IOException {
if (inputStream == null) {
return "";
}
try (InputStream input = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream(8192)) {
byte[] buffer = new byte[8192];
int read;
int total = 0;
while ((read = input.read(buffer)) != -1) {
if (read == 0) {
continue;
}
if ((long) total + read > MAX_LLM_RESPONSE_BYTES) {
throw new IOException("LLM response exceeds " + MAX_LLM_RESPONSE_BYTES + " bytes");
}
output.write(buffer, 0, read);
total += read;
}
return output.toString(StandardCharsets.UTF_8);
}
}
private JsonNode parseJsonOrThrow(String value) {
try {
return objectMapper.readTree(value);
@@ -165,6 +165,10 @@ public class AppearancePatentTaskService {
}
ParsedWorkbook parsed = parseWorkbook(input, source);
int maxParseRows = Math.max(1, properties.getMaxParseRows());
if ((long) allRows.size() + parsed.allRows().size() > maxParseRows) {
throw new BusinessException("解析总行数超过上限: " + maxParseRows);
}
totalRows += parsed.totalRows();
droppedRows += parsed.droppedRows();
allRows.addAll(parsed.allRows());
@@ -2295,6 +2299,7 @@ public class AppearancePatentTaskService {
int total = 0;
int dropped = 0;
int validRows = 0;
int maxParseRows = Math.max(1, properties.getMaxParseRows());
String currentBlockBaseId = "";
String currentGroupKey = "";
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
@@ -2314,6 +2319,9 @@ public class AppearancePatentTaskService {
continue;
}
validRows++;
if (validRows > maxParseRows) {
throw new BusinessException("解析行数超过上限: " + maxParseRows);
}
AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo();
vo.setSourceFileKey(source.getFileKey());
vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName()));
@@ -27,13 +27,19 @@ import java.util.Objects;
@Slf4j
public class AppearancePatentExcelParser {
private static final int DEFAULT_MAX_ROWS = 50_000;
public ParsedSheet parse(File input) {
return parse(input, DEFAULT_MAX_ROWS);
}
public ParsedSheet parse(File input, int maxRows) {
if (input == null) {
throw new IllegalArgumentException("input must not be null");
}
try (FileInputStream fis = new FileInputStream(input);
Workbook workbook = WorkbookFactory.create(fis)) {
return parseWorkbook(workbook);
return parseWorkbook(workbook, maxRows);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
@@ -47,7 +53,7 @@ public class AppearancePatentExcelParser {
throw new IllegalArgumentException("input must not be null");
}
try (Workbook workbook = WorkbookFactory.create(input)) {
return parseWorkbook(workbook);
return parseWorkbook(workbook, DEFAULT_MAX_ROWS);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
@@ -56,7 +62,8 @@ public class AppearancePatentExcelParser {
}
}
private ParsedSheet parseWorkbook(Workbook workbook) {
private ParsedSheet parseWorkbook(Workbook workbook, int maxRows) {
int safeMaxRows = Math.max(1, maxRows);
DataFormatter formatter = new DataFormatter();
Sheet sheet = workbook.getSheetAt(0);
Row header = sheet.getRow(0);
@@ -89,6 +96,9 @@ public class AppearancePatentExcelParser {
if (id.isBlank() && asin.isBlank() && country.isBlank()) {
continue;
}
if (rows.size() >= safeMaxRows) {
throw new BusinessException("解析行数超过上限: " + safeMaxRows);
}
rows.add(new AppearanceExcelRow(
i + 1,
id,
@@ -75,15 +75,16 @@ public class CollectDataBrandBatchFilter {
List<String> batchBrands = distinctNonBlank(batch.stream()
.filter(row -> row != null)
.map(CollectDataResultRowVo::getBrand).toList());
Map<String, String> cachedVerdicts = snapshotVerdicts(batchBrands);
List<String> uncachedBrands = new ArrayList<>();
for (String brand : batchBrands) {
if (!verdictCache.containsKey(normalizeBrand(brand))) {
if (!cachedVerdicts.containsKey(normalizeBrand(brand))) {
uncachedBrands.add(brand);
}
}
if (uncachedBrands.isEmpty() && !batchBrands.isEmpty()) {
// 本批次品牌全部命中缓存,无需远程调用。
classify(batch, verdictCache, rejected, queryFailed, accepted);
classify(batch, cachedVerdicts, rejected, queryFailed, accepted);
continue;
}
if (batchBrands.isEmpty()) {
@@ -95,14 +96,7 @@ public class CollectDataBrandBatchFilter {
}
continue;
}
Map<String, String> batchVerdicts = new LinkedHashMap<>();
for (String brand : batchBrands) {
String normalized = normalizeBrand(brand);
String cached = verdictCache.get(normalized);
if (cached != null) {
batchVerdicts.put(normalized, cached);
}
}
Map<String, String> batchVerdicts = new LinkedHashMap<>(cachedVerdicts);
try {
batchVerdicts.putAll(checkAndCache(uncachedBrands));
} catch (RuntimeException ex) {
@@ -141,15 +135,38 @@ public class CollectDataBrandBatchFilter {
return verdicts;
}
private void putBounded(String brand, String verdict) {
if (verdictCache.containsKey(brand)) {
return;
private Map<String, String> snapshotVerdicts(List<String> brands) {
Map<String, String> snapshot = new LinkedHashMap<>();
if (brands == null || brands.isEmpty()) {
return snapshot;
}
verdictCache.put(brand, verdict);
if (verdictCache.size() > cacheCapacity) {
var it = verdictCache.entrySet().iterator();
it.next();
it.remove();
synchronized (verdictCache) {
for (String brand : brands) {
String normalized = normalizeBrand(brand);
if (!normalized.isBlank()) {
String verdict = verdictCache.get(normalized);
if (verdict != null) {
snapshot.put(normalized, verdict);
}
}
}
}
return snapshot;
}
private void putBounded(String brand, String verdict) {
synchronized (verdictCache) {
if (verdictCache.containsKey(brand)) {
return;
}
verdictCache.put(brand, verdict);
if (verdictCache.size() > cacheCapacity) {
var it = verdictCache.entrySet().iterator();
if (it.hasNext()) {
it.next();
it.remove();
}
}
}
}
@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@@ -15,6 +16,7 @@ public class DedupeRunRequest {
@Valid
@NotEmpty(message = "请先上传待处理文件")
@Size(max = 200, message = "单次最多提交 200 个文件")
@Schema(description = "已上传源文件列表")
private List<DedupeSourceFileDto> files;
@@ -17,11 +17,13 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.File;
@@ -35,6 +37,9 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
@@ -52,6 +57,8 @@ public class DedupeRunService {
private static final int MAX_PARALLEL_FILES = 4;
/** 单用户同时运行中的去重任务数上限 */
private static final int MAX_RUNNING_TASKS_PER_USER = 2;
/** 单次请求最多允许的源文件数,避免每个文件形成大量排队对象。 */
private static final int MAX_INPUT_FILES = 200;
private static final long COMPLETED_PROGRESS_RETENTION_MILLIS = 60 * 60 * 1000L;
private final FileTaskMapper fileTaskMapper;
@@ -67,21 +74,35 @@ public class DedupeRunService {
/** userId -> 运行中任务数,限制单用户并发任务 */
private final Map<Long, AtomicInteger> runningTaskCountMap = new ConcurrentHashMap<>();
private final Semaphore fileParallelSemaphore = new Semaphore(MAX_PARALLEL_FILES);
/** 共享有界文件执行器;不再为请求中的每个文件创建虚拟线程。 */
private final ExecutorService fileExecutor = Executors.newFixedThreadPool(
MAX_PARALLEL_FILES, runnable -> {
Thread thread = new Thread(runnable, "dedupe-file-worker");
thread.setDaemon(true);
return thread;
});
/**
* 提交去重任务:立即返回进度快照(runId),异步执行 流式读取 + 多文件并行 + 结果上传。
*/
public DedupeRunProgressVo submitRun(DedupeRunRequest request) {
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("用户 ID 不合法");
}
if (request.getFiles() == null || request.getFiles().isEmpty()) {
throw new BusinessException("请先上传待处理文件");
}
if (request.getFiles().size() > MAX_INPUT_FILES) {
throw new BusinessException("单次最多提交 " + MAX_INPUT_FILES + " 个文件");
}
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) {
throw new BusinessException("请至少选择一种 ID 保留规则");
}
cleanupExpiredProgress();
AtomicInteger runningCount = runningTaskCountMap.computeIfAbsent(request.getUserId(), k -> new AtomicInteger(0));
if (runningCount.get() >= MAX_RUNNING_TASKS_PER_USER) {
if (!tryAcquireRunningTaskSlot(request.getUserId())) {
throw new BusinessException("已有其他去重任务正在处理中,请等待完成后再试");
}
runningCount.incrementAndGet();
String runId = IdUtil.fastSimpleUUID();
FileTaskEntity task = new FileTaskEntity();
@@ -95,7 +116,13 @@ public class DedupeRunService {
task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
try {
fileTaskMapper.insert(task);
} catch (RuntimeException ex) {
// 任务落库失败时释放已经预留的用户并发名额,避免后续请求被永久拒绝。
decrementRunningCount(request.getUserId());
throw ex;
}
DedupeRunProgressVo progress = new DedupeRunProgressVo();
progress.setRunId(runId);
@@ -147,9 +174,9 @@ public class DedupeRunService {
try {
AtomicInteger processedCount = new AtomicInteger(0);
List<Thread> workers = new ArrayList<>(request.getFiles().size());
List<Future<?>> workers = new ArrayList<>(request.getFiles().size());
for (DedupeSourceFileDto sourceFile : request.getFiles()) {
Thread worker = Thread.ofVirtual().start(() -> {
workers.add(fileExecutor.submit(() -> {
DedupeResultItemVo item = processFile(sourceFile, request, folderMode, task, archiveEntries);
synchronized (progress) {
progress.setProcessedCount(processedCount.incrementAndGet());
@@ -163,11 +190,10 @@ public class DedupeRunService {
outcomeItems.add(item);
}
}
});
workers.add(worker);
}));
}
for (Thread worker : workers) {
worker.join();
for (Future<?> worker : workers) {
worker.get();
}
} catch (Exception ex) {
log.error("dedupe run async aborted runId={} error", runId, ex);
@@ -821,6 +847,19 @@ public class DedupeRunService {
}
}
private boolean tryAcquireRunningTaskSlot(Long userId) {
AtomicInteger runningCount = runningTaskCountMap.computeIfAbsent(userId, ignored -> new AtomicInteger());
while (true) {
int current = runningCount.get();
if (current >= MAX_RUNNING_TASKS_PER_USER) {
return false;
}
if (runningCount.compareAndSet(current, current + 1)) {
return true;
}
}
}
private void decrementRunningCount(Long userId) {
AtomicInteger runningCount = runningTaskCountMap.get(userId);
if (runningCount != null && runningCount.decrementAndGet() <= 0) {
@@ -828,6 +867,16 @@ public class DedupeRunService {
}
}
@PreDestroy
void shutdownFileExecutor() {
fileExecutor.shutdownNow();
}
@Scheduled(fixedDelayString = "${aiimage.dedupe.run-progress-cleanup-delay-ms:300000}")
public void cleanupExpiredProgressScheduled() {
cleanupExpiredProgress();
}
private void cleanupExpiredProgress() {
long cutoff = System.currentTimeMillis() - COMPLETED_PROGRESS_RETENTION_MILLIS;
runCompletedAtMap.forEach((runId, completedAt) -> {
@@ -23,6 +23,7 @@ import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
@@ -53,6 +54,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.function.Supplier;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
@@ -81,6 +83,15 @@ public class DedupeTotalDataService {
private final Map<String, Long> importCompletedAtMap = new ConcurrentHashMap<>();
private final Map<String, Long> deleteImportCompletedAtMap = new ConcurrentHashMap<>();
/** 导入任务并发上限,避免 POI 解析和批量数据库写入叠加。 */
private final Semaphore importSlots = new Semaphore(4);
@Value("${aiimage.dedupe.total-data.max-import-file-bytes:104857600}")
private long maxImportFileBytes = 100L * 1024 * 1024;
@Value("${aiimage.dedupe.total-data.max-import-rows:500000}")
private int maxImportRows = 500_000;
private TransactionTemplate newRequiresNewTemplate() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
@@ -411,8 +422,14 @@ public class DedupeTotalDataService {
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 文件");
}
if (maxImportFileBytes > 0 && file.getSize() > maxImportFileBytes) {
throw new BusinessException("导入文件超过大小上限");
}
AdminUserEntity uploader = getOperator(operatorId);
ShopManageGroupEntity group = resolveWritableGroup(groupId, uploader);
if (!importSlots.tryAcquire()) {
throw new BusinessException("导入任务过多,请稍后重试");
}
String importId = IdUtil.fastSimpleUUID();
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
progress.setStatus("pending");
@@ -431,6 +448,7 @@ public class DedupeTotalDataService {
Thread.ofVirtual().start(() -> runImportTask(
importId, tempFile, filename, uploader.getId(), uploader.getUsername(), group.getId()));
} catch (Exception e) {
importSlots.release();
importProgressMap.remove(importId);
importOwnerMap.remove(importId);
importGroupMap.remove(importId);
@@ -463,8 +481,14 @@ public class DedupeTotalDataService {
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 文件");
}
if (maxImportFileBytes > 0 && file.getSize() > maxImportFileBytes) {
throw new BusinessException("导入文件超过大小上限");
}
AdminUserEntity operator = getOperator(operatorId);
ShopManageGroupEntity group = resolveWritableGroup(groupId, operator);
if (!importSlots.tryAcquire()) {
throw new BusinessException("导入任务过多,请稍后重试");
}
String importId = IdUtil.fastSimpleUUID();
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
progress.setStatus("pending");
@@ -483,6 +507,7 @@ public class DedupeTotalDataService {
Thread.ofVirtual().start(() -> runDeleteImportTask(
importId, tempFile, filename, operator.getId(), group.getId()));
} catch (Exception e) {
importSlots.release();
deleteImportProgressMap.remove(importId);
deleteImportOwnerMap.remove(importId);
deleteImportGroupMap.remove(importId);
@@ -510,6 +535,7 @@ public class DedupeTotalDataService {
Long operatorId, Long groupId) {
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
if (progress == null) {
importSlots.release();
return;
}
progress.setStatus("running");
@@ -529,6 +555,7 @@ public class DedupeTotalDataService {
} finally {
deleteQuietly(tempFile);
deleteImportCompletedAtMap.put(importId, System.currentTimeMillis());
importSlots.release();
}
}
@@ -536,6 +563,7 @@ public class DedupeTotalDataService {
Long uploaderUserId, String uploaderUsername, Long groupId) {
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
if (progress == null) {
importSlots.release();
return;
}
progress.setStatus("running");
@@ -554,6 +582,7 @@ public class DedupeTotalDataService {
} finally {
deleteQuietly(tempFile);
importCompletedAtMap.put(importId, System.currentTimeMillis());
importSlots.release();
}
}
@@ -659,6 +688,9 @@ public class DedupeTotalDataService {
List<String> pendingValues = new ArrayList<>(IMPORT_BATCH_SIZE);
Map<String, String> pendingCountries = new HashMap<>();
int totalRows = Math.max(sheet.getLastRowNum(), 0);
if (maxImportRows > 0 && totalRows > maxImportRows) {
throw new BusinessException("导入行数超过上限: " + maxImportRows);
}
int asinCount = 0;
int insertedCount = 0;
int skippedCount = 0;
@@ -836,6 +868,9 @@ public class DedupeTotalDataService {
Set<String> seenInFile = new HashSet<>();
int totalRows = Math.max(sheet.getLastRowNum(), 0);
if (maxImportRows > 0 && totalRows > maxImportRows) {
throw new BusinessException("导入行数超过上限: " + maxImportRows);
}
int asinCount = 0;
int deletedCount = 0;
int skippedCount = 0;
@@ -35,7 +35,7 @@ public class DigitalHumanVersionService {
private final DigitalHumanVersionMapper versionMapper;
private final OssStorageService ossStorageService;
@Transactional
/** 文件复制、校验和 OSS 上传均在事务外执行,避免长时间占用数据库连接。 */
public DigitalHumanVersionVo uploadVersion(String version, MultipartFile file, String changelog,
String minClientVersion, String createdBy) {
// 检查版本号是否已存在
@@ -119,13 +119,19 @@ public class LocalFileStorageService {
if (!baseDir.exists()) {
return null;
}
String indexedName = sourceFileIndex.get(fileKey);
String indexedName;
synchronized (sourceFileIndex) {
// LinkedHashMap 使用 access-orderget 也会修改链表结构,必须纳入同一把锁。
indexedName = sourceFileIndex.get(fileKey);
}
if (indexedName != null && isPlainName(indexedName)) {
File indexed = FileUtil.file(baseDir, indexedName);
if (indexed.isFile()) {
return indexed;
}
sourceFileIndex.remove(fileKey);
synchronized (sourceFileIndex) {
sourceFileIndex.remove(fileKey, indexedName);
}
}
File[] matchedFiles = baseDir.listFiles(pathname -> pathname.isFile()
&& (pathname.getName().equals(fileKey) || pathname.getName().startsWith(fileKey + ".")));
@@ -18,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.Semaphore;
@@ -151,8 +152,25 @@ public class RustfsObjectStorageService {
try (var stream = buildClient(deadlineNanos).getObject(GetObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build())) {
return stream.readAllBytes();
.build());
ByteArrayOutputStream output = new ByteArrayOutputStream(8192)) {
long maxBytes = properties.getMaxStoredPayloadBytes() > 0
? properties.getMaxStoredPayloadBytes()
: 100L * 1024 * 1024;
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = stream.read(buffer)) != -1) {
if (read == 0) {
continue;
}
total += read;
if (total > maxBytes) {
throw new IllegalArgumentException("transient payload exceeds configured read limit: " + maxBytes);
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
});
}

Some files were not shown because too many files have changed in this diff Show More