feat(web): migrate operations overview

This commit is contained in:
biss committed 2026-10-04 08:42:09 +08:00
1 parent eead9ed7f2
commit 069e2b300b
3 files changed
+23 -1

No files matched your search

+2
View File
@@ -56,6 +56,7 @@ const ClassroomRepairsPage = lazy(() => import('../pages/ClassroomRepairsPage'))
const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatisticsPage')) const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatisticsPage'))
const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage')) const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage'))
const PluginsPage = lazy(() => import('../pages/PluginsPage')) const PluginsPage = lazy(() => import('../pages/PluginsPage'))
const OperationsPage = lazy(() => import('../pages/OperationsPage'))
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
@@ -160,6 +161,7 @@ function AppRoutes() {
<Route path="/grades/:sheetId/statistics" element={<CourseGradeStatisticsPage />} /> <Route path="/grades/:sheetId/statistics" element={<CourseGradeStatisticsPage />} />
<Route path="/event-analytics" element={<EventAnalyticsPage />} /> <Route path="/event-analytics" element={<EventAnalyticsPage />} />
<Route path="/plugins" element={<PluginsPage />} /> <Route path="/plugins" element={<PluginsPage />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
+1 -1
View File
@@ -141,7 +141,7 @@ export default function AppShell() {
{ key: '/students', icon: <IdcardOutlined />, label: '学生档案' }, { key: '/students', icon: <IdcardOutlined />, label: '学生档案' },
] : []), ] : []),
...(user?.roles.includes('SuperAdmin') ...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }, { key: '/plugins', icon: <AppstoreOutlined />, label: '插件平台' }] ? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }, { key: '/plugins', icon: <AppstoreOutlined />, label: '插件平台' }, { key: '/operations', icon: <SafetyOutlined />, label: '运维与审计' }]
: []), : []),
{ key: 'legacy', icon: <ExportOutlined />, label: '返回现有系统' }, { key: 'legacy', icon: <ExportOutlined />, label: '返回现有系统' },
], [isStudentHome, isTeacherHome, user?.roles]) ], [isStudentHome, isTeacherHome, user?.roles])
+20
View File
@@ -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<Summary>('/operations/summary')).data }); const backups = useQuery({ queryKey: ['operations-backups'], queryFn: async () => (await http.get<Backup[]>('/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 <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
if (summary.isError) return <main className="page-container"><Alert type="error" showIcon message="运维控制台无法加载" description={apiErrorMessage(summary.error)} /></main>
const data = summary.data!
return <main className="page-container"><PageHeader kicker="SYSTEM WATCH" title="运维与审计控制台" description={`最近更新:${stamp(data.generatedAt)}`} actions={<Button icon={<ReloadOutlined />} onClick={() => void refresh()}>刷新状态</Button>} /><Alert type="info" message="Swagger 文档" description={swagger.data?.isEnabled ? '文档已开放。' : '文档已关闭。'} action={<Switch checked={swagger.data?.isEnabled} onChange={setSwagger} />} /><section className="ops-grid">{data.health.components.map((item) => <article key={item.key}><Tag color={item.status === 'healthy' ? 'green' : item.status === 'warning' ? 'orange' : 'red'}>{item.status}</Tag><strong>{item.label}</strong><span>{item.detail}{item.latencyMilliseconds != null && ` · ${item.latencyMilliseconds} ms`}</span></article>)}</section><section className="ops-grid">{data.alerts.map((item) => <Alert key={item.id} type={item.severity === 'critical' ? 'error' : 'warning'} message={item.title} description={item.detail} />)}</section><section><h3>备份与恢复演练</h3><Table<Backup> rowKey="id" dataSource={backups.data} pagination={false} columns={[{ title: '备份', dataIndex: 'fileName' }, { title: '提供方', dataIndex: 'provider' }, { title: '创建时间', render: (_, item) => stamp(item.createdAt) }, { title: '演练', render: (_, item) => <Button type="link" onClick={() => drill(item)}>{item.lastDrillSucceeded === true ? '再次演练' : '运行恢复演练'}</Button> }]} /></section></main>
}