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