feat: add admin layout, auth, user management, and routing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-21 17:19:58 +08:00
parent da9c0e8a41
commit 4dad572731
43 changed files with 4633 additions and 286 deletions

136
README.md
View File

@ -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)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
- React 19 + TypeScript 6
- 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).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
src/
├── components/ # 共享组件
│ ├── AuditLogTable # 审计日志 ProTable
│ ├── ConfirmDangerModal # 高危操作确认弹窗
│ ├── DetailDrawer # 侧滑详情抽屉
│ ├── EChartsChartContainer # 图表容器
│ ├── EmptyState # 空状态占位
│ ├── MetricCard # 指标卡
│ ├── StatusTag # 状态标签
│ ├── AuthGuard # 登录鉴权守卫
│ ├── PageLoading # 路由懒加载 Spin
│ └── PermissionGuard # 角色权限守卫
├── config/
│ └── menu.tsx # 菜单树与角色过滤
├── constants/
│ └── roles.ts # 角色标签/颜色/层级
├── contexts/
│ └── AuthContext.tsx # 认证上下文
├── hooks/
│ └── use-auth-query.ts # React Query 认证 hooks
├── layouts/
│ └── AdminLayout.tsx # 管理后台布局(侧边栏/顶栏/面包屑)
├── pages/
│ ├── Dashboard # 数据看板(指标卡 + 图表 + 审计日志)
│ ├── UserManagement # 管理员 CRUD
│ ├── Login # 登录页
│ ├── Placeholder # 占位页
│ ├── 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
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
npm install
npm run dev # 开发模式,默认 http://localhost:5173
npm run build # 生产构建
```
## 认证流程
1. `AuthGuard` 检查登录状态,未登录跳转 `/login`
2. `PermissionGuard` 检查角色权限,不足跳转 `/403`
3. HTTP 客户端自动处理 401 → refresh token → 重试请求
4. refresh token 失败时强制退出到 `/login`
## 角色体系
| 角色 | 说明 | 权限范围 |
|------|------|---------|
| SUPER_ADMIN | 超级管理员 | 全部权限,可管理其他管理员 |
| ADMIN | 管理员 | 用户管理、仪表盘、审计日志 |
| OPERATIONS | 运营人员 | 仪表盘、审计日志(只读) |
| DEVELOPER | 开发者 | 仪表盘(只读) |
| READONLY | 只读用户 | 仪表盘(只读) |

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-projects-tmp</title>
<title>知习 Admin</title>
</head>
<body>
<div id="root"></div>

2434
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,20 @@
"preview": "vite preview"
},
"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-dom": "^19.2.6"
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -23,6 +32,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"

View File

@ -1 +1 @@
@import "tailwindcss";
/* App-level styles */

View File

@ -1,10 +1,21 @@
import { Suspense, lazy } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import MainLayout from './layouts/MainLayout'
import Dashboard from './pages/Dashboard'
import './App.css'
import { AuthProvider } from './contexts/AuthContext'
import AuthGuard from './components/AuthGuard'
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()
@ -12,13 +23,74 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<Dashboard />} />
</Route>
</Routes>
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/403" element={<ForbiddenPage />} />
<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>
</QueryClientProvider>
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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

View 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>
)
}

View 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}</>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}</>
}

View 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
View 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
View 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
}

View 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
}

View 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()
},
})
}

View File

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

111
src/layouts/AdminLayout.tsx Normal file
View 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>
)
}

View File

@ -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
View 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
View 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
View 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>
)
}

View File

@ -1,31 +1,176 @@
import { Card, Row, Col, Statistic } from 'antd'
import { UserOutlined, BookOutlined, CloudOutlined } from '@ant-design/icons'
import { useMemo } from 'react'
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() {
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 (
<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]}>
<Col span={6}>
<Card>
<Statistic title="今日注册" value={0} prefix={<UserOutlined />} />
</Card>
<Col xs={12} sm={12} lg={6}>
<MetricCard
title="总用户数"
value={stats?.totalUsers}
loading={statsLoading}
prefix={<UserOutlined />}
trend="up"
trendValue={`+${stats?.newUsersToday ?? 0}`}
trendLabel="今日新增"
/>
</Col>
<Col span={6}>
<Card>
<Statistic title="知识库总数" value={4} prefix={<BookOutlined />} />
</Card>
<Col xs={12} sm={12} lg={6}>
<MetricCard
title="知识库总数"
value={stats?.totalKnowledgeBases}
loading={statsLoading}
prefix={<BookOutlined />}
trend="up"
trendValue={`+${stats?.newKbsToday ?? 0}`}
trendLabel="今日新增"
/>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日 AI 调用" value={0} prefix={<CloudOutlined />} />
</Card>
<Col xs={12} sm={12} lg={6}>
<MetricCard
title="今日 AI 调用"
value={stats?.totalAiCallsToday}
loading={statsLoading}
prefix={<CloudOutlined />}
/>
</Col>
<Col span={6}>
<Card>
<Statistic title="活跃用户" value={0} prefix={<UserOutlined />} />
</Card>
<Col xs={12} sm={12} lg={6}>
<MetricCard
title="文件存储"
value={stats ? formatStorage(stats.totalStorageBytes) : undefined}
loading={statsLoading}
prefix={<FileOutlined />}
suffix={`${stats?.totalFiles ?? 0} 个文件`}
/>
</Col>
</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>
)
}

207
src/pages/Login.tsx Normal file
View 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>
)
}

View File

@ -0,0 +1,5 @@
import { Result } from 'antd'
export default function Placeholder({ title }: { title: string }) {
return <Result title={title} subTitle="页面开发中" />
}

View 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
View 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
View 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
View 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
View 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
View 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(),
},
]

View 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
View 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
View 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'
}

View File

@ -16,6 +16,9 @@
"jsx": "react-jsx",
/* Linting */
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"ignoreDeprecations": "6.0",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,

View File

@ -1,9 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, 'src'),
},
},
server: {
port: 5174,
proxy: {