diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx
index 46c09ef..ff0878e 100644
--- a/web-react/src/app/App.tsx
+++ b/web-react/src/app/App.tsx
@@ -56,6 +56,7 @@ const ClassroomRepairsPage = lazy(() => import('../pages/ClassroomRepairsPage'))
const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatisticsPage'))
const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage'))
const PluginsPage = lazy(() => import('../pages/PluginsPage'))
+const OperationsPage = lazy(() => import('../pages/OperationsPage'))
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
@@ -160,6 +161,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx
index 1c28068..1c5a4f0 100644
--- a/web-react/src/layout/AppShell.tsx
+++ b/web-react/src/layout/AppShell.tsx
@@ -141,7 +141,7 @@ export default function AppShell() {
{ key: '/students', icon: , label: '学生档案' },
] : []),
...(user?.roles.includes('SuperAdmin')
- ? [{ key: '/users', icon: , label: '用户与权限' }, { key: '/plugins', icon: , label: '插件平台' }]
+ ? [{ key: '/users', icon: , label: '用户与权限' }, { key: '/plugins', icon: , label: '插件平台' }, { key: '/operations', icon: , label: '运维与审计' }]
: []),
{ key: 'legacy', icon: , label: '返回现有系统' },
], [isStudentHome, isTeacherHome, user?.roles])
diff --git a/web-react/src/pages/OperationsPage.tsx b/web-react/src/pages/OperationsPage.tsx
new file mode 100644
index 0000000..3a117ee
--- /dev/null
+++ b/web-react/src/pages/OperationsPage.tsx
@@ -0,0 +1,20 @@
+import { App, Alert, Button, Skeleton, Switch, Table, Tag } from 'antd'
+import { ReloadOutlined } from '@ant-design/icons'
+import { useQuery } from '@tanstack/react-query'
+import http, { apiErrorMessage } from '../api/http'
+import PageHeader from '../components/PageHeader'
+
+type Health = 'healthy' | 'warning' | 'unhealthy'
+type Backup = { id: string; fileName: string; provider: string; createdAt: string; sizeBytes: number; lastDrillSucceeded?: boolean }
+type Summary = { generatedAt: string; health: { components: { key: string; label: string; status: Health; detail: string; latencyMilliseconds?: number }[] }; counters: { auditEvents24Hours: number; serverErrors24Hours: number; failedJobs7Days: number; criticalAlerts: number }; alerts: { id: string; severity: 'critical' | 'warning'; title: string; detail: string }[] }
+const stamp = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
+export default function OperationsPage() {
+ const { message, modal } = App.useApp(); const summary = useQuery({ queryKey: ['operations-summary'], queryFn: async () => (await http.get('/operations/summary')).data }); const backups = useQuery({ queryKey: ['operations-backups'], queryFn: async () => (await http.get('/operations/backups')).data }); const swagger = useQuery({ queryKey: ['operations-swagger'], queryFn: async () => (await http.get<{ isEnabled: boolean }>('/operations/swagger')).data })
+ async function refresh() { await Promise.all([summary.refetch(), backups.refetch(), swagger.refetch()]) }
+ async function setSwagger(isEnabled: boolean) { try { await http.put('/operations/swagger', { isEnabled }); await swagger.refetch(); message.success(isEnabled ? 'Swagger 已开放。' : 'Swagger 已关闭。') } catch (error) { message.error(apiErrorMessage(error)) } }
+ function drill(item: Backup) { modal.confirm({ title: '运行恢复演练', content: `将把“${item.fileName}”恢复到隔离数据库,业务数据库不会被覆盖。`, okText: '开始隔离演练', onOk: async () => { try { const result = await http.post<{ succeeded: boolean; detail: string }>(`/operations/backups/${item.id}/restore-drill`, { confirmation: 'RESTORE_DRILL' }, { timeout: 1800000 }); result.data.succeeded ? message.success(result.data.detail) : message.error(result.data.detail); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) }
+ if (summary.isLoading || backups.isLoading || swagger.isLoading) return
+ if (summary.isError) return
+ const data = summary.data!
+ return } onClick={() => void refresh()}>刷新状态} />} />{data.health.components.map((item) => {item.status}{item.label}{item.detail}{item.latencyMilliseconds != null && ` · ${item.latencyMilliseconds} ms`})}{data.alerts.map((item) => )}备份与恢复演练
rowKey="id" dataSource={backups.data} pagination={false} columns={[{ title: '备份', dataIndex: 'fileName' }, { title: '提供方', dataIndex: 'provider' }, { title: '创建时间', render: (_, item) => stamp(item.createdAt) }, { title: '演练', render: (_, item) => }]} />
+}