Compare commits
43 Commits
445c139bce
...
40224d8e56
| Author | SHA1 | Date | |
|---|---|---|---|
| 40224d8e56 | |||
| d760838e71 | |||
| 60fa5a818b | |||
| 837fc9bcc0 | |||
| aad11c71bb | |||
| 4fd4d68460 | |||
| 836ea6ecc7 | |||
| 18a48e80b4 | |||
| 5b94eb1af6 | |||
| 0524489ac2 | |||
| 37d021500a | |||
| a7b184fbd7 | |||
| 1a0159b7fc | |||
| 877a690635 | |||
| 2957c575e6 | |||
| 115df85063 | |||
| 8b07321fbb | |||
| 2d0d836d9a | |||
| 7c8374424d | |||
| 46622d9f42 | |||
| d3154a48b8 | |||
| 7ae595c91a | |||
| 9f73af5911 | |||
| 1cfdcb5fb3 | |||
| bd4b2a579e | |||
| ba12e04d1e | |||
| 7ffae78919 | |||
| d397c88c3c | |||
| 83b935c11b | |||
| 13e7a4c602 | |||
| c3f160afa5 | |||
| f4f7268045 | |||
| 50e70c43ab | |||
| f1fa5f7adf | |||
| ab7255a55b | |||
| 02e0dc20b8 | |||
| 8e828c1204 | |||
| 26edb9e6ed | |||
| 4a0e464308 | |||
| af68b20f87 | |||
| 7065dbb53e | |||
| 6f0357e41c | |||
| ec7d56d566 |
@@ -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('用户管理')
|
||||
})
|
||||
Generated
+2230
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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' } })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* /admin-vue/ 基准路径与 History 路由约定(任务 2 冻结的单一事实源)。
|
||||
* Vite base、Vue Router history base、Nginx location 三者必须共用此常量。
|
||||
*/
|
||||
export const APP_BASE_PATH = '/admin-vue/'
|
||||
|
||||
/** 前端路由一律使用 History 模式,不引入 hash(旧 #tab 兼容不在目标契约内)。 */
|
||||
export const APP_HISTORY_MODE = 'history' as const
|
||||
|
||||
function invalidBaseMessage(value: unknown): string {
|
||||
return `管理后台 base 路径非法: ${JSON.stringify(value)},必须以 / 开头且形如 /xxx/,不允许空值或 ..`
|
||||
}
|
||||
|
||||
/** 校验并归一化 base 路径:保证以 / 开头、无 ..、无内部连续斜杠、以单个 / 结尾。 */
|
||||
export function ensureAppBasePath(value: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new Error(invalidBaseMessage(value))
|
||||
if (!value.startsWith('/')) throw new Error(invalidBaseMessage(value))
|
||||
if (value.includes('//')) throw new Error(invalidBaseMessage(value))
|
||||
if (value.includes('..')) throw new Error(invalidBaseMessage(value))
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
/** 把后台页面相对路径段拼成绝对 URL(不含 base 前缀的历史兼容子路径段由路由负责)。 */
|
||||
export function joinAdminPath(...segments: string[]): string {
|
||||
const parts: string[] = []
|
||||
for (const segment of segments) {
|
||||
for (const raw of segment.split('/')) {
|
||||
const part = raw.trim()
|
||||
if (!part || part === '.') continue
|
||||
if (part === '..') {
|
||||
throw new Error(`admin 路由段不允许出现 ..(越权跳转): ${segments.join('/')}`)
|
||||
}
|
||||
parts.push(part)
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) return APP_BASE_PATH
|
||||
return `${APP_BASE_PATH}${parts.join('/')}`
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 -> kebab(sidebarDeep -> --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
|
||||
}
|
||||
@@ -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
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 === '\/'/, '内容级空态限定根路由')
|
||||
})
|
||||
@@ -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: '页面不存在' \}/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 确认 + runLogout;store 退出清态并跳登录。
|
||||
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/)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
APP_BASE_PATH,
|
||||
APP_HISTORY_MODE,
|
||||
ensureAppBasePath,
|
||||
joinAdminPath,
|
||||
} from '../src/config/app.ts'
|
||||
import { resolveWorkspaceRoot, walk } from '../src/config/workspace.ts'
|
||||
|
||||
const ROOT = resolveWorkspaceRoot()
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walk(ROOT)
|
||||
}
|
||||
|
||||
function literalBaseOccurrences(file: string): number {
|
||||
const content = readFileSync(join(ROOT, file), 'utf8')
|
||||
const matches = content.match(/['"]\/admin-vue\/['"]/g)
|
||||
return matches ? matches.length : 0
|
||||
}
|
||||
|
||||
test('test_task_002_history_base_contract_normal_primary_path', () => {
|
||||
// 正常主路径:冻结契约常量可被路由/构建直接消费。
|
||||
assert.equal(APP_BASE_PATH, '/admin-vue/')
|
||||
assert.equal(APP_HISTORY_MODE, 'history')
|
||||
assert.equal(joinAdminPath('account', 'users'), '/admin-vue/account/users')
|
||||
const router = readFileSync(join(ROOT, 'src/router/index.ts'), 'utf8')
|
||||
assert.match(router, /createWebHistory\(APP_BASE_PATH\)/)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_normal_variant_input', () => {
|
||||
// 正常变体:无尾斜杠/带尾斜杠/单段带斜杠的等价输入得到同一结果。
|
||||
assert.equal(ensureAppBasePath('/admin-vue'), APP_BASE_PATH)
|
||||
assert.equal(ensureAppBasePath('/admin-vue/'), APP_BASE_PATH)
|
||||
assert.equal(joinAdminPath('account/users'), joinAdminPath('account', 'users'))
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_normal_repeated_operation_is_idempotent', () => {
|
||||
// 正常重复:归一化与拼接幂等、不依赖可变状态。
|
||||
assert.equal(ensureAppBasePath(ensureAppBasePath('/admin-vue')), APP_BASE_PATH)
|
||||
assert.equal(joinAdminPath('account', 'users'), joinAdminPath('account', 'users'))
|
||||
const once = joinAdminPath('shop-center', 'list')
|
||||
const twice = joinAdminPath('shop-center', 'list')
|
||||
assert.equal(once, twice)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_boundary_empty_input', () => {
|
||||
// 边界空值:无段拼接回到基准根路径;空 base 被拒绝并带语义错误。
|
||||
assert.equal(joinAdminPath(), APP_BASE_PATH)
|
||||
assert.throws(() => ensureAppBasePath(''), /base 路径非法/)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_boundary_single_item', () => {
|
||||
// 边界单元素:单个路由段生成单一绝对地址。
|
||||
assert.equal(joinAdminPath('users'), '/admin-vue/users')
|
||||
assert.ok(APP_BASE_PATH.endsWith('/'))
|
||||
assert.ok(!APP_BASE_PATH.includes('//'))
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限:段内多余斜杠与首尾空白被收敛,不产生双斜杠或空格。
|
||||
const path = joinAdminPath(' /account/ ', ' users/ ')
|
||||
assert.equal(path, '/admin-vue/account/users')
|
||||
assert.equal((APP_BASE_PATH.match(/\//g) || []).length, 2, '基准路径只含一个目录段')
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_invalid_input_rejected', () => {
|
||||
// 异常输入:缺 / 前缀、含内部双斜杠、含 .. 越权段均被拒绝并给出消息。
|
||||
assert.throws(() => ensureAppBasePath('admin-vue'), /base 路径非法/)
|
||||
assert.throws(() => ensureAppBasePath('//admin-vue/'), /base 路径非法/)
|
||||
assert.throws(() => joinAdminPath('..', 'x'), /不允许出现 \.\./)
|
||||
assert.throws(() => joinAdminPath('/../etc'), /不允许出现 \.\./)
|
||||
})
|
||||
|
||||
test('test_task_002_history_base_contract_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败:非法 base 值(undefined)返回可操作错误;base 字面量必须单一来源。
|
||||
assert.throws(() => ensureAppBasePath(undefined as unknown as string), (e: Error) => {
|
||||
return /base 路径非法/.test(e.message)
|
||||
})
|
||||
const offenders = sourceFiles()
|
||||
.map((file) => ({ file, count: literalBaseOccurrences(file) }))
|
||||
.filter(({ file, count }) => count > 0)
|
||||
assert.deepEqual(
|
||||
offenders.map((o) => o.file),
|
||||
['src/config/app.ts'],
|
||||
`'/admin-vue/' 字面量必须只出现在单一事实源 app.ts: ${JSON.stringify(offenders)}`,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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)?'/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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/Session(Axios 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 需携带当前目标路径')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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:/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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('; ')}`)
|
||||
})
|
||||
@@ -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', () => {
|
||||
// 边界单元素:单次失败只写一次 error;loading 在 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*\{/)
|
||||
})
|
||||
@@ -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\(/)
|
||||
})
|
||||
@@ -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 查找)')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 保存失败错误而非置空菜单')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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(', ')}`)
|
||||
})
|
||||
@@ -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 默认值')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'), [], '未命中路由不展开任何分组')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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, '页面懒加载应只存在于注册表')
|
||||
})
|
||||
@@ -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), [])
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
+9
-1
@@ -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) {
|
||||
|
||||
+27
-3
@@ -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);
|
||||
|
||||
+8
@@ -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()));
|
||||
|
||||
+13
-3
@@ -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,
|
||||
|
||||
+35
-18
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
|
||||
+59
-10
@@ -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) -> {
|
||||
|
||||
+35
@@ -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;
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
// 检查版本号是否已存在
|
||||
|
||||
+8
-2
@@ -119,13 +119,19 @@ public class LocalFileStorageService {
|
||||
if (!baseDir.exists()) {
|
||||
return null;
|
||||
}
|
||||
String indexedName = sourceFileIndex.get(fileKey);
|
||||
String indexedName;
|
||||
synchronized (sourceFileIndex) {
|
||||
// LinkedHashMap 使用 access-order,get 也会修改链表结构,必须纳入同一把锁。
|
||||
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 + ".")));
|
||||
|
||||
+20
-2
@@ -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
Reference in New Issue
Block a user