feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# 开发环境配置
VITE_API_BASE_URL=/api/v1

View File

@@ -0,0 +1,5 @@
# Admin Frontend 环境变量配置示例
# 复制此文件为 .env.local 进行本地开发配置
# API 基础地址
VITE_API_BASE_URL=/api/v1

View File

@@ -0,0 +1,3 @@
# 生产环境配置
# 部署时根据实际后端地址修改
VITE_API_BASE_URL=/api/v1

24
frontend/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/admin/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用户管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5451
frontend/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --configLoader native",
"build": "tsc -b && vite build --configLoader native",
"lint": "eslint .",
"preview": "vite preview",
"test": "node ./scripts/run-vitest.mjs",
"test:ui": "node ./scripts/run-vitest.mjs --ui",
"test:coverage": "node ./scripts/run-vitest.mjs --run --coverage",
"test:run": "node ./scripts/run-vitest.mjs --run",
"e2e": "node ./scripts/run-playwright-cdp-e2e.mjs",
"e2e:full": "node ./scripts/run-playwright-cdp-e2e.mjs",
"e2e:full:win": "powershell -ExecutionPolicy Bypass -File ./scripts/run-playwright-auth-e2e.ps1",
"e2e:smoke": "node ./scripts/run-cdp-smoke.mjs",
"e2e:smoke:win": "powershell -ExecutionPolicy Bypass -File ./scripts/run-cdp-smoke-bootstrap.ps1",
"e2e:auth-smoke:win": "powershell -ExecutionPolicy Bypass -File ./scripts/run-cdp-auth-smoke.ps1",
"e2e:report": "node -e \"console.error('Playwright runner report is not supported in this environment; use docs/evidence instead.'); process.exit(1)\""
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^5.29.3",
"dayjs": "^1.11.20",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^24.12.0",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/coverage-v8": "^4.1.2",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^26.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.3",
"vitest": "^4.1.2"
},
"overrides": {
"picomatch": "4.0.4",
"minimatch@3": {
"brace-expansion": "1.1.13"
},
"minimatch@10": {
"brace-expansion": "5.0.5"
}
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,185 @@
import process from 'node:process'
import path from 'node:path'
import net from 'node:net'
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
function parseArgs(argv) {
const args = new Map()
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (!value.startsWith('--')) {
continue
}
const key = value.slice(2)
const nextValue = argv[index + 1]
if (nextValue && !nextValue.startsWith('--')) {
args.set(key, nextValue)
index += 1
continue
}
args.set(key, 'true')
}
return args
}
const args = parseArgs(process.argv.slice(2))
const port = Number(args.get('port') ?? process.env.SMTP_CAPTURE_PORT ?? 2525)
const outputPath = path.resolve(args.get('output') ?? process.env.SMTP_CAPTURE_OUTPUT ?? './smtp-capture.jsonl')
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid SMTP capture port: ${port}`)
}
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, '', 'utf8')
let writeQueue = Promise.resolve()
function queueMessageWrite(message) {
writeQueue = writeQueue.then(() => appendFile(outputPath, `${JSON.stringify(message)}\n`, 'utf8'))
return writeQueue
}
function createSessionState() {
return {
buffer: '',
dataMode: false,
mailFrom: '',
rcptTo: [],
data: '',
}
}
const server = net.createServer((socket) => {
socket.setEncoding('utf8')
let session = createSessionState()
const reply = (line) => {
socket.write(`${line}\r\n`)
}
const resetMessageState = () => {
session.dataMode = false
session.mailFrom = ''
session.rcptTo = []
session.data = ''
}
const flushBuffer = async () => {
while (true) {
if (session.dataMode) {
const messageTerminatorIndex = session.buffer.indexOf('\r\n.\r\n')
if (messageTerminatorIndex === -1) {
session.data += session.buffer
session.buffer = ''
return
}
session.data += session.buffer.slice(0, messageTerminatorIndex)
session.buffer = session.buffer.slice(messageTerminatorIndex + 5)
const capturedMessage = {
timestamp: new Date().toISOString(),
mailFrom: session.mailFrom,
rcptTo: session.rcptTo,
data: session.data.replace(/\r\n\.\./g, '\r\n.'),
}
await queueMessageWrite(capturedMessage)
resetMessageState()
reply('250 OK')
continue
}
const lineEndIndex = session.buffer.indexOf('\r\n')
if (lineEndIndex === -1) {
return
}
const line = session.buffer.slice(0, lineEndIndex)
session.buffer = session.buffer.slice(lineEndIndex + 2)
const normalized = line.toUpperCase()
if (normalized.startsWith('EHLO')) {
socket.write('250-localhost\r\n250 OK\r\n')
continue
}
if (normalized.startsWith('HELO')) {
reply('250 OK')
continue
}
if (normalized.startsWith('MAIL FROM:')) {
resetMessageState()
session.mailFrom = line.slice('MAIL FROM:'.length).trim()
reply('250 OK')
continue
}
if (normalized.startsWith('RCPT TO:')) {
session.rcptTo.push(line.slice('RCPT TO:'.length).trim())
reply('250 OK')
continue
}
if (normalized === 'DATA') {
session.dataMode = true
session.data = ''
reply('354 End data with <CR><LF>.<CR><LF>')
continue
}
if (normalized === 'RSET') {
resetMessageState()
reply('250 OK')
continue
}
if (normalized === 'NOOP') {
reply('250 OK')
continue
}
if (normalized === 'QUIT') {
reply('221 Bye')
socket.end()
return
}
reply('250 OK')
}
}
socket.on('data', (chunk) => {
session.buffer += chunk
void flushBuffer().catch((error) => {
console.error(error?.stack ?? String(error))
socket.destroy(error)
})
})
socket.on('error', () => {})
reply('220 localhost ESMTP ready')
})
server.listen(port, '127.0.0.1', () => {
console.log(`SMTP capture listening on 127.0.0.1:${port}`)
})
async function shutdown() {
server.close()
await writeQueue.catch(() => {})
}
process.on('SIGINT', () => {
void shutdown().finally(() => process.exit(0))
})
process.on('SIGTERM', () => {
void shutdown().finally(() => process.exit(0))
})

View File

@@ -0,0 +1,316 @@
param(
[string]$AdminUsername = 'e2e_admin',
[string]$AdminPassword = 'E2EAdmin@123456',
[string]$AdminEmail = 'e2e_admin@example.com',
[int]$BrowserPort = 0
)
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
$goPathDir = Join-Path $tempCacheRoot 'gopath'
$serverExePath = Join-Path $env:TEMP ("ums-server-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir | Out-Null
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 120,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)][string]$WorkingDirectory
)
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
} else {
$process = Start-Process `
-FilePath $FilePath `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
}
return [pscustomobject]@{
Name = $Name
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Stop-ManagedProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
try {
taskkill /PID $Handle.Process.Id /T /F *> $null
} catch {
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Show-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if (Test-Path $Handle.StdOut) {
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
}
if (Test-Path $Handle.StdErr) {
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
}
}
function Remove-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
}
$backendHandle = $null
$frontendHandle = $null
$startedBackend = $false
$startedFrontend = $false
$adminInitialized = $false
try {
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
go build -o $serverExePath .\cmd\server\main.go
if ($LASTEXITCODE -ne 0) {
throw 'server build failed'
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
}
$backendWasRunning = Test-UrlReady -Url 'http://127.0.0.1:8080/health'
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
$env:UMS_ADMIN_USERNAME = $AdminUsername
$env:UMS_ADMIN_PASSWORD = $AdminPassword
$env:UMS_ADMIN_EMAIL = $AdminEmail
$env:UMS_ADMIN_RESET_PASSWORD = 'true'
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$initOutput = go run .\tools\init_admin.go 2>&1 | Out-String
$initExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($initExitCode -eq 0) {
$adminInitialized = $true
} else {
$verifyOutput = go run .\tools\verify_admin.go 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -and $verifyOutput -match 'password valid: True|password valid: true') {
Write-Host 'init_admin fallback: existing admin credentials verified'
$adminInitialized = $true
} else {
Write-Host $initOutput
}
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_RESET_PASSWORD -ErrorAction SilentlyContinue
}
if (-not $adminInitialized -and -not $backendWasRunning) {
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend-bootstrap' `
-FilePath $serverExePath `
-WorkingDirectory $projectRoot
$startedBackend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend bootstrap'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
$backendHandle = $null
Start-Sleep -Seconds 1
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
$env:UMS_ADMIN_USERNAME = $AdminUsername
$env:UMS_ADMIN_PASSWORD = $AdminPassword
$env:UMS_ADMIN_EMAIL = $AdminEmail
$env:UMS_ADMIN_RESET_PASSWORD = 'true'
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$initOutput = go run .\tools\init_admin.go 2>&1 | Out-String
$initExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($initExitCode -eq 0) {
$adminInitialized = $true
} else {
$verifyOutput = go run .\tools\verify_admin.go 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -and $verifyOutput -match 'password valid: True|password valid: true') {
Write-Host 'init_admin fallback: existing admin credentials verified'
$adminInitialized = $true
} else {
Write-Host $initOutput
}
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_RESET_PASSWORD -ErrorAction SilentlyContinue
}
}
if (-not $adminInitialized) {
throw 'init_admin failed'
}
if (-not $backendWasRunning) {
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend' `
-FilePath $serverExePath `
-ArgumentList @() `
-WorkingDirectory $projectRoot
try {
Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
}
if (-not (Test-UrlReady -Url 'http://127.0.0.1:3000')) {
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', '3000') `
-WorkingDirectory $frontendRoot
$startedFrontend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:3000' -Label 'frontend'
} catch {
Show-ManagedProcessLogs $frontendHandle
throw
}
}
$env:E2E_LOGIN_USERNAME = $AdminUsername
$env:E2E_LOGIN_PASSWORD = $AdminPassword
Push-Location $frontendRoot
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') -Port $BrowserPort
} finally {
Pop-Location
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
}
} finally {
if ($startedFrontend) {
Stop-ManagedProcess $frontendHandle
Remove-ManagedProcessLogs $frontendHandle
}
if ($startedBackend) {
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
}
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
}

View File

@@ -0,0 +1,205 @@
param(
[int]$BrowserPort = 0
)
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
$goPathDir = Join-Path $tempCacheRoot 'gopath'
$serverExePath = Join-Path $env:TEMP ("ums-server-smoke-" + [guid]::NewGuid().ToString('N') + '.exe')
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir | Out-Null
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 120,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)][string]$WorkingDirectory
)
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
} else {
$process = Start-Process `
-FilePath $FilePath `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
}
return [pscustomobject]@{
Name = $Name
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Stop-ManagedProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
try {
taskkill /PID $Handle.Process.Id /T /F *> $null
} catch {
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Show-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if (Test-Path $Handle.StdOut) {
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
}
if (Test-Path $Handle.StdErr) {
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
}
}
function Remove-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
}
$backendHandle = $null
$frontendHandle = $null
$startedBackend = $false
$startedFrontend = $false
try {
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
go build -o $serverExePath .\cmd\server\main.go
if ($LASTEXITCODE -ne 0) {
throw 'server build failed'
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
}
if (-not (Test-UrlReady -Url 'http://127.0.0.1:8080/health')) {
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend-smoke' `
-FilePath $serverExePath `
-WorkingDirectory $projectRoot
$startedBackend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend smoke'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
}
if (-not (Test-UrlReady -Url 'http://127.0.0.1:3000')) {
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend-smoke' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', '3000') `
-WorkingDirectory $frontendRoot
$startedFrontend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:3000' -Label 'frontend smoke'
} catch {
Show-ManagedProcessLogs $frontendHandle
throw
}
}
Push-Location $frontendRoot
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') -Port $BrowserPort
} finally {
Pop-Location
}
} finally {
if ($startedFrontend) {
Stop-ManagedProcess $frontendHandle
Remove-ManagedProcessLogs $frontendHandle
}
if ($startedBackend) {
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
}
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
param(
[int]$Port = 0,
[string[]]$Command = @('node', './scripts/run-cdp-smoke.mjs')
)
$ErrorActionPreference = 'Stop'
if (-not $Command -or $Command.Count -eq 0) {
throw 'Command must not be empty'
}
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 60,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$listener.Start()
try {
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Resolve-BrowserPath {
if ($env:E2E_BROWSER_PATH) {
return $env:E2E_BROWSER_PATH
}
if ($env:CHROME_HEADLESS_SHELL_PATH) {
return $env:CHROME_HEADLESS_SHELL_PATH
}
if ($env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
return $env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
}
$baseDir = Join-Path $env:LOCALAPPDATA 'ms-playwright'
$candidate = Get-ChildItem $baseDir -Directory -Filter 'chromium_headless_shell-*' |
Sort-Object Name -Descending |
Select-Object -First 1
if ($candidate) {
return (Join-Path $candidate.FullName 'chrome-headless-shell-win64\chrome-headless-shell.exe')
}
foreach ($fallback in @(
'C:\Program Files\Google\Chrome\Application\chrome.exe',
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
)) {
if (Test-Path $fallback) {
return $fallback
}
}
throw 'No compatible browser found; set E2E_BROWSER_PATH or CHROME_HEADLESS_SHELL_PATH explicitly if needed'
}
function Test-HeadlessShellBrowser {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath
)
return [System.IO.Path]::GetFileName($BrowserPath).ToLowerInvariant().Contains('headless-shell')
}
function Get-BrowserArguments {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath,
[Parameter(Mandatory = $true)][int]$Port,
[Parameter(Mandatory = $true)][string]$ProfileDir
)
$arguments = @(
"--remote-debugging-port=$Port",
"--user-data-dir=$ProfileDir",
'--no-sandbox'
)
if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) {
$arguments += '--single-process'
} else {
$arguments += @(
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--headless=new'
)
}
$arguments += 'about:blank'
return $arguments
}
function Get-BrowserProcessIds {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath
)
$processName = [System.IO.Path]::GetFileNameWithoutExtension($BrowserPath)
try {
return @(Get-Process -Name $processName -ErrorAction Stop | Select-Object -ExpandProperty Id)
} catch {
return @()
}
}
function Get-BrowserProcessesByProfile {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath,
[Parameter(Mandatory = $true)][string]$ProfileDir
)
$processFileName = [System.IO.Path]::GetFileName($BrowserPath)
$profileFragment = $ProfileDir.ToLowerInvariant()
try {
return @(
Get-CimInstance Win32_Process -Filter ("Name = '{0}'" -f $processFileName) -ErrorAction Stop |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.ToLowerInvariant().Contains($profileFragment)
}
)
} catch {
return @()
}
}
function Get-ChildProcessIds {
param(
[Parameter(Mandatory = $true)][int]$ParentId
)
$pending = [System.Collections.Generic.Queue[int]]::new()
$seen = [System.Collections.Generic.HashSet[int]]::new()
$pending.Enqueue($ParentId)
while ($pending.Count -gt 0) {
$currentParentId = $pending.Dequeue()
try {
$children = @(Get-CimInstance Win32_Process -Filter ("ParentProcessId = {0}" -f $currentParentId) -ErrorAction Stop)
} catch {
$children = @()
}
foreach ($child in $children) {
if ($seen.Add([int]$child.ProcessId)) {
$pending.Enqueue([int]$child.ProcessId)
}
}
}
return @($seen)
}
function Get-BrowserCleanupIds {
param(
[Parameter(Mandatory = $true)]$Handle
)
$ids = [System.Collections.Generic.HashSet[int]]::new()
if ($Handle.Process) {
$null = $ids.Add([int]$Handle.Process.Id)
foreach ($childId in Get-ChildProcessIds -ParentId $Handle.Process.Id) {
$null = $ids.Add([int]$childId)
}
}
foreach ($processInfo in Get-BrowserProcessesByProfile -BrowserPath $Handle.BrowserPath -ProfileDir $Handle.ProfileDir) {
$null = $ids.Add([int]$processInfo.ProcessId)
}
$liveIds = @()
foreach ($processId in $ids) {
try {
Get-Process -Id $processId -ErrorAction Stop | Out-Null
$liveIds += $processId
} catch {
# Process already exited.
}
}
return @($liveIds | Sort-Object -Unique)
}
function Start-BrowserProcess {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath,
[Parameter(Mandatory = $true)][int]$Port,
[Parameter(Mandatory = $true)][string]$ProfileDir
)
$baselineIds = Get-BrowserProcessIds -BrowserPath $BrowserPath
$arguments = Get-BrowserArguments -BrowserPath $BrowserPath -Port $Port -ProfileDir $ProfileDir
$stdoutPath = Join-Path $ProfileDir 'browser-stdout.log'
$stderrPath = Join-Path $ProfileDir 'browser-stderr.log'
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
$process = Start-Process `
-FilePath $BrowserPath `
-ArgumentList $arguments `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
return [pscustomobject]@{
BrowserPath = $BrowserPath
BaselineIds = $baselineIds
ProfileDir = $ProfileDir
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Show-BrowserLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
foreach ($path in @($Handle.StdOut, $Handle.StdErr)) {
if (-not [string]::IsNullOrWhiteSpace($path) -and (Test-Path $path)) {
Get-Content $path -ErrorAction SilentlyContinue
}
}
}
function Stop-BrowserProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
foreach ($cleanupCommand in @(
{ param($id) taskkill /PID $id /T /F *> $null },
{ param($id) Stop-Process -Id $id -Force -ErrorAction Stop }
)) {
try {
& $cleanupCommand $Handle.Process.Id
} catch {
# Ignore cleanup errors here; the residual PID check below is authoritative.
}
}
}
$residualIds = @()
for ($attempt = 0; $attempt -lt 12; $attempt++) {
$residualIds = @(Get-BrowserCleanupIds -Handle $Handle)
foreach ($processId in $residualIds) {
foreach ($cleanupCommand in @(
{ param($id) taskkill /PID $id /T /F *> $null },
{ param($id) Stop-Process -Id $id -Force -ErrorAction Stop }
)) {
try {
& $cleanupCommand $processId
} catch {
# Ignore per-process cleanup errors during retry loop.
}
}
}
Start-Sleep -Milliseconds 500
$residualIds = @(Get-BrowserCleanupIds -Handle $Handle)
if ($residualIds.Count -eq 0) {
break
}
}
if ($residualIds.Count -gt 0) {
throw "browser cleanup leaked PIDs: $($residualIds -join ', ')"
}
}
function Remove-BrowserLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
$paths = @($Handle.StdOut, $Handle.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($paths.Count -gt 0) {
Remove-Item $paths -Force -ErrorAction SilentlyContinue
}
}
$browserPath = Resolve-BrowserPath
Write-Host "CDP browser: $browserPath"
$Port = if ($Port -gt 0) { $Port } else { Get-FreeTcpPort }
$profileRoot = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path '.cache\cdp-profiles'
New-Item -ItemType Directory -Force $profileRoot | Out-Null
$profileDir = Join-Path $profileRoot "pw-profile-cdp-smoke-win-$Port"
$browserReadyUrl = "http://127.0.0.1:$Port/json/version"
$browserCdpBaseUrl = "http://127.0.0.1:$Port"
$browserHandle = $null
try {
for ($attempt = 1; $attempt -le 2; $attempt++) {
Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue
$browserHandle = Start-BrowserProcess -BrowserPath $browserPath -Port $Port -ProfileDir $profileDir
try {
Wait-UrlReady -Url $browserReadyUrl -Label "browser CDP endpoint (attempt $attempt)"
Write-Host "CDP endpoint ready: $browserReadyUrl"
break
} catch {
Show-BrowserLogs $browserHandle
Stop-BrowserProcess $browserHandle
Remove-BrowserLogs $browserHandle
$browserHandle = $null
if ($attempt -eq 2) {
throw
}
}
}
if (-not $env:E2E_COMMAND_TIMEOUT_MS) {
$env:E2E_COMMAND_TIMEOUT_MS = '120000'
}
$env:E2E_SKIP_BROWSER_LAUNCH = '1'
$env:E2E_CDP_PORT = "$Port"
$env:E2E_CDP_BASE_URL = $browserCdpBaseUrl
$env:E2E_PLAYWRIGHT_CDP_URL = $browserCdpBaseUrl
$env:E2E_EXTERNAL_CDP = '1'
$commandName = $Command[0]
$commandArgs = @()
if ($Command.Count -gt 1) {
$commandArgs = $Command[1..($Command.Count - 1)]
}
Write-Host "Launching command: $commandName $($commandArgs -join ' ')"
& $commandName @commandArgs
if ($LASTEXITCODE -ne 0) {
throw "command failed with exit code $LASTEXITCODE"
}
} finally {
Stop-BrowserProcess $browserHandle
Remove-BrowserLogs $browserHandle
Remove-Item Env:E2E_SKIP_BROWSER_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:E2E_CDP_PORT -ErrorAction SilentlyContinue
Remove-Item Env:E2E_CDP_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_PLAYWRIGHT_CDP_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXTERNAL_CDP -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue
}

View File

@@ -0,0 +1,297 @@
param(
[string]$AdminUsername = 'e2e_admin',
[string]$AdminPassword = 'E2EAdmin@123456',
[string]$AdminEmail = 'e2e_admin@example.com',
[int]$BrowserPort = 0,
[int]$BackendPort = 0,
[int]$FrontendPort = 0
)
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
$goPathDir = Join-Path $tempCacheRoot 'gopath'
$serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
$e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N'))
$e2eDataRoot = Join-Path $e2eRunRoot 'data'
$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db'
$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl'
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$listener.Start()
try {
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 120,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)][string]$WorkingDirectory
)
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
} else {
$process = Start-Process `
-FilePath $FilePath `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
}
return [pscustomobject]@{
Name = $Name
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Stop-ManagedProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
try {
taskkill /PID $Handle.Process.Id /T /F *> $null
} catch {
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Show-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if (Test-Path $Handle.StdOut) {
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
}
if (Test-Path $Handle.StdErr) {
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
}
}
function Remove-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
}
$backendHandle = $null
$frontendHandle = $null
$smtpHandle = $null
$selectedBackendPort = if ($BackendPort -gt 0) { $BackendPort } else { Get-FreeTcpPort }
$selectedFrontendPort = if ($FrontendPort -gt 0) { $FrontendPort } else { Get-FreeTcpPort }
$selectedSMTPPort = Get-FreeTcpPort
$backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
try {
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
go build -o $serverExePath .\cmd\server\main.go
if ($LASTEXITCODE -ne 0) {
throw 'server build failed'
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
}
$env:UMS_SERVER_PORT = "$selectedBackendPort"
$env:UMS_DATABASE_SQLITE_PATH = $e2eDbPath
$env:UMS_SERVER_MODE = 'debug'
$env:UMS_PASSWORD_RESET_SITE_URL = $frontendBaseUrl
$env:UMS_CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
$env:UMS_LOGGING_OUTPUT = 'stdout'
$env:UMS_EMAIL_HOST = '127.0.0.1'
$env:UMS_EMAIL_PORT = "$selectedSMTPPort"
$env:UMS_EMAIL_FROM_EMAIL = 'noreply@test.local'
$env:UMS_EMAIL_FROM_NAME = 'UMS E2E'
Write-Host "playwright e2e backend: $backendBaseUrl"
Write-Host "playwright e2e frontend: $frontendBaseUrl"
Write-Host "playwright e2e smtp: 127.0.0.1:$selectedSMTPPort"
Write-Host "playwright e2e sqlite: $e2eDbPath"
$smtpHandle = Start-ManagedProcess `
-Name 'ums-smtp-capture' `
-FilePath 'node' `
-ArgumentList @((Join-Path $PSScriptRoot 'mock-smtp-capture.mjs'), '--port', "$selectedSMTPPort", '--output', $smtpCaptureFile) `
-WorkingDirectory $frontendRoot
Start-Sleep -Milliseconds 500
if ($smtpHandle.Process -and $smtpHandle.Process.HasExited) {
Show-ManagedProcessLogs $smtpHandle
throw 'smtp capture server failed to start'
}
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend-playwright' `
-FilePath $serverExePath `
-WorkingDirectory $projectRoot
try {
Wait-UrlReady -Url "$backendBaseUrl/health" -Label 'backend'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
$env:VITE_API_PROXY_TARGET = $backendBaseUrl
$env:VITE_API_BASE_URL = '/api/v1'
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend-playwright' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', "$selectedFrontendPort") `
-WorkingDirectory $frontendRoot
try {
Wait-UrlReady -Url $frontendBaseUrl -Label 'frontend'
} catch {
Show-ManagedProcessLogs $frontendHandle
throw
}
$env:E2E_LOGIN_USERNAME = $AdminUsername
$env:E2E_LOGIN_PASSWORD = $AdminPassword
$env:E2E_LOGIN_EMAIL = $AdminEmail
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
$env:E2E_EXTERNAL_WEB_SERVER = '1'
$env:E2E_BASE_URL = $frontendBaseUrl
$env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile
Push-Location $frontendRoot
try {
$lastError = $null
for ($attempt = 1; $attempt -le 2; $attempt++) {
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge 2) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
}
}
if ($lastError) {
throw $lastError
}
} finally {
Pop-Location
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue
Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
}
} finally {
Stop-ManagedProcess $frontendHandle
Remove-ManagedProcessLogs $frontendHandle
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
Stop-ManagedProcess $smtpHandle
Remove-ManagedProcessLogs $smtpHandle
Remove-Item Env:UMS_SERVER_PORT -ErrorAction SilentlyContinue
Remove-Item Env:UMS_DATABASE_SQLITE_PATH -ErrorAction SilentlyContinue
Remove-Item Env:UMS_SERVER_MODE -ErrorAction SilentlyContinue
Remove-Item Env:UMS_PASSWORD_RESET_SITE_URL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue
Remove-Item Env:UMS_LOGGING_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_HOST -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_PORT -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_FROM_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_FROM_NAME -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import { parseCLI, startVitest } from 'vitest/node'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const { filter, options } = parseCLI(['vitest', ...process.argv.slice(2)])
const { coverage: coverageOptions, ...cliOptions } = options
const baseCoverage = {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.interface.ts',
'src/test/**',
'src/main.tsx',
'src/vite-env.d.ts',
],
}
function resolveCoverageConfig(option) {
if (!option) {
return {
...baseCoverage,
enabled: false,
}
}
if (option === true) {
return {
...baseCoverage,
enabled: true,
}
}
return {
...baseCoverage,
...option,
enabled: option.enabled ?? true,
}
}
const ctx = await startVitest(
'test',
filter,
{
...cliOptions,
root,
config: false,
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
coverage: resolveCoverageConfig(coverageOptions),
pool: cliOptions.pool ?? 'threads',
fileParallelism: cliOptions.fileParallelism ?? false,
maxWorkers: cliOptions.maxWorkers ?? 1,
testTimeout: cliOptions.testTimeout ?? 10000,
hookTimeout: cliOptions.hookTimeout ?? 10000,
clearMocks: true,
},
{
plugins: [react()],
resolve: {
preserveSymlinks: true,
alias: {
'@': path.resolve(root, 'src'),
},
},
},
)
if (!ctx?.shouldKeepServer()) {
await ctx?.exit()
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import App from './App'
const routerProviderMock = vi.fn((props: unknown) => {
void props
return <div data-testid="router-provider" />
})
const errorBoundaryMock = vi.fn(({ children }: { children: ReactNode }) => (
<div data-testid="error-boundary">{children}</div>
))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
RouterProvider: (props: unknown) => routerProviderMock(props),
}
})
vi.mock('@/components/common', () => ({
ErrorBoundary: (props: { children: React.ReactNode }) => errorBoundaryMock(props),
}))
describe('App', () => {
it('renders the router provider inside the error boundary shell', () => {
render(<App />)
expect(screen.getByTestId('error-boundary')).toBeInTheDocument()
expect(screen.getByTestId('router-provider')).toBeInTheDocument()
expect(errorBoundaryMock).toHaveBeenCalledTimes(1)
expect(routerProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
router: expect.any(Object),
}),
)
})
})

View File

@@ -0,0 +1,38 @@
/**
* Admin Frontend App Shell
*
* 项目:用户管理系统 Admin 后台
* 技术栈React 18 + TypeScript + Vite + Ant Design 5
*/
import { Suspense } from 'react'
import { RouterProvider } from 'react-router-dom'
import { Spin } from 'antd'
import { ErrorBoundary } from '@/components/common'
import { router } from './router'
const routeFallback = (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-canvas)',
}}
>
<Spin size="large" />
</div>
)
function App() {
return (
<ErrorBoundary>
<Suspense fallback={routeFallback}>
<RouterProvider router={router} />
</Suspense>
</ErrorBoundary>
)
}
export default App

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { RootLayout } from './RootLayout'
const authProviderMock = vi.fn(({ children }: { children: ReactNode }) => (
<div data-testid="auth-provider">{children}</div>
))
vi.mock('react-router-dom', () => ({
Outlet: () => <div data-testid="root-outlet" />,
}))
vi.mock('./providers/AuthProvider', () => ({
AuthProvider: (props: { children: ReactNode }) => authProviderMock(props),
}))
describe('RootLayout', () => {
it('wraps the route outlet with the auth provider', () => {
render(<RootLayout />)
expect(screen.getByTestId('auth-provider')).toBeInTheDocument()
expect(screen.getByTestId('root-outlet')).toBeInTheDocument()
expect(authProviderMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom'
import { AuthProvider } from './providers/AuthProvider'
export function RootLayout() {
return (
<AuthProvider>
<Outlet />
</AuthProvider>
)
}

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installWindowGuards, restoreWindowGuardsForTest } from './installWindowGuards'
describe('installWindowGuards', () => {
const logger = vi.fn()
beforeEach(() => {
logger.mockReset()
restoreWindowGuardsForTest()
})
afterEach(() => {
restoreWindowGuardsForTest()
})
it('blocks native dialogs and popup windows with structured logs', () => {
installWindowGuards(logger)
expect(window.alert('danger')).toBeUndefined()
expect(window.confirm('continue?')).toBe(false)
expect(window.prompt('name?', 'admin')).toBeNull()
expect(window.open('https://example.com', '_blank')).toBeNull()
expect(logger).toHaveBeenCalledTimes(4)
expect(logger.mock.calls[0][0]).toContain('native-alert-blocked')
expect(logger.mock.calls[1][0]).toContain('native-confirm-blocked')
expect(logger.mock.calls[2][0]).toContain('native-prompt-blocked')
expect(logger.mock.calls[3][0]).toContain('popup-blocked')
})
it('logs window errors and unhandled promise rejections', () => {
installWindowGuards(logger)
const runtimeError = new Error('boom')
window.dispatchEvent(new ErrorEvent('error', {
message: runtimeError.message,
filename: '/app.js',
lineno: 12,
colno: 34,
error: runtimeError,
}))
const rejectionEvent = new Event('unhandledrejection') as PromiseRejectionEvent
Object.defineProperty(rejectionEvent, 'promise', {
value: Promise.resolve(),
configurable: true,
})
Object.defineProperty(rejectionEvent, 'reason', {
value: new Error('rejected'),
configurable: true,
})
window.dispatchEvent(rejectionEvent)
expect(logger).toHaveBeenCalledTimes(2)
expect(logger.mock.calls[0][0]).toContain('[window-guard] error')
expect(logger.mock.calls[1][0]).toContain('[window-guard] unhandledrejection')
})
it('does not install twice', () => {
installWindowGuards(logger)
installWindowGuards(logger)
window.alert('once')
expect(logger).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,144 @@
type GuardLogger = (message?: unknown, ...optionalParams: unknown[]) => void
type WindowGuardOriginals = {
alert: typeof window.alert
confirm: typeof window.confirm
prompt: typeof window.prompt
open: typeof window.open
}
type WindowGuardListeners = {
error: (event: ErrorEvent) => void
unhandledrejection: (event: PromiseRejectionEvent) => void
}
declare global {
interface Window {
__UMS_WINDOW_GUARDS_INSTALLED__?: boolean
__UMS_WINDOW_GUARDS_ORIGINALS__?: WindowGuardOriginals
__UMS_WINDOW_GUARDS_LISTENERS__?: WindowGuardListeners
}
}
function formatValue(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value instanceof Error) {
return value.stack || value.message
}
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function reportWindowEvent(
logger: GuardLogger,
category: string,
payload: Record<string, unknown>,
) {
logger(`[window-guard] ${category}`, payload)
}
export function installWindowGuards(logger: GuardLogger = console.error) {
if (typeof window === 'undefined' || window.__UMS_WINDOW_GUARDS_INSTALLED__) {
return
}
window.__UMS_WINDOW_GUARDS_ORIGINALS__ = {
alert: window.alert.bind(window),
confirm: window.confirm.bind(window),
prompt: window.prompt.bind(window),
open: window.open.bind(window),
}
const onError = (event: ErrorEvent) => {
reportWindowEvent(logger, 'error', {
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
error: formatValue(event.error),
})
}
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
reportWindowEvent(logger, 'unhandledrejection', {
reason: formatValue(event.reason),
})
}
window.__UMS_WINDOW_GUARDS_LISTENERS__ = {
error: onError,
unhandledrejection: onUnhandledRejection,
}
window.addEventListener('error', onError)
window.addEventListener('unhandledrejection', onUnhandledRejection)
window.alert = (message?: unknown) => {
reportWindowEvent(logger, 'native-alert-blocked', {
message: formatValue(message),
})
}
window.confirm = (message?: string) => {
reportWindowEvent(logger, 'native-confirm-blocked', {
message: formatValue(message),
fallback: false,
})
return false
}
window.prompt = (message?: string, defaultValue?: string) => {
reportWindowEvent(logger, 'native-prompt-blocked', {
message: formatValue(message),
defaultValue: formatValue(defaultValue ?? ''),
fallback: null,
})
return null
}
window.open = (url?: string | URL, target?: string, features?: string) => {
reportWindowEvent(logger, 'popup-blocked', {
url: typeof url === 'string' ? url : url?.toString() ?? '',
target: target ?? '',
features: features ?? '',
fallback: null,
})
return null
}
window.__UMS_WINDOW_GUARDS_INSTALLED__ = true
}
export function restoreWindowGuardsForTest() {
if (typeof window === 'undefined') {
return
}
const originals = window.__UMS_WINDOW_GUARDS_ORIGINALS__
const listeners = window.__UMS_WINDOW_GUARDS_LISTENERS__
if (!originals) {
window.__UMS_WINDOW_GUARDS_INSTALLED__ = false
return
}
if (listeners) {
window.removeEventListener('error', listeners.error)
window.removeEventListener('unhandledrejection', listeners.unhandledrejection)
delete window.__UMS_WINDOW_GUARDS_LISTENERS__
}
window.alert = originals.alert
window.confirm = originals.confirm
window.prompt = originals.prompt
window.open = originals.open
delete window.__UMS_WINDOW_GUARDS_ORIGINALS__
window.__UMS_WINDOW_GUARDS_INSTALLED__ = false
}

View File

@@ -0,0 +1,442 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Role, SessionUser, TokenBundle } from '@/types'
import { useAuth } from './auth-context'
import { AuthProvider } from './AuthProvider'
let storedAccessToken: string | null = null
let storedUser: SessionUser | null = null
let storedRoles: Role[] = []
const navigateMock = vi.fn()
const getMock = vi.fn()
const setRefreshTokenMock = vi.fn()
const clearRefreshTokenMock = vi.fn()
const hasSessionPresenceCookieMock = vi.fn()
const setAccessTokenMock = vi.fn((token: string, expiresIn: number) => {
void expiresIn
storedAccessToken = token
})
const getCurrentUserMock = vi.fn(() => storedUser)
const setCurrentUserMock = vi.fn((user: SessionUser) => {
storedUser = user
})
const getCurrentRolesMock = vi.fn(() => storedRoles)
const setCurrentRolesMock = vi.fn((roles: Role[]) => {
storedRoles = roles
})
const clearSessionMock = vi.fn(() => {
storedAccessToken = null
storedUser = null
storedRoles = []
})
const isAuthenticatedMock = vi.fn(() => storedAccessToken !== null && storedUser !== null)
const isAccessTokenExpiredMock = vi.fn()
const initCSRFTokenMock = vi.fn()
const clearCSRFTokenMock = vi.fn()
const logoutRequestMock = vi.fn()
const refreshSessionMock = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
vi.mock('@/lib/http', () => ({
get: (path: string) => getMock(path),
}))
vi.mock('@/lib/storage', () => ({
setRefreshToken: (token: string | null | undefined) => setRefreshTokenMock(token),
clearRefreshToken: () => clearRefreshTokenMock(),
hasSessionPresenceCookie: () => hasSessionPresenceCookieMock(),
}))
vi.mock('@/lib/http/auth-session', () => ({
setAccessToken: (token: string, expiresIn: number) => setAccessTokenMock(token, expiresIn),
getCurrentUser: () => getCurrentUserMock(),
setCurrentUser: (user: SessionUser) => setCurrentUserMock(user),
getCurrentRoles: () => getCurrentRolesMock(),
setCurrentRoles: (roles: Role[]) => setCurrentRolesMock(roles),
clearSession: () => clearSessionMock(),
isAuthenticated: () => isAuthenticatedMock(),
isAccessTokenExpired: () => isAccessTokenExpiredMock(),
}))
vi.mock('@/lib/http/csrf', () => ({
initCSRFToken: () => initCSRFTokenMock(),
clearCSRFToken: () => clearCSRFTokenMock(),
}))
vi.mock('@/services/auth', () => ({
logout: () => logoutRequestMock(),
refreshSession: () => refreshSessionMock(),
}))
const adminUser: SessionUser = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
}
const adminRoles: Role[] = [
{
id: 1,
name: 'Administrator',
code: 'admin',
description: 'System administrator',
is_system: true,
is_default: false,
status: 1,
},
]
const refreshedSession: TokenBundle = {
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 7200,
user: adminUser,
}
const loginSession: TokenBundle = {
access_token: 'login-access-token',
refresh_token: 'login-refresh-token',
expires_in: 3600,
user: adminUser,
}
const operatorUser: SessionUser = {
id: 2,
username: 'operator',
email: 'operator@example.com',
phone: '13900139000',
nickname: 'Operator',
avatar: '',
status: 1,
}
const operatorRoles: Role[] = [
{
id: 2,
name: 'Operator',
code: 'operator',
description: 'Operations user',
is_system: false,
is_default: false,
status: 1,
},
]
function Probe() {
const auth = useAuth()
return (
<div>
<span data-testid="loading">{String(auth.isLoading)}</span>
<span data-testid="authenticated">{String(auth.isAuthenticated)}</span>
<span data-testid="username">{auth.user?.username ?? ''}</span>
<span data-testid="roles">{auth.roles.map((role) => role.code).join(',')}</span>
<button onClick={() => void auth.onLoginSuccess(loginSession)} type="button">
login-success
</button>
<button onClick={() => void auth.refreshUser()} type="button">
refresh-user
</button>
<button onClick={() => void auth.logout()} type="button">
logout
</button>
</div>
)
}
function renderAuthProvider() {
return render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
}
async function waitForProviderIdle() {
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
})
}
describe('AuthProvider', () => {
beforeEach(() => {
window.history.pushState({}, '', '/')
storedAccessToken = null
storedUser = null
storedRoles = []
navigateMock.mockReset()
getMock.mockReset()
setRefreshTokenMock.mockReset()
clearRefreshTokenMock.mockReset()
hasSessionPresenceCookieMock.mockReset()
setAccessTokenMock.mockReset()
getCurrentUserMock.mockReset()
setCurrentUserMock.mockReset()
getCurrentRolesMock.mockReset()
setCurrentRolesMock.mockReset()
clearSessionMock.mockReset()
isAuthenticatedMock.mockReset()
isAccessTokenExpiredMock.mockReset()
initCSRFTokenMock.mockReset()
clearCSRFTokenMock.mockReset()
logoutRequestMock.mockReset()
isAccessTokenExpiredMock.mockReturnValue(true)
isAuthenticatedMock.mockImplementation(() => storedAccessToken !== null && storedUser !== null)
getCurrentUserMock.mockImplementation(() => storedUser)
setCurrentUserMock.mockImplementation((user: SessionUser) => {
storedUser = user
})
getCurrentRolesMock.mockImplementation(() => storedRoles)
setCurrentRolesMock.mockImplementation((roles: Role[]) => {
storedRoles = roles
})
setAccessTokenMock.mockImplementation((token: string, expiresIn: number) => {
void expiresIn
storedAccessToken = token
})
clearSessionMock.mockImplementation(() => {
storedAccessToken = null
storedUser = null
storedRoles = []
})
hasSessionPresenceCookieMock.mockReturnValue(false)
initCSRFTokenMock.mockResolvedValue(undefined)
clearCSRFTokenMock.mockReturnValue(undefined)
logoutRequestMock.mockResolvedValue(undefined)
refreshSessionMock.mockReset()
})
it('reuses an in-memory authenticated session when the access token is still valid', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
renderAuthProvider()
await waitForProviderIdle()
expect(refreshSessionMock).not.toHaveBeenCalled()
expect(navigateMock).not.toHaveBeenCalled()
expect(clearRefreshTokenMock).not.toHaveBeenCalled()
expect(clearSessionMock).not.toHaveBeenCalled()
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('true')
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
})
it('clears the local session when auth state has no current user and no backend session cookie exists', async () => {
storedAccessToken = 'dangling-access-token'
isAuthenticatedMock.mockReturnValue(true)
isAccessTokenExpiredMock.mockReturnValue(false)
getCurrentUserMock.mockReturnValue(null)
renderAuthProvider()
await waitForProviderIdle()
expect(refreshSessionMock).not.toHaveBeenCalled()
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
expect(screen.getByTestId('username').textContent).toBe('')
})
it('restores the session by refreshing through the backend cookie flow', async () => {
hasSessionPresenceCookieMock.mockReturnValue(true)
refreshSessionMock.mockResolvedValue(refreshedSession)
getMock.mockResolvedValue(adminRoles)
renderAuthProvider()
await waitFor(() => {
expect(refreshSessionMock).toHaveBeenCalledTimes(1)
})
await waitForProviderIdle()
expect(setAccessTokenMock).toHaveBeenCalledWith('access-token', 7200)
expect(setRefreshTokenMock).toHaveBeenCalledWith('refresh-token')
expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser)
expect(setCurrentRolesMock).toHaveBeenCalledWith(adminRoles)
expect(getMock).toHaveBeenCalledWith('/users/1/roles')
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(navigateMock).not.toHaveBeenCalled()
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
})
it('restores the session with empty roles when the role lookup fails after refresh', async () => {
hasSessionPresenceCookieMock.mockReturnValue(true)
refreshSessionMock.mockResolvedValue(refreshedSession)
getMock.mockRejectedValue(new Error('roles lookup failed'))
renderAuthProvider()
await waitForProviderIdle()
expect(setAccessTokenMock).toHaveBeenCalledWith('access-token', 7200)
expect(setRefreshTokenMock).toHaveBeenCalledWith('refresh-token')
expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser)
expect(setCurrentRolesMock).toHaveBeenCalledWith([])
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('true')
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles').textContent).toBe('')
})
it('clears local state when refresh fails against the backend cookie flow', async () => {
hasSessionPresenceCookieMock.mockReturnValue(true)
refreshSessionMock.mockRejectedValue(new Error('missing refresh cookie'))
renderAuthProvider()
await waitFor(() => {
expect(refreshSessionMock).toHaveBeenCalledTimes(1)
})
await waitForProviderIdle()
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(navigateMock).not.toHaveBeenCalled()
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
})
it('persists tokens, user, roles, and csrf state after login success', async () => {
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
getMock.mockResolvedValue(adminRoles)
fireEvent.click(screen.getByRole('button', { name: 'login-success' }))
await waitFor(() => {
expect(setAccessTokenMock).toHaveBeenCalledWith('login-access-token', 3600)
})
expect(setRefreshTokenMock).toHaveBeenCalledWith('login-refresh-token')
expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser)
expect(setCurrentRolesMock).toHaveBeenCalledWith(adminRoles)
expect(getMock).toHaveBeenCalledWith('/users/1/roles')
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('true')
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
})
it('refreshes the current user and roles from the backend', async () => {
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
getMock.mockImplementation((path: string) => {
if (path === '/auth/userinfo') {
return Promise.resolve(operatorUser)
}
if (path === '/users/2/roles') {
return Promise.resolve(operatorRoles)
}
return Promise.reject(new Error(`Unexpected path: ${path}`))
})
fireEvent.click(screen.getByRole('button', { name: 'refresh-user' }))
await waitFor(() => {
expect(setCurrentUserMock).toHaveBeenCalledWith(operatorUser)
})
expect(setCurrentRolesMock).toHaveBeenCalledWith(operatorRoles)
expect(screen.getByTestId('username')).toHaveTextContent('operator')
expect(screen.getByTestId('roles')).toHaveTextContent('operator')
})
it('logs refreshUser failures without corrupting the current auth state', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
getMock.mockRejectedValue(new Error('userinfo failed'))
fireEvent.click(screen.getByRole('button', { name: 'refresh-user' }))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to refresh user info:',
expect.any(Error),
)
})
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
consoleErrorSpy.mockRestore()
})
it('clears the local session and navigates to login when logout succeeds', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
fireEvent.click(screen.getByRole('button', { name: 'logout' }))
await waitFor(() => {
expect(logoutRequestMock).toHaveBeenCalledTimes(1)
})
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(clearCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith('/login')
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
expect(screen.getByTestId('username').textContent).toBe('')
})
it('clears the local session and navigates to login when logout fails', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
logoutRequestMock.mockRejectedValue(new Error('logout failed'))
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
logoutRequestMock.mockRejectedValue(new Error('logout failed'))
fireEvent.click(screen.getByRole('button', { name: 'logout' }))
await waitFor(() => {
expect(logoutRequestMock).toHaveBeenCalledTimes(1)
})
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(clearCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith('/login')
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
expect(screen.getByTestId('username').textContent).toBe('')
})
})

View File

@@ -0,0 +1,201 @@
/**
* AuthProvider - 全局会话上下文
*
* 提供:
* - 会话状态user, roles, isAdmin
* - 登录/登出方法
* - 会话恢复(启动时自动刷新)
*/
import {
useEffect,
useState,
useCallback,
type ReactNode,
} from 'react'
import { useNavigate } from 'react-router-dom'
import type { SessionUser, Role, TokenBundle } from '@/types'
import { get } from '@/lib/http'
import {
setRefreshToken,
clearRefreshToken,
hasSessionPresenceCookie,
} from '@/lib/storage'
import {
setAccessToken,
getCurrentUser,
setCurrentUser,
getCurrentRoles,
setCurrentRoles,
clearSession,
isAuthenticated,
isAccessTokenExpired,
} from '@/lib/http/auth-session'
import { initCSRFToken, clearCSRFToken } from '@/lib/http/csrf'
import { logout as logoutRequest, refreshSession } from '@/services/auth'
import { AuthContext, type AuthContextValue } from './auth-context'
// ==================== Provider ====================
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<SessionUser | null>(getCurrentUser())
const [roles, setRoles] = useState<Role[]>(getCurrentRoles())
const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate()
const effectiveUser = user ?? getCurrentUser()
const effectiveRoles = roles.length > 0 ? roles : getCurrentRoles()
// 判断是否为管理员
const isAdmin = effectiveRoles.some((role) => role.code === 'admin')
/**
* 获取用户角色
*/
const fetchUserRoles = useCallback(async (userId: number): Promise<Role[]> => {
try {
const result = await get<Role[]>(`/users/${userId}/roles`)
return result
} catch {
return []
}
}, [])
/**
* 登录成功回调
*/
const onLoginSuccess = useCallback(async (tokenBundle: TokenBundle) => {
// 保存 tokens
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
// 保存用户信息
setCurrentUser(tokenBundle.user)
setUser(tokenBundle.user)
// 获取角色
const userRoles = await fetchUserRoles(tokenBundle.user.id)
setCurrentRoles(userRoles)
setRoles(userRoles)
// 初始化 CSRF Token
await initCSRFToken()
}, [fetchUserRoles])
/**
* 刷新用户信息
*/
const refreshUser = useCallback(async () => {
try {
const userInfo = await get<SessionUser>('/auth/userinfo')
setCurrentUser(userInfo)
setUser(userInfo)
const userRoles = await fetchUserRoles(userInfo.id)
setCurrentRoles(userRoles)
setRoles(userRoles)
} catch {
// 刷新失败,清除会话
setUser(null)
setRoles([])
}
}, [fetchUserRoles])
/**
* 登出
*/
const logout = useCallback(async () => {
try {
await logoutRequest()
} catch {
// 忽略登出请求错误
} finally {
// 无论请求成功与否,都清除本地会话和 CSRF Token
clearRefreshToken()
clearSession()
clearCSRFToken()
setUser(null)
setRoles([])
navigate('/login')
}
}, [navigate])
/**
* 会话恢复(应用启动时,只运行一次)
*/
useEffect(() => {
const restoreSession = async () => {
// 如果已有 access_token 且未过期,直接使用
if (isAuthenticated() && !isAccessTokenExpired()) {
const currentUser = getCurrentUser()
const currentRoles = getCurrentRoles()
if (currentUser) {
setUser(currentUser)
setRoles(currentRoles)
await initCSRFToken()
setIsLoading(false)
return
}
}
if (!hasSessionPresenceCookie()) {
clearRefreshToken()
clearSession()
setUser(null)
setRoles([])
setIsLoading(false)
return
}
try {
const result = await refreshSession()
// 保存 tokens
setAccessToken(result.access_token, result.expires_in)
setRefreshToken(result.refresh_token)
// 保存用户信息
setCurrentUser(result.user)
setUser(result.user)
// 获取角色
const userRoles = await fetchUserRoles(result.user.id)
setCurrentRoles(userRoles)
setRoles(userRoles)
await initCSRFToken()
} catch {
// 刷新失败,清除会话
clearRefreshToken()
clearSession()
setUser(null)
setRoles([])
}
setIsLoading(false)
}
restoreSession()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在挂载时运行一次,不依赖 location.pathname
const value: AuthContextValue = {
user: effectiveUser,
roles: effectiveRoles,
isAdmin,
isAuthenticated: effectiveUser !== null && isAuthenticated(),
isLoading,
onLoginSuccess,
logout,
refreshUser,
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import zhCN from 'antd/locale/zh_CN'
import { describe, expect, it, vi } from 'vitest'
import { ThemeProvider } from './ThemeProvider'
const configProviderMock = vi.fn(
({ children }: { children: ReactNode }) => <div data-testid="config-provider">{children}</div>,
)
vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd')
return {
...actual,
ConfigProvider: (props: { children: ReactNode; theme: unknown; locale: unknown }) => configProviderMock(props),
}
})
describe('ThemeProvider', () => {
it('passes the theme tokens and locale to ConfigProvider', () => {
render(
<ThemeProvider>
<div>theme child</div>
</ThemeProvider>,
)
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
expect(screen.getByText('theme child')).toBeInTheDocument()
expect(configProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
locale: zhCN,
theme: expect.objectContaining({
token: expect.objectContaining({
colorPrimary: '#0e5a6a',
colorSuccess: '#217a5b',
colorWarning: '#b7791f',
colorError: '#b33a3a',
colorInfo: '#2d6a9f',
fontFamily: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
borderRadius: 10,
}),
components: expect.objectContaining({
Table: expect.objectContaining({
headerBg: '#f8f5ef',
borderColor: '#d6d0c3',
}),
Card: expect.objectContaining({
borderRadiusLG: 16,
}),
Button: expect.objectContaining({
controlHeightLG: 44,
}),
Input: expect.objectContaining({
controlHeight: 36,
}),
Select: expect.objectContaining({
controlHeight: 36,
}),
}),
}),
}),
)
})
})

View File

@@ -0,0 +1,89 @@
/**
* 主题配置 Provider
* 使用 Ant Design 5 的 ConfigProvider 覆盖主题 Token
*/
import { ConfigProvider, type ThemeConfig } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import type { ReactNode } from 'react'
/**
* Ant Design 主题配置
* 基于 Mineral Console 视觉方向
*/
const themeConfig: ThemeConfig = {
token: {
// 主色
colorPrimary: '#0e5a6a',
colorPrimaryHover: '#0a4b59',
colorPrimaryActive: '#083d4a',
// 成功色
colorSuccess: '#217a5b',
// 警告色
colorWarning: '#b7791f',
// 错误色
colorError: '#b33a3a',
// 信息色
colorInfo: '#2d6a9f',
// 字体
fontFamily: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
// 圆角
borderRadius: 10,
borderRadiusLG: 16,
borderRadiusSM: 6,
// 链接
colorLink: '#0e5a6a',
colorLinkHover: '#0a4b59',
colorLinkActive: '#083d4a',
},
components: {
// 表格组件定制
Table: {
headerBg: '#f8f5ef',
borderColor: '#d6d0c3',
rowHoverBg: 'rgba(14, 90, 106, 0.04)',
},
// 卡片组件定制
Card: {
borderRadiusLG: 16,
boxShadowTertiary: '0 10px 30px rgba(23, 33, 43, 0.06)',
},
// 按钮组件定制
Button: {
borderRadius: 10,
controlHeight: 36,
controlHeightLG: 44,
controlHeightSM: 28,
},
// 输入框组件定制
Input: {
borderRadius: 10,
controlHeight: 36,
},
// 选择器组件定制
Select: {
borderRadius: 10,
controlHeight: 36,
},
},
}
interface ThemeProviderProps {
children: ReactNode
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<ConfigProvider theme={themeConfig} locale={zhCN}>
{children}
</ConfigProvider>
)
}

View File

@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react'
import type { Role, SessionUser, TokenBundle } from '@/types'
export interface AuthContextValue {
user: SessionUser | null
roles: Role[]
isAdmin: boolean
isAuthenticated: boolean
isLoading: boolean
onLoginSuccess: (tokenBundle: TokenBundle) => Promise<void>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
export const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -0,0 +1,210 @@
import { createElement, type ComponentType, type ReactElement, type ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Navigate, type RouteObject } from 'react-router-dom'
type PageDefinition = {
exportName: string
routePath: string
requireAdmin?: boolean
}
type RouterFixture = {
lazyPage: typeof import('./router').lazyPage
router: { routes: RouteObject[] }
}
type LazyType = {
_init: (payload: unknown) => unknown
_payload: unknown
}
const publicPages: PageDefinition[] = [
{ routePath: '/login', exportName: 'LoginPage' },
{ routePath: '/register', exportName: 'RegisterPage' },
{ routePath: '/bootstrap-admin', exportName: 'BootstrapAdminPage' },
{ routePath: '/activate-account', exportName: 'ActivateAccountPage' },
{ routePath: '/login/oauth/callback', exportName: 'OAuthCallbackPage' },
{ routePath: '/forgot-password', exportName: 'ForgotPasswordPage' },
{ routePath: '/reset-password', exportName: 'ResetPasswordPage' },
]
const protectedPages: PageDefinition[] = [
{ routePath: 'dashboard', exportName: 'DashboardPage', requireAdmin: true },
{ routePath: 'users', exportName: 'UsersPage', requireAdmin: true },
{ routePath: 'roles', exportName: 'RolesPage', requireAdmin: true },
{ routePath: 'permissions', exportName: 'PermissionsPage', requireAdmin: true },
{ routePath: 'logs/login', exportName: 'LoginLogsPage', requireAdmin: true },
{ routePath: 'logs/operation', exportName: 'OperationLogsPage', requireAdmin: true },
{ routePath: 'webhooks', exportName: 'WebhooksPage' },
{ routePath: 'import-export', exportName: 'ImportExportPage', requireAdmin: true },
{ routePath: 'profile', exportName: 'ProfilePage' },
{ routePath: 'profile/security', exportName: 'ProfileSecurityPage' },
]
const resetModulePaths = ['./router', 'react-router-dom']
afterEach(() => {
vi.clearAllMocks()
for (const modulePath of resetModulePaths) {
vi.doUnmock(modulePath)
}
vi.resetModules()
})
function asRouteObject(route: unknown): RouteObject {
return route as RouteObject
}
function asElement(node: ReactNode | undefined): ReactElement<{ children?: ReactNode }> | null {
return node && typeof node === 'object' && 'type' in node ? (node as ReactElement<{ children?: ReactNode }>) : null
}
function asLazyType(type: unknown): LazyType {
return type as LazyType
}
function getComponentName(type: unknown): string | undefined {
return typeof type === 'function' ? type.name : undefined
}
function getRouteByPath(routes: RouteObject[], path: string): RouteObject {
const route = routes.find((candidate) => candidate.path === path)
expect(route).toBeDefined()
return route as RouteObject
}
function expectLazyElement(node: ReactNode | undefined): LazyType {
const element = asElement(node)
expect(element).not.toBeNull()
const lazyType = asLazyType(element?.type)
expect(typeof lazyType).toBe('object')
expect(typeof lazyType._init).toBe('function')
return lazyType
}
async function resolveLazyType(lazyType: LazyType): Promise<ComponentType<object>> {
for (;;) {
try {
return lazyType._init(lazyType._payload) as ComponentType<object>
} catch (thrown) {
if (thrown && typeof (thrown as PromiseLike<unknown>).then === 'function') {
await thrown
continue
}
throw thrown
}
}
}
async function expectResolvedLazyName(node: ReactNode | undefined, expectedName: string) {
const resolved = await resolveLazyType(expectLazyElement(node))
expect(resolved.name).toBe(expectedName)
}
async function loadRouterFixture(): Promise<RouterFixture> {
for (const modulePath of resetModulePaths) {
vi.doUnmock(modulePath)
}
vi.resetModules()
const module = await import('./router')
return {
lazyPage: module.lazyPage,
router: module.router as { routes: RouteObject[] },
}
}
describe('router', () => {
it('maps each route to the expected shell and lazy page component shape', async () => {
const { router } = await loadRouterFixture()
const rootRoute = asRouteObject(router.routes[0])
const rootChildren = (rootRoute.children ?? []).map(asRouteObject)
expect(getComponentName(asElement(rootRoute.element)?.type)).toBe('RootLayout')
expect(
rootChildren
.map((route) => route.path)
.filter((path): path is string => Boolean(path)),
).toEqual([
'/login',
'/register',
'/bootstrap-admin',
'/activate-account',
'/login/oauth/callback',
'/forgot-password',
'/reset-password',
'/',
'*',
])
for (const page of publicPages) {
const route = getRouteByPath(rootChildren, page.routePath)
await expectResolvedLazyName(route.element, page.exportName)
}
const protectedRoute = getRouteByPath(rootChildren, '/')
const protectedElement = asElement(protectedRoute.element)
const protectedChildren = (protectedRoute.children ?? []).map(asRouteObject)
expect(getComponentName(protectedElement?.type)).toBe('RequireAuth')
expect(getComponentName(asElement(protectedElement?.props.children)?.type)).toBe('AdminLayout')
expect(
protectedChildren
.map((route) => route.path)
.filter((path): path is string => Boolean(path)),
).toEqual([
'dashboard',
'users',
'roles',
'permissions',
'logs/login',
'logs/operation',
'webhooks',
'import-export',
'profile',
'profile/security',
])
const indexRoute = protectedChildren.find((route) => route.index)
const indexElement = asElement(indexRoute?.element)
expect(indexRoute).toBeDefined()
expect(indexElement?.type).toBe(Navigate)
expect(indexElement?.props).toEqual(expect.objectContaining({ to: '/dashboard', replace: true }))
for (const page of protectedPages.filter((candidate) => candidate.requireAdmin)) {
const route = getRouteByPath(protectedChildren, page.routePath)
const element = asElement(route.element)
expect(getComponentName(element?.type)).toBe('RequireAdmin')
await expectResolvedLazyName(element?.props.children, page.exportName)
}
for (const page of protectedPages.filter((candidate) => !candidate.requireAdmin)) {
const route = getRouteByPath(protectedChildren, page.routePath)
expect(getComponentName(asElement(route.element)?.type)).not.toBe('RequireAdmin')
await expectResolvedLazyName(route.element, page.exportName)
}
const notFoundRoute = getRouteByPath(rootChildren, '*')
await expectResolvedLazyName(notFoundRoute.element, 'NotFoundPage')
})
it('resolves valid lazy exports and rejects invalid lazy exports clearly', async () => {
const { lazyPage } = await loadRouterFixture()
const LoginPage: ComponentType<object> = () => null
const validLazyPage = lazyPage(async () => ({ LoginPage }), 'LoginPage')
const invalidLazyPage = lazyPage(async () => ({ LoginPage: 'not-a-component' }), 'LoginPage')
await expect(resolveLazyType(expectLazyElement(createElement(validLazyPage)))).resolves.toBe(LoginPage)
await expect(resolveLazyType(expectLazyElement(createElement(invalidLazyPage)))).rejects.toThrow(
'lazy route export "LoginPage" is not a React component',
)
})
})

View File

@@ -0,0 +1,223 @@
/**
* 应用路由配置
*
* 路由结构:
* - /login - 登录页(公开)
* - /forgot-password - 忘记密码(公开)
* - /reset-password - 重置密码(公开)
* - /dashboard - 总览(需登录)
* - /users - 用户管理(需管理员)
* - /roles - 角色管理(需管理员)
* - /permissions - 权限管理(需管理员)
* - /logs/login - 登录日志(需管理员)
* - /logs/operation - 操作日志(需管理员)
* - /webhooks - Webhooks需登录
* - /import-export - 导入导出(需管理员)
* - /profile - 个人资料(需登录)
* - /profile/security - 安全设置(需登录)
*/
import { createElement, lazy, type ComponentType, type LazyExoticComponent } from 'react'
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { AdminLayout } from '@/layouts'
import { RootLayout } from './RootLayout'
// 路由守卫
import { RequireAuth, RequireAdmin } from '@/components/guards'
export function lazyPage<T extends ComponentType<object>>(
loader: () => Promise<Record<string, unknown>>,
exportName: string,
): LazyExoticComponent<T> {
return lazy(async () => {
const module = await loader()
const component = module[exportName]
if (typeof component !== 'function') {
throw new Error(`lazy route export "${exportName}" is not a React component`)
}
return { default: component as T }
})
}
function renderLazy<T extends ComponentType<object>>(Component: LazyExoticComponent<T>) {
return createElement(Component)
}
const LoginPage = lazyPage(() => import('@/pages/auth/LoginPage'), 'LoginPage')
const RegisterPage = lazyPage(() => import('@/pages/auth/RegisterPage'), 'RegisterPage')
const BootstrapAdminPage = lazyPage(() => import('@/pages/auth/BootstrapAdminPage'), 'BootstrapAdminPage')
const ActivateAccountPage = lazyPage(() => import('@/pages/auth/ActivateAccountPage'), 'ActivateAccountPage')
const OAuthCallbackPage = lazyPage(() => import('@/pages/auth/OAuthCallbackPage'), 'OAuthCallbackPage')
const ForgotPasswordPage = lazyPage(() => import('@/pages/auth/ForgotPasswordPage'), 'ForgotPasswordPage')
const ResetPasswordPage = lazyPage(() => import('@/pages/auth/ResetPasswordPage'), 'ResetPasswordPage')
const DashboardPage = lazyPage(() => import('@/pages/admin/DashboardPage'), 'DashboardPage')
const UsersPage = lazyPage(() => import('@/pages/admin/UsersPage'), 'UsersPage')
const DevicesPage = lazyPage(() => import('@/pages/admin/DevicesPage'), 'DevicesPage')
const RolesPage = lazyPage(() => import('@/pages/admin/RolesPage'), 'RolesPage')
const PermissionsPage = lazyPage(() => import('@/pages/admin/PermissionsPage'), 'PermissionsPage')
const LoginLogsPage = lazyPage(() => import('@/pages/admin/LoginLogsPage'), 'LoginLogsPage')
const OperationLogsPage = lazyPage(() => import('@/pages/admin/OperationLogsPage'), 'OperationLogsPage')
const WebhooksPage = lazyPage(() => import('@/pages/admin/WebhooksPage'), 'WebhooksPage')
const ImportExportPage = lazyPage(() => import('@/pages/admin/ImportExportPage'), 'ImportExportPage')
const SettingsPage = lazyPage(() => import('@/pages/admin/SettingsPage'), 'SettingsPage')
const ProfilePage = lazyPage(() => import('@/pages/admin/ProfilePage'), 'ProfilePage')
const ProfileSecurityPage = lazyPage(() => import('@/pages/admin/ProfileSecurityPage'), 'ProfileSecurityPage')
const NotFoundPage = lazyPage(() => import('@/pages/NotFoundPage'), 'NotFoundPage')
export const router = createBrowserRouter(
[
// 根布局 - 提供 AuthProvider 上下文
{
element: <RootLayout />,
children: [
// 公开路由 - 认证页
{
path: '/login',
element: renderLazy(LoginPage),
},
{
path: '/register',
element: renderLazy(RegisterPage),
},
{
path: '/bootstrap-admin',
element: renderLazy(BootstrapAdminPage),
},
{
path: '/activate-account',
element: renderLazy(ActivateAccountPage),
},
{
path: '/login/oauth/callback',
element: renderLazy(OAuthCallbackPage),
},
{
path: '/forgot-password',
element: renderLazy(ForgotPasswordPage),
},
{
path: '/reset-password',
element: renderLazy(ResetPasswordPage),
},
// 受保护路由 - 管理后台
{
path: '/',
element: (
<RequireAuth>
<AdminLayout />
</RequireAuth>
),
children: [
// 默认跳转到 Dashboard
{
index: true,
element: <Navigate to="/dashboard" replace />,
},
// Dashboard - 需要登录
{
path: 'dashboard',
element: (
<RequireAdmin>
{renderLazy(DashboardPage)}
</RequireAdmin>
),
},
// 管理功能 - 需要管理员权限
{
path: 'users',
element: (
<RequireAdmin>
{renderLazy(UsersPage)}
</RequireAdmin>
),
},
{
path: 'devices',
element: (
<RequireAdmin>
{renderLazy(DevicesPage)}
</RequireAdmin>
),
},
{
path: 'roles',
element: (
<RequireAdmin>
{renderLazy(RolesPage)}
</RequireAdmin>
),
},
{
path: 'permissions',
element: (
<RequireAdmin>
{renderLazy(PermissionsPage)}
</RequireAdmin>
),
},
// 日志 - 需要管理员权限
{
path: 'logs/login',
element: (
<RequireAdmin>
{renderLazy(LoginLogsPage)}
</RequireAdmin>
),
},
{
path: 'logs/operation',
element: (
<RequireAdmin>
{renderLazy(OperationLogsPage)}
</RequireAdmin>
),
},
// 集成能力
{
path: 'webhooks',
element: renderLazy(WebhooksPage),
},
{
path: 'import-export',
element: (
<RequireAdmin>
{renderLazy(ImportExportPage)}
</RequireAdmin>
),
},
{
path: 'settings',
element: (
<RequireAdmin>
{renderLazy(SettingsPage)}
</RequireAdmin>
),
},
// 个人中心
{
path: 'profile',
element: renderLazy(ProfilePage),
},
{
path: 'profile/security',
element: renderLazy(ProfileSecurityPage),
},
],
},
// 404
{
path: '*',
element: renderLazy(NotFoundPage),
},
],
},
],
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ErrorBoundary } from './ErrorBoundary'
function ThrowingChild(): never {
throw new Error('boom')
}
function suppressBoundaryError() {
const handler = (event: ErrorEvent) => {
if (event.error instanceof Error && event.error.message === 'boom') {
event.preventDefault()
}
}
window.addEventListener('error', handler)
return () => window.removeEventListener('error', handler)
}
describe('ErrorBoundary', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders children when no error is thrown', () => {
render(
<ErrorBoundary>
<div>safe child</div>
</ErrorBoundary>,
)
expect(screen.getByText('safe child')).toBeInTheDocument()
})
it('renders the provided fallback when a child throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const cleanupErrorHandler = suppressBoundaryError()
render(
<ErrorBoundary fallback={<div>custom fallback</div>}>
<ThrowingChild />
</ErrorBoundary>,
)
expect(screen.getByText('custom fallback')).toBeInTheDocument()
cleanupErrorHandler()
})
it('renders the default error state and resets to the root path', async () => {
const user = userEvent.setup()
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const cleanupErrorHandler = suppressBoundaryError()
const locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location')
Object.defineProperty(window, 'location', {
configurable: true,
value: { href: '/current' },
})
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
)
expect(screen.getByText('页面出错了')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '刷新页面' }))
expect(window.location.href).toBe('/')
cleanupErrorHandler()
if (locationDescriptor) {
Object.defineProperty(window, 'location', locationDescriptor)
}
})
})

View File

@@ -0,0 +1,70 @@
/**
* ErrorBoundary - React 错误边界组件
* 捕获子组件树中的 JavaScript 错误,记录错误并显示备用 UI
*/
import { Component, type ReactNode, type ErrorInfo } from 'react'
import { Result, Button } from 'antd'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 可以将错误日志上报给服务器
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
// 刷新页面
window.location.href = '/'
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-canvas)',
}}>
<Result
status="error"
title="页面出错了"
subTitle={this.state.error?.message || '抱歉,页面遇到了问题'}
extra={
<Button type="primary" onClick={this.handleReset}>
</Button>
}
/>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1 @@
export { ErrorBoundary } from './ErrorBoundary'

View File

@@ -0,0 +1,55 @@
/**
* PageHeader 样式
*/
.container {
margin-bottom: 24px;
}
.breadcrumb {
margin-bottom: 12px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.titleArea {
flex: 1;
min-width: 0;
}
.title {
margin: 0 !important;
font-size: 20px !important;
font-weight: 600 !important;
color: var(--color-text-strong) !important;
}
.description {
margin: 4px 0 0 0 !important;
font-size: 14px;
}
.actions {
flex-shrink: 0;
}
.footer {
margin-top: 16px;
}
/* 响应式 */
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: stretch;
}
.actions {
margin-top: 12px;
}
}

View File

@@ -0,0 +1,65 @@
/**
* PageHeader - 页面头部组件
*
* 包含面包屑导航、页面标题、描述、操作按钮
*/
import { Breadcrumb, Typography, Space, type BreadcrumbProps } from 'antd'
import type { ReactNode } from 'react'
import styles from './PageHeader.module.css'
const { Title, Paragraph } = Typography
interface PageHeaderProps {
/** 面包屑项 */
breadcrumb?: BreadcrumbProps['items']
/** 页面标题 */
title: string
/** 页面描述 */
description?: string
/** 操作按钮区 */
actions?: ReactNode
/** 底部额外内容 */
footer?: ReactNode
}
export function PageHeader({
breadcrumb,
title,
description,
actions,
footer,
}: PageHeaderProps) {
return (
<div className={styles.container}>
{breadcrumb && breadcrumb.length > 0 && (
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
)}
<div className={styles.header}>
<div className={styles.titleArea}>
<Title level={4} className={styles.title}>
{title}
</Title>
{description && (
<Paragraph type="secondary" className={styles.description}>
{description}
</Paragraph>
)}
</div>
{actions && (
<Space className={styles.actions}>
{actions}
</Space>
)}
</div>
{footer && (
<div className={styles.footer}>
{footer}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export { PageHeader } from './PageHeader'

View File

@@ -0,0 +1,2 @@
export { ErrorBoundary } from './ErrorBoundary'
export { PageHeader } from './PageHeader'

View File

@@ -0,0 +1,45 @@
/**
* PageState 样式
*/
.container {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 48px 24px;
}
.spinContent {
padding: 24px;
}
.emptyContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 48px 24px;
}
.emptyIcon {
margin-bottom: 16px;
color: var(--color-text-muted);
font-size: 48px;
}
.emptyText {
color: var(--color-text-muted);
font-size: 14px;
margin-bottom: 16px;
}
.errorContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 48px 24px;
}

View File

@@ -0,0 +1,151 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { PageEmpty, PageError, PageLoading } from './PageState'
vi.mock('antd', () => ({
Button: ({
children,
onClick,
icon,
htmlType,
...props
}: {
children?: ReactNode
onClick?: () => void
icon?: ReactNode
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void icon
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
Empty: ({
description,
children,
}: {
description?: ReactNode
children?: ReactNode
}) => (
<div data-testid="empty">
<div data-testid="empty-description">{description}</div>
{children}
</div>
),
Result: ({
status,
title,
subTitle,
extra,
}: {
status?: string
title?: ReactNode
subTitle?: ReactNode
extra?: ReactNode | ReactNode[]
}) => (
<div data-testid="result" data-status={status}>
<div>{title}</div>
<div>{subTitle}</div>
<div>{extra}</div>
</div>
),
Spin: ({
size,
tip,
children,
}: {
size?: string
tip?: ReactNode
children?: ReactNode
}) => (
<div data-testid="spin" data-size={size}>
<span>{tip}</span>
{children}
</div>
),
}))
vi.mock('@ant-design/icons', () => ({
PlusOutlined: () => <span>plus-icon</span>,
ReloadOutlined: () => <span>reload-icon</span>,
}))
describe('PageState', () => {
it('renders PageLoading with both default and custom tips', () => {
render(
<>
<PageLoading />
<PageLoading tip="loading-dashboard" />
</>,
)
expect(screen.getAllByTestId('spin')).toHaveLength(2)
expect(screen.getByText('loading-dashboard')).toBeInTheDocument()
})
it('renders PageEmpty without an action when the action handler is incomplete', () => {
render(<PageEmpty description="no data" actionText="create now" />)
expect(screen.getByTestId('empty-description')).toHaveTextContent('no data')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders PageEmpty action button and invokes the handler when clicked', async () => {
const user = userEvent.setup()
const onAction = vi.fn()
render(
<PageEmpty
description="empty table"
actionText="add first item"
onAction={onAction}
actionProps={{ 'data-action': 'create' }}
/>,
)
const button = screen.getByRole('button', { name: 'add first item' })
expect(button).toHaveAttribute('data-action', 'create')
await user.click(button)
expect(onAction).toHaveBeenCalledTimes(1)
})
it('renders PageError defaults without a retry button when onRetry is absent', () => {
render(<PageError />)
expect(screen.getByTestId('result')).toHaveAttribute('data-status', 'error')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders PageError retry and extra actions when provided', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
render(
<PageError
title="load failed"
description="service unavailable"
retryText="retry now"
onRetry={onRetry}
extra={<span>contact support</span>}
/>,
)
expect(screen.getByText('load failed')).toBeInTheDocument()
expect(screen.getByText('service unavailable')).toBeInTheDocument()
expect(screen.getByText('contact support')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry now' }))
expect(onRetry).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,113 @@
/**
* 页面状态组件
*
* 提供:
* - PageLoading: 页面级加载状态
* - PageEmpty: 页面级空状态
* - PageError: 页面级错误状态
*/
import { Spin, Button, Result, Empty, type ButtonProps } from 'antd'
import { ReloadOutlined, PlusOutlined } from '@ant-design/icons'
import type { ReactNode } from 'react'
import styles from './PageState.module.css'
// ==================== PageLoading ====================
interface PageLoadingProps {
/** 加载提示文字 */
tip?: string
}
export function PageLoading({ tip = '加载中...' }: PageLoadingProps) {
return (
<div className={styles.container}>
<Spin size="large" tip={tip}>
<div className={styles.spinContent} />
</Spin>
</div>
)
}
// ==================== PageEmpty ====================
interface PageEmptyProps {
/** 空状态描述 */
description?: string | ReactNode
/** 主操作按钮文字 */
actionText?: string
/** 主操作按钮点击 */
onAction?: () => void
/** 主操作按钮属性 */
actionProps?: ButtonProps
}
export function PageEmpty({
description = '暂无数据',
actionText,
onAction,
actionProps,
}: PageEmptyProps) {
return (
<div className={styles.emptyContainer}>
<Empty description={description}>
{actionText && onAction && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onAction}
{...actionProps}
>
{actionText}
</Button>
)}
</Empty>
</div>
)
}
// ==================== PageError ====================
interface PageErrorProps {
/** 错误标题 */
title?: string
/** 错误描述 */
description?: string | ReactNode
/** 重试按钮文字 */
retryText?: string
/** 重试按钮点击 */
onRetry?: () => void
/** 额外操作 */
extra?: ReactNode
}
export function PageError({
title = '加载失败',
description = '数据加载失败,请稍后重试',
retryText = '重新加载',
onRetry,
extra,
}: PageErrorProps) {
return (
<div className={styles.errorContainer}>
<Result
status="error"
title={title}
subTitle={description}
extra={[
onRetry && (
<Button
key="retry"
type="primary"
icon={<ReloadOutlined />}
onClick={onRetry}
>
{retryText}
</Button>
),
extra,
].filter(Boolean)}
/>
</div>
)
}

View File

@@ -0,0 +1 @@
export { PageLoading, PageEmpty, PageError } from './PageState'

View File

@@ -0,0 +1 @@
export { PageLoading, PageEmpty, PageError } from './PageState'

View File

@@ -0,0 +1,30 @@
/**
* RequireAdmin - 管理员守卫
*
* 非管理员时跳转到个人资料页。
* 修复:加入 isLoading 检查,避免会话恢复期间误跳转。
*/
import { Navigate } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import type { ReactNode } from 'react'
interface RequireAdminProps {
children: ReactNode
}
export function RequireAdmin({ children }: RequireAdminProps) {
const { isAdmin, isLoading } = useAuth()
// 会话恢复中,等待完成再判断
if (isLoading) {
return null
}
// 非管理员,跳转到个人资料页
if (!isAdmin) {
return <Navigate to="/profile" replace />
}
return children
}

View File

@@ -0,0 +1,40 @@
/**
* RequireAuth - 登录守卫
*
* 未登录时跳转到登录页
*/
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { Spin } from 'antd'
import type { ReactNode } from 'react'
interface RequireAuthProps {
children: ReactNode
}
export function RequireAuth({ children }: RequireAuthProps) {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
// 加载中显示 loading
if (isLoading) {
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Spin size="large" />
</div>
)
}
// 未登录,跳转到登录页
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return children
}

View File

@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import type { ReactNode } from 'react'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import { RequireAdmin } from './RequireAdmin'
import { RequireAuth } from './RequireAuth'
const baseAuthContextValue: AuthContextValue = {
user: null,
roles: [],
isAdmin: false,
isAuthenticated: false,
isLoading: false,
onLoginSuccess: async () => {},
logout: async () => {},
refreshUser: async () => {},
}
function LocationProbe() {
const location = useLocation()
const fromPath =
(location.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? 'none'
return (
<div>
<span data-testid="pathname">{location.pathname}</span>
<span data-testid="from-path">{fromPath}</span>
</div>
)
}
function renderWithAuth(
authContextValue: Partial<AuthContextValue>,
router: ReactNode,
) {
const value: AuthContextValue = {
...baseAuthContextValue,
...authContextValue,
}
return render(
<AuthContext.Provider value={value}>
{router}
</AuthContext.Provider>,
)
}
describe('RequireAuth', () => {
it('shows a loading indicator while auth state is being restored', () => {
const { container } = renderWithAuth(
{ isLoading: true },
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={(
<RequireAuth>
<div>private content</div>
</RequireAuth>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(screen.queryByText('private content')).not.toBeInTheDocument()
})
it('redirects unauthenticated users to login and preserves the original route', async () => {
renderWithAuth(
{ isAuthenticated: false, isLoading: false },
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={(
<RequireAuth>
<div>private content</div>
</RequireAuth>
)}
/>
<Route path="/login" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByTestId('pathname')).toHaveTextContent('/login')
expect(screen.getByTestId('from-path')).toHaveTextContent('/users')
})
it('renders protected content when authenticated', () => {
renderWithAuth(
{
isAuthenticated: true,
isLoading: false,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={(
<RequireAuth>
<div>private content</div>
</RequireAuth>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('private content')).toBeInTheDocument()
})
})
describe('RequireAdmin', () => {
it('waits silently while auth state is still loading', () => {
const { container } = renderWithAuth(
{ isLoading: true, isAdmin: false },
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route
path="/dashboard"
element={(
<RequireAdmin>
<div>admin dashboard</div>
</RequireAdmin>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(container).toBeEmptyDOMElement()
})
it('redirects non-admin users to profile', async () => {
renderWithAuth(
{ isLoading: false, isAdmin: false, isAuthenticated: true },
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route
path="/dashboard"
element={(
<RequireAdmin>
<div>admin dashboard</div>
</RequireAdmin>
)}
/>
<Route path="/profile" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByTestId('pathname')).toHaveTextContent('/profile')
})
it('renders admin-only content for admins', () => {
renderWithAuth(
{ isLoading: false, isAdmin: true, isAuthenticated: true },
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route
path="/dashboard"
element={(
<RequireAdmin>
<div>admin dashboard</div>
</RequireAdmin>
)}
/>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('admin dashboard')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,2 @@
export { RequireAuth } from './RequireAuth'
export { RequireAdmin } from './RequireAdmin'

View File

@@ -0,0 +1,29 @@
/**
* 统一内容卡片组件
*
* 功能:
* - 提供统一的内容展示区域样式
* - 遵循 warm-elegant 设计主题
*/
import { Card } from 'antd'
import styles from './PageLayout.module.css'
interface ContentCardProps {
children: React.ReactNode
className?: string
style?: React.CSSProperties
title?: React.ReactNode
}
export function ContentCard({ children, className, style, title }: ContentCardProps) {
return (
<Card
className={`${styles.contentCard} ${className || ''}`}
style={style}
title={title}
>
{children}
</Card>
)
}

View File

@@ -0,0 +1,23 @@
/**
* 统一筛选卡片组件
*
* 功能:
* - 提供统一的筛选区域样式
* - 遵循 warm-elegant 设计主题
*/
import { Card } from 'antd'
import styles from './PageLayout.module.css'
interface FilterCardProps {
children: React.ReactNode
className?: string
}
export function FilterCard({ children, className }: FilterCardProps) {
return (
<Card className={`${styles.filterCard} ${className || ''}`}>
{children}
</Card>
)
}

View File

@@ -0,0 +1,123 @@
/**
* 统一页面布局样式
* 遵循 warm-elegant 设计主题
*/
.pageLayout {
padding: var(--space-5, 24px);
max-width: var(--page-max-width, 1440px);
margin: 0 auto;
min-height: calc(100vh - 64px - 48px); /* 减去header和padding */
}
/* 筛选卡片样式 */
.filterCard {
margin-bottom: var(--space-4, 16px);
border-radius: var(--radius-md, 16px) !important;
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
background: var(--color-surface, #ffffff) !important;
border: none !important;
}
.filterCard :global(.ant-card-body) {
padding: var(--space-4, 16px) var(--space-5, 24px) !important;
}
/* 表格卡片样式 */
.tableCard {
border-radius: var(--radius-md, 16px) !important;
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
background: var(--color-surface, #ffffff) !important;
border: none !important;
}
.tableCard :global(.ant-card-body) {
padding: 0 !important;
}
.tableCard :global(.ant-table-wrapper) {
border-radius: var(--radius-md, 16px);
overflow: hidden;
}
/* 树形卡片样式 */
.treeCard {
border-radius: var(--radius-md, 16px) !important;
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
background: var(--color-surface, #ffffff) !important;
border: none !important;
}
.treeCard :global(.ant-card-body) {
padding: var(--space-5, 24px) !important;
}
/* 内容卡片样式 */
.contentCard {
border-radius: var(--radius-md, 16px) !important;
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
background: var(--color-surface, #ffffff) !important;
border: none !important;
}
.contentCard :global(.ant-card-body) {
padding: var(--space-5, 24px) !important;
}
/* 操作栏样式 */
.actionBar {
display: flex;
gap: var(--space-2, 8px);
align-items: center;
}
/* 页面头部样式 */
.pageHeader {
margin-bottom: var(--space-5, 24px);
}
.pageHeaderTitle {
font-size: 24px;
font-weight: 600;
color: var(--color-text-strong, #17212b);
margin: 0 0 var(--space-2, 8px) 0;
}
.pageHeaderDescription {
font-size: 14px;
color: var(--color-text-muted, #677380);
margin: 0;
}
.pageHeaderActions {
margin-left: auto;
}
/* 筛选表单样式 */
.filterForm {
display: flex;
flex-wrap: wrap;
gap: var(--space-3, 12px);
align-items: center;
}
/* 表格操作按钮统一样式 */
.tableActionButton {
padding: 0 var(--space-1, 4px) !important;
}
/* 响应式适配 */
@media (max-width: 768px) {
.pageLayout {
padding: var(--space-3, 12px);
}
.filterForm {
flex-direction: column;
align-items: stretch;
}
.filterForm > * {
width: 100% !important;
}
}

View File

@@ -0,0 +1,22 @@
/**
* 统一页面布局容器
*
* 功能:
* - 提供统一的页面布局结构
* - 遵循 warm-elegant 设计主题
*/
import styles from './PageLayout.module.css'
interface PageLayoutProps {
children: React.ReactNode
className?: string
}
export function PageLayout({ children, className }: PageLayoutProps) {
return (
<div className={`${styles.pageLayout} ${className || ''}`}>
{children}
</div>
)
}

View File

@@ -0,0 +1,23 @@
/**
* 统一表格卡片组件
*
* 功能:
* - 提供统一的表格区域样式
* - 遵循 warm-elegant 设计主题
*/
import { Card } from 'antd'
import styles from './PageLayout.module.css'
interface TableCardProps {
children: React.ReactNode
className?: string
}
export function TableCard({ children, className }: TableCardProps) {
return (
<Card className={`${styles.tableCard} ${className || ''}`}>
{children}
</Card>
)
}

View File

@@ -0,0 +1,23 @@
/**
* 统一树形卡片组件
*
* 功能:
* - 提供统一的树形展示区域样式
* - 遵循 warm-elegant 设计主题
*/
import { Card } from 'antd'
import styles from './PageLayout.module.css'
interface TreeCardProps {
children: React.ReactNode
className?: string
}
export function TreeCard({ children, className }: TreeCardProps) {
return (
<Card className={`${styles.treeCard} ${className || ''}`}>
{children}
</Card>
)
}

View File

@@ -0,0 +1,9 @@
/**
* 统一页面布局组件导出
*/
export { PageLayout } from './PageLayout'
export { FilterCard } from './FilterCard'
export { TableCard } from './TableCard'
export { TreeCard } from './TreeCard'
export { ContentCard } from './ContentCard'

View File

@@ -0,0 +1,11 @@
/**
* 布局组件导出
*/
export {
PageLayout,
FilterCard,
TableCard,
TreeCard,
ContentCard,
} from './PageLayout'

View File

@@ -0,0 +1,3 @@
`src/features` 保留为业务复用层目录。
当前已补齐目录骨架,后续需要将页面内可复用的业务交互逐步下沉到对应子目录。

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

View File

@@ -0,0 +1,209 @@
/**
* AdminLayout 样式
*/
.layout {
min-height: 100vh;
background: var(--color-canvas);
}
/* 加载状态 */
.loadingContainer {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-canvas);
}
/* 侧边栏 */
.sider {
background: var(--color-layout) !important;
border-right: 1px solid var(--color-border-soft);
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: var(--color-text-strong);
border-bottom: 1px solid var(--color-border-soft);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 16px;
}
.menu {
background: transparent !important;
border: none !important;
}
.menu :global(.ant-menu-item),
.menu :global(.ant-menu-submenu-title) {
margin: 4px 8px !important;
border-radius: var(--radius-sm) !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.menu :global(.ant-menu-item-selected) {
background: var(--color-primary) !important;
color: var(--color-text-inverse) !important;
}
/* 确保子菜单可展开 */
.menu :global(.ant-menu-submenu-arrow) {
pointer-events: none !important;
}
/* 顶栏 */
.header {
height: 64px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border-soft);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.headerLeft {
display: flex;
align-items: center;
gap: 16px;
}
.collapseBtn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-base);
font-size: 18px;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.collapseBtn:hover {
background: var(--color-surface-muted);
}
.breadcrumb {
font-size: 14px;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 0;
}
.breadcrumbLink {
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--motion-fast);
}
.breadcrumbLink:hover {
color: var(--color-primary);
}
.breadcrumbCurrent {
color: var(--color-text-base);
font-weight: 500;
}
.breadcrumbSeparator {
margin: 0 8px;
color: var(--color-text-muted);
}
.headerRight {
display: flex;
align-items: center;
gap: 16px;
}
.userTrigger {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.userTrigger:hover {
background: var(--color-surface-muted);
}
.userName {
font-size: 14px;
color: var(--color-text-base);
}
/* 内容区 */
.content {
padding: 24px;
min-height: calc(100vh - 64px);
max-width: var(--page-max-width);
width: 100%;
margin: 0 auto;
}
/* 响应式 */
@media (max-width: 1024px) {
.content {
padding: 16px;
}
}
/* 跳过链接 - 可访问性 */
.skipLink {
position: absolute;
top: -40px;
left: 0;
background: var(--color-primary);
color: var(--color-text-inverse);
padding: 8px 16px;
z-index: 1000;
transition: top 0.2s;
text-decoration: none;
border-radius: 0 0 4px 0;
pointer-events: auto;
}
.skipLink:focus {
top: 0;
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* 侧边栏层级 */
.sider {
z-index: 100;
}
/* 确保布局不被遮挡 */
.layout {
position: relative;
}
/* 移动端抽屉样式 */
.mobileDrawer :global(.ant-drawer-header) {
border-bottom: 1px solid var(--color-border-soft);
}
.mobileDrawer :global(.ant-drawer-body) {
padding: 0;
}

View File

@@ -0,0 +1,469 @@
import type { ReactNode } from 'react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import { AdminLayout } from './AdminLayout'
import styles from './AdminLayout.module.css'
const logoutMock = vi.fn(async () => {})
function flattenChildren(children: ReactNode): string {
if (children === null || children === undefined || typeof children === 'boolean') {
return ''
}
if (typeof children === 'string' || typeof children === 'number') {
return String(children)
}
if (Array.isArray(children)) {
return children.map(flattenChildren).join(' ').trim()
}
if (typeof children === 'object' && 'props' in children) {
return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children)
}
return ''
}
vi.mock('antd', async () => {
const React = await import('react')
type MenuItem = {
key?: string
label?: ReactNode
children?: MenuItem[]
type?: string
onClick?: () => void
}
const Layout = Object.assign(
({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<div data-testid="layout" className={className}>
{children}
</div>
),
{
Sider: ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<aside data-testid="sider" className={className}>
{children}
</aside>
),
Header: ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<header data-testid="header" className={className}>
{children}
</header>
),
Content: ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<main data-testid="content" className={className}>
{children}
</main>
),
},
)
function Menu({
items = [],
onClick,
selectedKeys = [],
defaultOpenKeys = [],
}: {
items?: MenuItem[]
onClick?: (info: { key: string }) => void
selectedKeys?: string[]
defaultOpenKeys?: string[]
}) {
const [openKeys, setOpenKeys] = React.useState((defaultOpenKeys ?? []).map(String))
React.useEffect(() => {
setOpenKeys((defaultOpenKeys ?? []).map(String))
}, [defaultOpenKeys])
const renderItem = (item: MenuItem): ReactNode => {
if (item.type === 'divider') {
return <hr key="divider" />
}
const key = String(item.key ?? flattenChildren(item.label))
const label = flattenChildren(item.label)
const hasChildren = Boolean(item.children?.length)
return (
<div key={key}>
<button
type="button"
data-testid={`menu-item-${key}`}
onClick={() => {
if (hasChildren) {
setOpenKeys((current) => (
current.includes(key)
? current.filter((value) => value !== key)
: [...current, key]
))
return
}
onClick?.({ key })
}}
>
{label}
</button>
{hasChildren && openKeys.includes(key) ? item.children?.map(renderItem) : null}
</div>
)
}
return (
<div
data-testid="menu"
data-open-keys={openKeys.join(',')}
data-selected-keys={(selectedKeys ?? []).join(',')}
>
{items.map((item) => renderItem(item as MenuItem))}
</div>
)
}
function Dropdown({
children,
menu,
}: {
children?: ReactNode
menu?: { items?: MenuItem[] }
}) {
const [open, setOpen] = React.useState(false)
return (
<div>
<button type="button" data-testid="dropdown-trigger" onClick={() => setOpen((value) => !value)}>
{children}
</button>
{open ? (
<div data-testid="dropdown-menu">
{menu?.items?.map((item, index) => {
if (!item || item.type === 'divider') {
return <hr key={`dropdown-divider-${index}`} />
}
const key = String(item.key ?? index)
return (
<button
key={key}
type="button"
data-testid={`dropdown-item-${key}`}
onClick={() => {
item.onClick?.()
setOpen(false)
}}
>
{flattenChildren(item.label)}
</button>
)
})}
</div>
) : null}
</div>
)
}
return {
Avatar: ({
src,
style,
icon,
size,
}: {
src?: string | null
style?: { backgroundColor?: string }
icon?: ReactNode
size?: number
}) => (
<div
data-testid="avatar"
data-src={src ?? ''}
data-background={style?.backgroundColor ?? ''}
data-size={String(size ?? '')}
>
{src ? <img alt="avatar" src={src} /> : icon}
</div>
),
Button: ({
children,
icon,
onClick,
htmlType,
...props
}: {
children?: ReactNode
icon?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children ?? icon}
</button>
),
Drawer: ({
open,
title,
children,
onClose,
}: {
open?: boolean
title?: ReactNode
children?: ReactNode
onClose?: () => void
}) => (
open ? (
<div data-testid="drawer">
<div data-testid="drawer-title">{title}</div>
<button type="button" onClick={onClose}>close drawer</button>
{children}
</div>
) : null
),
Dropdown,
Layout,
Menu,
Spin: ({
tip,
size,
children,
}: {
tip?: ReactNode
size?: string
children?: ReactNode
}) => (
<div aria-busy="true" data-testid="spin" data-tip={flattenChildren(tip)} data-size={size}>
{children}
</div>
),
}
})
vi.mock('@ant-design/icons', () => ({
ApiOutlined: () => <span>api-icon</span>,
DashboardOutlined: () => <span>dashboard-icon</span>,
FileTextOutlined: () => <span>file-text-icon</span>,
LogoutOutlined: () => <span>logout-icon</span>,
MenuFoldOutlined: () => <span>menu-fold-icon</span>,
MenuOutlined: () => <span>menu-icon</span>,
MenuUnfoldOutlined: () => <span>menu-unfold-icon</span>,
SafetyOutlined: () => <span>safety-icon</span>,
SettingOutlined: () => <span>setting-icon</span>,
UserOutlined: () => <span>user-icon</span>,
}))
const baseAuthContextValue: AuthContextValue = {
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'admin-nickname',
avatar: '',
status: 1,
},
roles: [],
isAdmin: true,
isAuthenticated: true,
isLoading: false,
onLoginSuccess: async () => {},
logout: () => logoutMock(),
refreshUser: async () => {},
}
function setWindowWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
})
}
function renderAdminLayout(
authContextValue: Partial<AuthContextValue> = {},
initialEntry: string = '/profile/security',
layoutChildren?: ReactNode,
) {
const value: AuthContextValue = {
...baseAuthContextValue,
...authContextValue,
}
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<AuthContext.Provider value={value}>
<Routes>
<Route path="/" element={<AdminLayout>{layoutChildren}</AdminLayout>}>
<Route path="dashboard" element={<div>Dashboard Page</div>} />
<Route path="users" element={<div>Users Page</div>} />
<Route path="roles" element={<div>Roles Page</div>} />
<Route path="permissions" element={<div>Permissions Page</div>} />
<Route path="logs/login" element={<div>Login Logs Page</div>} />
<Route path="logs/operation" element={<div>Operation Logs Page</div>} />
<Route path="webhooks" element={<div>Webhooks Page</div>} />
<Route path="import-export" element={<div>Import Export Page</div>} />
<Route path="profile" element={<div>Profile Page</div>} />
<Route path="profile/security" element={<div>Security Page</div>} />
</Route>
</Routes>
</AuthContext.Provider>
</MemoryRouter>,
)
}
describe('AdminLayout', () => {
beforeEach(() => {
logoutMock.mockClear()
setWindowWidth(1280)
})
afterEach(() => {
setWindowWidth(1280)
vi.restoreAllMocks()
})
it('shows a loading state while the session is restoring', () => {
renderAdminLayout({ isLoading: true })
expect(screen.getByTestId('spin')).toHaveAttribute('data-tip')
expect(screen.queryByText('Security Page')).not.toBeInTheDocument()
})
it('renders desktop admin navigation, breadcrumbs, collapse state, dropdown actions, and mobile drawer navigation', async () => {
const user = userEvent.setup()
const { container } = renderAdminLayout({ isAdmin: true }, '/profile/security')
expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('用户管理系统')
expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('admin-nickname')
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile')
expect(screen.getByText('Security Page')).toBeInTheDocument()
const breadcrumbLink = container.querySelector(`.${styles.breadcrumbLink}`)
expect(breadcrumbLink).not.toBeNull()
await user.click(breadcrumbLink as HTMLElement)
await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument())
await user.click(screen.getByTestId('menu-item-access-control'))
await user.click(screen.getByTestId('menu-item-/users'))
await waitFor(() => expect(screen.getByText('Users Page')).toBeInTheDocument())
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByTestId('dropdown-item-security'))
await waitFor(() => expect(screen.getByText('Security Page')).toBeInTheDocument())
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByTestId('dropdown-item-profile'))
await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument())
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByTestId('dropdown-item-logout'))
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
const collapseButton = screen.getByText('menu-fold-icon').closest('button')
expect(collapseButton).not.toBeNull()
await user.click(collapseButton as HTMLButtonElement)
expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('UMS')
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', '')
expect(screen.getByText('menu-unfold-icon')).toBeInTheDocument()
await act(async () => {
setWindowWidth(375)
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => expect(screen.getByRole('button', { name: 'menu-icon' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
const drawer = screen.getByTestId('drawer')
expect(within(drawer).getByTestId('drawer-title')).toHaveTextContent('UMS')
await user.click(within(drawer).getByTestId('menu-item-/dashboard'))
await waitFor(() => expect(screen.getByText('Dashboard Page')).toBeInTheDocument())
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('renders the reduced mobile menu for non-admin users and uses avatar and username fallbacks correctly', async () => {
const user = userEvent.setup()
setWindowWidth(375)
const { container } = renderAdminLayout(
{
isAdmin: false,
user: {
id: 2,
username: 'operator-name',
email: 'operator@example.com',
phone: '',
nickname: '',
avatar: 'https://example.com/avatar.png',
status: 1,
},
},
'/profile',
)
expect(screen.queryByTestId('menu-item-access-control')).not.toBeInTheDocument()
expect(screen.queryByTestId('menu-item-logs')).not.toBeInTheDocument()
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile')
expect(screen.getByTestId('avatar')).toHaveAttribute('data-src', 'https://example.com/avatar.png')
expect(screen.getByTestId('avatar')).toHaveAttribute('data-background', '')
expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('operator-name')
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
const drawer = screen.getByTestId('drawer')
await user.click(within(drawer).getByTestId('menu-item-/webhooks'))
await waitFor(() => expect(screen.getByText('Webhooks Page')).toBeInTheDocument())
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => {
const { container } = renderAdminLayout(
{
user: null,
},
'/logs/login',
<div>Injected Layout Content</div>,
)
expect(screen.getByText('Injected Layout Content')).toBeInTheDocument()
expect(screen.queryByText('Login Logs Page')).not.toBeInTheDocument()
expect(container.querySelector(`.${styles.userName}`)?.textContent?.trim().length).toBeGreaterThan(0)
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-selected-keys', '/logs/login')
expect(container.querySelector(`.${styles.breadcrumb}`)).toHaveTextContent('审计日志')
})
})

View File

@@ -0,0 +1,329 @@
/**
* AdminLayout - 管理后台布局
*
* 布局:侧栏 248px + 顶栏 64px + 内容区
*/
import { useState, useEffect } from 'react'
import { Layout, Menu, Avatar, Dropdown, Spin, Drawer, Button, type MenuProps } from 'antd'
import {
DashboardOutlined,
SafetyOutlined,
FileTextOutlined,
ApiOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
LogoutOutlined,
SettingOutlined,
} from '@ant-design/icons'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs'
import styles from './AdminLayout.module.css'
const { Sider, Header, Content } = Layout
// 管理员菜单配置
const adminMenuItems: MenuProps['items'] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '总览',
},
{
key: 'access-control',
icon: <SafetyOutlined />,
label: '访问控制',
children: [
{ key: '/users', label: '用户管理' },
{ key: '/roles', label: '角色管理' },
{ key: '/permissions', label: '权限管理' },
],
},
{
key: 'logs',
icon: <FileTextOutlined />,
label: '审计日志',
children: [
{ key: '/logs/login', label: '登录日志' },
{ key: '/logs/operation', label: '操作日志' },
],
},
{
key: 'integration',
icon: <ApiOutlined />,
label: '集成能力',
children: [
{ key: '/webhooks', label: 'Webhooks' },
{ key: '/import-export', label: '导入导出' },
],
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
],
},
]
// 非管理员菜单配置(只有 Webhooks 和个人中心)
const userMenuItems: MenuProps['items'] = [
{
key: '/webhooks',
icon: <ApiOutlined />,
label: 'Webhooks',
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
],
},
]
interface AdminLayoutProps {
children?: ReactNode
}
export function AdminLayout({ children }: AdminLayoutProps) {
const [collapsed, setCollapsed] = useState(false)
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const { user, isAdmin, logout, isLoading } = useAuth()
const breadcrumbItems = useBreadcrumbs()
// 检测移动端
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 移动端切换侧边栏
const toggleMobileDrawer = () => {
setMobileDrawerOpen(!mobileDrawerOpen)
}
// 移动端菜单点击后关闭抽屉
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
setMobileDrawerOpen(false)
}
// 根据是否为管理员选择菜单
const menuItems = isAdmin ? adminMenuItems : userMenuItems
// 当前选中的菜单
const selectedKeys = [location.pathname]
// 当前展开的菜单组(根据路径决定哪个分组展开)
const openKeys = collapsed
? []
: [
...(location.pathname.startsWith('/users') ||
location.pathname.startsWith('/roles') ||
location.pathname.startsWith('/permissions')
? ['access-control']
: []),
...(location.pathname.startsWith('/logs') ? ['logs'] : []),
...(location.pathname.startsWith('/webhooks') ||
location.pathname.startsWith('/import-export')
? ['integration']
: []),
...(location.pathname.startsWith('/profile') ? ['profile'] : []),
]
const handleMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
}
// 处理面包屑点击
const handleBreadcrumbClick = (path: string) => {
navigate(path)
}
// 处理登出
const handleLogout = () => {
void logout()
}
// 用户下拉菜单
const userDropdownItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人资料',
onClick: () => navigate('/profile'),
},
{
key: 'security',
icon: <SettingOutlined />,
label: '安全设置',
onClick: () => navigate('/profile/security'),
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
onClick: handleLogout,
},
]
// 加载中状态
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" tip="正在恢复会话..." />
</div>
)
}
return (
<Layout className={styles.layout}>
{/* 跳过链接 - 便于键盘用户快速跳转到主要内容 */}
<a href="#main-content" className={styles.skipLink}>
</a>
{/* 侧边栏 */}
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={248}
collapsedWidth={84}
className={styles.sider}
trigger={null}
>
{/* Logo 区域 */}
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
{/* 导航菜单 */}
<Menu
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={openKeys}
items={menuItems}
onClick={handleMenuClick}
className={styles.menu}
style={{ pointerEvents: 'auto' }}
/>
</Sider>
{/* 右侧主体 */}
<Layout>
{/* 顶栏 */}
<Header className={styles.header}>
<div className={styles.headerLeft}>
{/* 折叠/菜单按钮 - 移动端显示菜单图标,桌面端显示折叠图标 */}
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined />}
onClick={toggleMobileDrawer}
className={styles.collapseBtn}
/>
) : (
<button
className={styles.collapseBtn}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
)}
{/* 面包屑 */}
{breadcrumbItems && breadcrumbItems.length > 0 && (
<div className={styles.breadcrumb}>
{breadcrumbItems.map((item, index) => (
<span key={index}>
{item.path ? (
<a
className={styles.breadcrumbLink}
onClick={() => handleBreadcrumbClick(item.path as string)}
>
{item.title}
</a>
) : (
<span className={styles.breadcrumbCurrent}>
{item.title}
</span>
)}
{index < breadcrumbItems.length - 1 && (
<span className={styles.breadcrumbSeparator}>/</span>
)}
</span>
))}
</div>
)}
</div>
<div className={styles.headerRight}>
{/* 用户信息 */}
<Dropdown menu={{ items: userDropdownItems }} placement="bottomRight">
<div className={styles.userTrigger}>
<Avatar
size={32}
icon={<UserOutlined />}
src={user?.avatar || null}
style={{ backgroundColor: user?.avatar ? undefined : 'var(--color-primary)' }}
/>
<span className={styles.userName}>
{user?.nickname || user?.username || '用户'}
</span>
</div>
</Dropdown>
</div>
</Header>
{/* 内容区 */}
<Content id="main-content" className={styles.content}>
{children || <Outlet />}
</Content>
</Layout>
{/* 移动端抽屉式导航 */}
<Drawer
title={
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
}
placement="left"
onClose={toggleMobileDrawer}
open={mobileDrawerOpen}
size="default"
className={styles.mobileDrawer}
styles={{ body: { padding: 0 } }}
>
<Menu
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={openKeys}
items={menuItems}
onClick={handleMobileMenuClick}
className={styles.menu}
style={{ pointerEvents: 'auto' }}
/>
</Drawer>
</Layout>
)
}

View File

@@ -0,0 +1 @@
export { AdminLayout } from './AdminLayout'

View File

@@ -0,0 +1,105 @@
/**
* AuthLayout 样式
*/
.container {
display: flex;
min-height: 100vh;
}
/* 左侧品牌区 */
.brand {
width: 480px;
min-width: 400px;
background: var(--gradient-shell);
padding: 48px;
display: flex;
align-items: flex-end;
position: relative;
overflow: hidden;
}
.brand::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 20%, rgba(14, 90, 106, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(194, 109, 58, 0.08) 0%, transparent 50%);
pointer-events: none;
}
.brandContent {
position: relative;
z-index: 1;
}
.brandTitle {
font-size: 32px;
font-weight: 600;
color: var(--color-text-strong);
margin-bottom: 12px;
}
.brandDesc {
font-size: 16px;
color: var(--color-text-muted);
margin-bottom: 32px;
}
.features {
list-style: none;
padding: 0;
}
.features li {
font-size: 14px;
color: var(--color-text-base);
padding: 8px 0;
padding-left: 24px;
position: relative;
}
.features li::before {
content: '✓';
position: absolute;
left: 0;
color: var(--color-success);
font-weight: 600;
}
/* 右侧表单区 */
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background: var(--color-canvas);
}
.formContainer {
width: 100%;
max-width: 420px;
}
/* 响应式 */
@media (max-width: 1024px) {
.brand {
width: 360px;
min-width: 320px;
}
}
@media (max-width: 768px) {
.brand {
display: none;
}
.main {
padding: 24px;
}
}

View File

@@ -0,0 +1,42 @@
/**
* AuthLayout - 认证页面布局
* 用于登录、忘记密码、重置密码等页面
*
* 布局:左侧品牌区 + 右侧表单区
*/
import type { ReactNode } from 'react'
import styles from './AuthLayout.module.css'
interface AuthLayoutProps {
children: ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className={styles.container}>
{/* 左侧品牌区 */}
<aside className={styles.brand}>
<div className={styles.brandContent}>
<h1 className={styles.brandTitle}></h1>
<p className={styles.brandDesc}>
</p>
<ul className={styles.features}>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</aside>
{/* 右侧表单区 */}
<main className={styles.main}>
<div className={styles.formContainer}>
{children}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1 @@
export { AuthLayout } from './AuthLayout'

View File

@@ -0,0 +1,2 @@
export { AuthLayout } from './AuthLayout'
export { AdminLayout } from './AdminLayout'

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
buildOAuthCallbackReturnTo,
parseOAuthCallbackHash,
sanitizeAuthRedirect,
} from './oauth'
describe('oauth auth helpers', () => {
it('sanitizes redirect paths to internal routes only', () => {
expect(sanitizeAuthRedirect('/users')).toBe('/users')
expect(sanitizeAuthRedirect('https://evil.example.com')).toBe('/dashboard')
expect(sanitizeAuthRedirect('//evil.example.com')).toBe('/dashboard')
expect(sanitizeAuthRedirect('users')).toBe('/dashboard')
})
it('builds oauth callback return url on current origin', () => {
expect(buildOAuthCallbackReturnTo('/users')).toBe('http://localhost:3000/login/oauth/callback?redirect=%2Fusers')
})
it('parses oauth callback hash payload', () => {
expect(parseOAuthCallbackHash('#status=success&code=abc&provider=github')).toEqual({
status: 'success',
code: 'abc',
provider: 'github',
message: '',
})
})
})

View File

@@ -0,0 +1,27 @@
export function sanitizeAuthRedirect(target: string | null | undefined, fallback: string = '/dashboard'): string {
const value = (target || '').trim()
if (!value.startsWith('/') || value.startsWith('//')) {
return fallback
}
return value
}
export function buildOAuthCallbackReturnTo(redirectPath: string): string {
const callbackUrl = new URL('/login/oauth/callback', window.location.origin)
if (redirectPath && redirectPath !== '/dashboard') {
callbackUrl.searchParams.set('redirect', redirectPath)
}
return callbackUrl.toString()
}
export function parseOAuthCallbackHash(hash: string): Record<string, string> {
const normalized = hash.startsWith('#') ? hash.slice(1) : hash
const values = new URLSearchParams(normalized)
return {
status: values.get('status') || '',
code: values.get('code') || '',
provider: values.get('provider') || '',
message: values.get('message') || '',
}
}

View File

@@ -0,0 +1,11 @@
/**
* 应用配置
* 从环境变量中读取配置项
*/
export const config = {
/**
* API 基础地址
*/
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api/v1',
} as const

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { AppError, ErrorType, isAppError } from './AppError'
import { getErrorMessage, isFormValidationError } from './index'
describe('AppError', () => {
it('uses the default status and type when options are omitted', () => {
const error = new AppError(1001, 'business failed')
expect(error).toBeInstanceOf(AppError)
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('AppError')
expect(error.code).toBe(1001)
expect(error.status).toBe(500)
expect(error.type).toBe(ErrorType.BUSINESS)
expect(error.cause).toBeUndefined()
})
it('keeps explicit options including cause and exposes type guards', () => {
const cause = new Error('root cause')
const authByStatus = new AppError(2001, 'status-auth', {
status: 401,
type: ErrorType.BUSINESS,
cause,
})
const forbiddenByStatus = new AppError(2002, 'status-forbidden', {
status: 403,
type: ErrorType.BUSINESS,
})
const networkError = AppError.network('network failed', cause)
expect(authByStatus.cause).toBe(cause)
expect(authByStatus.isAuthError()).toBe(true)
expect(forbiddenByStatus.isForbidden()).toBe(true)
expect(networkError.isNetworkError()).toBe(true)
})
it('maps backend responses to the expected error type for each status family', () => {
const unauthorized = AppError.fromResponse({ code: 40101, message: 'unauthorized' }, 401)
const forbidden = AppError.fromResponse({ code: 40301, message: 'forbidden' }, 403)
const notFound = AppError.fromResponse({ code: 40401, message: 'missing' }, 404)
const network = AppError.fromResponse({ code: 50001, message: 'server error' }, 500)
const business = AppError.fromResponse({ code: 40001, message: 'business error' }, 400)
expect(unauthorized.type).toBe(ErrorType.AUTH)
expect(forbidden.type).toBe(ErrorType.FORBIDDEN)
expect(notFound.type).toBe(ErrorType.NOT_FOUND)
expect(network.type).toBe(ErrorType.NETWORK)
expect(business.type).toBe(ErrorType.BUSINESS)
})
it('creates auth, forbidden, and validation errors with the expected defaults', () => {
const auth = AppError.auth()
const forbidden = AppError.forbidden()
const validation = AppError.validation('validation failed')
expect(auth.code).toBe(401)
expect(auth.status).toBe(401)
expect(auth.type).toBe(ErrorType.AUTH)
expect(auth.message.length).toBeGreaterThan(0)
expect(forbidden.code).toBe(403)
expect(forbidden.status).toBe(403)
expect(forbidden.type).toBe(ErrorType.FORBIDDEN)
expect(forbidden.message.length).toBeGreaterThan(0)
expect(validation.code).toBe(400)
expect(validation.status).toBe(400)
expect(validation.type).toBe(ErrorType.VALIDATION)
expect(validation.message).toBe('validation failed')
})
it('returns user-facing messages for each supported error type', () => {
const networkMessage = AppError.network('network failed').getUserMessage()
const authMessage = AppError.auth('custom auth').getUserMessage()
const forbiddenMessage = AppError.forbidden('custom forbidden').getUserMessage()
const notFoundMessage = new AppError(40401, 'missing', {
status: 404,
type: ErrorType.NOT_FOUND,
}).getUserMessage()
const validationMessage = AppError.validation('validation failed').getUserMessage()
const customUnknownMessage = new AppError(9001, 'custom unknown', {
type: ErrorType.UNKNOWN,
}).getUserMessage()
const fallbackUnknownMessage = new AppError(9002, '', {
type: ErrorType.UNKNOWN,
}).getUserMessage()
expect(networkMessage.length).toBeGreaterThan(0)
expect(networkMessage).not.toBe('network failed')
expect(authMessage.length).toBeGreaterThan(0)
expect(authMessage).not.toBe('custom auth')
expect(forbiddenMessage.length).toBeGreaterThan(0)
expect(forbiddenMessage).not.toBe('custom forbidden')
expect(notFoundMessage.length).toBeGreaterThan(0)
expect(notFoundMessage).not.toBe('missing')
expect(validationMessage).toBe('validation failed')
expect(customUnknownMessage).toBe('custom unknown')
expect(fallbackUnknownMessage.length).toBeGreaterThan(0)
})
it('identifies AppError instances correctly', () => {
expect(isAppError(new AppError(1, 'boom'))).toBe(true)
expect(isAppError(new Error('boom'))).toBe(false)
expect(isAppError('boom')).toBe(false)
})
})
describe('error helpers', () => {
it('uses the AppError user message when available', () => {
const error = AppError.validation('invalid form')
expect(getErrorMessage(error, 'fallback')).toBe('invalid form')
})
it('falls back to generic Error messages and finally to the provided fallback', () => {
expect(getErrorMessage(new Error('plain error'), 'fallback')).toBe('plain error')
expect(getErrorMessage({ foo: 'bar' }, 'fallback')).toBe('fallback')
})
it('detects form validation errors only for objects with an errorFields array', () => {
expect(isFormValidationError({ errorFields: [] })).toBe(true)
expect(isFormValidationError({ errorFields: 'nope' })).toBe(false)
expect(isFormValidationError(null)).toBe(false)
})
})

View File

@@ -0,0 +1,172 @@
/**
* AppError - 应用统一错误模型
*
* 用于统一处理后端业务错误和前端运行时错误
*/
/**
* 错误类型常量
*/
export const ErrorType = {
/** 业务错误 - 后端返回的业务逻辑错误 */
BUSINESS: 'BUSINESS',
/** 网络错误 - 请求失败、超时等 */
NETWORK: 'NETWORK',
/** 认证错误 - 401 未登录或 token 过期 */
AUTH: 'AUTH',
/** 权限错误 - 403 无权限访问 */
FORBIDDEN: 'FORBIDDEN',
/** 资源不存在 - 404 */
NOT_FOUND: 'NOT_FOUND',
/** 验证错误 - 表单校验失败 */
VALIDATION: 'VALIDATION',
/** 未知错误 */
UNKNOWN: 'UNKNOWN',
} as const
export type ErrorTypeValue = typeof ErrorType[keyof typeof ErrorType]
/**
* 应用错误类
*/
export class AppError extends Error {
/** 错误码 */
readonly code: number
/** HTTP 状态码 */
readonly status: number
/** 错误类型 */
readonly type: ErrorTypeValue
/** 原始错误 */
readonly cause?: Error
constructor(
code: number,
message: string,
options?: {
status?: number
type?: ErrorTypeValue
cause?: Error
}
) {
super(message)
this.name = 'AppError'
this.code = code
this.status = options?.status ?? 500
this.type = options?.type ?? ErrorType.BUSINESS
this.cause = options?.cause
// 确保 instanceof 正常工作
Object.setPrototypeOf(this, AppError.prototype)
}
/**
* 从后端响应创建错误
*/
static fromResponse(response: { code: number; message: string }, status: number): AppError {
let type: ErrorTypeValue = ErrorType.BUSINESS
if (status === 401) {
type = ErrorType.AUTH
} else if (status === 403) {
type = ErrorType.FORBIDDEN
} else if (status === 404) {
type = ErrorType.NOT_FOUND
} else if (status >= 500) {
type = ErrorType.NETWORK
}
return new AppError(response.code, response.message, { status, type })
}
/**
* 创建网络错误
*/
static network(message: string, cause?: Error): AppError {
return new AppError(0, message, {
status: 0,
type: ErrorType.NETWORK,
cause,
})
}
/**
* 创建认证错误
*/
static auth(message: string = '请先登录'): AppError {
return new AppError(401, message, {
status: 401,
type: ErrorType.AUTH,
})
}
/**
* 创建权限错误
*/
static forbidden(message: string = '无权限访问'): AppError {
return new AppError(403, message, {
status: 403,
type: ErrorType.FORBIDDEN,
})
}
/**
* 创建验证错误
*/
static validation(message: string): AppError {
return new AppError(400, message, {
status: 400,
type: ErrorType.VALIDATION,
})
}
/**
* 判断是否为认证错误
*/
isAuthError(): boolean {
return this.type === ErrorType.AUTH || this.status === 401
}
/**
* 判断是否为权限错误
*/
isForbidden(): boolean {
return this.type === ErrorType.FORBIDDEN || this.status === 403
}
/**
* 判断是否为网络错误
*/
isNetworkError(): boolean {
return this.type === ErrorType.NETWORK
}
/**
* 获取用户友好的错误消息
*/
getUserMessage(): string {
switch (this.type) {
case ErrorType.NETWORK:
return '网络连接失败,请检查网络后重试'
case ErrorType.AUTH:
return '登录已过期,请重新登录'
case ErrorType.FORBIDDEN:
return '您没有权限执行此操作'
case ErrorType.NOT_FOUND:
return '请求的资源不存在'
case ErrorType.VALIDATION:
return this.message
default:
return this.message || '操作失败,请稍后重试'
}
}
}
/**
* 判断是否为 AppError
*/
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError
}

View File

@@ -0,0 +1,26 @@
import { AppError, ErrorType, isAppError } from './AppError'
export { AppError, ErrorType, isAppError }
export function getErrorMessage(error: unknown, fallback: string): string {
if (isAppError(error)) {
return error.getUserMessage()
}
if (error instanceof Error && error.message) {
return error.message
}
return fallback
}
export function isFormValidationError(
error: unknown,
): error is { errorFields: unknown[] } {
return (
typeof error === 'object' &&
error !== null &&
'errorFields' in error &&
Array.isArray((error as { errorFields?: unknown[] }).errorFields)
)
}

View File

@@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
import { renderHook } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import { useBreadcrumbs } from './useBreadcrumbs'
function createWrapper(pathname: string) {
return function Wrapper({ children }: { children: ReactNode }) {
return <MemoryRouter initialEntries={[pathname]}>{children}</MemoryRouter>
}
}
describe('useBreadcrumbs', () => {
it('returns an empty breadcrumb list at the root path', () => {
const { result } = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/'),
})
expect(result.current).toEqual([])
})
it('maps known single-segment routes to a terminal breadcrumb item', () => {
const { result } = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/dashboard'),
})
expect(result.current).toEqual([
{
title: '概览',
path: undefined,
},
])
})
it('builds nested breadcrumbs for supported child routes', () => {
const { logsResult } = {
logsResult: renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/logs/login'),
}),
}
expect(logsResult.result.current).toEqual([
{
title: '审计日志',
path: '/logs',
},
{
title: '登录日志',
path: undefined,
},
])
const profileResult = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/profile/security'),
})
expect(profileResult.result.current).toEqual([
{
title: '个人资料',
path: '/profile',
},
{
title: '安全设置',
path: undefined,
},
])
})
it('skips unknown route segments while keeping known ancestors', () => {
const { result } = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/logs/unknown'),
})
expect(result.current).toEqual([
{
title: '审计日志',
path: '/logs',
},
])
})
})

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import type { BreadcrumbProps } from 'antd'
const breadcrumbNameMap: Record<string, string> = {
'/dashboard': '概览',
'/users': '用户管理',
'/roles': '角色管理',
'/permissions': '权限管理',
'/logs': '审计日志',
'/logs/login': '登录日志',
'/logs/operation': '操作日志',
'/webhooks': 'Webhooks',
'/import-export': '导入导出',
'/profile': '个人资料',
'/profile/security': '安全设置',
}
export function useBreadcrumbs(): BreadcrumbProps['items'] {
const location = useLocation()
return useMemo(() => {
const pathSnippets = location.pathname.split('/').filter(Boolean)
if (pathSnippets.length === 0) {
return []
}
const items: BreadcrumbProps['items'] = []
let currentPath = ''
pathSnippets.forEach((snippet, index) => {
currentPath += `/${snippet}`
const name = breadcrumbNameMap[currentPath]
if (!name) {
return
}
items.push({
title: name,
path: index === pathSnippets.length - 1 ? undefined : currentPath,
})
})
return items
}, [location.pathname])
}

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const user = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1 as const,
}
const roles = [
{
id: 1,
name: 'Administrator',
code: 'admin',
description: 'System administrator',
is_system: true,
is_default: false,
status: 1 as const,
},
]
describe('auth-session', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('stores and clears the session state in memory', async () => {
const session = await import('@/lib/http/auth-session')
session.setAccessToken('access-token', 60)
session.setCurrentUser(user)
session.setCurrentRoles(roles)
expect(session.getAccessToken()).toBe('access-token')
expect(session.getCurrentUser()).toEqual(user)
expect(session.getCurrentRoles()).toEqual(roles)
expect(session.getRoleCodes()).toEqual(['admin'])
expect(session.isAdmin()).toBe(true)
expect(session.isAuthenticated()).toBe(true)
session.clearSession()
expect(session.getAccessToken()).toBeNull()
expect(session.getCurrentUser()).toBeNull()
expect(session.getCurrentRoles()).toEqual([])
expect(session.isAuthenticated()).toBe(false)
})
it('starts empty after a module reload because the session is memory-only', async () => {
let session = await import('@/lib/http/auth-session')
session.setAccessToken('access-token', 60)
session.setCurrentUser(user)
session.setCurrentRoles(roles)
vi.resetModules()
session = await import('@/lib/http/auth-session')
expect(session.getAccessToken()).toBeNull()
expect(session.getCurrentUser()).toBeNull()
expect(session.getCurrentRoles()).toEqual([])
expect(session.isAuthenticated()).toBe(false)
})
it('marks the token as expired before the hard expiry time', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-21T00:00:00Z'))
const session = await import('@/lib/http/auth-session')
session.setAccessToken('access-token', 60)
expect(session.isAccessTokenExpired()).toBe(false)
vi.advanceTimersByTime(31_000)
expect(session.isAccessTokenExpired()).toBe(true)
session.clearSession()
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,101 @@
import type { SessionUser, Role } from '@/types'
interface SessionState {
accessToken: string | null
expiresAt: number | null
user: SessionUser | null
roles: Role[]
isRefreshing: boolean
refreshPromise: Promise<void> | null
}
const sessionState: SessionState = {
accessToken: null,
expiresAt: null,
user: null,
roles: [],
isRefreshing: false,
refreshPromise: null,
}
export function getAccessToken(): string | null {
return sessionState.accessToken
}
export function setAccessToken(token: string, expiresIn: number): void {
sessionState.accessToken = token
sessionState.expiresAt = Date.now() + expiresIn * 1000
}
export function clearAccessToken(): void {
sessionState.accessToken = null
sessionState.expiresAt = null
}
export function isAccessTokenExpired(): boolean {
if (!sessionState.expiresAt) {
return true
}
return Date.now() > sessionState.expiresAt - 30_000
}
export function getCurrentUser(): SessionUser | null {
return sessionState.user
}
export function setCurrentUser(user: SessionUser): void {
sessionState.user = user
}
export function getCurrentRoles(): Role[] {
return sessionState.roles
}
export function setCurrentRoles(roles: Role[]): void {
sessionState.roles = roles
}
export function isAdmin(): boolean {
return sessionState.roles.some((role) => role.code === 'admin')
}
export function getRoleCodes(): string[] {
return sessionState.roles.map((role) => role.code)
}
export function isAuthenticated(): boolean {
return sessionState.accessToken !== null && sessionState.user !== null
}
export function clearSession(): void {
sessionState.accessToken = null
sessionState.expiresAt = null
sessionState.user = null
sessionState.roles = []
sessionState.isRefreshing = false
sessionState.refreshPromise = null
}
export function isRefreshing(): boolean {
return sessionState.isRefreshing
}
export function startRefreshing(): void {
sessionState.isRefreshing = true
}
export function endRefreshing(): void {
sessionState.isRefreshing = false
}
export function getRefreshPromise(): Promise<void> | null {
return sessionState.refreshPromise
}
export function setRefreshPromise(promise: Promise<void>): void {
sessionState.refreshPromise = promise
}
export function clearRefreshPromise(): void {
sessionState.refreshPromise = null
}

View File

@@ -0,0 +1,785 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type JsonResponseInit = ResponseInit & {
status?: number
}
function jsonResponse(data: unknown, init: JsonResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
...init,
})
}
async function loadModules() {
vi.resetModules()
const session = await import('@/lib/http/auth-session')
const storage = await import('@/lib/storage')
const csrf = await import('@/lib/http/csrf')
const errors = await import('@/lib/errors')
const client = await import('@/lib/http/client')
return {
...session,
...storage,
...csrf,
...errors,
...client,
}
}
describe('http client', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
it('builds query-string urls and skips undefined params without auth headers', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
const result = await get(
'/users',
{ page: 2, active: true, keyword: undefined },
{ auth: false },
)
expect(result).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [requestUrl, requestInit] = fetchMock.mock.calls[0]
expect(String(requestUrl)).toBe(`${window.location.origin}/api/v1/users?page=2&active=true`)
expect(requestInit?.headers).not.toMatchObject({
Authorization: expect.any(String),
})
})
it('supports relative api base urls without a leading slash', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'api/custom')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
await get('/status', undefined, { auth: false })
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/custom/status`,
expect.any(Object),
)
})
it('supports absolute api base urls', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
await get('/status', undefined, { auth: false })
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/base/status',
expect.any(Object),
)
})
it('sends FormData without forcing a JSON content type', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { uploaded: true },
}),
)
const { post } = await loadModules()
const formData = new FormData()
formData.append('file', new Blob(['demo'], { type: 'text/plain' }), 'demo.txt')
const result = await post('/upload', formData, { auth: false })
expect(result).toEqual({ uploaded: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [requestUrl, requestInit] = fetchMock.mock.calls[0]
const headers = requestInit?.headers as Record<string, string> | undefined
expect(String(requestUrl)).toContain('/api/v1/upload')
expect(requestInit?.body).toBe(formData)
expect(requestInit?.credentials).toBe('include')
expect(headers?.['Content-Type']).toBeUndefined()
})
it('adds csrf and json headers for protected write requests', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { saved: true },
}),
)
const { CSRF_HEADER_NAME, put, setCSRFToken } = await loadModules()
setCSRFToken('csrf-token')
const result = await put('/users/1', { nickname: 'Demo' }, { auth: false })
expect(result).toEqual({ saved: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'PUT',
body: JSON.stringify({ nickname: 'Demo' }),
headers: {
'Content-Type': 'application/json',
[CSRF_HEADER_NAME]: 'csrf-token',
},
})
})
it('adds csrf headers to delete requests', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { deleted: true },
}),
)
const { CSRF_HEADER_NAME, del, setCSRFToken } = await loadModules()
setCSRFToken('csrf-token')
const result = await del('/users/1', { auth: false })
expect(result).toEqual({ deleted: true })
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'DELETE',
headers: {
[CSRF_HEADER_NAME]: 'csrf-token',
},
})
})
it('refreshes an expired access token before sending the business request', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-new',
refresh_token: 'refresh-token-new',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', -1)
setRefreshToken('refresh-token-old')
const data = await get('/protected')
expect(data).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[0][1]).toMatchObject({
credentials: 'include',
method: 'POST',
body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
})
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer access-token-new',
})
})
it('waits for an in-flight refresh promise before sending the request', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
setAccessToken('queued-access-token', 3600)
startRefreshing()
setRefreshPromise(Promise.resolve())
const result = await get('/protected')
expect(result).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer queued-access-token',
})
})
it('clears the local session when refresh fails before the business request is sent', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('expired-access-token', -1)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('clears the local session when refresh returns a business error payload', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 10001,
message: 'refresh failed',
data: null,
}),
)
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('expired-access-token', -1)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('retries once after a 401 response and rotates the in-memory refresh token', async () => {
const fetchMock = vi.mocked(fetch)
const capturedHeaders: Array<Record<string, string> | undefined> = []
fetchMock
.mockImplementationOnce(async (_url, requestInit) => {
capturedHeaders.push(
requestInit?.headers
? { ...(requestInit.headers as Record<string, string>) }
: undefined,
)
return new Response(null, { status: 401 })
})
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-retried',
refresh_token: 'refresh-token-retried',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockImplementationOnce(async (_url, requestInit) => {
capturedHeaders.push(
requestInit?.headers
? { ...(requestInit.headers as Record<string, string>) }
: undefined,
)
return jsonResponse({
code: 0,
message: 'ok',
data: { retried: true },
})
})
const { get, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
const data = await get('/protected')
expect(data).toEqual({ retried: true })
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(capturedHeaders[0]).toMatchObject({
Authorization: 'Bearer access-token-old',
})
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[1][1]).toMatchObject({
credentials: 'include',
method: 'POST',
body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
})
expect(capturedHeaders[1]).toMatchObject({
Authorization: 'Bearer access-token-retried',
})
expect(getRefreshToken()).toBe('refresh-token-retried')
})
it('reuses an in-flight refresh token when a 401 retry happens during another refresh', async () => {
const fetchMock = vi.mocked(fetch)
const {
get,
setAccessToken,
setRefreshPromise,
startRefreshing,
} = await loadModules()
fetchMock
.mockImplementationOnce(async () => {
startRefreshing()
setAccessToken('shared-refresh-token', 3600)
setRefreshPromise(Promise.resolve())
return new Response(null, { status: 401 })
})
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { retried: true },
}),
)
setAccessToken('access-token-old', 3600)
const data = await get('/protected')
expect(data).toEqual({ retried: true })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer shared-refresh-token',
})
})
it('fails the 401 retry when the shared refresh finishes without an access token', async () => {
const fetchMock = vi.mocked(fetch)
const {
clearAccessToken,
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshPromise,
setRefreshToken,
startRefreshing,
} = await loadModules()
fetchMock.mockImplementationOnce(async () => {
startRefreshing()
clearAccessToken()
setRefreshPromise(Promise.resolve())
return new Response(null, { status: 401 })
})
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('clears the local session when the retried request still returns 401', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-retried',
refresh_token: 'refresh-token-retried',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('maps 403 responses to forbidden errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }))
const { ErrorType, get } = await loadModules()
await expect(get('/forbidden', undefined, { auth: false })).rejects.toMatchObject({
status: 403,
type: ErrorType.FORBIDDEN,
})
})
it('maps 404 responses to not-found errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))
const { ErrorType, get } = await loadModules()
await expect(get('/missing', undefined, { auth: false })).rejects.toMatchObject({
status: 404,
type: ErrorType.NOT_FOUND,
})
})
it('maps other non-ok responses to network errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
const { ErrorType, get } = await loadModules()
await expect(get('/broken', undefined, { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
})
it('maps non-zero business responses to AppError.fromResponse', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 10001,
message: 'business failure',
data: null,
}),
)
const { ErrorType, get } = await loadModules()
await expect(get('/business', undefined, { auth: false })).rejects.toMatchObject({
code: 10001,
status: 200,
type: ErrorType.BUSINESS,
})
})
it('converts aborted requests into timeout AppErrors', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const { ErrorType, request } = await loadModules()
const requestPromise = expect(request('/slow', { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await vi.advanceTimersByTimeAsync(30_000)
await requestPromise
})
it('propagates a caller abort signal into the request timeout controller', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const controller = new AbortController()
const { ErrorType, request } = await loadModules()
const requestPromise = expect(
request('/slow', { auth: false, signal: controller.signal }),
).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await Promise.resolve()
controller.abort()
await requestPromise
})
it('retries downloads after a 401 and returns the blob payload', async () => {
const fetchMock = vi.mocked(fetch)
const downloadedBlob = { kind: 'downloaded-blob' } as unknown as Blob
const successResponse = {
ok: true,
status: 200,
blob: vi.fn().mockResolvedValue(downloadedBlob),
} as unknown as Response
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'download-access-token',
refresh_token: 'download-refresh-token',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(successResponse)
const { download, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
const blob = await download('/export')
expect(blob).toBe(downloadedBlob)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[2][1]?.headers).toMatchObject({
Authorization: 'Bearer download-access-token',
})
expect(getRefreshToken()).toBe('download-refresh-token')
})
it('maps failed downloads to network AppErrors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
const { ErrorType, download } = await loadModules()
await expect(download('/export', undefined, { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
})
it('clears the local session when a download retry still returns 401', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'download-access-token',
refresh_token: 'download-refresh-token',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
download,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(download('/export')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('converts aborted downloads into timeout AppErrors', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const { ErrorType, download } = await loadModules()
const downloadPromise = expect(
download('/export', undefined, { auth: false }),
).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await vi.advanceTimersByTimeAsync(30_000)
await downloadPromise
})
it('builds upload form data with additional fields', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { uploaded: true },
}),
)
const { upload } = await loadModules()
const file = new File(['demo'], 'avatar.png', { type: 'image/png' })
const result = await upload(
'/upload',
file,
'asset',
{ folder: 'avatars' },
{ auth: false },
)
expect(result).toEqual({ uploaded: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const requestInit = fetchMock.mock.calls[0][1]
const body = requestInit?.body as FormData
expect(requestInit?.method).toBe('POST')
expect(body.get('folder')).toBe('avatars')
expect(body.get('asset')).toBeInstanceOf(File)
expect((body.get('asset') as File).name).toBe('avatar.png')
})
})

View File

@@ -0,0 +1,367 @@
import { config } from '@/lib/config'
import { AppError, ErrorType } from '@/lib/errors'
import type { ApiResponse, RequestOptions } from '@/types'
import {
clearRefreshPromise,
clearSession,
endRefreshing,
getAccessToken,
getRefreshPromise,
isAccessTokenExpired,
isRefreshing,
setAccessToken,
setRefreshPromise,
startRefreshing,
} from './auth-session'
import { clearRefreshToken, getRefreshToken, setRefreshToken } from '../storage'
import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf'
import type { TokenBundle } from '@/types'
const DEFAULT_TIMEOUT = 30_000
function isFormDataBody(body: unknown): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData
}
function serializeBody(body: unknown): BodyInit | undefined {
if (body === undefined || body === null) {
return undefined
}
if (isFormDataBody(body)) {
return body
}
return JSON.stringify(body)
}
function resolveApiBaseUrl(): URL {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl)
? config.apiBaseUrl
: config.apiBaseUrl.startsWith('/')
? config.apiBaseUrl
: `/${config.apiBaseUrl}`
const baseUrl = new URL(rawBaseUrl, origin)
if (!baseUrl.pathname.endsWith('/')) {
baseUrl.pathname = `${baseUrl.pathname}/`
}
return baseUrl
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(path.replace(/^\/+/, ''), resolveApiBaseUrl())
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.append(key, String(value))
}
}
}
return url.toString()
}
function cleanupSessionOnAuthFailure(): never {
clearRefreshToken()
clearSession()
throw AppError.auth('会话已过期,请重新登录')
}
function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
if (signal) {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
return {
signal: controller.signal,
cleanup: () => window.clearTimeout(timeoutId),
}
}
async function parseJsonResponse<T>(response: Response): Promise<ApiResponse<T>> {
return response.json() as Promise<ApiResponse<T>>
}
async function refreshAccessToken(): Promise<TokenBundle> {
const refreshToken = getRefreshToken()
const body = refreshToken ? JSON.stringify({ refresh_token: refreshToken }) : undefined
const response = await fetch(buildUrl('/auth/refresh'), {
method: 'POST',
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body,
})
if (!response.ok) {
return cleanupSessionOnAuthFailure()
}
const result = await parseJsonResponse<TokenBundle>(response)
if (result.code !== 0) {
return cleanupSessionOnAuthFailure()
}
return result.data
}
async function performRefresh(): Promise<string> {
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {
await promise
}
const token = getAccessToken()
if (!token) {
return cleanupSessionOnAuthFailure()
}
return token
}
startRefreshing()
const promise = (async () => {
try {
const tokenBundle = await refreshAccessToken()
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
return tokenBundle.access_token
} finally {
endRefreshing()
clearRefreshPromise()
}
})()
setRefreshPromise(
promise.then(
() => undefined,
() => undefined,
),
)
return promise
}
async function resolveAuthorizationHeader(auth: boolean): Promise<string | null> {
if (!auth) {
return null
}
let token = getAccessToken()
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {
await promise
token = getAccessToken()
}
}
if (token && isAccessTokenExpired()) {
token = await performRefresh()
}
return token
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const {
method = 'GET',
headers = {},
body,
params,
auth = true,
credentials = 'include',
signal,
} = options
const url = buildUrl(path, params)
const requestHeaders: Record<string, string> = { ...headers }
if (body !== undefined && body !== null && !isFormDataBody(body) && !requestHeaders['Content-Type']) {
requestHeaders['Content-Type'] = 'application/json'
}
if (CSRF_PROTECTED_METHODS.includes(method)) {
Object.assign(requestHeaders, getCSRFHeaders())
}
const authToken = await resolveAuthorizationHeader(auth)
if (authToken) {
requestHeaders.Authorization = `Bearer ${authToken}`
}
const timeout = createTimeoutSignal(signal)
try {
let response = await fetch(url, {
method,
headers: requestHeaders,
body: serializeBody(body),
credentials,
signal: timeout.signal,
})
if (response.status === 401 && auth) {
const refreshedToken = await performRefresh()
requestHeaders.Authorization = `Bearer ${refreshedToken}`
response = await fetch(url, {
method,
headers: requestHeaders,
body: serializeBody(body),
credentials,
signal: timeout.signal,
})
}
if (response.status === 401) {
return cleanupSessionOnAuthFailure()
}
if (!response.ok) {
if (response.status === 403) {
throw AppError.forbidden()
}
if (response.status === 404) {
throw new AppError(404, '请求的资源不存在', {
status: 404,
type: ErrorType.NOT_FOUND,
})
}
throw AppError.network(`请求失败: ${response.status}`)
}
const result = await parseJsonResponse<T>(response)
if (result.code !== 0) {
throw AppError.fromResponse(result, response.status)
}
return result.data
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw AppError.network('请求超时,请稍后重试')
}
throw error
} finally {
timeout.cleanup()
}
}
export function get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
options?: Omit<RequestOptions, 'method' | 'params' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'GET', params })
}
export function post<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'POST', body })
}
export function put<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'PUT', body })
}
export function del<T>(
path: string,
options?: Omit<RequestOptions, 'method'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'DELETE' })
}
async function resolveAuthorizedHeaders(options?: Omit<RequestOptions, 'method' | 'params' | 'body'>): Promise<Record<string, string>> {
const headers: Record<string, string> = { ...(options?.headers ?? {}) }
if (options?.auth !== false) {
const token = await resolveAuthorizationHeader(true)
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
return headers
}
export async function download(
path: string,
params?: Record<string, string | number | boolean | undefined>,
options?: Omit<RequestOptions, 'method' | 'params'>,
): Promise<Blob> {
const url = buildUrl(path, params)
const headers = await resolveAuthorizedHeaders(options)
const timeout = createTimeoutSignal(options?.signal)
try {
let response = await fetch(url, {
headers,
credentials: options?.credentials ?? 'include',
signal: timeout.signal,
})
if (response.status === 401 && options?.auth !== false) {
const refreshedToken = await performRefresh()
headers.Authorization = `Bearer ${refreshedToken}`
response = await fetch(url, {
headers,
credentials: options?.credentials ?? 'include',
signal: timeout.signal,
})
}
if (response.status === 401) {
return cleanupSessionOnAuthFailure()
}
if (!response.ok) {
throw AppError.network(`下载失败: ${response.status}`)
}
return response.blob()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw AppError.network('下载超时,请稍后重试')
}
throw error
} finally {
timeout.cleanup()
}
}
export async function upload<T>(
path: string,
file: File,
fieldName: string = 'file',
additionalData?: Record<string, string>,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
const formData = new FormData()
formData.append(fieldName, file)
if (additionalData) {
for (const [key, value] of Object.entries(additionalData)) {
formData.append(key, value)
}
}
return request<T>(path, {
...options,
method: 'POST',
body: formData,
})
}
export { request }

View File

@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function jsonResponse(data: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
...init,
})
}
async function loadCsrfModule() {
vi.resetModules()
return import('./csrf')
}
function clearCsrfCookie() {
if (typeof document === 'undefined') {
return
}
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
}
describe('csrf helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
clearCsrfCookie()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
clearCsrfCookie()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
it('returns null when cookie lookup runs without a document', async () => {
vi.stubGlobal('document', undefined)
const { getCSRFTokenFromCookie, getCSRFHeaders } = await loadCsrfModule()
expect(getCSRFTokenFromCookie()).toBeNull()
expect(getCSRFHeaders()).toEqual({})
})
it('stores csrf tokens in memory and falls back to the cookie for headers', async () => {
const {
CSRF_HEADER_NAME,
clearCSRFToken,
getCSRFHeaders,
getCSRFToken,
setCSRFToken,
} = await loadCsrfModule()
setCSRFToken('memory-token')
expect(getCSRFToken()).toBe('memory-token')
expect(getCSRFHeaders()).toEqual({
[CSRF_HEADER_NAME]: 'memory-token',
})
clearCSRFToken()
document.cookie = 'csrftoken=cookie-token; path=/'
expect(getCSRFToken()).toBeNull()
expect(getCSRFHeaders()).toEqual({
[CSRF_HEADER_NAME]: 'cookie-token',
})
})
it('prefers an existing csrf cookie and skips the network bootstrap', async () => {
const fetchMock = vi.mocked(fetch)
document.cookie = 'csrftoken=cookie-token; path=/'
const { getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBe('cookie-token')
expect(getCSRFToken()).toBe('cookie-token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches and stores a csrf token from the default relative api base', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
data: {
csrf_token: 'api-token',
},
}),
)
const { getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBe('api-token')
expect(getCSRFToken()).toBe('api-token')
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/v1/auth/csrf-token`,
{
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
},
)
})
it('supports api base urls without a leading slash', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'api/custom')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
data: {
csrf_token: 'custom-token',
},
}),
)
const { initCSRFToken } = await loadCsrfModule()
await initCSRFToken()
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/custom/auth/csrf-token`,
expect.any(Object),
)
})
it('supports absolute api base urls', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
data: {
csrf_token: 'absolute-token',
},
}),
)
const { initCSRFToken } = await loadCsrfModule()
await initCSRFToken()
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/base/auth/csrf-token',
expect.any(Object),
)
})
it('falls back to a cookie exposed after the csrf bootstrap request fails', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockRejectedValueOnce(new Error('network failed'))
const cookieSpy = vi
.spyOn(document, 'cookie', 'get')
.mockReturnValueOnce('')
.mockReturnValueOnce('csrftoken=fallback-token')
const { getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBe('fallback-token')
expect(getCSRFToken()).toBe('fallback-token')
expect(fetchMock).toHaveBeenCalledTimes(1)
cookieSpy.mockRestore()
})
it('returns null when the bootstrap response does not contain a csrf token', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 1,
data: {},
}),
)
const { getCSRFHeaders, getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBeNull()
expect(getCSRFToken()).toBeNull()
expect(getCSRFHeaders()).toEqual({})
})
})

View File

@@ -0,0 +1,145 @@
/**
* CSRF Token 管理
*
* CSRF 保护机制:
* 1. GET 请求获取 CSRF Token从 cookie 或 API
* 2. POST/PUT/DELETE 请求将 Token 添加到 X-CSRF-Token 头
*
* 注意:由于使用 Bearer Token 认证(存储在内存中),
* CSRF 风险相对较低,但为增强安全性仍建议对关键操作启用。
*/
// 注意:避免从 './client' 导入,防止循环依赖
// 使用原生 fetch 获取 CSRF Token
import { config } from '@/lib/config'
// CSRF Token 存储
let csrfToken: string | null = null
/**
* 获取 CSRF Token
*/
export function getCSRFToken(): string | null {
return csrfToken
}
/**
* 设置 CSRF Token
*/
export function setCSRFToken(token: string): void {
csrfToken = token
}
/**
* 从 cookie 中读取 CSRF Token
* Django/Laravel 等框架通常在 cookie 中设置 csrftoken
*/
export function getCSRFTokenFromCookie(): string | null {
if (typeof document === 'undefined') {
return null
}
const match = document.cookie.match(/csrftoken=([^;]+)/)
return match ? match[1] : null
}
/**
* 解析 API 基础 URL
* 注意:此函数复制自 client.ts 以避免循环依赖
*/
function resolveApiBaseUrl(): URL {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl)
? config.apiBaseUrl
: config.apiBaseUrl.startsWith('/')
? config.apiBaseUrl
: `/${config.apiBaseUrl}`
const baseUrl = new URL(rawBaseUrl, origin)
if (!baseUrl.pathname.endsWith('/')) {
baseUrl.pathname = `${baseUrl.pathname}/`
}
return baseUrl
}
/**
* 构建完整 URL
*/
function buildUrl(path: string): string {
const normalizedPath = path.replace(/^\/+/, '')
const url = new URL(normalizedPath, resolveApiBaseUrl())
return url.toString()
}
/**
* 初始化 CSRF Token
* 从 cookie 或 API 获取 Token 并存储
*/
export async function initCSRFToken(): Promise<string | null> {
// 优先从 cookie 获取
let token = getCSRFTokenFromCookie()
if (!token) {
try {
// 使用原生 fetch 避免循环依赖
const response = await fetch(buildUrl('/auth/csrf-token'), {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
const result = await response.json()
// 后端返回字段名为 csrf_token
if (result.code === 0 && result.data?.csrf_token) {
token = result.data.csrf_token
}
}
} catch {
// API 不支持,使用 cookie 中的 token如果有
token = getCSRFTokenFromCookie()
}
}
if (token) {
setCSRFToken(token)
}
return token
}
/**
* 清除 CSRF Token登出时调用
*/
export function clearCSRFToken(): void {
csrfToken = null
}
/**
* CSRF Token 头名称
*/
export const CSRF_HEADER_NAME = 'X-CSRF-Token'
/**
* 获取带 CSRF Token 的请求头
* 用于 POST/PUT/DELETE 请求
*/
export function getCSRFHeaders(): Record<string, string> {
const token = csrfToken || getCSRFTokenFromCookie()
if (!token) {
return {}
}
return {
[CSRF_HEADER_NAME]: token
}
}
/**
* 需要 CSRF 保护的方法列表
*/
export const CSRF_PROTECTED_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']

View File

@@ -0,0 +1,32 @@
export {
get,
post,
put,
del,
download,
upload,
request,
} from './client'
export {
getAccessToken,
setAccessToken,
clearAccessToken,
isAccessTokenExpired,
getCurrentUser,
setCurrentUser,
getCurrentRoles,
setCurrentRoles,
isAdmin,
getRoleCodes,
isAuthenticated,
clearSession,
isRefreshing,
startRefreshing,
endRefreshing,
getRefreshPromise,
setRefreshPromise,
clearRefreshPromise,
} from './auth-session'
export { AppError, ErrorType, isAppError } from '@/lib/errors'

View File

@@ -0,0 +1,4 @@
export * from './config'
export * from './errors'
export * from './http'
export * from './storage'

View File

@@ -0,0 +1,7 @@
export {
getRefreshToken,
setRefreshToken,
clearRefreshToken,
hasRefreshToken,
hasSessionPresenceCookie,
} from './token-storage'

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearRefreshToken,
getRefreshToken,
hasRefreshToken,
hasSessionPresenceCookie,
setRefreshToken,
} from './token-storage'
const originalDocument = globalThis.document
describe('token-storage', () => {
afterEach(() => {
clearRefreshToken()
vi.restoreAllMocks()
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: originalDocument,
})
})
it('stores refresh tokens in memory and normalizes empty values to null', () => {
setRefreshToken(' refresh-token ')
expect(getRefreshToken()).toBe('refresh-token')
expect(hasRefreshToken()).toBe(true)
setRefreshToken(' ')
expect(getRefreshToken()).toBeNull()
expect(hasRefreshToken()).toBe(false)
setRefreshToken(undefined)
expect(getRefreshToken()).toBeNull()
})
it('clears the in-memory refresh token explicitly', () => {
setRefreshToken('token-to-clear')
expect(hasRefreshToken()).toBe(true)
clearRefreshToken()
expect(getRefreshToken()).toBeNull()
expect(hasRefreshToken()).toBe(false)
})
it('detects the session presence cookie when it is present among other cookies', () => {
vi.spyOn(document, 'cookie', 'get').mockReturnValue('foo=bar; ums_session_present=1; theme=dark')
expect(hasSessionPresenceCookie()).toBe(true)
})
it('returns false when the session presence cookie is absent', () => {
vi.spyOn(document, 'cookie', 'get').mockReturnValue('foo=bar; theme=dark')
expect(hasSessionPresenceCookie()).toBe(false)
})
it('returns false when document is unavailable', () => {
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: undefined,
})
expect(hasSessionPresenceCookie()).toBe(false)
})
})

View File

@@ -0,0 +1,38 @@
/**
* In-memory refresh token storage.
*
* The authoritative session continuity mechanism is now the backend-managed
* HttpOnly refresh cookie. This module only keeps a process-local copy so the
* current tab can still send an explicit logout payload when available.
*/
let refreshToken: string | null = null
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
export function getRefreshToken(): string | null {
return refreshToken
}
export function setRefreshToken(token: string | null | undefined): void {
const value = (token || '').trim()
refreshToken = value || null
}
export function clearRefreshToken(): void {
refreshToken = null
}
export function hasRefreshToken(): boolean {
return refreshToken !== null
}
export function hasSessionPresenceCookie(): boolean {
if (typeof document === 'undefined') {
return false
}
return document.cookie
.split(';')
.map((cookie) => cookie.trim())
.some((cookie) => cookie.startsWith(`${SESSION_PRESENCE_COOKIE_NAME}=`))
}

View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { installWindowGuards } from '@/app/bootstrap/installWindowGuards'
import { ThemeProvider } from '@/app/providers/ThemeProvider'
// 使用 @/ 别名导入 App
import App from '@/app/App'
// 全局样式
import '@/styles/global.css'
installWindowGuards()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { NotFoundPage } from './NotFoundPage'
const navigateMock = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
describe('NotFoundPage', () => {
it('renders the 404 state and routes users back to the dashboard', async () => {
const user = userEvent.setup()
render(<NotFoundPage />)
expect(screen.getByText('404')).toBeInTheDocument()
expect(screen.getByText('抱歉,您访问的页面不存在')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '返回首页' }))
expect(navigateMock).toHaveBeenCalledWith('/dashboard')
})
})

View File

@@ -0,0 +1,31 @@
/**
* 404 页面
*/
import { Result, Button } from 'antd'
import { useNavigate } from 'react-router-dom'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-canvas)',
}}>
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在"
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
</Button>
}
/>
</div>
)
}

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