feat: ship persistent ai playground and alerts foundation
This commit is contained in:
@@ -1,221 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
|
||||
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface Alert {
|
||||
id: number
|
||||
severity: 'critical' | 'warning' | 'info'
|
||||
status: 'active' | 'acknowledged' | 'resolved'
|
||||
datasource_name: string
|
||||
message: string
|
||||
created_at: string
|
||||
acknowledged_at?: string
|
||||
resolved_at?: string
|
||||
}
|
||||
import { AlertOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons'
|
||||
import { Tabs, Typography } from 'antd'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { BGPAlertsPanel } from './BGPAlerts'
|
||||
import { SituationalAlertsPanel } from './SituationalAlerts'
|
||||
import { SystemAlertsPanel } from './SystemAlerts'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const ALERT_TABS = [
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统告警',
|
||||
icon: <AlertOutlined />,
|
||||
children: <SystemAlertsPanel />,
|
||||
},
|
||||
{
|
||||
key: 'bgp',
|
||||
label: 'BGP 告警',
|
||||
icon: <DeploymentUnitOutlined />,
|
||||
children: <BGPAlertsPanel />,
|
||||
},
|
||||
{
|
||||
key: 'situational',
|
||||
label: '态势告警',
|
||||
icon: <RadarChartOutlined />,
|
||||
children: <SituationalAlertsPanel />,
|
||||
},
|
||||
]
|
||||
|
||||
function Alerts() {
|
||||
const { token } = useAuthStore()
|
||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/alerts', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
setAlerts(data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch alerts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts()
|
||||
}, [token])
|
||||
|
||||
const handleAcknowledge = async (alertId: number) => {
|
||||
try {
|
||||
await fetch(`/api/v1/alerts/${alertId}/acknowledge`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
fetchAlerts()
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge alert:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (alertId: number) => {
|
||||
try {
|
||||
await fetch(`/api/v1/alerts/${alertId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ resolution: '已处理' }),
|
||||
})
|
||||
fetchAlerts()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve alert:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'severity',
|
||||
key: 'severity',
|
||||
render: (s: string) => {
|
||||
const colors: Record<string, string> = { critical: 'error', warning: 'warning', info: 'blue' }
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
critical: <AlertOutlined />,
|
||||
warning: <AlertOutlined />,
|
||||
info: <InfoCircleOutlined />,
|
||||
}
|
||||
return (
|
||||
<Tag color={colors[s]} icon={icons[s]}>
|
||||
{s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => {
|
||||
const colors: Record<string, string> = { active: 'red', acknowledged: 'orange', resolved: 'green' }
|
||||
return (
|
||||
<Tag color={colors[s]}>
|
||||
{s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: '数据源', dataIndex: 'datasource_name', key: 'datasource_name' },
|
||||
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => formatDateTimeZhCN(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Alert) => (
|
||||
<Space>
|
||||
{record.status === 'active' && (
|
||||
<Button type="link" size="small" onClick={() => handleAcknowledge(record.id)}>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'resolved' && (
|
||||
<Button type="link" size="small" onClick={() => handleResolve(record.id)}>
|
||||
解决
|
||||
</Button>
|
||||
)}
|
||||
<Button type="link" size="small" onClick={() => { setSelectedAlert(record); setDetailVisible(true); }}>
|
||||
详情
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const stats = alerts.reduce(
|
||||
(acc, alert) => {
|
||||
if (alert.status === 'active') {
|
||||
acc[alert.severity]++
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ critical: 0, warning: 0, info: 0 } as Record<string, number>
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const requestedTab = searchParams.get('tab') || 'system'
|
||||
const activeTab = useMemo(
|
||||
() => (ALERT_TABS.some((item) => item.key === requestedTab) ? requestedTab : 'system'),
|
||||
[requestedTab],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="严重告警"
|
||||
value={stats.critical}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
prefix={<AlertOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="警告"
|
||||
value={stats.warning}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
prefix={<AlertOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="告警列表"
|
||||
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
||||
>
|
||||
<div className="table-scroll-region">
|
||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
<div className="page-shell alerts-page">
|
||||
<div className="page-shell__header alerts-page__header">
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>告警工作台</Title>
|
||||
<Text type="secondary">
|
||||
统一查看系统告警、BGP 告警和跨模块态势告警。主工作区保持单屏,具体证据和 AI 简报在各个 Tab 内处理。
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="告警详情"
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
{selectedAlert && (
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="级别">
|
||||
<Tag color={selectedAlert.severity === 'critical' ? 'error' : selectedAlert.severity === 'warning' ? 'warning' : 'blue'}>
|
||||
{selectedAlert.severity}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={selectedAlert.status === 'active' ? 'red' : selectedAlert.status === 'acknowledged' ? 'orange' : 'green'}>
|
||||
{selectedAlert.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
|
||||
{selectedAlert.acknowledged_at && (
|
||||
<Descriptions.Item label="确认时间">
|
||||
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedAlert.resolved_at && (
|
||||
<Descriptions.Item label="解决时间">
|
||||
{formatDateTimeZhCN(selectedAlert.resolved_at)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
<div className="page-shell__body alerts-page__body">
|
||||
<Tabs
|
||||
className="alerts-page__tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setSearchParams({ tab: key })}
|
||||
items={ALERT_TABS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal file
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ReloadOutlined, RobotOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
|
||||
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const gateway = getSituationalAwarenessGateway()
|
||||
|
||||
function severityColor(severity: string) {
|
||||
if (severity === 'critical') return 'red'
|
||||
if (severity === 'high') return 'orange'
|
||||
if (severity === 'medium') return 'gold'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
export function BGPAlertsPanel() {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||||
const [briefLoading, setBriefLoading] = useState(false)
|
||||
const [briefModalOpen, setBriefModalOpen] = useState(false)
|
||||
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [incidentRows, anomalyRows] = await Promise.all([
|
||||
gateway.getBGPIncidents(50),
|
||||
gateway.getBGPAnomalies(80),
|
||||
])
|
||||
setIncidents(incidentRows)
|
||||
setAnomalies(anomalyRows)
|
||||
} catch (error) {
|
||||
console.error('Failed to load BGP alerts:', error)
|
||||
messageApi.error('BGP 告警加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [])
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
activeIncidents: incidents.filter((item) => item.status === 'active').length,
|
||||
criticalIncidents: incidents.filter((item) => item.severity === 'critical').length,
|
||||
activeAnomalies: anomalies.filter((item) => item.status === 'active').length,
|
||||
highRiskAnomalies: anomalies.filter((item) => ['critical', 'high'].includes(item.severity)).length,
|
||||
}),
|
||||
[anomalies, incidents],
|
||||
)
|
||||
|
||||
const handleGenerateBrief = async () => {
|
||||
setBriefModalOpen(true)
|
||||
setBriefLoading(true)
|
||||
try {
|
||||
const record = await gateway.generateBGPBrief()
|
||||
setBrief(record)
|
||||
messageApi.success('BGP AI 简报已生成')
|
||||
} catch (error) {
|
||||
console.error('Failed to generate BGP brief:', error)
|
||||
messageApi.error('BGP AI 简报生成失败')
|
||||
} finally {
|
||||
setBriefLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const incidentColumns: TableColumnsType<BGPIncident> = [
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{ title: '类型', dataIndex: 'incident_type', width: 180 },
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '影响前缀',
|
||||
dataIndex: 'affected_prefixes',
|
||||
width: 220,
|
||||
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
|
||||
},
|
||||
{ title: '摘要', dataIndex: 'summary', width: 320 },
|
||||
]
|
||||
|
||||
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{ title: '类型', dataIndex: 'anomaly_type', width: 180 },
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 200,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{ title: '摘要', dataIndex: 'summary', width: 320 },
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="alerts-tab-panel bgp-alerts-page">
|
||||
{contextHolder}
|
||||
<Space className="bgp-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div className="alerts-tab-panel__head">
|
||||
<div>
|
||||
<Text strong>BGP 告警</Text>
|
||||
<div className="alerts-tab-panel__subtitle">把 BGP incidents 与 anomalies 当作告警工作台来快速筛查控制平面风险。</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
|
||||
生成 BGP AI 简报
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="bgp-alerts-page__table-card">
|
||||
<Tabs
|
||||
className="bgp-alerts-page__tabs"
|
||||
items={[
|
||||
{
|
||||
key: 'incidents',
|
||||
label: 'BGP 事件',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<Table<BGPIncident>
|
||||
columns={incidentColumns}
|
||||
dataSource={incidents}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: 'BGP 异常',
|
||||
children: (
|
||||
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||
<Table<BGPAnomaly>
|
||||
columns={anomalyColumns}
|
||||
dataSource={anomalies}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title="BGP AI 简报"
|
||||
open={briefModalOpen}
|
||||
onCancel={() => setBriefModalOpen(false)}
|
||||
footer={null}
|
||||
width={920}
|
||||
className="bgp-page__brief-modal"
|
||||
style={{ top: 24 }}
|
||||
styles={{ body: { paddingTop: 12 } }}
|
||||
>
|
||||
{briefLoading ? (
|
||||
<div className="bgp-page__brief-loading">
|
||||
<Spin tip="正在生成 BGP AI 简报..." />
|
||||
</div>
|
||||
) : brief ? (
|
||||
<div className="bgp-page__brief-modal-body">
|
||||
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
|
||||
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default BGPAlertsPanel
|
||||
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal file
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { DeploymentUnitOutlined, ReloadOutlined, RobotOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
||||
import {
|
||||
getSituationalAwarenessGateway,
|
||||
type SituationalAlertBriefResponse,
|
||||
} from '../../services/situational-awareness'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const gateway = getSituationalAwarenessGateway()
|
||||
|
||||
interface AlertStatsResponse {
|
||||
critical: number
|
||||
warning: number
|
||||
info: number
|
||||
}
|
||||
|
||||
export function SituationalAlertsPanel() {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [systemStats, setSystemStats] = useState<AlertStatsResponse | null>(null)
|
||||
const [bgpSummary, setBgpSummary] = useState<BGPSummarySnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [briefLoading, setBriefLoading] = useState(false)
|
||||
const [briefError, setBriefError] = useState<string | null>(null)
|
||||
const [briefResult, setBriefResult] = useState<SituationalAlertBriefResponse | null>(null)
|
||||
|
||||
const loadOverview = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [alertStatsResponse, bgpSummaryResponse] = await Promise.all([
|
||||
axios.get<AlertStatsResponse>(`${API_BASE_URL}/alerts/stats`),
|
||||
gateway.getBGPSummary(),
|
||||
])
|
||||
setSystemStats(alertStatsResponse.data)
|
||||
setBgpSummary(bgpSummaryResponse)
|
||||
} catch (error) {
|
||||
console.error('Failed to load situational alerts overview:', error)
|
||||
messageApi.error('态势告警概览加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview()
|
||||
}, [])
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
activeSystemAlerts: (systemStats?.critical || 0) + (systemStats?.warning || 0) + (systemStats?.info || 0),
|
||||
criticalSystemAlerts: systemStats?.critical || 0,
|
||||
activeBGPIncidents: bgpSummary?.incidentSummary?.by_status?.active || 0,
|
||||
criticalBGPIncidents: bgpSummary?.incidentSummary?.by_severity?.critical || 0,
|
||||
}),
|
||||
[bgpSummary, systemStats],
|
||||
)
|
||||
|
||||
const handleGenerateBrief = async () => {
|
||||
setBriefOpen(true)
|
||||
setBriefLoading(true)
|
||||
setBriefError(null)
|
||||
try {
|
||||
const response = await axios.post<SituationalAlertBriefResponse>(`${API_BASE_URL}/ai/situational-alerts/brief`, {})
|
||||
setBriefResult(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate situational alert brief:', error)
|
||||
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||
setBriefError(detail || '生成态势告警 AI 简报失败')
|
||||
messageApi.error('生成态势告警 AI 简报失败')
|
||||
} finally {
|
||||
setBriefLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="alerts-tab-panel situational-alerts-page">
|
||||
{contextHolder}
|
||||
<Space className="situational-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div className="alerts-tab-panel__head">
|
||||
<div>
|
||||
<Text strong>态势告警</Text>
|
||||
<div className="alerts-tab-panel__subtitle">把系统告警与 BGP 风险放在同一视角下综合研判,适合做值班总览和跨模块优先级排序。</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
|
||||
生成态势 AI 简报
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadOverview()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="系统告警侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="BGP 风险侧">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
|
||||
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
<div className="alerts-brief-drawer">
|
||||
{briefLoading ? (
|
||||
<div className="alerts-brief-drawer__loading">
|
||||
<Spin tip="正在生成态势告警 AI 简报..." />
|
||||
</div>
|
||||
) : null}
|
||||
{!briefLoading && briefError ? (
|
||||
<Alert type="error" showIcon message="态势告警 AI 简报生成失败" description={briefError} />
|
||||
) : null}
|
||||
{!briefLoading && briefResult ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card size="small">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃系统告警">{String(briefResult.context.active_system_alerts ?? '-')}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃 BGP 事件">{String(briefResult.context.active_bgp_incidents ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card size="small" title="事实输入">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{briefResult.facts.map((fact, index) => (
|
||||
<div key={`${index}-${fact}`} className="alerts-brief-fact">
|
||||
<Text strong>{index + 1}.</Text>
|
||||
<Text>{fact}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" title="AI 简报">
|
||||
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
|
||||
</Card>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SituationalAlertsPanel
|
||||
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal file
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
|
||||
function renderAlertSeverityTag(value: AlertRecord['severity']) {
|
||||
const colorMap = { critical: 'error', warning: 'warning', info: 'blue' }
|
||||
const labelMap = { critical: '严重', warning: '警告', info: '信息' }
|
||||
const iconMap = {
|
||||
critical: <AlertOutlined />,
|
||||
warning: <AlertOutlined />,
|
||||
info: <InfoCircleOutlined />,
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag color={colorMap[value]} icon={iconMap[value]}>
|
||||
{labelMap[value]}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAlertStatusTag(value: AlertRecord['status']) {
|
||||
const colorMap = { active: 'red', acknowledged: 'orange', resolved: 'green' }
|
||||
const labelMap = { active: '待处理', acknowledged: '已确认', resolved: '已解决' }
|
||||
return <Tag color={colorMap[value]}>{labelMap[value]}</Tag>
|
||||
}
|
||||
|
||||
export function SystemAlertsPanel() {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [alerts, setAlerts] = useState<AlertRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [briefLoading, setBriefLoading] = useState(false)
|
||||
const [briefError, setBriefError] = useState<string | null>(null)
|
||||
const [briefResult, setBriefResult] = useState<AlertBriefResponse | null>(null)
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.get<{ data: AlertRecord[] }>(`${API_BASE_URL}/alerts`)
|
||||
setAlerts(response.data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system alerts:', error)
|
||||
messageApi.error('系统告警加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAlerts()
|
||||
}, [])
|
||||
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
alerts.reduce(
|
||||
(accumulator, item) => {
|
||||
if (item.status === 'active') {
|
||||
accumulator[item.severity] += 1
|
||||
}
|
||||
return accumulator
|
||||
},
|
||||
{ critical: 0, warning: 0, info: 0 } as Record<AlertRecord['severity'], number>,
|
||||
),
|
||||
[alerts],
|
||||
)
|
||||
|
||||
const handleAcknowledge = async (alertId: number) => {
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/alerts/${alertId}/acknowledge`)
|
||||
messageApi.success('告警已确认')
|
||||
await fetchAlerts()
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge alert:', error)
|
||||
messageApi.error('确认告警失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (alertId: number) => {
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/alerts/${alertId}/resolve`, { resolution: '已处理' })
|
||||
messageApi.success('告警已解决')
|
||||
await fetchAlerts()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve alert:', error)
|
||||
messageApi.error('解决告警失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateBrief = async () => {
|
||||
setBriefOpen(true)
|
||||
setBriefLoading(true)
|
||||
setBriefError(null)
|
||||
try {
|
||||
const response = await axios.post<AlertBriefResponse>(`${API_BASE_URL}/ai/alerts/brief`, {})
|
||||
setBriefResult(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate system alert brief:', error)
|
||||
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||
setBriefError(detail || '生成系统告警 AI 简报失败')
|
||||
messageApi.error('生成系统告警 AI 简报失败')
|
||||
} finally {
|
||||
setBriefLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AlertRecord> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'severity',
|
||||
width: 108,
|
||||
render: (value: AlertRecord['severity']) => renderAlertSeverityTag(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 108,
|
||||
render: (value: AlertRecord['status']) => renderAlertStatusTag(value),
|
||||
},
|
||||
{
|
||||
title: '数据源',
|
||||
dataIndex: 'datasource_name',
|
||||
width: 180,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
dataIndex: 'message',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
{record.status === 'active' ? (
|
||||
<Button type="link" size="small" onClick={() => void handleAcknowledge(record.id)}>
|
||||
确认
|
||||
</Button>
|
||||
) : null}
|
||||
{record.status !== 'resolved' ? (
|
||||
<Button type="link" size="small" onClick={() => void handleResolve(record.id)}>
|
||||
解决
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedAlert(record)
|
||||
setDetailVisible(true)
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="alerts-tab-panel system-alerts-page">
|
||||
{contextHolder}
|
||||
<Space className="system-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div className="alerts-tab-panel__head">
|
||||
<div>
|
||||
<Text strong>系统告警</Text>
|
||||
<div className="alerts-tab-panel__subtitle">聚焦平台运行、采集链路和系统内部异常。</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
|
||||
生成 AI 简报
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void fetchAlerts()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="system-alerts-page__table-card" title="系统告警列表">
|
||||
<div className="table-scroll-region system-alerts-page__table-region">
|
||||
<Table<AlertRecord>
|
||||
columns={columns}
|
||||
dataSource={alerts}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1100, y: 480 }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Modal title="告警详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null} width={640}>
|
||||
{selectedAlert ? (
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="级别">{renderAlertSeverityTag(selectedAlert.severity)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{renderAlertStatusTag(selectedAlert.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="数据源">{selectedAlert.datasource_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="确认时间">{formatDateTimeZhCN(selectedAlert.acknowledged_at || null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="解决时间">{formatDateTimeZhCN(selectedAlert.resolved_at || null)}</Descriptions.Item>
|
||||
<Descriptions.Item label="处理说明">{selectedAlert.resolution_notes || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||
<div className="alerts-brief-drawer">
|
||||
{briefLoading ? (
|
||||
<div className="alerts-brief-drawer__loading">
|
||||
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
||||
</div>
|
||||
) : null}
|
||||
{!briefLoading && briefError ? (
|
||||
<Alert type="error" showIcon message="系统告警 AI 简报生成失败" description={briefError} />
|
||||
) : null}
|
||||
{!briefLoading && briefResult ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card size="small">
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="待处理告警">{String(briefResult.context.active_alerts ?? '-')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card size="small" title="事实输入">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{briefResult.facts.map((fact, index) => (
|
||||
<div key={`${index}-${fact}`} className="alerts-brief-fact">
|
||||
<Text strong>{index + 1}.</Text>
|
||||
<Text>{fact}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" title="AI 简报">
|
||||
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
|
||||
</Card>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemAlertsPanel
|
||||
@@ -66,6 +66,19 @@ function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
|
||||
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
|
||||
}
|
||||
|
||||
function renderBriefContextValue(value: unknown) {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? JSON.stringify(value) : '-'
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length === 0) return '-'
|
||||
return entries.map(([key, count]) => `${key}: ${String(count)}`).join(',')
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
|
||||
return [record.city, record.country].filter(Boolean).join(', ') || '-'
|
||||
}
|
||||
@@ -574,7 +587,23 @@ function BGP() {
|
||||
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
|
||||
{formatDateTimeZhCN(brief.generated_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事实条目">{brief.facts.length}</Descriptions.Item>
|
||||
<Descriptions.Item label="Incident 总数">{renderBriefContextValue(brief.context.incident_total)}</Descriptions.Item>
|
||||
<Descriptions.Item label="活跃观测站">{renderBriefContextValue(brief.context.active_collectors)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{brief.facts.length > 0 ? (
|
||||
<div className="bgp-page__brief-facts">
|
||||
<Text strong>事实快照</Text>
|
||||
<div className="bgp-page__brief-fact-list">
|
||||
{brief.facts.slice(0, 3).map((fact, index) => (
|
||||
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
|
||||
<Text strong>{index + 1}.</Text>
|
||||
<Text>{fact}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bgp-page__brief-empty">
|
||||
@@ -727,6 +756,34 @@ function BGP() {
|
||||
>
|
||||
{brief ? (
|
||||
<div className="bgp-page__brief-modal-body">
|
||||
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
|
||||
<div className="bgp-page__brief-evidence">
|
||||
{brief.facts.length > 0 ? (
|
||||
<Card size="small" title="事实输入快照" className="bgp-page__brief-evidence-card">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{brief.facts.map((fact, index) => (
|
||||
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
|
||||
<Text strong>{index + 1}.</Text>
|
||||
<Text>{fact}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{Object.keys(brief.context || {}).length > 0 ? (
|
||||
<Card size="small" title="结构化上下文" className="bgp-page__brief-evidence-card">
|
||||
<Descriptions size="small" column={1}>
|
||||
{Object.entries(brief.context).map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={key}>
|
||||
{renderBriefContextValue(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user