Shared backend + frontend for multi-customer EMS deployments. - 12 enterprise modules: quota, cost, charging, maintenance, analysis, etc. - 120+ API endpoints, 37 database tables - Customer config mechanism (CUSTOMER env var + YAML config) - Collectors: Modbus TCP, MQTT, HTTP API, Sungrow iSolarCloud - Frontend: React 19 + Ant Design + ECharts + Three.js - Infrastructure: Redis cache, rate limiting, aggregation engine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
860 lines
32 KiB
TypeScript
860 lines
32 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Card, Row, Col, Statistic, Tag, Tabs, Button, Table, Space, Progress,
|
|
Drawer, Descriptions, Timeline, Badge, Select, message, Tooltip, Empty,
|
|
Modal, List, Calendar, Input,
|
|
} from 'antd';
|
|
import {
|
|
RobotOutlined, HeartOutlined, AlertOutlined, MedicineBoxOutlined,
|
|
ToolOutlined, BulbOutlined, SyncOutlined, ArrowUpOutlined,
|
|
ArrowDownOutlined, MinusOutlined, ThunderboltOutlined,
|
|
ExperimentOutlined, SafetyCertificateOutlined, EyeOutlined,
|
|
CheckCircleOutlined, CloseCircleOutlined, WarningOutlined,
|
|
InfoCircleOutlined, FireOutlined,
|
|
} from '@ant-design/icons';
|
|
import ReactECharts from 'echarts-for-react';
|
|
import dayjs from 'dayjs';
|
|
import {
|
|
getAiOpsDashboard, getAiOpsHealth, getAiOpsHealthHistory,
|
|
getAiOpsAnomalies, updateAnomalyStatus, triggerAnomalyScan,
|
|
getAiOpsDiagnostics, runDeviceDiagnostics,
|
|
getAiOpsPredictions, getAiOpsMaintenanceSchedule,
|
|
getAiOpsInsights, triggerInsights, triggerHealthCalc, triggerPredictions,
|
|
} from '../../services/api';
|
|
|
|
const severityColors: Record<string, string> = {
|
|
critical: 'red', warning: 'orange', info: 'blue',
|
|
};
|
|
const statusColors: Record<string, string> = {
|
|
healthy: 'green', warning: 'orange', critical: 'red',
|
|
detected: 'red', investigating: 'orange', resolved: 'green', false_positive: 'default',
|
|
generated: 'blue', reviewed: 'cyan', action_taken: 'green',
|
|
predicted: 'orange', scheduled: 'blue', completed: 'green', false_alarm: 'default',
|
|
};
|
|
const trendIcons: Record<string, React.ReactNode> = {
|
|
improving: <ArrowUpOutlined style={{ color: '#52c41a' }} />,
|
|
stable: <MinusOutlined style={{ color: '#1890ff' }} />,
|
|
degrading: <ArrowDownOutlined style={{ color: '#f5222d' }} />,
|
|
};
|
|
const anomalyTypeLabels: Record<string, string> = {
|
|
power_drop: '功率下降', efficiency_loss: '能效降低', abnormal_temperature: '温度异常',
|
|
communication_loss: '通讯中断', pattern_deviation: '模式偏移',
|
|
};
|
|
const urgencyColors: Record<string, string> = {
|
|
critical: 'red', high: 'orange', medium: 'blue', low: 'default',
|
|
};
|
|
const impactColors: Record<string, string> = {
|
|
high: 'red', medium: 'orange', low: 'blue',
|
|
};
|
|
const insightTypeLabels: Record<string, string> = {
|
|
efficiency_trend: '效率趋势', cost_anomaly: '费用异常',
|
|
performance_comparison: '性能对比', seasonal_pattern: '季节性规律',
|
|
};
|
|
|
|
// ── Tab: Health Overview ───────────────────────────────────────────
|
|
|
|
function HealthOverview() {
|
|
const [devices, setDevices] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [detailDevice, setDetailDevice] = useState<any>(null);
|
|
const [history, setHistory] = useState<any[]>([]);
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
|
|
useEffect(() => { loadHealth(); }, []);
|
|
|
|
const loadHealth = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getAiOpsHealth();
|
|
setDevices(Array.isArray(data) ? data : []);
|
|
} catch { message.error('加载健康数据失败'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const loadHistory = async (deviceId: number) => {
|
|
setHistoryLoading(true);
|
|
try {
|
|
const data = await getAiOpsHealthHistory(deviceId, { days: 30 });
|
|
setHistory(Array.isArray(data) ? data : []);
|
|
} catch { /* ignore */ }
|
|
finally { setHistoryLoading(false); }
|
|
};
|
|
|
|
const showDetail = (device: any) => {
|
|
setDetailDevice(device);
|
|
loadHistory(device.device_id);
|
|
};
|
|
|
|
const handleRecalculate = async () => {
|
|
message.loading({ content: '正在计算健康评分...', key: 'calc' });
|
|
try {
|
|
await triggerHealthCalc();
|
|
message.success({ content: '健康评分计算完成', key: 'calc' });
|
|
loadHealth();
|
|
} catch { message.error({ content: '计算失败', key: 'calc' }); }
|
|
};
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 80) return '#52c41a';
|
|
if (score >= 60) return '#faad14';
|
|
return '#f5222d';
|
|
};
|
|
|
|
const gaugeOption = (score: number) => ({
|
|
series: [{
|
|
type: 'gauge',
|
|
startAngle: 200,
|
|
endAngle: -20,
|
|
min: 0,
|
|
max: 100,
|
|
itemStyle: { color: getScoreColor(score) },
|
|
progress: { show: true, width: 12 },
|
|
pointer: { show: false },
|
|
axisLine: { lineStyle: { width: 12, color: [[1, '#e6e6e6']] } },
|
|
axisTick: { show: false },
|
|
splitLine: { show: false },
|
|
axisLabel: { show: false },
|
|
detail: {
|
|
valueAnimation: true,
|
|
fontSize: 24,
|
|
fontWeight: 'bold',
|
|
offsetCenter: [0, '0%'],
|
|
formatter: '{value}',
|
|
color: getScoreColor(score),
|
|
},
|
|
data: [{ value: score }],
|
|
}],
|
|
});
|
|
|
|
const historyOption = history.length > 0 ? {
|
|
tooltip: { trigger: 'axis' },
|
|
xAxis: {
|
|
type: 'category',
|
|
data: history.map((h: any) => dayjs(h.timestamp).format('MM-DD HH:mm')),
|
|
},
|
|
yAxis: { type: 'value', min: 0, max: 100, name: '健康评分' },
|
|
series: [{
|
|
type: 'line',
|
|
data: history.map((h: any) => h.health_score),
|
|
smooth: true,
|
|
areaStyle: { opacity: 0.15 },
|
|
markLine: {
|
|
data: [
|
|
{ yAxis: 80, label: { formatter: '健康' }, lineStyle: { color: '#52c41a', type: 'dashed' } },
|
|
{ yAxis: 60, label: { formatter: '警告' }, lineStyle: { color: '#faad14', type: 'dashed' } },
|
|
],
|
|
},
|
|
}],
|
|
grid: { top: 30, right: 30, bottom: 30, left: 50 },
|
|
} : null;
|
|
|
|
const radarOption = detailDevice?.factors ? {
|
|
radar: {
|
|
indicator: [
|
|
{ name: '功率稳定', max: 100 },
|
|
{ name: '能效水平', max: 100 },
|
|
{ name: '告警频率', max: 100 },
|
|
{ name: '运行时间', max: 100 },
|
|
{ name: '温度状态', max: 100 },
|
|
],
|
|
},
|
|
series: [{
|
|
type: 'radar',
|
|
data: [{
|
|
value: [
|
|
detailDevice.factors.power_stability || 0,
|
|
detailDevice.factors.efficiency || 0,
|
|
detailDevice.factors.alarm_frequency || 0,
|
|
detailDevice.factors.uptime || 0,
|
|
detailDevice.factors.temperature || 0,
|
|
],
|
|
areaStyle: { opacity: 0.2 },
|
|
}],
|
|
}],
|
|
} : null;
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
|
<HeartOutlined style={{ marginRight: 8 }} />
|
|
设备健康总览
|
|
</span>
|
|
<Button icon={<SyncOutlined />} onClick={handleRecalculate}>重新计算</Button>
|
|
</div>
|
|
<Row gutter={[16, 16]}>
|
|
{loading ? (
|
|
<Col span={24}><Card loading /></Col>
|
|
) : devices.length === 0 ? (
|
|
<Col span={24}><Card><Empty description="暂无健康评分数据,请点击「重新计算」" /></Card></Col>
|
|
) : devices.map((d: any) => (
|
|
<Col xs={24} sm={12} md={8} lg={6} key={d.device_id}>
|
|
<Card
|
|
hoverable
|
|
onClick={() => showDetail(d)}
|
|
styles={{ body: { padding: 16, textAlign: 'center' } }}
|
|
>
|
|
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{d.device_name}</div>
|
|
<Tag color="blue" style={{ marginBottom: 8 }}>{d.device_type}</Tag>
|
|
<div style={{ height: 130 }}>
|
|
<ReactECharts option={gaugeOption(d.health_score)} style={{ height: 130 }} />
|
|
</div>
|
|
<Space>
|
|
<Tag color={statusColors[d.status]}>{d.status === 'healthy' ? '健康' : d.status === 'warning' ? '警告' : '危险'}</Tag>
|
|
<span>{trendIcons[d.trend]} {d.trend === 'improving' ? '改善' : d.trend === 'degrading' ? '下降' : '稳定'}</span>
|
|
</Space>
|
|
</Card>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
|
|
<Drawer
|
|
title={detailDevice ? `${detailDevice.device_name} - 健康详情` : ''}
|
|
open={!!detailDevice}
|
|
onClose={() => setDetailDevice(null)}
|
|
width={720}
|
|
>
|
|
{detailDevice && (
|
|
<>
|
|
<Descriptions column={2} bordered size="small" style={{ marginBottom: 24 }}>
|
|
<Descriptions.Item label="设备">{detailDevice.device_name}</Descriptions.Item>
|
|
<Descriptions.Item label="类型">{detailDevice.device_type}</Descriptions.Item>
|
|
<Descriptions.Item label="健康评分">
|
|
<span style={{ fontSize: 24, fontWeight: 'bold', color: getScoreColor(detailDevice.health_score) }}>
|
|
{detailDevice.health_score}
|
|
</span>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="状态">
|
|
<Tag color={statusColors[detailDevice.status]}>
|
|
{detailDevice.status === 'healthy' ? '健康' : detailDevice.status === 'warning' ? '警告' : '危险'}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="趋势" span={2}>
|
|
{trendIcons[detailDevice.trend]} {detailDevice.trend === 'improving' ? '持续改善' : detailDevice.trend === 'degrading' ? '持续下降' : '保持稳定'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Card title="因素分析" size="small">
|
|
{radarOption && <ReactECharts option={radarOption} style={{ height: 250 }} />}
|
|
</Card>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Card title="评分历史" size="small" loading={historyLoading}>
|
|
{historyOption ? (
|
|
<ReactECharts option={historyOption} style={{ height: 250 }} />
|
|
) : (
|
|
<Empty description="暂无历史数据" />
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</>
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab: Anomaly Center ────────────────────────────────────────────
|
|
|
|
function AnomalyCenter() {
|
|
const [anomalies, setAnomalies] = useState<any>({ total: 0, items: [] });
|
|
const [loading, setLoading] = useState(true);
|
|
const [filters, setFilters] = useState<Record<string, any>>({});
|
|
const [page, setPage] = useState(1);
|
|
|
|
useEffect(() => { loadAnomalies(); }, [page, filters]);
|
|
|
|
const loadAnomalies = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getAiOpsAnomalies({ ...filters, page, page_size: 15 });
|
|
setAnomalies(data || { total: 0, items: [] });
|
|
} catch { message.error('加载异常数据失败'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleScan = async () => {
|
|
message.loading({ content: '正在扫描异常...', key: 'scan' });
|
|
try {
|
|
const result = await triggerAnomalyScan() as any;
|
|
message.success({ content: `扫描完成,发现 ${result?.anomalies_found || 0} 个异常`, key: 'scan' });
|
|
loadAnomalies();
|
|
} catch { message.error({ content: '扫描失败', key: 'scan' }); }
|
|
};
|
|
|
|
const handleStatusUpdate = async (id: number, status: string) => {
|
|
try {
|
|
await updateAnomalyStatus(id, { status });
|
|
message.success('状态已更新');
|
|
loadAnomalies();
|
|
} catch { message.error('更新失败'); }
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '时间', dataIndex: 'detected_at', width: 160,
|
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
|
},
|
|
{ title: '设备', dataIndex: 'device_name', width: 120 },
|
|
{
|
|
title: '异常类型', dataIndex: 'anomaly_type', width: 100,
|
|
render: (v: string) => anomalyTypeLabels[v] || v,
|
|
},
|
|
{
|
|
title: '严重度', dataIndex: 'severity', width: 80,
|
|
render: (v: string) => <Tag color={severityColors[v]}>{v === 'critical' ? '严重' : v === 'warning' ? '警告' : '信息'}</Tag>,
|
|
},
|
|
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
|
{
|
|
title: '偏差', dataIndex: 'deviation_percent', width: 80,
|
|
render: (v: number) => v != null ? `${v}%` : '-',
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'status', width: 100,
|
|
render: (v: string) => <Tag color={statusColors[v]}>{v === 'detected' ? '已检测' : v === 'investigating' ? '调查中' : v === 'resolved' ? '已解决' : '误报'}</Tag>,
|
|
},
|
|
{
|
|
title: '操作', width: 200,
|
|
render: (_: any, r: any) => r.status === 'detected' ? (
|
|
<Space size="small">
|
|
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'investigating')}>调查</Button>
|
|
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'resolved')}>已解决</Button>
|
|
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'false_positive')}>误报</Button>
|
|
</Space>
|
|
) : r.status === 'investigating' ? (
|
|
<Space size="small">
|
|
<Button size="small" type="primary" onClick={() => handleStatusUpdate(r.id, 'resolved')}>已解决</Button>
|
|
<Button size="small" onClick={() => handleStatusUpdate(r.id, 'false_positive')}>误报</Button>
|
|
</Space>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
|
<Space wrap>
|
|
<Select
|
|
placeholder="严重度" allowClear style={{ width: 120 }}
|
|
onChange={(v) => setFilters((f) => ({ ...f, severity: v }))}
|
|
options={[
|
|
{ label: '严重', value: 'critical' },
|
|
{ label: '警告', value: 'warning' },
|
|
{ label: '信息', value: 'info' },
|
|
]}
|
|
/>
|
|
<Select
|
|
placeholder="状态" allowClear style={{ width: 120 }}
|
|
onChange={(v) => setFilters((f) => ({ ...f, status: v }))}
|
|
options={[
|
|
{ label: '已检测', value: 'detected' },
|
|
{ label: '调查中', value: 'investigating' },
|
|
{ label: '已解决', value: 'resolved' },
|
|
{ label: '误报', value: 'false_positive' },
|
|
]}
|
|
/>
|
|
</Space>
|
|
<Button type="primary" icon={<ExperimentOutlined />} onClick={handleScan}>扫描异常</Button>
|
|
</div>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={anomalies.items}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={{
|
|
current: page,
|
|
pageSize: 15,
|
|
total: anomalies.total,
|
|
onChange: setPage,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab: Diagnostic Panel ──────────────────────────────────────────
|
|
|
|
function DiagnosticPanel() {
|
|
const [reports, setReports] = useState<any>({ total: 0, items: [] });
|
|
const [loading, setLoading] = useState(true);
|
|
const [detailReport, setDetailReport] = useState<any>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [devices, setDevices] = useState<any[]>([]);
|
|
|
|
useEffect(() => { loadReports(); loadDeviceList(); }, [page]);
|
|
|
|
const loadReports = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getAiOpsDiagnostics({ page, page_size: 15 });
|
|
setReports(data || { total: 0, items: [] });
|
|
} catch { message.error('加载诊断报告失败'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const loadDeviceList = async () => {
|
|
try {
|
|
const data = await getAiOpsHealth();
|
|
setDevices(Array.isArray(data) ? data : []);
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const handleRun = async (deviceId: number) => {
|
|
message.loading({ content: '正在运行诊断...', key: 'diag' });
|
|
try {
|
|
const result = await runDeviceDiagnostics(deviceId);
|
|
message.success({ content: '诊断完成', key: 'diag' });
|
|
setDetailReport(result);
|
|
loadReports();
|
|
} catch { message.error({ content: '诊断失败', key: 'diag' }); }
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '时间', dataIndex: 'generated_at', width: 160,
|
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
|
},
|
|
{ title: '设备', dataIndex: 'device_name', width: 120 },
|
|
{
|
|
title: '类型', dataIndex: 'report_type', width: 80,
|
|
render: (v: string) => v === 'routine' ? '常规' : v === 'triggered' ? '触发' : '综合',
|
|
},
|
|
{
|
|
title: '发现', dataIndex: 'findings', ellipsis: true,
|
|
render: (v: any[]) => v?.length ? v.map((f: any) => f.finding).join('; ') : '-',
|
|
},
|
|
{
|
|
title: '影响评估', dataIndex: 'estimated_impact',
|
|
render: (v: any) => v ? (
|
|
<span>
|
|
{v.energy_loss_kwh > 0 && <Tag>电量损失 {v.energy_loss_kwh} kWh</Tag>}
|
|
{v.cost_impact_yuan > 0 && <Tag color="orange">费用 {v.cost_impact_yuan} 元</Tag>}
|
|
{v.energy_loss_kwh === 0 && v.cost_impact_yuan === 0 && <Tag color="green">无影响</Tag>}
|
|
</span>
|
|
) : '-',
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'status', width: 80,
|
|
render: (v: string) => <Tag color={statusColors[v]}>{v === 'generated' ? '已生成' : v === 'reviewed' ? '已审阅' : '已处理'}</Tag>,
|
|
},
|
|
{
|
|
title: '操作', width: 80,
|
|
render: (_: any, r: any) => (
|
|
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetailReport(r)}>查看</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
|
<MedicineBoxOutlined style={{ marginRight: 8 }} />
|
|
诊断报告
|
|
</span>
|
|
<Select
|
|
placeholder="选择设备运行诊断" style={{ width: 300 }}
|
|
showSearch optionFilterProp="label"
|
|
onChange={(v) => handleRun(v)}
|
|
options={devices.map((d: any) => ({ label: `${d.device_name} (${d.device_type})`, value: d.device_id }))}
|
|
/>
|
|
</div>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={reports.items}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={{ current: page, pageSize: 15, total: reports.total, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
|
|
size="small"
|
|
/>
|
|
|
|
<Drawer
|
|
title="诊断报告详情"
|
|
open={!!detailReport}
|
|
onClose={() => setDetailReport(null)}
|
|
width={640}
|
|
>
|
|
{detailReport && (
|
|
<>
|
|
<Descriptions column={2} bordered size="small" style={{ marginBottom: 16 }}>
|
|
<Descriptions.Item label="设备">{detailReport.device_name}</Descriptions.Item>
|
|
<Descriptions.Item label="类型">{detailReport.report_type}</Descriptions.Item>
|
|
<Descriptions.Item label="时间" span={2}>{dayjs(detailReport.generated_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<Card title="诊断发现" size="small" style={{ marginBottom: 16 }}>
|
|
<Timeline
|
|
items={(detailReport.findings || []).map((f: any) => ({
|
|
color: f.severity === 'warning' ? 'orange' : f.severity === 'critical' ? 'red' : 'blue',
|
|
children: (
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{f.finding}</div>
|
|
<div style={{ color: '#888', fontSize: 12 }}>{f.detail}</div>
|
|
</div>
|
|
),
|
|
}))}
|
|
/>
|
|
</Card>
|
|
|
|
{detailReport.recommendations?.length > 0 && (
|
|
<Card title="建议措施" size="small" style={{ marginBottom: 16 }}>
|
|
<List
|
|
size="small"
|
|
dataSource={detailReport.recommendations}
|
|
renderItem={(r: any) => (
|
|
<List.Item>
|
|
<List.Item.Meta
|
|
avatar={<Tag color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'orange' : 'blue'}>{r.priority === 'high' ? '高' : r.priority === 'medium' ? '中' : '低'}</Tag>}
|
|
title={r.action}
|
|
description={r.detail}
|
|
/>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{detailReport.estimated_impact && (
|
|
<Card title="影响评估" size="small">
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Statistic title="预计电量损失" value={detailReport.estimated_impact.energy_loss_kwh} suffix="kWh" />
|
|
</Col>
|
|
<Col span={12}>
|
|
<Statistic title="预计费用影响" value={detailReport.estimated_impact.cost_impact_yuan} suffix="元" prefix="¥" />
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
)}
|
|
</>
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab: Maintenance Predictor ─────────────────────────────────────
|
|
|
|
function MaintenancePredictor() {
|
|
const [predictions, setPredictions] = useState<any>({ total: 0, items: [] });
|
|
const [schedule, setSchedule] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
|
|
useEffect(() => { loadData(); }, [page]);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [pred, sched] = await Promise.all([
|
|
getAiOpsPredictions({ page, page_size: 15 }),
|
|
getAiOpsMaintenanceSchedule(),
|
|
]);
|
|
setPredictions(pred || { total: 0, items: [] });
|
|
setSchedule(Array.isArray(sched) ? sched : []);
|
|
} catch { message.error('加载预测数据失败'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
message.loading({ content: '正在生成维护预测...', key: 'pred' });
|
|
try {
|
|
const result = await triggerPredictions() as any;
|
|
message.success({ content: `生成 ${result?.generated || 0} 条预测`, key: 'pred' });
|
|
loadData();
|
|
} catch { message.error({ content: '生成失败', key: 'pred' }); }
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '设备', dataIndex: 'device_name', width: 120,
|
|
},
|
|
{ title: '部件', dataIndex: 'component', width: 120 },
|
|
{ title: '故障模式', dataIndex: 'failure_mode', ellipsis: true },
|
|
{
|
|
title: '概率', dataIndex: 'probability', width: 80,
|
|
render: (v: number) => <Progress percent={Math.round(v * 100)} size="small" />,
|
|
},
|
|
{
|
|
title: '预计故障日期', dataIndex: 'predicted_failure_date', width: 120,
|
|
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
|
|
},
|
|
{
|
|
title: '紧急度', dataIndex: 'urgency', width: 80,
|
|
render: (v: string) => <Tag color={urgencyColors[v]}>{v === 'critical' ? '紧急' : v === 'high' ? '高' : v === 'medium' ? '中' : '低'}</Tag>,
|
|
},
|
|
{
|
|
title: '停机(h)', dataIndex: 'estimated_downtime_hours', width: 80,
|
|
},
|
|
{
|
|
title: '维修费(元)', dataIndex: 'estimated_repair_cost', width: 100,
|
|
render: (v: number) => v ? `${v.toLocaleString()}` : '-',
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'status', width: 80,
|
|
render: (v: string) => <Tag color={statusColors[v]}>{v === 'predicted' ? '预测' : v === 'scheduled' ? '已排期' : v === 'completed' ? '完成' : '误报'}</Tag>,
|
|
},
|
|
];
|
|
|
|
const calendarSchedule = schedule.reduce((acc: any, item: any) => {
|
|
if (item.predicted_failure_date) {
|
|
const key = dayjs(item.predicted_failure_date).format('YYYY-MM-DD');
|
|
if (!acc[key]) acc[key] = [];
|
|
acc[key].push(item);
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, any[]>);
|
|
|
|
const dateCellRender = (value: dayjs.Dayjs) => {
|
|
const key = value.format('YYYY-MM-DD');
|
|
const items = calendarSchedule[key];
|
|
if (!items) return null;
|
|
return (
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|
{items.slice(0, 2).map((item: any, i: number) => (
|
|
<li key={i}>
|
|
<Badge color={urgencyColors[item.urgency] || 'blue'} text={<span style={{ fontSize: 11 }}>{item.device_name}</span>} />
|
|
</li>
|
|
))}
|
|
{items.length > 2 && <li style={{ fontSize: 11, color: '#999' }}>+{items.length - 2} more</li>}
|
|
</ul>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
|
<ToolOutlined style={{ marginRight: 8 }} />
|
|
预测性维护
|
|
</span>
|
|
<Button type="primary" icon={<ExperimentOutlined />} onClick={handleGenerate}>生成预测</Button>
|
|
</div>
|
|
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'list',
|
|
label: '预测列表',
|
|
children: (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={predictions.items}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={{ current: page, pageSize: 15, total: predictions.total, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
|
|
size="small"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'calendar',
|
|
label: '维护日历',
|
|
children: (
|
|
<Card>
|
|
<Calendar cellRender={(value, info) => {
|
|
if (info.type === 'date') return dateCellRender(value);
|
|
return null;
|
|
}} />
|
|
</Card>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab: Insights Board ────────────────────────────────────────────
|
|
|
|
function InsightsBoard() {
|
|
const [insights, setInsights] = useState<any>({ total: 0, items: [] });
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => { loadInsights(); }, []);
|
|
|
|
const loadInsights = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getAiOpsInsights({ page_size: 50 });
|
|
setInsights(data || { total: 0, items: [] });
|
|
} catch { message.error('加载洞察数据失败'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
message.loading({ content: '正在生成运营洞察...', key: 'ins' });
|
|
try {
|
|
await triggerInsights();
|
|
message.success({ content: '洞察生成完成', key: 'ins' });
|
|
loadInsights();
|
|
} catch { message.error({ content: '生成失败', key: 'ins' }); }
|
|
};
|
|
|
|
const typeIcons: Record<string, React.ReactNode> = {
|
|
efficiency_trend: <ThunderboltOutlined />,
|
|
cost_anomaly: <FireOutlined />,
|
|
performance_comparison: <BarChartOutlined />,
|
|
seasonal_pattern: <SafetyCertificateOutlined />,
|
|
};
|
|
|
|
const BarChartOutlined = () => <span>{"#"}</span>;
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
|
<BulbOutlined style={{ marginRight: 8 }} />
|
|
运营洞察
|
|
</span>
|
|
<Button type="primary" icon={<BulbOutlined />} onClick={handleGenerate}>生成洞察</Button>
|
|
</div>
|
|
{loading ? <Card loading /> : insights.items?.length === 0 ? (
|
|
<Card><Empty description="暂无运营洞察,请点击「生成洞察」" /></Card>
|
|
) : (
|
|
<Row gutter={[16, 16]}>
|
|
{insights.items?.map((insight: any) => (
|
|
<Col xs={24} sm={12} lg={8} key={insight.id}>
|
|
<Card
|
|
hoverable
|
|
styles={{ body: { padding: 16 } }}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
<Tag color={impactColors[insight.impact_level]}>
|
|
{insight.impact_level === 'high' ? '高影响' : insight.impact_level === 'medium' ? '中影响' : '低影响'}
|
|
</Tag>
|
|
<Tag>{insightTypeLabels[insight.insight_type] || insight.insight_type}</Tag>
|
|
</div>
|
|
<div style={{ fontSize: 15, fontWeight: 500, marginBottom: 8 }}>{insight.title}</div>
|
|
<div style={{ color: '#666', fontSize: 13, marginBottom: 12, minHeight: 40 }}>{insight.description}</div>
|
|
{insight.actionable && insight.recommended_action && (
|
|
<div style={{
|
|
background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 4,
|
|
padding: '8px 12px', fontSize: 12,
|
|
}}>
|
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />
|
|
{insight.recommended_action}
|
|
</div>
|
|
)}
|
|
<div style={{ marginTop: 8, fontSize: 11, color: '#999' }}>
|
|
{dayjs(insight.generated_at).format('YYYY-MM-DD HH:mm')}
|
|
{insight.valid_until && ` | 有效至 ${dayjs(insight.valid_until).format('MM-DD')}`}
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main Page ──────────────────────────────────────────────────────
|
|
|
|
export default function AIOperations() {
|
|
const [dashboard, setDashboard] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => { loadDashboard(); }, []);
|
|
|
|
const loadDashboard = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getAiOpsDashboard();
|
|
setDashboard(data);
|
|
} catch { /* initial load may fail if no data */ }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const health = dashboard?.health || {};
|
|
const anomalyStats = dashboard?.anomalies?.stats || {};
|
|
|
|
return (
|
|
<div>
|
|
{/* Overview cards */}
|
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
<Col xs={12} sm={6}>
|
|
<Card styles={{ body: { padding: 16 } }}>
|
|
<Statistic
|
|
title="平均健康评分"
|
|
value={health.avg_score || '--'}
|
|
suffix="/100"
|
|
prefix={<HeartOutlined style={{ color: '#52c41a' }} />}
|
|
loading={loading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card styles={{ body: { padding: 16 } }}>
|
|
<Statistic
|
|
title="健康/警告/危险"
|
|
value={`${health.healthy || 0}/${health.warning || 0}/${health.critical || 0}`}
|
|
prefix={<SafetyCertificateOutlined style={{ color: '#1890ff' }} />}
|
|
loading={loading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card styles={{ body: { padding: 16 } }}>
|
|
<Statistic
|
|
title="近7天异常"
|
|
value={anomalyStats.total || 0}
|
|
prefix={<AlertOutlined style={{ color: '#faad14' }} />}
|
|
loading={loading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card styles={{ body: { padding: 16 } }}>
|
|
<Statistic
|
|
title="待处理预测"
|
|
value={dashboard?.predictions?.length || 0}
|
|
prefix={<ToolOutlined style={{ color: '#722ed1' }} />}
|
|
loading={loading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Tabs */}
|
|
<Card>
|
|
<Tabs
|
|
defaultActiveKey="health"
|
|
items={[
|
|
{
|
|
key: 'health',
|
|
label: <span><HeartOutlined /> 设备健康</span>,
|
|
children: <HealthOverview />,
|
|
},
|
|
{
|
|
key: 'anomalies',
|
|
label: <span><AlertOutlined /> 异常检测</span>,
|
|
children: <AnomalyCenter />,
|
|
},
|
|
{
|
|
key: 'diagnostics',
|
|
label: <span><MedicineBoxOutlined /> 智能诊断</span>,
|
|
children: <DiagnosticPanel />,
|
|
},
|
|
{
|
|
key: 'maintenance',
|
|
label: <span><ToolOutlined /> 预测维护</span>,
|
|
children: <MaintenancePredictor />,
|
|
},
|
|
{
|
|
key: 'insights',
|
|
label: <span><BulbOutlined /> 运营洞察</span>,
|
|
children: <InsightsBoard />,
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|