feat: M0-06+M0-07 admin web — Content Safety + Metrics pages
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 5s

This commit is contained in:
WangDL 2026-05-22 23:21:15 +08:00
parent e28f962147
commit 2413cdf561
5 changed files with 144 additions and 0 deletions

View File

@ -105,6 +105,14 @@ function App() {
path="config"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><ConfigPage /></Suspense></PermissionGuard>}
/>
<Route
path="metrics"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><MetricsPage /></Suspense></PermissionGuard>}
/>
<Route
path="safety"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><CSPage /></Suspense></PermissionGuard>}
/>
<Route
path="events"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>}

View File

@ -30,7 +30,11 @@ export const adminMenuItems: AdminMenuItem[] = [
{ path: '/billing', name: 'API 用量', icon: <DollarOutlined />, requiredRole: 'SUPER_ADMIN' },
{ path: '/git', name: '代码仓库', icon: <CodeOutlined /> },
{ path: '/ops', name: '系统运维', icon: <CloudServerOutlined />, requiredRole: 'SUPER_ADMIN', children: [
{ path: '/metrics', name: '接口监控' },
{ path: '/servers', name: '服务器' },
{ path: '/events', name: '事件队列' },
{ path: '/config', name: '配置管理' },
{ path: '/safety', name: '内容安全' },
{ path: '/servers', name: '服务器' },
{ path: '/events', name: '事件队列' },
]},

View File

@ -25,6 +25,8 @@ const breadcrumbMap: Record<string, string> = {
'/git': '代码仓库',
'/servers': '服务器运维',
'/config': '配置管理',
'/metrics': '接口监控',
'/safety': '内容安全',
'/events': '事件队列',
'/audit': '审计日志',
}

View File

@ -0,0 +1,73 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Space, Tabs } from 'antd'
import { ReloadOutlined, PlusOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons'
import { api } from '@/services/http-client'
import dayjs from 'dayjs'
const { Title, Text } = Typography
function CSPage() {
const { modal, message } = App.useApp()
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [newWord, setNewWord] = useState('')
const [category, setCategory] = useState('general')
const [risk, setRisk] = useState('medium')
const { data: words } = useQuery({ queryKey: ['safety', 'words'], queryFn: () => api.get('/admin-api/content-safety/words') })
const { data: checks } = useQuery({ queryKey: ['safety', 'checks'], queryFn: () => api.get('/admin-api/content-safety/checks') })
const addWord = async () => {
await api.post('/admin-api/content-safety/words', { word: newWord, category, riskLevel: risk })
message.success('已添加'); setAddOpen(false); setNewWord('')
qc.invalidateQueries({ queryKey: ['safety'] })
}
const removeWord = (id: string) => modal.confirm({
title: '删除敏感词', okType: 'danger',
onOk: async () => { await api.delete(`/admin-api/content-safety/words/${id}`); qc.invalidateQueries({ queryKey: ['safety'] }) },
})
const wordCols = [
{ title: '词汇', dataIndex: 'word', width: 150 },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '等级', dataIndex: 'riskLevel', width: 80, render: (v: string) => <Tag color={v==='critical'?'red':v==='high'?'orange':v==='medium'?'blue':'default'}>{v}</Tag> },
{ title: '状态', dataIndex: 'enabled', width: 70, render: (v: boolean) => v ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '添加时间', dataIndex: 'createdAt', width: 140, render: (d: string) => dayjs(d).format('MM-DD HH:mm') },
{ title: '操作', width: 80, render: (_: any, r: any) => <Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => removeWord(r.id)} /> },
]
const checkCols = [
{ title: '时间', dataIndex: 'createdAt', width: 130, render: (d: string) => dayjs(d).format('MM-DD HH:mm:ss') },
{ title: '类型', dataIndex: 'contentType', width: 100 },
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v: string) => <Text style={{ fontSize: 12 }}>{v?.slice(0, 80)}</Text> },
{ title: '风险', dataIndex: 'riskLevel', width: 70, render: (v: string) => <Tag color={v==='critical'?'red':v==='high'?'orange':'default'}>{v}</Tag> },
{ title: '结果', dataIndex: 'result', width: 80, render: (v: string) => <Tag color={v==='passed'?'green':v==='blocked'?'red':'gold'}>{v}</Tag> },
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><SafetyOutlined /> </Title>
<Space>
<Button icon={<PlusOutlined />} type="primary" onClick={() => { setNewWord(''); setAddOpen(true) }}></Button>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['safety'] })}></Button>
</Space>
</div>
<Tabs items={[
{ key: 'words', label: '敏感词库', children: <Table dataSource={words || []} columns={wordCols} rowKey="id" pagination={false} size="small" /> },
{ key: 'checks', label: '审核记录', children: <Table dataSource={checks || []} columns={checkCols} rowKey="id" pagination={{ pageSize: 20 }} size="small" /> },
]} />
<Modal title="新增敏感词" open={addOpen} onOk={addWord} onCancel={() => setAddOpen(false)} okText="添加">
<Input placeholder="词汇" value={newWord} onChange={e => setNewWord(e.target.value)} style={{ marginBottom: 12 }} />
<Select value={category} onChange={setCategory} style={{ width: '100%', marginBottom: 12 }} options={[{ label: '通用', value: 'general' }, { label: '政治', value: 'political' }, { label: '色情', value: 'adult' }, { label: '暴力', value: 'violence' }]} />
<Select value={risk} onChange={setRisk} style={{ width: '100%' }} options={[{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '严重', value: 'critical' }]} />
</Modal>
</div>
)
}
export default CSPage

57
src/pages/Metrics.tsx Normal file
View File

@ -0,0 +1,57 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Table, Card, Row, Col, Statistic, Button, Typography, App } from 'antd'
import { ReloadOutlined, DashboardOutlined } from '@ant-design/icons'
import { api } from '@/services/http-client'
import dayjs from 'dayjs'
const { Title } = Typography
function MetricsPage() {
const qc = useQueryClient()
const { data: overview } = useQuery({ queryKey: ['metrics', 'overview'], queryFn: () => api.get('/admin-api/metrics/overview'), staleTime: 10_000 })
const { data: top } = useQuery({ queryKey: ['metrics', 'top'], queryFn: () => api.get('/admin-api/metrics/top?limit=15'), staleTime: 10_000 })
const { data: recent } = useQuery({ queryKey: ['metrics', 'recent'], queryFn: () => api.get('/admin-api/metrics/recent?limit=30'), staleTime: 5_000 })
const topCols = [
{ title: '接口', dataIndex: 'path', width: 300, ellipsis: true },
{ title: '方法', dataIndex: 'method', width: 70 },
{ title: '调用', dataIndex: 'calls', width: 70, align: 'center' as const },
{ title: '平均耗时', dataIndex: 'avgDuration', width: 100, align: 'center' as const, render: (v: number) => <span style={{ color: v > 1000 ? '#ff4d4f' : v > 500 ? '#faad14' : '#52c41a' }}>{v}ms</span> },
]
const recentCols = [
{ title: '时间', dataIndex: 'createdAt', width: 140, render: (d: string) => dayjs(d).format('HH:mm:ss') },
{ title: '接口', dataIndex: 'path', ellipsis: true },
{ title: '方法', dataIndex: 'method', width: 60 },
{ title: '状态', dataIndex: 'statusCode', width: 60, render: (v: number) => <span style={{ color: v >= 400 ? '#ff4d4f' : '#52c41a' }}>{v}</span> },
{ title: '耗时', dataIndex: 'duration', width: 70, align: 'center' as const, render: (v: number) => `${v}ms` },
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><DashboardOutlined /> </Title>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['metrics'] })}></Button>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}><Card size="small"><Statistic title="今日调用" value={overview?.todayCalls || 0} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="平均耗时" value={overview?.avgDuration || 0} suffix="ms" valueStyle={{ color: (overview?.avgDuration || 0) > 500 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="错误数" value={overview?.errorCount || 0} valueStyle={{ color: (overview?.errorCount || 0) > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
<Col span={6}><Card size="small"><Statistic title="总调用" value={overview?.total || 0} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card size="small" title="耗时排行"><Table dataSource={top || []} columns={topCols} rowKey="path" pagination={false} size="small" /></Card>
</Col>
<Col span={12}>
<Card size="small" title="最近请求"><Table dataSource={recent || []} columns={recentCols} rowKey="id" pagination={false} size="small" /></Card>
</Col>
</Row>
</div>
)
}
export default MetricsPage