feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
2
frontend/admin/.env.development
Normal file
2
frontend/admin/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
5
frontend/admin/.env.example
Normal file
5
frontend/admin/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Admin Frontend 环境变量配置示例
|
||||
# 复制此文件为 .env.local 进行本地开发配置
|
||||
|
||||
# API 基础地址
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
3
frontend/admin/.env.production
Normal file
3
frontend/admin/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# 生产环境配置
|
||||
# 部署时根据实际后端地址修改
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
24
frontend/admin/.gitignore
vendored
Normal file
24
frontend/admin/.gitignore
vendored
Normal 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
73
frontend/admin/README.md
Normal 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/admin/eslint.config.js
Normal file
23
frontend/admin/eslint.config.js
Normal 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
13
frontend/admin/index.html
Normal 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
5451
frontend/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
frontend/admin/package.json
Normal file
61
frontend/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/admin/public/favicon.svg
Normal file
1
frontend/admin/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/admin/public/icons.svg
Normal file
24
frontend/admin/public/icons.svg
Normal 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 |
185
frontend/admin/scripts/mock-smtp-capture.mjs
Normal file
185
frontend/admin/scripts/mock-smtp-capture.mjs
Normal 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))
|
||||
})
|
||||
316
frontend/admin/scripts/run-cdp-auth-smoke.ps1
Normal file
316
frontend/admin/scripts/run-cdp-auth-smoke.ps1
Normal 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
|
||||
}
|
||||
205
frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1
Normal file
205
frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1
Normal 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
|
||||
}
|
||||
1624
frontend/admin/scripts/run-cdp-smoke.mjs
Normal file
1624
frontend/admin/scripts/run-cdp-smoke.mjs
Normal file
File diff suppressed because it is too large
Load Diff
397
frontend/admin/scripts/run-cdp-smoke.ps1
Normal file
397
frontend/admin/scripts/run-cdp-smoke.ps1
Normal 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
|
||||
}
|
||||
297
frontend/admin/scripts/run-playwright-auth-e2e.ps1
Normal file
297
frontend/admin/scripts/run-playwright-auth-e2e.ps1
Normal 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
|
||||
}
|
||||
1176
frontend/admin/scripts/run-playwright-cdp-e2e.mjs
Normal file
1176
frontend/admin/scripts/run-playwright-cdp-e2e.mjs
Normal file
File diff suppressed because it is too large
Load Diff
79
frontend/admin/scripts/run-vitest.mjs
Normal file
79
frontend/admin/scripts/run-vitest.mjs
Normal 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()
|
||||
}
|
||||
40
frontend/admin/src/app/App.test.tsx
Normal file
40
frontend/admin/src/app/App.test.tsx
Normal 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),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
38
frontend/admin/src/app/App.tsx
Normal file
38
frontend/admin/src/app/App.tsx
Normal 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
|
||||
27
frontend/admin/src/app/RootLayout.test.tsx
Normal file
27
frontend/admin/src/app/RootLayout.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
10
frontend/admin/src/app/RootLayout.tsx
Normal file
10
frontend/admin/src/app/RootLayout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AuthProvider } from './providers/AuthProvider'
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Outlet />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
67
frontend/admin/src/app/bootstrap/installWindowGuards.test.ts
Normal file
67
frontend/admin/src/app/bootstrap/installWindowGuards.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
144
frontend/admin/src/app/bootstrap/installWindowGuards.ts
Normal file
144
frontend/admin/src/app/bootstrap/installWindowGuards.ts
Normal 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
|
||||
}
|
||||
442
frontend/admin/src/app/providers/AuthProvider.test.tsx
Normal file
442
frontend/admin/src/app/providers/AuthProvider.test.tsx
Normal 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('')
|
||||
})
|
||||
})
|
||||
201
frontend/admin/src/app/providers/AuthProvider.tsx
Normal file
201
frontend/admin/src/app/providers/AuthProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/admin/src/app/providers/ThemeProvider.test.tsx
Normal file
65
frontend/admin/src/app/providers/ThemeProvider.test.tsx
Normal 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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
89
frontend/admin/src/app/providers/ThemeProvider.tsx
Normal file
89
frontend/admin/src/app/providers/ThemeProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/admin/src/app/providers/auth-context.ts
Normal file
24
frontend/admin/src/app/providers/auth-context.ts
Normal 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
|
||||
}
|
||||
210
frontend/admin/src/app/router.test.tsx
Normal file
210
frontend/admin/src/app/router.test.tsx
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
223
frontend/admin/src/app/router.tsx
Normal file
223
frontend/admin/src/app/router.tsx
Normal 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),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
BIN
frontend/admin/src/assets/hero.png
Normal file
BIN
frontend/admin/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/admin/src/assets/react.svg
Normal file
1
frontend/admin/src/assets/react.svg
Normal 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 |
1
frontend/admin/src/assets/vite.svg
Normal file
1
frontend/admin/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/components/common/PageHeader/index.ts
Normal file
1
frontend/admin/src/components/common/PageHeader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PageHeader } from './PageHeader'
|
||||
2
frontend/admin/src/components/common/index.ts
Normal file
2
frontend/admin/src/components/common/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
export { PageHeader } from './PageHeader'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
113
frontend/admin/src/components/feedback/PageState/PageState.tsx
Normal file
113
frontend/admin/src/components/feedback/PageState/PageState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PageLoading, PageEmpty, PageError } from './PageState'
|
||||
1
frontend/admin/src/components/feedback/index.ts
Normal file
1
frontend/admin/src/components/feedback/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PageLoading, PageEmpty, PageError } from './PageState'
|
||||
30
frontend/admin/src/components/guards/RequireAdmin.tsx
Normal file
30
frontend/admin/src/components/guards/RequireAdmin.tsx
Normal 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
|
||||
}
|
||||
40
frontend/admin/src/components/guards/RequireAuth.tsx
Normal file
40
frontend/admin/src/components/guards/RequireAuth.tsx
Normal 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
|
||||
}
|
||||
188
frontend/admin/src/components/guards/guards.test.tsx
Normal file
188
frontend/admin/src/components/guards/guards.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
2
frontend/admin/src/components/guards/index.ts
Normal file
2
frontend/admin/src/components/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RequireAuth } from './RequireAuth'
|
||||
export { RequireAdmin } from './RequireAdmin'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
23
frontend/admin/src/components/layout/PageLayout/TreeCard.tsx
Normal file
23
frontend/admin/src/components/layout/PageLayout/TreeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/admin/src/components/layout/PageLayout/index.ts
Normal file
9
frontend/admin/src/components/layout/PageLayout/index.ts
Normal 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'
|
||||
11
frontend/admin/src/components/layout/index.ts
Normal file
11
frontend/admin/src/components/layout/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 布局组件导出
|
||||
*/
|
||||
|
||||
export {
|
||||
PageLayout,
|
||||
FilterCard,
|
||||
TableCard,
|
||||
TreeCard,
|
||||
ContentCard,
|
||||
} from './PageLayout'
|
||||
3
frontend/admin/src/features/README.md
Normal file
3
frontend/admin/src/features/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
`src/features` 保留为业务复用层目录。
|
||||
|
||||
当前已补齐目录骨架,后续需要将页面内可复用的业务交互逐步下沉到对应子目录。
|
||||
1
frontend/admin/src/features/auth/.gitkeep
Normal file
1
frontend/admin/src/features/auth/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/devices/.gitkeep
Normal file
1
frontend/admin/src/features/devices/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/import-export/.gitkeep
Normal file
1
frontend/admin/src/features/import-export/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/permissions/.gitkeep
Normal file
1
frontend/admin/src/features/permissions/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/profile/.gitkeep
Normal file
1
frontend/admin/src/features/profile/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/roles/.gitkeep
Normal file
1
frontend/admin/src/features/roles/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/totp/.gitkeep
Normal file
1
frontend/admin/src/features/totp/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/users/.gitkeep
Normal file
1
frontend/admin/src/features/users/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
frontend/admin/src/features/webhooks/.gitkeep
Normal file
1
frontend/admin/src/features/webhooks/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
111
frontend/admin/src/index.css
Normal file
111
frontend/admin/src/index.css
Normal 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);
|
||||
}
|
||||
209
frontend/admin/src/layouts/AdminLayout/AdminLayout.module.css
Normal file
209
frontend/admin/src/layouts/AdminLayout/AdminLayout.module.css
Normal 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;
|
||||
}
|
||||
469
frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx
Normal file
469
frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx
Normal 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('审计日志')
|
||||
})
|
||||
})
|
||||
329
frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx
Normal file
329
frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/layouts/AdminLayout/index.ts
Normal file
1
frontend/admin/src/layouts/AdminLayout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AdminLayout } from './AdminLayout'
|
||||
105
frontend/admin/src/layouts/AuthLayout/AuthLayout.module.css
Normal file
105
frontend/admin/src/layouts/AuthLayout/AuthLayout.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
42
frontend/admin/src/layouts/AuthLayout/AuthLayout.tsx
Normal file
42
frontend/admin/src/layouts/AuthLayout/AuthLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/layouts/AuthLayout/index.ts
Normal file
1
frontend/admin/src/layouts/AuthLayout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AuthLayout } from './AuthLayout'
|
||||
2
frontend/admin/src/layouts/index.ts
Normal file
2
frontend/admin/src/layouts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AuthLayout } from './AuthLayout'
|
||||
export { AdminLayout } from './AdminLayout'
|
||||
29
frontend/admin/src/lib/auth/oauth.test.ts
Normal file
29
frontend/admin/src/lib/auth/oauth.test.ts
Normal 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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
27
frontend/admin/src/lib/auth/oauth.ts
Normal file
27
frontend/admin/src/lib/auth/oauth.ts
Normal 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') || '',
|
||||
}
|
||||
}
|
||||
11
frontend/admin/src/lib/config.ts
Normal file
11
frontend/admin/src/lib/config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 应用配置
|
||||
* 从环境变量中读取配置项
|
||||
*/
|
||||
|
||||
export const config = {
|
||||
/**
|
||||
* API 基础地址
|
||||
*/
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api/v1',
|
||||
} as const
|
||||
126
frontend/admin/src/lib/errors/AppError.test.ts
Normal file
126
frontend/admin/src/lib/errors/AppError.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
172
frontend/admin/src/lib/errors/AppError.ts
Normal file
172
frontend/admin/src/lib/errors/AppError.ts
Normal 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
|
||||
}
|
||||
26
frontend/admin/src/lib/errors/index.ts
Normal file
26
frontend/admin/src/lib/errors/index.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
82
frontend/admin/src/lib/hooks/useBreadcrumbs.test.tsx
Normal file
82
frontend/admin/src/lib/hooks/useBreadcrumbs.test.tsx
Normal 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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
48
frontend/admin/src/lib/hooks/useBreadcrumbs.ts
Normal file
48
frontend/admin/src/lib/hooks/useBreadcrumbs.ts
Normal 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])
|
||||
}
|
||||
83
frontend/admin/src/lib/http/auth-session.test.ts
Normal file
83
frontend/admin/src/lib/http/auth-session.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
101
frontend/admin/src/lib/http/auth-session.ts
Normal file
101
frontend/admin/src/lib/http/auth-session.ts
Normal 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
|
||||
}
|
||||
785
frontend/admin/src/lib/http/client.test.ts
Normal file
785
frontend/admin/src/lib/http/client.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
367
frontend/admin/src/lib/http/client.ts
Normal file
367
frontend/admin/src/lib/http/client.ts
Normal 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 }
|
||||
192
frontend/admin/src/lib/http/csrf.test.ts
Normal file
192
frontend/admin/src/lib/http/csrf.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
145
frontend/admin/src/lib/http/csrf.ts
Normal file
145
frontend/admin/src/lib/http/csrf.ts
Normal 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']
|
||||
32
frontend/admin/src/lib/http/index.ts
Normal file
32
frontend/admin/src/lib/http/index.ts
Normal 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'
|
||||
4
frontend/admin/src/lib/index.ts
Normal file
4
frontend/admin/src/lib/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './config'
|
||||
export * from './errors'
|
||||
export * from './http'
|
||||
export * from './storage'
|
||||
7
frontend/admin/src/lib/storage/index.ts
Normal file
7
frontend/admin/src/lib/storage/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
clearRefreshToken,
|
||||
hasRefreshToken,
|
||||
hasSessionPresenceCookie,
|
||||
} from './token-storage'
|
||||
68
frontend/admin/src/lib/storage/token-storage.test.ts
Normal file
68
frontend/admin/src/lib/storage/token-storage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
38
frontend/admin/src/lib/storage/token-storage.ts
Normal file
38
frontend/admin/src/lib/storage/token-storage.ts
Normal 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}=`))
|
||||
}
|
||||
18
frontend/admin/src/main.tsx
Normal file
18
frontend/admin/src/main.tsx
Normal 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>,
|
||||
)
|
||||
30
frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx
Normal file
30
frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
31
frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx
Normal file
31
frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx
Normal 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
Reference in New Issue
Block a user