feat: add admin layout, auth, user management, and routing
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
da9c0e8a41
commit
4dad572731
136
README.md
136
README.md
@ -1,73 +1,81 @@
|
|||||||
# React + TypeScript + Vite
|
# 知习 Admin
|
||||||
|
|
||||||
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)
|
- React 19 + TypeScript 6
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
- Vite 8
|
||||||
|
- Ant Design 5 + @ant-design/pro-components 2.8
|
||||||
|
- @tanstack/react-query 5
|
||||||
|
- ECharts 5 + echarts-for-react 3
|
||||||
|
- dayjs
|
||||||
|
- react-router-dom v7
|
||||||
|
|
||||||
## 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).
|
```
|
||||||
|
src/
|
||||||
## Expanding the ESLint configuration
|
├── components/ # 共享组件
|
||||||
|
│ ├── AuditLogTable # 审计日志 ProTable
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
│ ├── ConfirmDangerModal # 高危操作确认弹窗
|
||||||
|
│ ├── DetailDrawer # 侧滑详情抽屉
|
||||||
```js
|
│ ├── EChartsChartContainer # 图表容器
|
||||||
export default defineConfig([
|
│ ├── EmptyState # 空状态占位
|
||||||
globalIgnores(['dist']),
|
│ ├── MetricCard # 指标卡
|
||||||
{
|
│ ├── StatusTag # 状态标签
|
||||||
files: ['**/*.{ts,tsx}'],
|
│ ├── AuthGuard # 登录鉴权守卫
|
||||||
extends: [
|
│ ├── PageLoading # 路由懒加载 Spin
|
||||||
// Other configs...
|
│ └── PermissionGuard # 角色权限守卫
|
||||||
|
├── config/
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
│ └── menu.tsx # 菜单树与角色过滤
|
||||||
tseslint.configs.recommendedTypeChecked,
|
├── constants/
|
||||||
// Alternatively, use this for stricter rules
|
│ └── roles.ts # 角色标签/颜色/层级
|
||||||
tseslint.configs.strictTypeChecked,
|
├── contexts/
|
||||||
// Optionally, add this for stylistic rules
|
│ └── AuthContext.tsx # 认证上下文
|
||||||
tseslint.configs.stylisticTypeChecked,
|
├── hooks/
|
||||||
|
│ └── use-auth-query.ts # React Query 认证 hooks
|
||||||
// Other configs...
|
├── layouts/
|
||||||
],
|
│ └── AdminLayout.tsx # 管理后台布局(侧边栏/顶栏/面包屑)
|
||||||
languageOptions: {
|
├── pages/
|
||||||
parserOptions: {
|
│ ├── Dashboard # 数据看板(指标卡 + 图表 + 审计日志)
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
│ ├── UserManagement # 管理员 CRUD
|
||||||
tsconfigRootDir: import.meta.dirname,
|
│ ├── Login # 登录页
|
||||||
},
|
│ ├── Placeholder # 占位页
|
||||||
// other options...
|
│ ├── 403/404/500 # 错误页
|
||||||
},
|
├── routes/
|
||||||
},
|
│ └── index.tsx # 路由配置(懒加载 + 角色要求)
|
||||||
])
|
├── services/
|
||||||
|
│ ├── admin-api.ts # 类型化 API 函数
|
||||||
|
│ ├── http-client.ts # Fetch 封装(401 自动刷新队列)
|
||||||
|
│ ├── token-store.ts # Token 本地存储
|
||||||
|
│ └── mock-data.ts # DEV 模式 Mock 数据
|
||||||
|
└── types/
|
||||||
|
├── admin.ts # AdminUser, DashboardStats, AuditLog 等
|
||||||
|
└── api.ts # PaginatedResult, ApiError
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
// eslint.config.js
|
npm install
|
||||||
import reactX from 'eslint-plugin-react-x'
|
npm run dev # 开发模式,默认 http://localhost:5173
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
npm run build # 生产构建
|
||||||
|
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 认证流程
|
||||||
|
|
||||||
|
1. `AuthGuard` 检查登录状态,未登录跳转 `/login`
|
||||||
|
2. `PermissionGuard` 检查角色权限,不足跳转 `/403`
|
||||||
|
3. HTTP 客户端自动处理 401 → refresh token → 重试请求
|
||||||
|
4. refresh token 失败时强制退出到 `/login`
|
||||||
|
|
||||||
|
## 角色体系
|
||||||
|
|
||||||
|
| 角色 | 说明 | 权限范围 |
|
||||||
|
|------|------|---------|
|
||||||
|
| SUPER_ADMIN | 超级管理员 | 全部权限,可管理其他管理员 |
|
||||||
|
| ADMIN | 管理员 | 用户管理、仪表盘、审计日志 |
|
||||||
|
| OPERATIONS | 运营人员 | 仪表盘、审计日志(只读) |
|
||||||
|
| DEVELOPER | 开发者 | 仪表盘(只读) |
|
||||||
|
| READONLY | 只读用户 | 仪表盘(只读) |
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin-projects-tmp</title>
|
<title>知习 Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
2434
package-lock.json
generated
2434
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -10,11 +10,20 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.2.3",
|
||||||
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
|
"@tanstack/react-query": "^5.100.11",
|
||||||
|
"antd": "^5.29.3",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@ -23,6 +32,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12"
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
@import "tailwindcss";
|
/* App-level styles */
|
||||||
|
|||||||
92
src/App.tsx
92
src/App.tsx
@ -1,10 +1,21 @@
|
|||||||
|
import { Suspense, lazy } from 'react'
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import MainLayout from './layouts/MainLayout'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import Dashboard from './pages/Dashboard'
|
import AuthGuard from './components/AuthGuard'
|
||||||
import './App.css'
|
import PermissionGuard from './components/PermissionGuard'
|
||||||
|
import PageLoading from './components/PageLoading'
|
||||||
|
import AdminLayout from './layouts/AdminLayout'
|
||||||
|
|
||||||
|
const Login = lazy(() => import('./pages/Login'))
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||||
|
const UserManagement = lazy(() => import('./pages/UserManagement'))
|
||||||
|
const Placeholder = lazy(() => import('./pages/Placeholder'))
|
||||||
|
const ForbiddenPage = lazy(() => import('./pages/403'))
|
||||||
|
const NotFoundPage = lazy(() => import('./pages/404'))
|
||||||
|
const ServerErrorPage = lazy(() => import('./pages/500'))
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
@ -12,13 +23,74 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route path="/" element={<MainLayout />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Routes>
|
||||||
</Route>
|
<Route path="/login" element={<Login />} />
|
||||||
</Routes>
|
<Route path="/403" element={<ForbiddenPage />} />
|
||||||
</BrowserRouter>
|
<Route path="/500" element={<ServerErrorPage />} />
|
||||||
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<AuthGuard>
|
||||||
|
<AdminLayout />
|
||||||
|
</AuthGuard>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route
|
||||||
|
path="users"
|
||||||
|
element={
|
||||||
|
<PermissionGuard requiredRole="ADMIN">
|
||||||
|
<UserManagement />
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="users/admins"
|
||||||
|
element={
|
||||||
|
<PermissionGuard requiredRole="SUPER_ADMIN">
|
||||||
|
<UserManagement />
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="users/members" element={<Placeholder title="普通用户" />} />
|
||||||
|
<Route
|
||||||
|
path="membership"
|
||||||
|
element={
|
||||||
|
<PermissionGuard requiredRole="ADMIN">
|
||||||
|
<Placeholder title="会员与额度" />
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="knowledge/bases" element={<Placeholder title="知识库列表" />} />
|
||||||
|
<Route path="knowledge/sources" element={<Placeholder title="知识源列表" />} />
|
||||||
|
<Route path="imports" element={<Placeholder title="文档导入" />} />
|
||||||
|
<Route path="ai-costs" element={<Placeholder title="AI 调用与成本" />} />
|
||||||
|
<Route path="files" element={<Placeholder title="文件与 COS" />} />
|
||||||
|
<Route
|
||||||
|
path="settings"
|
||||||
|
element={
|
||||||
|
<PermissionGuard requiredRole="ADMIN">
|
||||||
|
<Placeholder title="系统配置" />
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="audit"
|
||||||
|
element={
|
||||||
|
<PermissionGuard requiredRole="ADMIN">
|
||||||
|
<Placeholder title="审计日志" />
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
180
src/components/AuditLogTable.tsx
Normal file
180
src/components/AuditLogTable.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { Tag, Tooltip } from 'antd'
|
||||||
|
import { EyeOutlined } from '@ant-design/icons'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { AuditLog } from '@/types/admin'
|
||||||
|
import DetailDrawer from './DetailDrawer'
|
||||||
|
|
||||||
|
interface AuditLogTableProps {
|
||||||
|
dataSource: AuditLog[]
|
||||||
|
loading?: boolean
|
||||||
|
pagination?: { current: number; pageSize: number; total: number }
|
||||||
|
onPageChange?: (page: number, pageSize: number) => void
|
||||||
|
headerTitle?: string
|
||||||
|
toolbarActions?: React.ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
LOGOUT: '退出',
|
||||||
|
LOGIN_FAILED: '登录失败',
|
||||||
|
CREATE_ADMIN: '创建管理员',
|
||||||
|
UPDATE_USER_STATUS: '更新用户状态',
|
||||||
|
DELETE_FILE: '删除文件',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
LOGIN: 'green',
|
||||||
|
LOGOUT: 'default',
|
||||||
|
LOGIN_FAILED: 'red',
|
||||||
|
CREATE_ADMIN: 'blue',
|
||||||
|
UPDATE_USER_STATUS: 'orange',
|
||||||
|
DELETE_FILE: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogTable({
|
||||||
|
dataSource,
|
||||||
|
loading = false,
|
||||||
|
pagination,
|
||||||
|
onPageChange,
|
||||||
|
headerTitle,
|
||||||
|
toolbarActions,
|
||||||
|
}: AuditLogTableProps) {
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null)
|
||||||
|
|
||||||
|
const columns: ProColumns<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tooltip title={dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')}>
|
||||||
|
{dayjs(record.createdAt).format('MM-DD HH:mm')}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作者',
|
||||||
|
dataIndex: 'adminUserDisplayName',
|
||||||
|
width: 120,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => record.adminUserDisplayName || record.adminUserEmail || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 110,
|
||||||
|
render: (_, record) => {
|
||||||
|
const label = actionLabels[record.action] || record.action
|
||||||
|
return (
|
||||||
|
<Tag color={actionColors[record.action] || 'default'}>{label}</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '资源',
|
||||||
|
dataIndex: 'resourceType',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
if (!record.resourceType) return '-'
|
||||||
|
const label = resourceTypeLabels[record.resourceType] || record.resourceType
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{record.resourceId && (
|
||||||
|
<span style={{ color: '#999', fontSize: 12, marginLeft: 4 }}>
|
||||||
|
({record.resourceId.length > 10 ? record.resourceId.slice(0, 10) + '...' : record.resourceId})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
search: false,
|
||||||
|
render: (_, record) => record.ip || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
width: 55,
|
||||||
|
render: (_, record) => [
|
||||||
|
<Tooltip key="view" title="查看详情">
|
||||||
|
<a onClick={() => { setSelectedLog(record); setDetailOpen(true) }}>
|
||||||
|
<EyeOutlined />
|
||||||
|
</a>
|
||||||
|
</Tooltip>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<AuditLog>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
options={false}
|
||||||
|
headerTitle={headerTitle}
|
||||||
|
toolbar={{ actions: toolbarActions }}
|
||||||
|
pagination={
|
||||||
|
pagination
|
||||||
|
? {
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailDrawer
|
||||||
|
open={detailOpen}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
title="审计日志详情"
|
||||||
|
>
|
||||||
|
{selectedLog && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<DetailItem label="时间" value={dayjs(selectedLog.createdAt).format('YYYY-MM-DD HH:mm:ss')} />
|
||||||
|
<DetailItem label="操作者" value={selectedLog.adminUserDisplayName || selectedLog.adminUserEmail || '-'} />
|
||||||
|
<DetailItem label="操作类型" value={actionLabels[selectedLog.action] || selectedLog.action} />
|
||||||
|
<DetailItem label="资源类型" value={selectedLog.resourceType || '-'} />
|
||||||
|
<DetailItem label="资源 ID" value={selectedLog.resourceId || '-'} />
|
||||||
|
<DetailItem label="变更前" value={selectedLog.beforeJson ? JSON.stringify(selectedLog.beforeJson, null, 2) : '-'} />
|
||||||
|
<DetailItem label="变更后" value={selectedLog.afterJson ? JSON.stringify(selectedLog.afterJson, null, 2) : '-'} />
|
||||||
|
<DetailItem label="IP 地址" value={selectedLog.ip || '-'} />
|
||||||
|
<DetailItem label="User Agent" value={selectedLog.userAgent || '-'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DetailDrawer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceTypeLabels: Record<string, string> = {
|
||||||
|
AdminUser: '管理员',
|
||||||
|
User: '用户',
|
||||||
|
UploadedFile: '文件',
|
||||||
|
KnowledgeBase: '知识库',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 14, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/AuthGuard.tsx
Normal file
23
src/components/AuthGuard.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
const redirect = location.pathname === '/login' ? '/' : location.pathname + location.search
|
||||||
|
return <Navigate to={`/login?redirect=${encodeURIComponent(redirect)}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
75
src/components/ConfirmDangerModal.tsx
Normal file
75
src/components/ConfirmDangerModal.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Modal, Input, Typography } from 'antd'
|
||||||
|
|
||||||
|
interface ConfirmDangerModalProps {
|
||||||
|
open: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
targetName: string
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDangerModal({
|
||||||
|
open,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
targetName,
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmDangerModalProps) {
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
if (inputValue === targetName) {
|
||||||
|
onConfirm()
|
||||||
|
setInputValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setInputValue('')
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
title={title}
|
||||||
|
okText="确认删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{
|
||||||
|
danger: true,
|
||||||
|
disabled: inputValue !== targetName,
|
||||||
|
loading,
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
{description || `此操作不可撤销。请输入要删除的对象名称确认:`}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#fff1f0',
|
||||||
|
border: '1px solid #ffa39e',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{targetName}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={`请输入 "${targetName}" 确认`}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/components/DetailDrawer.tsx
Normal file
38
src/components/DetailDrawer.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Drawer, Spin } from 'antd'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface DetailDrawerProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
loading?: boolean
|
||||||
|
width?: number
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
loading = false,
|
||||||
|
width = 560,
|
||||||
|
children,
|
||||||
|
}: DetailDrawerProps) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
width={width}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '120px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/components/EChartsChartContainer.tsx
Normal file
38
src/components/EChartsChartContainer.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Card, Spin, Empty } from 'antd'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface EChartsChartContainerProps {
|
||||||
|
title: string
|
||||||
|
loading?: boolean
|
||||||
|
isEmpty?: boolean
|
||||||
|
emptyDescription?: string
|
||||||
|
extra?: ReactNode
|
||||||
|
style?: React.CSSProperties
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EChartsChartContainer({
|
||||||
|
title,
|
||||||
|
loading = false,
|
||||||
|
isEmpty = false,
|
||||||
|
emptyDescription = '暂无数据',
|
||||||
|
extra,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: EChartsChartContainerProps) {
|
||||||
|
return (
|
||||||
|
<Card title={title} extra={extra} style={style}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '60px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '60px 0' }}>
|
||||||
|
<Empty description={emptyDescription} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/components/EmptyState.tsx
Normal file
29
src/components/EmptyState.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Empty, Button } from 'antd'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
description?: string
|
||||||
|
action?: {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
image?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({ description = '暂无数据', action, image }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '80px 0' }}>
|
||||||
|
<Empty
|
||||||
|
image={image || Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={description}
|
||||||
|
>
|
||||||
|
{action && (
|
||||||
|
<Button type="primary" icon={action.icon} onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/MetricCard.tsx
Normal file
61
src/components/MetricCard.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Card, Statistic, Spin, Typography } from 'antd'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string
|
||||||
|
value?: number | string
|
||||||
|
prefix?: ReactNode
|
||||||
|
suffix?: ReactNode
|
||||||
|
precision?: number
|
||||||
|
loading?: boolean
|
||||||
|
trend?: 'up' | 'down'
|
||||||
|
trendValue?: string
|
||||||
|
trendLabel?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
precision,
|
||||||
|
loading = false,
|
||||||
|
trend,
|
||||||
|
trendValue,
|
||||||
|
trendLabel,
|
||||||
|
onClick,
|
||||||
|
}: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<Card hoverable={!!onClick} onClick={onClick} style={{ height: '100%' }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '24px 0' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Statistic
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
prefix={prefix}
|
||||||
|
suffix={suffix}
|
||||||
|
precision={precision}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!loading && trend && trendValue && (
|
||||||
|
<Typography.Text
|
||||||
|
type={trend === 'up' ? 'success' : 'danger'}
|
||||||
|
style={{ fontSize: 13, display: 'block', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
{trend === 'up' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||||
|
{' '}{trendValue}
|
||||||
|
{trendLabel && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
|
||||||
|
{trendLabel}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/components/PageLoading.tsx
Normal file
9
src/components/PageLoading.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Spin } from 'antd'
|
||||||
|
|
||||||
|
export default function PageLoading() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', minHeight: 300 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/components/PermissionGuard.tsx
Normal file
18
src/components/PermissionGuard.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import type { AdminRole } from '@/types/admin'
|
||||||
|
|
||||||
|
interface PermissionGuardProps {
|
||||||
|
requiredRole?: AdminRole
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PermissionGuard({ requiredRole, children }: PermissionGuardProps) {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
|
||||||
|
if (requiredRole && !hasPermission(requiredRole)) {
|
||||||
|
return <Navigate to="/403" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
20
src/components/StatusTag.tsx
Normal file
20
src/components/StatusTag.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Tag } from 'antd'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface StatusTagProps {
|
||||||
|
status: string
|
||||||
|
statusMap: Record<string, { label: string; color: string }>
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusTag({ status, statusMap, icon }: StatusTagProps) {
|
||||||
|
const config = statusMap[status]
|
||||||
|
if (!config) {
|
||||||
|
return <Tag>{status}</Tag>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tag color={config.color} icon={icon}>
|
||||||
|
{config.label}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/config/menu.tsx
Normal file
65
src/config/menu.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type React from 'react'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
ImportOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
CloudOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import type { AdminRole } from '@/types/admin'
|
||||||
|
import { hasRole } from '@/constants/roles'
|
||||||
|
|
||||||
|
export interface AdminMenuItem {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
requiredRole?: AdminRole
|
||||||
|
children?: AdminMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminMenuItems: AdminMenuItem[] = [
|
||||||
|
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
name: '用户管理',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
requiredRole: 'ADMIN',
|
||||||
|
children: [
|
||||||
|
{ path: '/users/admins', name: '管理员', requiredRole: 'SUPER_ADMIN' },
|
||||||
|
{ path: '/users/members', name: '普通用户' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/membership', name: '会员与额度', icon: <DollarOutlined />, requiredRole: 'ADMIN' },
|
||||||
|
{
|
||||||
|
path: '/knowledge',
|
||||||
|
name: '知识库管理',
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
children: [
|
||||||
|
{ path: '/knowledge/bases', name: '知识库列表' },
|
||||||
|
{ path: '/knowledge/sources', name: '知识源列表' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/imports', name: '文档导入', icon: <ImportOutlined /> },
|
||||||
|
{ path: '/ai-costs', name: 'AI 调用与成本', icon: <CloudOutlined /> },
|
||||||
|
{ path: '/files', name: '文件与 COS', icon: <FileOutlined /> },
|
||||||
|
{ path: '/settings', name: '系统配置', icon: <SettingOutlined />, requiredRole: 'ADMIN' },
|
||||||
|
{ path: '/audit', name: '审计日志', icon: <SafetyOutlined />, requiredRole: 'ADMIN' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function filterMenuByRole(items: AdminMenuItem[], role?: AdminRole): AdminMenuItem[] {
|
||||||
|
if (!role) return []
|
||||||
|
return items
|
||||||
|
.filter((item) => !item.requiredRole || hasRole(role, item.requiredRole))
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
children: item.children ? filterMenuByRole(item.children, role) : undefined,
|
||||||
|
}))
|
||||||
|
.filter((item) => {
|
||||||
|
if (item.children && item.children.length === 0) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
30
src/constants/roles.ts
Normal file
30
src/constants/roles.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { AdminRole } from '@/types/admin'
|
||||||
|
|
||||||
|
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
|
||||||
|
SUPER_ADMIN: '超级管理员',
|
||||||
|
ADMIN: '管理员',
|
||||||
|
OPERATIONS: '运营人员',
|
||||||
|
DEVELOPER: '开发者',
|
||||||
|
READONLY: '只读用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_ROLE_COLORS: Record<AdminRole, string> = {
|
||||||
|
SUPER_ADMIN: 'red',
|
||||||
|
ADMIN: 'volcano',
|
||||||
|
OPERATIONS: 'orange',
|
||||||
|
DEVELOPER: 'blue',
|
||||||
|
READONLY: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_ROLE_HIERARCHY: Record<AdminRole, AdminRole[]> = {
|
||||||
|
SUPER_ADMIN: ['SUPER_ADMIN', 'ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY'],
|
||||||
|
ADMIN: ['ADMIN', 'OPERATIONS', 'DEVELOPER', 'READONLY'],
|
||||||
|
OPERATIONS: ['OPERATIONS', 'READONLY'],
|
||||||
|
DEVELOPER: ['DEVELOPER', 'READONLY'],
|
||||||
|
READONLY: ['READONLY'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRole(currentRole: AdminRole | undefined, required: AdminRole): boolean {
|
||||||
|
if (!currentRole) return false
|
||||||
|
return ADMIN_ROLE_HIERARCHY[currentRole]?.includes(required) ?? false
|
||||||
|
}
|
||||||
55
src/contexts/AuthContext.tsx
Normal file
55
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react'
|
||||||
|
import { useAdminUserQuery, useLoginMutation, useLogoutMutation } from '@/hooks/use-auth-query'
|
||||||
|
import { hasRole } from '@/constants/roles'
|
||||||
|
import type { AdminUser, AdminRole } from '@/types/admin'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
adminUser: AdminUser | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
hasPermission: (requiredRole: AdminRole) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data: adminUser, isLoading } = useAdminUserQuery()
|
||||||
|
const loginMutation = useLoginMutation()
|
||||||
|
const logoutMutation = useLogoutMutation()
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
await loginMutation.mutateAsync({ email, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await logoutMutation.mutateAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = (requiredRole: AdminRole): boolean => {
|
||||||
|
if (!adminUser) return false
|
||||||
|
return hasRole(adminUser.role as AdminRole, requiredRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
adminUser: adminUser ?? null,
|
||||||
|
isAuthenticated: !!adminUser,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
hasPermission,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
54
src/hooks/use-auth-query.ts
Normal file
54
src/hooks/use-auth-query.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
loginAdmin,
|
||||||
|
logoutAdmin,
|
||||||
|
getCurrentAdmin,
|
||||||
|
} from '@/services/admin-api'
|
||||||
|
import { getAccessToken, setTokens, clearTokens, setStoredAdminUser } from '@/services/token-store'
|
||||||
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
|
||||||
|
export function useAdminUserQuery() {
|
||||||
|
return useQuery<AdminUser | null>({
|
||||||
|
queryKey: ['admin', 'me'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (!token) return null
|
||||||
|
const user = await getCurrentAdmin()
|
||||||
|
setStoredAdminUser(user)
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoginMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||||
|
loginAdmin(email, password),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTokens(data.accessToken, data.refreshToken)
|
||||||
|
setStoredAdminUser(data.adminUser)
|
||||||
|
queryClient.setQueryData(['admin', 'me'], data.adminUser)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogoutMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const token = localStorage.getItem('admin_refresh_token')
|
||||||
|
if (token) {
|
||||||
|
return logoutAdmin(token).catch(() => {})
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
clearTokens()
|
||||||
|
queryClient.setQueryData(['admin', 'me'], null)
|
||||||
|
queryClient.clear()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
108
src/index.css
108
src/index.css
@ -1,111 +1,9 @@
|
|||||||
:root {
|
body {
|
||||||
--text: #6b6375;
|
margin: 0;
|
||||||
--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;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
#root {
|
||||||
width: 1126px;
|
min-height: 100vh;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/layouts/AdminLayout.tsx
Normal file
111
src/layouts/AdminLayout.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { ProLayout } from '@ant-design/pro-components'
|
||||||
|
import { Dropdown, Avatar, Tag, Space, message } from 'antd'
|
||||||
|
import { LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { filterMenuByRole, adminMenuItems } from '@/config/menu'
|
||||||
|
import { ADMIN_ROLE_LABELS, ADMIN_ROLE_COLORS } from '@/constants/roles'
|
||||||
|
import type { AdminRole } from '@/types/admin'
|
||||||
|
|
||||||
|
const breadcrumbMap: Record<string, string> = {
|
||||||
|
'/': '总览',
|
||||||
|
'/users': '用户管理',
|
||||||
|
'/users/admins': '管理员',
|
||||||
|
'/users/members': '普通用户',
|
||||||
|
'/membership': '会员与额度',
|
||||||
|
'/knowledge': '知识库管理',
|
||||||
|
'/knowledge/bases': '知识库列表',
|
||||||
|
'/knowledge/sources': '知识源列表',
|
||||||
|
'/imports': '文档导入',
|
||||||
|
'/ai-costs': 'AI 调用与成本',
|
||||||
|
'/files': '文件与 COS',
|
||||||
|
'/settings': '系统配置',
|
||||||
|
'/audit': '审计日志',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { adminUser, logout, hasPermission } = useAuth()
|
||||||
|
|
||||||
|
const currentRole = adminUser?.role as AdminRole | undefined
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
message.success('已退出登录')
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMenuItems = [
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: (
|
||||||
|
<div style={{ padding: '4px 0' }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{adminUser?.displayName}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>{adminUser?.email}</div>
|
||||||
|
<Space size={4} style={{ marginTop: 4 }}>
|
||||||
|
<Tag color={ADMIN_ROLE_COLORS[currentRole ?? 'READONLY']} style={{ margin: 0 }}>
|
||||||
|
{ADMIN_ROLE_LABELS[currentRole ?? 'READONLY']}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV || import.meta.env.MODE !== 'production'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProLayout
|
||||||
|
title="知习 Admin"
|
||||||
|
logo={null}
|
||||||
|
location={location}
|
||||||
|
menuDataRender={() => {
|
||||||
|
const items = filterMenuByRole(adminMenuItems, currentRole)
|
||||||
|
return items
|
||||||
|
}}
|
||||||
|
menuItemRender={(item, dom) => (
|
||||||
|
<a onClick={() => item.path && navigate(item.path)}>{dom}</a>
|
||||||
|
)}
|
||||||
|
breadcrumbRender={(routers) => {
|
||||||
|
return (routers ?? []).map((r) => {
|
||||||
|
const path = (r as any).path ?? ''
|
||||||
|
const label = breadcrumbMap[path] || (r as any).breadcrumbName || path
|
||||||
|
return { ...r, breadcrumbName: label, title: label } as any
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
rightContentRender={() =>
|
||||||
|
hasPermission('READONLY') ? (
|
||||||
|
<Space>
|
||||||
|
{isDev && (
|
||||||
|
<Tag color="orange" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>
|
||||||
|
DEV
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
|
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Avatar size="small" icon={<UserOutlined />} />
|
||||||
|
<span>{adminUser?.displayName || '管理员'}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
siderWidth={220}
|
||||||
|
token={{
|
||||||
|
header: { heightLayoutHeader: 48 },
|
||||||
|
pageContainer: { paddingBlockPageContainerContent: 24, paddingInlinePageContainerContent: 24 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</ProLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
|
||||||
import { ProLayout } from '@ant-design/pro-components'
|
|
||||||
import {
|
|
||||||
DashboardOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
BookOutlined,
|
|
||||||
ImportOutlined,
|
|
||||||
DollarOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
FileOutlined,
|
|
||||||
CloudOutlined,
|
|
||||||
SafetyOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
|
|
||||||
const menuData = [
|
|
||||||
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
|
||||||
{ path: '/users', name: '用户管理', icon: <UserOutlined /> },
|
|
||||||
{ path: '/membership', name: '会员与额度', icon: <DollarOutlined /> },
|
|
||||||
{
|
|
||||||
path: '/knowledge',
|
|
||||||
name: '知识库管理',
|
|
||||||
icon: <BookOutlined />,
|
|
||||||
children: [
|
|
||||||
{ path: '/knowledge/bases', name: '知识库列表' },
|
|
||||||
{ path: '/knowledge/sources', name: '知识源列表' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/imports',
|
|
||||||
name: '文档导入',
|
|
||||||
icon: <ImportOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/ai-costs',
|
|
||||||
name: 'AI 调用与成本',
|
|
||||||
icon: <CloudOutlined />,
|
|
||||||
},
|
|
||||||
{ path: '/files', name: '文件与 COS', icon: <FileOutlined /> },
|
|
||||||
{ path: '/settings', name: '系统配置', icon: <SettingOutlined /> },
|
|
||||||
{ path: '/audit', name: '审计日志', icon: <SafetyOutlined /> },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function MainLayout() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProLayout
|
|
||||||
title="知习 Admin"
|
|
||||||
logo={null}
|
|
||||||
location={location}
|
|
||||||
menuDataRender={() => menuData}
|
|
||||||
menuItemRender={(item, dom) => (
|
|
||||||
<a onClick={() => item.path && navigate(item.path)}>{dom}</a>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</ProLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
20
src/pages/403.tsx
Normal file
20
src/pages/403.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Result, Button } from 'antd'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function ForbiddenPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="暂无访问权限"
|
||||||
|
subTitle="您的角色权限不足,无法访问此页面。如需访问请联系超级管理员提升权限。"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => navigate('/', { replace: true })}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/pages/404.tsx
Normal file
20
src/pages/404.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Result, Button } from 'antd'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="页面不存在"
|
||||||
|
subTitle="请检查 URL 地址是否正确。"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => navigate('/', { replace: true })}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/pages/500.tsx
Normal file
18
src/pages/500.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Result, Button } from 'antd'
|
||||||
|
|
||||||
|
export default function ServerErrorPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="500"
|
||||||
|
title="服务器错误"
|
||||||
|
subTitle="抱歉,服务器遇到了问题。请稍后重试。"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,31 +1,176 @@
|
|||||||
import { Card, Row, Col, Statistic } from 'antd'
|
import { useMemo } from 'react'
|
||||||
import { UserOutlined, BookOutlined, CloudOutlined } from '@ant-design/icons'
|
import { Row, Col, Typography } from 'antd'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import ReactEChartsCore from 'echarts-for-react/esm/core'
|
||||||
|
import * as echarts from 'echarts/core'
|
||||||
|
import { LineChart, BarChart } from 'echarts/charts'
|
||||||
|
import { GridComponent, TooltipComponent, TitleComponent, LegendComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
CloudOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import MetricCard from '@/components/MetricCard'
|
||||||
|
import EChartsChartContainer from '@/components/EChartsChartContainer'
|
||||||
|
import AuditLogTable from '@/components/AuditLogTable'
|
||||||
|
import { getDashboardStats, getAuditLogs } from '@/services/admin-api'
|
||||||
|
|
||||||
|
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, TitleComponent, LegendComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
function formatStorage(bytes: number): string {
|
||||||
|
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB'
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'
|
||||||
|
return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'stats'],
|
||||||
|
queryFn: getDashboardStats,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: auditData, isLoading: auditLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'audit-logs'],
|
||||||
|
queryFn: () => getAuditLogs({ page: 1, limit: 10 }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userTrendOption = useMemo(() => ({
|
||||||
|
grid: { top: 20, right: 20, bottom: 20, left: 40 },
|
||||||
|
tooltip: { trigger: 'axis' as const },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: stats?.userTrend.map((p) => dayjs(p.date).format('MM-DD')) || [],
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value' as const, axisLabel: { fontSize: 11 } },
|
||||||
|
series: [{
|
||||||
|
name: '日活用户',
|
||||||
|
type: 'line',
|
||||||
|
data: stats?.userTrend.map((p) => p.value) || [],
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { color: '#1677ff', width: 2 },
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(22,119,255,0.15)' },
|
||||||
|
{ offset: 1, color: 'rgba(22,119,255,0)' },
|
||||||
|
])},
|
||||||
|
}],
|
||||||
|
}), [stats])
|
||||||
|
|
||||||
|
const aiCallTrendOption = useMemo(() => ({
|
||||||
|
grid: { top: 20, right: 20, bottom: 20, left: 40 },
|
||||||
|
tooltip: { trigger: 'axis' as const },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: stats?.aiCallTrend.map((p) => dayjs(p.date).format('MM-DD')) || [],
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value' as const, axisLabel: { fontSize: 11 } },
|
||||||
|
series: [{
|
||||||
|
name: 'AI 调用',
|
||||||
|
type: 'bar',
|
||||||
|
data: stats?.aiCallTrend.map((p) => p.value) || [],
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#52c41a' },
|
||||||
|
{ offset: 1, color: '#b7eb8f' },
|
||||||
|
]),
|
||||||
|
borderRadius: [4, 4, 0, 0],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}), [stats])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div style={{ padding: '0 0 24px' }}>
|
||||||
|
<Typography.Title level={5} style={{ margin: '0 0 16px' }}>数据概览</Typography.Title>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col span={6}>
|
<Col xs={12} sm={12} lg={6}>
|
||||||
<Card>
|
<MetricCard
|
||||||
<Statistic title="今日注册" value={0} prefix={<UserOutlined />} />
|
title="总用户数"
|
||||||
</Card>
|
value={stats?.totalUsers}
|
||||||
|
loading={statsLoading}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
trend="up"
|
||||||
|
trendValue={`+${stats?.newUsersToday ?? 0}`}
|
||||||
|
trendLabel="今日新增"
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col xs={12} sm={12} lg={6}>
|
||||||
<Card>
|
<MetricCard
|
||||||
<Statistic title="知识库总数" value={4} prefix={<BookOutlined />} />
|
title="知识库总数"
|
||||||
</Card>
|
value={stats?.totalKnowledgeBases}
|
||||||
|
loading={statsLoading}
|
||||||
|
prefix={<BookOutlined />}
|
||||||
|
trend="up"
|
||||||
|
trendValue={`+${stats?.newKbsToday ?? 0}`}
|
||||||
|
trendLabel="今日新增"
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col xs={12} sm={12} lg={6}>
|
||||||
<Card>
|
<MetricCard
|
||||||
<Statistic title="今日 AI 调用" value={0} prefix={<CloudOutlined />} />
|
title="今日 AI 调用"
|
||||||
</Card>
|
value={stats?.totalAiCallsToday}
|
||||||
|
loading={statsLoading}
|
||||||
|
prefix={<CloudOutlined />}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col xs={12} sm={12} lg={6}>
|
||||||
<Card>
|
<MetricCard
|
||||||
<Statistic title="活跃用户" value={0} prefix={<UserOutlined />} />
|
title="文件存储"
|
||||||
</Card>
|
value={stats ? formatStorage(stats.totalStorageBytes) : undefined}
|
||||||
|
loading={statsLoading}
|
||||||
|
prefix={<FileOutlined />}
|
||||||
|
suffix={`${stats?.totalFiles ?? 0} 个文件`}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<EChartsChartContainer
|
||||||
|
title="日活用户趋势(近 30 天)"
|
||||||
|
loading={statsLoading}
|
||||||
|
isEmpty={!stats?.userTrend?.length}
|
||||||
|
>
|
||||||
|
<ReactEChartsCore
|
||||||
|
echarts={echarts}
|
||||||
|
option={userTrendOption}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
/>
|
||||||
|
</EChartsChartContainer>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<EChartsChartContainer
|
||||||
|
title="AI 调用趋势(近 30 天)"
|
||||||
|
loading={statsLoading}
|
||||||
|
isEmpty={!stats?.aiCallTrend?.length}
|
||||||
|
>
|
||||||
|
<ReactEChartsCore
|
||||||
|
echarts={echarts}
|
||||||
|
option={aiCallTrendOption}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
</EChartsChartContainer>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<AuditLogTable
|
||||||
|
headerTitle="最近操作日志"
|
||||||
|
dataSource={auditData?.items || []}
|
||||||
|
loading={auditLoading}
|
||||||
|
pagination={auditData ? { current: auditData.page, pageSize: auditData.limit, total: auditData.total } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
207
src/pages/Login.tsx
Normal file
207
src/pages/Login.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { Form, Input, Button, Typography, Alert, message, theme } from 'antd'
|
||||||
|
import { MailOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
|
const onFinish = async (values: { email: string; password: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login(values.email, values.password)
|
||||||
|
message.success('登录成功')
|
||||||
|
const redirect = searchParams.get('redirect') || '/'
|
||||||
|
navigate(redirect, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||||
|
{/* Left brand panel */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '0 0 480px',
|
||||||
|
background: `linear-gradient(160deg, #e6f4ff 0%, #bae0ff 40%, ${token.colorPrimary} 100%)`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 80,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -120,
|
||||||
|
right: -60,
|
||||||
|
width: 360,
|
||||||
|
height: 360,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -80,
|
||||||
|
left: -80,
|
||||||
|
width: 280,
|
||||||
|
height: 280,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '40%',
|
||||||
|
right: -40,
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', textAlign: 'center', maxWidth: 340 }}>
|
||||||
|
<div style={{ marginBottom: 40 }}>
|
||||||
|
<SafetyCertificateOutlined
|
||||||
|
style={{ fontSize: 56, color: 'rgba(255,255,255,0.9)', marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
<Title level={2} style={{ color: '#fff', marginBottom: 12, fontWeight: 600, letterSpacing: 2 }}>
|
||||||
|
知习 Admin
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16, marginBottom: 0 }}>
|
||||||
|
企业知识管理中台
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 60,
|
||||||
|
padding: '24px 0',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13 }}>
|
||||||
|
内部管理系统 · 授权访问
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right form panel */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
padding: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 380 }}>
|
||||||
|
<div style={{ marginBottom: 40 }}>
|
||||||
|
<Title level={3} style={{ marginBottom: 8, fontWeight: 600 }}>
|
||||||
|
登录后台
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||||
|
使用管理员邮箱和密码登录管理控制台
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
name="admin-login"
|
||||||
|
onFinish={onFinish}
|
||||||
|
autoComplete="off"
|
||||||
|
size="large"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入管理员邮箱' },
|
||||||
|
{ type: 'email', message: '邮箱格式不正确' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<MailOutlined style={{ color: token.colorTextQuaternary }} />}
|
||||||
|
placeholder="管理员邮箱"
|
||||||
|
style={{ height: 48, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: token.colorTextQuaternary }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
style={{ height: 48, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
style={{ height: 48, borderRadius: 8, fontSize: 16, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 48,
|
||||||
|
padding: '0 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12, color: token.colorTextQuaternary }}>
|
||||||
|
知习知识平台 · 内部管理系统
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/pages/Placeholder.tsx
Normal file
5
src/pages/Placeholder.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Result } from 'antd'
|
||||||
|
|
||||||
|
export default function Placeholder({ title }: { title: string }) {
|
||||||
|
return <Result title={title} subTitle="页面开发中" />
|
||||||
|
}
|
||||||
241
src/pages/UserManagement.tsx
Normal file
241
src/pages/UserManagement.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import type { ProColumns, ActionType } from '@ant-design/pro-components'
|
||||||
|
import { Button, Tag, message, Tooltip } from 'antd'
|
||||||
|
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
import { ADMIN_ROLE_LABELS, ADMIN_ROLE_COLORS } from '@/constants/roles'
|
||||||
|
import { getAdminUsers, deleteAdminUser } from '@/services/admin-api'
|
||||||
|
import DetailDrawer from '@/components/DetailDrawer'
|
||||||
|
import ConfirmDangerModal from '@/components/ConfirmDangerModal'
|
||||||
|
|
||||||
|
const statusLabelMap: Record<string, string> = {
|
||||||
|
ACTIVE: '正常',
|
||||||
|
DISABLED: '已禁用',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColorMap: Record<string, string> = {
|
||||||
|
ACTIVE: 'green',
|
||||||
|
DISABLED: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const actionRef = useRef<ActionType>(undefined)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: () => getAdminUsers({ page: 1, limit: 20 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteAdminUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||||
|
},
|
||||||
|
onError: () => message.error('删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AdminUser>[] = [
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'displayName',
|
||||||
|
width: 130,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '邮箱',
|
||||||
|
dataIndex: 'email',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 120,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
SUPER_ADMIN: { text: '超级管理员' },
|
||||||
|
ADMIN: { text: '管理员' },
|
||||||
|
OPERATIONS: { text: '运营人员' },
|
||||||
|
DEVELOPER: { text: '开发者' },
|
||||||
|
READONLY: { text: '只读用户' },
|
||||||
|
},
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tag color={ADMIN_ROLE_COLORS[record.role]}>{ADMIN_ROLE_LABELS[record.role]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
ACTIVE: { text: '正常' },
|
||||||
|
DISABLED: { text: '已禁用' },
|
||||||
|
},
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tag color={statusColorMap[record.status]}>{statusLabelMap[record.status]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后登录',
|
||||||
|
dataIndex: 'lastLoginAt',
|
||||||
|
width: 160,
|
||||||
|
search: false,
|
||||||
|
render: (_, record) =>
|
||||||
|
record.lastLoginAt
|
||||||
|
? new Date(record.lastLoginAt).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
search: false,
|
||||||
|
render: (_, record) =>
|
||||||
|
new Date(record.createdAt).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => [
|
||||||
|
<a
|
||||||
|
key="view"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(record)
|
||||||
|
setDetailOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</a>,
|
||||||
|
<Tooltip key="delete" title={record.role === 'SUPER_ADMIN' ? '不可删除超级管理员' : ''}>
|
||||||
|
<a
|
||||||
|
style={{ color: record.role === 'SUPER_ADMIN' ? '#ccc' : '#ff4d4f' }}
|
||||||
|
onClick={() => {
|
||||||
|
if (record.role !== 'SUPER_ADMIN') {
|
||||||
|
setDeleteTarget(record)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</a>
|
||||||
|
</Tooltip>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<AdminUser>
|
||||||
|
actionRef={actionRef}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items || []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
options={false}
|
||||||
|
pagination={
|
||||||
|
data
|
||||||
|
? {
|
||||||
|
current: data.page,
|
||||||
|
pageSize: data.limit,
|
||||||
|
total: data.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
headerTitle="管理员列表"
|
||||||
|
toolbar={{
|
||||||
|
actions: [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />}>
|
||||||
|
新建管理员
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="refresh"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-users'] })}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailDrawer
|
||||||
|
open={detailOpen}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
title="管理员详情"
|
||||||
|
>
|
||||||
|
{selectedUser && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>姓名</div>
|
||||||
|
<div style={{ fontSize: 14 }}>{selectedUser.displayName}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>邮箱</div>
|
||||||
|
<div style={{ fontSize: 14 }}>{selectedUser.email}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>角色</div>
|
||||||
|
<Tag color={ADMIN_ROLE_COLORS[selectedUser.role]}>{ADMIN_ROLE_LABELS[selectedUser.role]}</Tag>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>状态</div>
|
||||||
|
<Tag color={statusColorMap[selectedUser.status]}>{statusLabelMap[selectedUser.status]}</Tag>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>两步验证</div>
|
||||||
|
<div style={{ fontSize: 14 }}>{selectedUser.twoFactorEnabled ? '已启用' : '未启用'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>最后登录时间</div>
|
||||||
|
<div style={{ fontSize: 14 }}>{selectedUser.lastLoginAt || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>最后登录 IP</div>
|
||||||
|
<div style={{ fontSize: 14 }}>{selectedUser.lastLoginIp || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>创建时间</div>
|
||||||
|
<div style={{ fontSize: 14 }}>{selectedUser.createdAt}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DetailDrawer>
|
||||||
|
|
||||||
|
<ConfirmDangerModal
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) {
|
||||||
|
deleteMutation.mutate(deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="删除管理员"
|
||||||
|
description="此操作不可撤销。请输入管理员邮箱确认删除:"
|
||||||
|
targetName={deleteTarget?.email || ''}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/routes/index.tsx
Normal file
27
src/routes/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { lazy } from 'react'
|
||||||
|
import type { AdminRole } from '@/types/admin'
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('@/pages/Dashboard'))
|
||||||
|
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||||
|
|
||||||
|
export interface RouteConfig {
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
element: React.LazyExoticComponent<React.ComponentType<any>>
|
||||||
|
requiredRole?: AdminRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeConfig: RouteConfig[] = [
|
||||||
|
{ path: '/', title: '总览', element: Dashboard },
|
||||||
|
{ path: '/users', title: '用户管理', element: UserManagement, requiredRole: 'ADMIN' },
|
||||||
|
{ path: '/users/admins', title: '管理员', element: UserManagement, requiredRole: 'SUPER_ADMIN' },
|
||||||
|
{ path: '/users/members', title: '普通用户', element: UserManagement },
|
||||||
|
{ path: '/membership', title: '会员与额度', element: UserManagement, requiredRole: 'ADMIN' },
|
||||||
|
{ path: '/knowledge/bases', title: '知识库列表', element: UserManagement },
|
||||||
|
{ path: '/knowledge/sources', title: '知识源列表', element: UserManagement },
|
||||||
|
{ path: '/imports', title: '文档导入', element: UserManagement },
|
||||||
|
{ path: '/ai-costs', title: 'AI 调用与成本', element: UserManagement },
|
||||||
|
{ path: '/files', title: '文件与 COS', element: UserManagement },
|
||||||
|
{ path: '/settings', title: '系统配置', element: UserManagement, requiredRole: 'ADMIN' },
|
||||||
|
{ path: '/audit', title: '审计日志', element: UserManagement, requiredRole: 'ADMIN' },
|
||||||
|
]
|
||||||
136
src/services/admin-api.ts
Normal file
136
src/services/admin-api.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import type {
|
||||||
|
AdminUser,
|
||||||
|
DashboardStats,
|
||||||
|
AuditLog,
|
||||||
|
} from '@/types/admin'
|
||||||
|
import type { PaginatedResult, PaginationParams } from '@/types/api'
|
||||||
|
import { api } from './http-client'
|
||||||
|
import { MOCK_DASHBOARD_STATS, MOCK_AUDIT_LOGS } from './mock-data'
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
adminUser: AdminUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginAdmin(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
return api.post<LoginResponse>('/admin-api/auth/login', { email, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenPair {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
adminUser: AdminUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshAdminToken(refreshToken: string): Promise<TokenPair> {
|
||||||
|
return api.post<TokenPair>('/admin-api/auth/refresh', { refreshToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutAdmin(refreshToken: string): Promise<void> {
|
||||||
|
return api.post<void>('/admin-api/auth/logout', { refreshToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentAdmin(): Promise<AdminUser> {
|
||||||
|
return api.get<AdminUser>('/admin-api/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
|
try {
|
||||||
|
return await api.get<DashboardStats>('/admin-api/dashboard/stats')
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS
|
||||||
|
throw new Error('获取仪表盘数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Users ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AdminUsersQuery extends PaginationParams {
|
||||||
|
search?: string
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminUsers(
|
||||||
|
params: AdminUsersQuery,
|
||||||
|
): Promise<PaginatedResult<AdminUser>> {
|
||||||
|
try {
|
||||||
|
return await api.get<PaginatedResult<AdminUser>>(
|
||||||
|
`/admin-api/admin-users?${new URLSearchParams(params as Record<string, string>).toString()}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: params.page ?? 1,
|
||||||
|
limit: params.limit ?? 20,
|
||||||
|
totalPages: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('获取管理员列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminUserById(id: string): Promise<AdminUser> {
|
||||||
|
return api.get<AdminUser>(`/admin-api/admin-users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminUser(data: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
role: string
|
||||||
|
}): Promise<AdminUser> {
|
||||||
|
return api.post<AdminUser>('/admin-api/admin-users', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminUser(
|
||||||
|
id: string,
|
||||||
|
data: { role?: string; status?: string; displayName?: string },
|
||||||
|
): Promise<AdminUser> {
|
||||||
|
return api.put<AdminUser>(`/admin-api/admin-users/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminUser(id: string): Promise<void> {
|
||||||
|
return api.delete<void>(`/admin-api/admin-users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit Logs ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AuditLogsQuery extends PaginationParams {
|
||||||
|
adminUserId?: string
|
||||||
|
action?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogs(
|
||||||
|
params: AuditLogsQuery,
|
||||||
|
): Promise<PaginatedResult<AuditLog>> {
|
||||||
|
try {
|
||||||
|
return await api.get<PaginatedResult<AuditLog>>(
|
||||||
|
`/admin-api/audit-logs?${new URLSearchParams(params as Record<string, string>).toString()}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return {
|
||||||
|
items: MOCK_AUDIT_LOGS,
|
||||||
|
total: MOCK_AUDIT_LOGS.length,
|
||||||
|
page: params.page ?? 1,
|
||||||
|
limit: params.limit ?? 20,
|
||||||
|
totalPages: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('获取审计日志失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLogById(id: string): Promise<AuditLog> {
|
||||||
|
return api.get<AuditLog>(`/admin-api/audit-logs/${id}`)
|
||||||
|
}
|
||||||
93
src/services/api.ts
Normal file
93
src/services/api.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
const BASE_URL = ''
|
||||||
|
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
statusCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return localStorage.getItem('admin_access_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers })
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const refreshed = await tryRefresh()
|
||||||
|
if (refreshed) {
|
||||||
|
headers['Authorization'] = `Bearer ${getToken()}`
|
||||||
|
const retryRes = await fetch(`${BASE_URL}${path}`, { ...options, headers })
|
||||||
|
return retryRes.json()
|
||||||
|
}
|
||||||
|
localStorage.removeItem('admin_access_token')
|
||||||
|
localStorage.removeItem('admin_refresh_token')
|
||||||
|
localStorage.removeItem('admin_user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
throw new Error('登录已过期')
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: ApiResponse<T> = await res.json()
|
||||||
|
if (!json.success) {
|
||||||
|
throw new Error(json.message || '请求失败')
|
||||||
|
}
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||||
|
if (!refreshToken) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/admin-api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
})
|
||||||
|
if (!res.ok) return false
|
||||||
|
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json()
|
||||||
|
if (json.success) {
|
||||||
|
localStorage.setItem('admin_access_token', json.data.accessToken)
|
||||||
|
localStorage.setItem('admin_refresh_token', json.data.refreshToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get<T>(path: string) {
|
||||||
|
return request<T>(path)
|
||||||
|
},
|
||||||
|
post<T>(path: string, body?: unknown) {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
put<T>(path: string, body?: unknown) {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
delete<T>(path: string, body?: unknown) {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
113
src/services/http-client.ts
Normal file
113
src/services/http-client.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
setTokens,
|
||||||
|
clearTokens,
|
||||||
|
} from './token-store'
|
||||||
|
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
statusCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
httpStatus: number
|
||||||
|
code: number
|
||||||
|
constructor(message: string, httpStatus: number, code?: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.httpStatus = httpStatus
|
||||||
|
this.code = code ?? httpStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
const token = getRefreshToken()
|
||||||
|
if (!token) return false
|
||||||
|
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin-api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken: token }),
|
||||||
|
})
|
||||||
|
if (!res.ok) return false
|
||||||
|
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json()
|
||||||
|
if (json.success) {
|
||||||
|
setTokens(json.data.accessToken, json.data.refreshToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
retried = false,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getAccessToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(path, { ...options, headers })
|
||||||
|
|
||||||
|
if (res.status === 401 && !retried) {
|
||||||
|
const refreshed = await tryRefresh()
|
||||||
|
if (refreshed) {
|
||||||
|
return request<T>(path, options, true)
|
||||||
|
}
|
||||||
|
clearTokens()
|
||||||
|
window.location.href = '/login'
|
||||||
|
throw new ApiError('登录已过期', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: ApiResponse<T> = await res.json()
|
||||||
|
if (!json.success) {
|
||||||
|
throw new ApiError(json.message || '请求失败', res.status, json.statusCode)
|
||||||
|
}
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get<T>(path: string) {
|
||||||
|
return request<T>(path)
|
||||||
|
},
|
||||||
|
post<T>(path: string, body?: unknown) {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
put<T>(path: string, body?: unknown) {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
delete<T>(path: string, body?: unknown) {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
98
src/services/mock-data.ts
Normal file
98
src/services/mock-data.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { DashboardStats, AuditLog, TrendPoint } from '@/types/admin'
|
||||||
|
|
||||||
|
function makeTrend(days: number, base: number, variance: number): TrendPoint[] {
|
||||||
|
return Array.from({ length: days }, (_, i) => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - (days - 1 - i))
|
||||||
|
return {
|
||||||
|
date: d.toISOString().split('T')[0],
|
||||||
|
value: Math.max(0, base + Math.round((Math.random() - 0.5) * variance)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_DASHBOARD_STATS: DashboardStats = {
|
||||||
|
totalUsers: 1286,
|
||||||
|
newUsersToday: 23,
|
||||||
|
activeUsersToday: 347,
|
||||||
|
totalKnowledgeBases: 892,
|
||||||
|
newKbsToday: 15,
|
||||||
|
totalAiCallsToday: 4521,
|
||||||
|
totalFiles: 3412,
|
||||||
|
totalStorageBytes: 15_728_640_000,
|
||||||
|
userTrend: makeTrend(30, 40, 30),
|
||||||
|
aiCallTrend: makeTrend(30, 150, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_AUDIT_LOGS: AuditLog[] = [
|
||||||
|
{
|
||||||
|
id: 'log-001',
|
||||||
|
adminUserId: 'admin-001',
|
||||||
|
adminUserEmail: 'admin@longde.cloud',
|
||||||
|
adminUserDisplayName: '超级管理员',
|
||||||
|
action: 'LOGIN',
|
||||||
|
resourceType: null,
|
||||||
|
resourceId: null,
|
||||||
|
beforeJson: null,
|
||||||
|
afterJson: null,
|
||||||
|
ip: '120.53.227.155',
|
||||||
|
userAgent: 'Chrome/130.0',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-002',
|
||||||
|
adminUserId: 'admin-001',
|
||||||
|
adminUserEmail: 'admin@longde.cloud',
|
||||||
|
adminUserDisplayName: '超级管理员',
|
||||||
|
action: 'CREATE_ADMIN',
|
||||||
|
resourceType: 'AdminUser',
|
||||||
|
resourceId: 'admin-002',
|
||||||
|
beforeJson: null,
|
||||||
|
afterJson: { email: 'ops@longde.cloud', role: 'OPERATIONS' },
|
||||||
|
ip: '120.53.227.155',
|
||||||
|
userAgent: 'Chrome/130.0',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-003',
|
||||||
|
adminUserId: 'admin-002',
|
||||||
|
adminUserEmail: 'ops@longde.cloud',
|
||||||
|
adminUserDisplayName: '运营小王',
|
||||||
|
action: 'UPDATE_USER_STATUS',
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: 'user-123',
|
||||||
|
beforeJson: { status: 'active' },
|
||||||
|
afterJson: { status: 'disabled' },
|
||||||
|
ip: '120.53.227.156',
|
||||||
|
userAgent: 'Firefox/132.0',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-004',
|
||||||
|
adminUserId: 'admin-001',
|
||||||
|
adminUserEmail: 'admin@longde.cloud',
|
||||||
|
adminUserDisplayName: '超级管理员',
|
||||||
|
action: 'DELETE_FILE',
|
||||||
|
resourceType: 'UploadedFile',
|
||||||
|
resourceId: 'file-456',
|
||||||
|
beforeJson: { filename: '违规内容.pdf' },
|
||||||
|
afterJson: null,
|
||||||
|
ip: '120.53.227.155',
|
||||||
|
userAgent: 'Chrome/130.0',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-005',
|
||||||
|
adminUserId: 'admin-001',
|
||||||
|
adminUserEmail: 'admin@longde.cloud',
|
||||||
|
adminUserDisplayName: '超级管理员',
|
||||||
|
action: 'LOGIN_FAILED',
|
||||||
|
resourceType: null,
|
||||||
|
resourceId: null,
|
||||||
|
beforeJson: null,
|
||||||
|
afterJson: null,
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
userAgent: null,
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
|
||||||
|
},
|
||||||
|
]
|
||||||
35
src/services/token-store.ts
Normal file
35
src/services/token-store.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const ACCESS_TOKEN_KEY = 'admin_access_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
||||||
|
const ADMIN_USER_KEY = 'admin_user'
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(access: string, refresh: string): void {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, access)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens(): void {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ADMIN_USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredAdminUser<T>(): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ADMIN_USER_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as T) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredAdminUser<T>(user: T): void {
|
||||||
|
localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user))
|
||||||
|
}
|
||||||
46
src/types/admin.ts
Normal file
46
src/types/admin.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'OPERATIONS' | 'DEVELOPER' | 'READONLY'
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
role: AdminRole
|
||||||
|
status: 'ACTIVE' | 'DISABLED'
|
||||||
|
twoFactorEnabled: boolean
|
||||||
|
lastLoginAt: string | null
|
||||||
|
lastLoginIp: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
date: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalUsers: number
|
||||||
|
newUsersToday: number
|
||||||
|
activeUsersToday: number
|
||||||
|
totalKnowledgeBases: number
|
||||||
|
newKbsToday: number
|
||||||
|
totalAiCallsToday: number
|
||||||
|
totalFiles: number
|
||||||
|
totalStorageBytes: number
|
||||||
|
userTrend: TrendPoint[]
|
||||||
|
aiCallTrend: TrendPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string
|
||||||
|
adminUserId: string
|
||||||
|
adminUserEmail?: string
|
||||||
|
adminUserDisplayName?: string
|
||||||
|
action: string
|
||||||
|
resourceType: string | null
|
||||||
|
resourceId: string | null
|
||||||
|
beforeJson: unknown
|
||||||
|
afterJson: unknown
|
||||||
|
ip: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
14
src/types/api.ts
Normal file
14
src/types/api.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
sortBy?: string
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
@ -16,6 +16,9 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] },
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(import.meta.dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user