Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7003877cb2 | ||
|
|
ec3aab28c1 |
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"project": "zpark-ems",
|
||||
"project_version": "1.4.0",
|
||||
"project_version": "1.6.0",
|
||||
"customer": "Z-Park 中关村医疗器械园",
|
||||
"core_version": "1.4.0",
|
||||
"frontend_template_version": "1.4.0",
|
||||
"last_updated": "2026-04-06",
|
||||
"notes": "Solar KPIs (PR, revenue, equiv hours), version display, feature flag filtering"
|
||||
"notes": "String monitoring, I-V diagnosis stub, ROI simulator, knowledge base, remote config stub, mobile responsive"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#52c41a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>中关村医疗器械园智慧能源管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
18
frontend/public/manifest.json
Normal file
18
frontend/public/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Z-Park EMS",
|
||||
"short_name": "Z-Park EMS",
|
||||
"description": "中关村医疗器械园智慧能源管理平台",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a1628",
|
||||
"theme_color": "#52c41a",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
frontend/public/sw.js
Normal file
21
frontend/public/sw.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Simple service worker for PWA installability
|
||||
const CACHE_NAME = 'zpark-ems-v1';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Network-first strategy for API calls, cache-first for static assets
|
||||
if (event.request.url.includes('/api/')) {
|
||||
event.respondWith(fetch(event.request));
|
||||
} else {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(response => response || fetch(event.request))
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -23,8 +23,13 @@ import DataQuery from './pages/DataQuery';
|
||||
import Management from './pages/Management';
|
||||
import Prediction from './pages/Prediction';
|
||||
import EnergyStrategy from './pages/EnergyStrategy';
|
||||
import ROISimulator from './pages/ROISimulator';
|
||||
import AIOperations from './pages/AIOperations';
|
||||
import KnowledgeBase from './pages/KnowledgeBase';
|
||||
import BigScreen from './pages/BigScreen';
|
||||
import StringMonitoring from './pages/StringMonitoring';
|
||||
import IVDiagnosis from './pages/IVDiagnosis';
|
||||
import RemoteConfig from './pages/RemoteConfig';
|
||||
|
||||
import { isLoggedIn } from './utils/auth';
|
||||
|
||||
@@ -66,7 +71,12 @@ function AppContent() {
|
||||
<Route path="management" element={<Management />} />
|
||||
<Route path="prediction" element={<Prediction />} />
|
||||
<Route path="energy-strategy" element={<EnergyStrategy />} />
|
||||
<Route path="roi-simulator" element={<ROISimulator />} />
|
||||
<Route path="ai-operations" element={<AIOperations />} />
|
||||
<Route path="knowledge-base" element={<KnowledgeBase />} />
|
||||
<Route path="string-monitoring" element={<StringMonitoring />} />
|
||||
<Route path="iv-diagnosis" element={<IVDiagnosis />} />
|
||||
<Route path="remote-config" element={<RemoteConfig />} />
|
||||
<Route path="system/*" element={<SystemManagement />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
|
||||
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
|
||||
BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined,
|
||||
SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined,
|
||||
SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined, ApartmentOutlined, ScanOutlined,
|
||||
DollarOutlined, BookOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -36,6 +37,15 @@ export default function MainLayout() {
|
||||
const { darkMode, toggleDarkMode } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768) setCollapsed(true);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getBranding().then((res: any) => {
|
||||
setFeatures(res?.features || {});
|
||||
@@ -64,8 +74,17 @@ export default function MainLayout() {
|
||||
{ key: '/data-query', icon: <SearchOutlined />, label: t('menu.dataQuery', '数据查询') },
|
||||
{ key: '/prediction', icon: <RobotOutlined />, label: t('menu.prediction', 'AI预测') },
|
||||
{ key: '/management', icon: <SolutionOutlined />, label: t('menu.management', '管理体系') },
|
||||
{ key: '/knowledge-base', icon: <BookOutlined />, label: t('menu.knowledgeBase', '知识库') },
|
||||
{ key: '/energy-strategy', icon: <ThunderboltOutlined />, label: t('menu.energyStrategy', '策略优化') },
|
||||
{ key: '/roi-simulator', icon: <DollarOutlined />, label: t('menu.roiSimulator', '投资回报') },
|
||||
{ key: '/ai-operations', icon: <ExperimentOutlined />, label: t('menu.aiOperations', 'AI运维') },
|
||||
{ key: '/string-monitoring', icon: <ApartmentOutlined />, label: t('menu.stringMonitoring', '组串监控') },
|
||||
{ key: '/remote-config', icon: <SettingOutlined />, label: t('menu.remoteConfig', '远程配置') },
|
||||
{ key: 'diagnosis-group', icon: <ScanOutlined />, label: t('menu.diagnosis', '智能诊断'),
|
||||
children: [
|
||||
{ key: '/iv-diagnosis', icon: <ThunderboltOutlined />, label: t('menu.ivDiagnosis', 'IV曲线诊断') },
|
||||
],
|
||||
},
|
||||
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
|
||||
children: [
|
||||
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },
|
||||
|
||||
@@ -8,3 +8,10 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Register PWA service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
200
frontend/src/pages/Alarms/components/AlarmSubscription.tsx
Normal file
200
frontend/src/pages/Alarms/components/AlarmSubscription.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Table, Button, Modal, Form, Input, Select, Checkbox, Switch, Space, Tag, Popconfirm, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
interface AlarmSubscription {
|
||||
id: string;
|
||||
name: string;
|
||||
severity: ('critical' | 'warning' | 'info')[];
|
||||
deviceTypes: string[];
|
||||
notifyMethod: ('email' | 'webhook')[];
|
||||
email?: string;
|
||||
webhookUrl?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'zpark-alarm-subscriptions';
|
||||
|
||||
const severityOptions = [
|
||||
{ label: '紧急', value: 'critical' },
|
||||
{ label: '一般', value: 'warning' },
|
||||
{ label: '信息', value: 'info' },
|
||||
];
|
||||
|
||||
const deviceTypeOptions = [
|
||||
{ label: '全部设备', value: 'all' },
|
||||
{ label: '逆变器 (Sungrow)', value: 'sungrow_inverter' },
|
||||
{ label: '直流汇流箱', value: 'dc_combiner' },
|
||||
];
|
||||
|
||||
const notifyMethodOptions = [
|
||||
{ label: '邮件', value: 'email' },
|
||||
{ label: 'Webhook', value: 'webhook' },
|
||||
];
|
||||
|
||||
const severityColorMap: Record<string, string> = {
|
||||
critical: 'red',
|
||||
warning: 'gold',
|
||||
info: 'blue',
|
||||
};
|
||||
|
||||
const severityTextMap: Record<string, string> = {
|
||||
critical: '紧急',
|
||||
warning: '一般',
|
||||
info: '信息',
|
||||
};
|
||||
|
||||
const deviceTypeTextMap: Record<string, string> = {
|
||||
all: '全部设备',
|
||||
sungrow_inverter: '逆变器',
|
||||
dc_combiner: '直流汇流箱',
|
||||
};
|
||||
|
||||
function loadSubscriptions(): AlarmSubscription[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveSubscriptions(subs: AlarmSubscription[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(subs));
|
||||
}
|
||||
|
||||
export default function AlarmSubscriptionTab() {
|
||||
const [subscriptions, setSubscriptions] = useState<AlarmSubscription[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const notifyMethod = Form.useWatch('notifyMethod', form);
|
||||
|
||||
useEffect(() => {
|
||||
setSubscriptions(loadSubscriptions());
|
||||
}, []);
|
||||
|
||||
const persist = (next: AlarmSubscription[]) => {
|
||||
setSubscriptions(next);
|
||||
saveSubscriptions(next);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: AlarmSubscription) => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
persist(subscriptions.filter(s => s.id !== id));
|
||||
message.success('已删除');
|
||||
};
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
persist(subscriptions.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
|
||||
};
|
||||
|
||||
const handleSubmit = (values: any) => {
|
||||
if (editingId) {
|
||||
persist(subscriptions.map(s => s.id === editingId ? { ...s, ...values } : s));
|
||||
message.success('订阅已更新');
|
||||
} else {
|
||||
const newSub: AlarmSubscription = {
|
||||
...values,
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
enabled: true,
|
||||
};
|
||||
persist([...subscriptions, newSub]);
|
||||
message.success('订阅已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订阅名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '告警级别', dataIndex: 'severity',
|
||||
render: (vals: string[]) => vals?.map(v => (
|
||||
<Tag key={v} color={severityColorMap[v]}>{severityTextMap[v] || v}</Tag>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: '设备类型', dataIndex: 'deviceTypes',
|
||||
render: (vals: string[]) => vals?.map(v => deviceTypeTextMap[v] || v).join(', '),
|
||||
},
|
||||
{
|
||||
title: '通知方式', dataIndex: 'notifyMethod',
|
||||
render: (vals: string[]) => vals?.map(v => (
|
||||
<Tag key={v}>{v === 'email' ? '邮件' : 'Webhook'}</Tag>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: '启用', dataIndex: 'enabled', width: 80,
|
||||
render: (v: boolean, r: AlarmSubscription) => (
|
||||
<Switch checked={v} onChange={() => handleToggle(r.id)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 120,
|
||||
render: (_: any, r: AlarmSubscription) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title="确认删除此订阅?" onConfirm={() => handleDelete(r.id)} okText="删除" cancelText="取消">
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建订阅</Button>
|
||||
}>
|
||||
<Table columns={columns} dataSource={subscriptions} rowKey="id" size="small"
|
||||
pagination={false} locale={{ emptyText: '暂无订阅,点击"新建订阅"开始配置' }} />
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑订阅' : '新建订阅'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editingId ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="订阅名称" rules={[{ required: true, message: '请输入订阅名称' }]}>
|
||||
<Input placeholder="例: 紧急告警通知" />
|
||||
</Form.Item>
|
||||
<Form.Item name="severity" label="告警级别" rules={[{ required: true, message: '请选择告警级别' }]}>
|
||||
<Checkbox.Group options={severityOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="deviceTypes" label="设备类型" rules={[{ required: true, message: '请选择设备类型' }]}>
|
||||
<Select mode="multiple" options={deviceTypeOptions} placeholder="选择设备类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notifyMethod" label="通知方式" rules={[{ required: true, message: '请选择通知方式' }]}>
|
||||
<Checkbox.Group options={notifyMethodOptions} />
|
||||
</Form.Item>
|
||||
{notifyMethod?.includes('email') && (
|
||||
<Form.Item name="email" label="邮箱地址" rules={[{ required: true, type: 'email', message: '请输入有效邮箱' }]}>
|
||||
<Input placeholder="user@example.com" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{notifyMethod?.includes('webhook') && (
|
||||
<Form.Item name="webhookUrl" label="Webhook URL" rules={[{ required: true, type: 'url', message: '请输入有效URL' }]}>
|
||||
<Input placeholder="https://hooks.example.com/..." />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, Switch, Drawer, Row, Col, Statistic, message } from 'antd';
|
||||
import { PlusOutlined, CheckOutlined, ToolOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, CheckOutlined, ToolOutlined, HistoryOutlined, BellOutlined } from '@ant-design/icons';
|
||||
import AlarmSubscriptionTab from './components/AlarmSubscription';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import {
|
||||
getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm,
|
||||
@@ -263,6 +264,7 @@ export default function Alarms() {
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'analytics', label: '分析', children: <AlarmAnalyticsTab /> },
|
||||
{ key: 'subscription', label: <span><BellOutlined /> 订阅设置</span>, children: <AlarmSubscriptionTab /> },
|
||||
]} />
|
||||
|
||||
<Modal title="新建告警规则" open={showRuleModal} onCancel={() => setShowRuleModal(false)}
|
||||
|
||||
207
frontend/src/pages/Analysis/components/DeviceComparison.tsx
Normal file
207
frontend/src/pages/Analysis/components/DeviceComparison.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Select, Button, Table, Space, Spin, message, Empty } from 'antd';
|
||||
import { LineChartOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getDevices, getEnergyHistory } from '../../../services/api';
|
||||
|
||||
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1'];
|
||||
|
||||
interface DeviceOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DevicePowerData {
|
||||
device_id: number;
|
||||
device_name: string;
|
||||
records: { time: string; value: number }[];
|
||||
daily_total: number;
|
||||
}
|
||||
|
||||
export default function DeviceComparison() {
|
||||
const [devices, setDevices] = useState<DeviceOption[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [date, setDate] = useState<Dayjs>(dayjs().subtract(1, 'day'));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deviceData, setDeviceData] = useState<DevicePowerData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
}, []);
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const resp = await getDevices({ device_type: 'inverter' }) as any;
|
||||
const list = Array.isArray(resp) ? resp : resp?.items || resp?.devices || [];
|
||||
setDevices(list.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name || d.device_name || `Inverter ${d.id}`,
|
||||
})));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadComparison = async () => {
|
||||
if (selectedIds.length < 2) {
|
||||
message.warning('请至少选择2台设备进行对比');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const nextDay = date.add(1, 'day').format('YYYY-MM-DD');
|
||||
|
||||
const promises = selectedIds.map(id => {
|
||||
const dev = devices.find(d => d.id === id);
|
||||
return getEnergyHistory({
|
||||
device_id: id,
|
||||
data_type: 'power',
|
||||
granularity: 'hour',
|
||||
start_time: dateStr,
|
||||
end_time: nextDay,
|
||||
}).then((data: any) => {
|
||||
const records = Array.isArray(data) ? data : [];
|
||||
const mapped = records.map((r: any) => ({
|
||||
time: r.time,
|
||||
value: r.avg || r.value || 0,
|
||||
}));
|
||||
return {
|
||||
device_id: id,
|
||||
device_name: dev?.name || `Device ${id}`,
|
||||
records: mapped,
|
||||
daily_total: mapped.reduce((sum: number, r: any) => sum + r.value, 0),
|
||||
};
|
||||
}).catch(() => ({
|
||||
device_id: id,
|
||||
device_name: dev?.name || `Device ${id}`,
|
||||
records: [],
|
||||
daily_total: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
setDeviceData(results);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载对比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build unified time axis from all devices
|
||||
const allTimes = Array.from(new Set(
|
||||
deviceData.flatMap(d => d.records.map(r => r.time))
|
||||
)).sort();
|
||||
|
||||
const timeLabels = allTimes.map(t => {
|
||||
const d = new Date(t);
|
||||
return `${d.getHours().toString().padStart(2, '0')}:00`;
|
||||
});
|
||||
|
||||
const chartOption = deviceData.length > 0 ? {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
let tip = params[0]?.axisValueLabel || '';
|
||||
params.forEach((p: any) => {
|
||||
tip += `<br/><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color};margin-right:4px"></span>${p.seriesName}: ${(p.value ?? '-')} kW`;
|
||||
});
|
||||
return tip;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: deviceData.map(d => d.device_name),
|
||||
bottom: 0,
|
||||
},
|
||||
grid: { top: 30, right: 20, bottom: 50, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: timeLabels,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', name: 'kW' },
|
||||
series: deviceData.map((d, i) => {
|
||||
const timeMap = new Map(d.records.map(r => [r.time, r.value]));
|
||||
return {
|
||||
name: d.device_name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: allTimes.map(t => timeMap.get(t) ?? null),
|
||||
lineStyle: { color: COLORS[i % COLORS.length] },
|
||||
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||
connectNulls: true,
|
||||
};
|
||||
}),
|
||||
} : {};
|
||||
|
||||
const tableData = deviceData.length > 0 ? [{
|
||||
key: 'daily',
|
||||
label: '日发电量 (kWh)',
|
||||
...Object.fromEntries(deviceData.map(d => [d.device_name, d.daily_total.toFixed(1)])),
|
||||
}] : [];
|
||||
|
||||
const tableColumns = [
|
||||
{ title: '指标', dataIndex: 'label', fixed: 'left' as const, width: 150 },
|
||||
...deviceData.map(d => ({
|
||||
title: d.device_name,
|
||||
dataIndex: d.device_name,
|
||||
width: 120,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>选择设备 (2-5台):</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 300 }}
|
||||
placeholder="选择逆变器"
|
||||
value={selectedIds}
|
||||
onChange={(vals: number[]) => {
|
||||
if (vals.length <= 5) setSelectedIds(vals);
|
||||
else message.warning('最多选择5台设备');
|
||||
}}
|
||||
options={devices.map(d => ({ label: d.name, value: d.id }))}
|
||||
maxTagCount={3}
|
||||
allowClear
|
||||
/>
|
||||
<span>日期:</span>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={(d) => d && setDate(d)}
|
||||
disabledDate={(current) => current && current > dayjs()}
|
||||
/>
|
||||
<Button type="primary" icon={<LineChartOutlined />} onClick={loadComparison}
|
||||
disabled={selectedIds.length < 2}>
|
||||
对比
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{deviceData.length > 0 ? (
|
||||
<>
|
||||
<Card title="功率曲线对比" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={chartOption} style={{ height: 400 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="日发电量对比" size="small">
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={tableData}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
!loading && selectedIds.length >= 2 && <Empty description="点击「对比」加载数据" />
|
||||
)}
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
237
frontend/src/pages/Analysis/components/DispersionAnalysis.tsx
Normal file
237
frontend/src/pages/Analysis/components/DispersionAnalysis.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Tag, Table, DatePicker, Space, Spin, Empty, message } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getDevices, getEnergyHistory } from '../../../services/api';
|
||||
|
||||
interface DeviceEnergy {
|
||||
device_id: number;
|
||||
device_name: string;
|
||||
daily_energy: number;
|
||||
}
|
||||
|
||||
interface DispersionStats {
|
||||
mean: number;
|
||||
stdDev: number;
|
||||
dispersionRate: number;
|
||||
outliers: DeviceEnergy[];
|
||||
devices: DeviceEnergy[];
|
||||
}
|
||||
|
||||
function computeDispersion(devices: DeviceEnergy[]): DispersionStats {
|
||||
const values = devices.map(d => d.daily_energy);
|
||||
if (values.length === 0) {
|
||||
return { mean: 0, stdDev: 0, dispersionRate: 0, outliers: [], devices };
|
||||
}
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const variance = values.reduce((a, v) => a + (v - mean) ** 2, 0) / values.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
const dispersionRate = mean > 0 ? (stdDev / mean) * 100 : 0;
|
||||
const outliers = devices.filter(d => Math.abs(d.daily_energy - mean) > 2 * stdDev);
|
||||
return { mean, stdDev, dispersionRate, outliers, devices };
|
||||
}
|
||||
|
||||
export default function DispersionAnalysis() {
|
||||
const [date, setDate] = useState<Dayjs>(dayjs().subtract(1, 'day'));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<DispersionStats | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const devicesResp = await getDevices({ device_type: 'inverter' }) as any;
|
||||
const deviceList = Array.isArray(devicesResp) ? devicesResp : devicesResp?.items || devicesResp?.devices || [];
|
||||
|
||||
if (deviceList.length === 0) {
|
||||
setStats(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const nextDay = date.add(1, 'day').format('YYYY-MM-DD');
|
||||
|
||||
const energyPromises = deviceList.map((dev: any) =>
|
||||
getEnergyHistory({
|
||||
device_id: dev.id,
|
||||
data_type: 'power',
|
||||
granularity: 'hour',
|
||||
start_time: dateStr,
|
||||
end_time: nextDay,
|
||||
}).then((data: any) => {
|
||||
const records = Array.isArray(data) ? data : [];
|
||||
const totalEnergy = records.reduce((sum: number, r: any) => sum + (r.avg || r.value || 0), 0);
|
||||
return {
|
||||
device_id: dev.id,
|
||||
device_name: dev.name || dev.device_name || `Inverter ${dev.id}`,
|
||||
daily_energy: totalEnergy,
|
||||
};
|
||||
}).catch(() => ({
|
||||
device_id: dev.id,
|
||||
device_name: dev.name || dev.device_name || `Inverter ${dev.id}`,
|
||||
daily_energy: 0,
|
||||
}))
|
||||
);
|
||||
|
||||
const deviceEnergies = await Promise.all(energyPromises);
|
||||
setStats(computeDispersion(deviceEnergies));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载离散率数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [date]);
|
||||
|
||||
const getDispersionColor = (rate: number) => {
|
||||
if (rate < 10) return 'green';
|
||||
if (rate < 20) return 'orange';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const getDispersionLabel = (rate: number) => {
|
||||
if (rate < 10) return '优秀';
|
||||
if (rate < 20) return '一般';
|
||||
return '偏高';
|
||||
};
|
||||
|
||||
const barChartOption = stats ? {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0];
|
||||
const diff = p.value - stats.mean;
|
||||
const pct = stats.mean > 0 ? ((diff / stats.mean) * 100).toFixed(1) : '0';
|
||||
return `${p.name}<br/>发电量: ${p.value.toFixed(1)} kWh<br/>偏差: ${diff >= 0 ? '+' : ''}${diff.toFixed(1)} kWh (${pct}%)`;
|
||||
},
|
||||
},
|
||||
grid: { top: 40, right: 20, bottom: 60, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: stats.devices.map(d => d.device_name),
|
||||
axisLabel: { rotate: 30, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: stats.devices.map(d => ({
|
||||
value: d.daily_energy,
|
||||
itemStyle: {
|
||||
color: Math.abs(d.daily_energy - stats.mean) > 2 * stats.stdDev
|
||||
? '#f5222d'
|
||||
: d.daily_energy < stats.mean ? '#faad14' : '#52c41a',
|
||||
},
|
||||
})),
|
||||
barMaxWidth: 50,
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#1890ff', type: 'dashed', width: 2 },
|
||||
data: [{ yAxis: stats.mean, label: { formatter: `均值: ${stats.mean.toFixed(1)} kWh` } }],
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
} : {};
|
||||
|
||||
const outlierColumns = [
|
||||
{ title: '设备名称', dataIndex: 'device_name' },
|
||||
{
|
||||
title: '发电量 (kWh)', dataIndex: 'daily_energy',
|
||||
render: (v: number) => v.toFixed(1),
|
||||
},
|
||||
{
|
||||
title: '偏差', key: 'deviation',
|
||||
render: (_: any, record: DeviceEnergy) => {
|
||||
if (!stats) return '-';
|
||||
const diff = record.daily_energy - stats.mean;
|
||||
const pct = stats.mean > 0 ? ((diff / stats.mean) * 100).toFixed(1) : '0';
|
||||
return (
|
||||
<span style={{ color: diff < 0 ? '#f5222d' : '#52c41a' }}>
|
||||
{diff >= 0 ? '+' : ''}{diff.toFixed(1)} kWh ({pct}%)
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'status',
|
||||
render: (_: any, record: DeviceEnergy) => {
|
||||
if (!stats) return '-';
|
||||
const diff = record.daily_energy - stats.mean;
|
||||
return diff < 0
|
||||
? <Tag color="red">低于均值</Tag>
|
||||
: <Tag color="blue">高于均值</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<span>分析日期:</span>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={(d) => d && setDate(d)}
|
||||
disabledDate={(current) => current && current > dayjs()}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<div style={{ color: '#666', marginBottom: 4 }}>离散率</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>
|
||||
{stats.dispersionRate.toFixed(1)}%
|
||||
<Tag color={getDispersionColor(stats.dispersionRate)} style={{ marginLeft: 8, fontSize: 14 }}>
|
||||
{getDispersionLabel(stats.dispersionRate)}
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<div style={{ color: '#666', marginBottom: 4 }}>平均发电量</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>{stats.mean.toFixed(1)} <span style={{ fontSize: 14, color: '#999' }}>kWh</span></div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<div style={{ color: '#666', marginBottom: 4 }}>标准差</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600 }}>{stats.stdDev.toFixed(2)} <span style={{ fontSize: 14, color: '#999' }}>kWh</span></div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="逆变器发电量对比" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={barChartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
{stats.outliers.length > 0 && (
|
||||
<Card title={`异常设备 (偏离>2σ) — ${stats.outliers.length} 台`} size="small">
|
||||
<Table
|
||||
columns={outlierColumns}
|
||||
dataSource={stats.outliers}
|
||||
rowKey="device_id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!loading && <Empty description="暂无逆变器数据" />
|
||||
)}
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import YoyAnalysis from './YoyAnalysis';
|
||||
import MomAnalysis from './MomAnalysis';
|
||||
import CostAnalysis from './CostAnalysis';
|
||||
import SubitemAnalysis from './SubitemAnalysis';
|
||||
import DeviceComparison from './components/DeviceComparison';
|
||||
import DispersionAnalysis from './components/DispersionAnalysis';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -182,9 +184,13 @@ function ComparisonView() {
|
||||
{renderMetricCard('碳排放', m1.carbonEmission, m2.carbonEmission, 'kg', 2)}
|
||||
</Row>
|
||||
|
||||
<Card title="能耗趋势对比" size="small">
|
||||
<Card title="能耗趋势对比" size="small" style={{ marginBottom: 24 }}>
|
||||
<ReactECharts option={comparisonChartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="设备对比" size="small" style={{ marginBottom: 0 }}>
|
||||
<DeviceComparison />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -324,6 +330,7 @@ export default function Analysis() {
|
||||
items={[
|
||||
{ key: 'overview', label: '能耗概览', children: overviewContent },
|
||||
{ key: 'comparison', label: '数据对比', children: <ComparisonView /> },
|
||||
{ key: 'dispersion', label: '离散率分析', children: <DispersionAnalysis /> },
|
||||
{ key: 'loss', label: '损耗分析', children: <LossAnalysis /> },
|
||||
{ key: 'yoy', label: '同比分析', children: <YoyAnalysis /> },
|
||||
{ key: 'mom', label: '环比分析', children: <MomAnalysis /> },
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getEnergyFlow } from '../../../services/api';
|
||||
import { Spin, Typography, Space } from 'antd';
|
||||
import { FireOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
realtime?: {
|
||||
@@ -16,81 +10,324 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function EnergyFlow({ realtime }: Props) {
|
||||
const [flowData, setFlowData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getEnergyFlow()
|
||||
.then((data: any) => setFlowData(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const pv = realtime?.pv_power || 0;
|
||||
const hp = realtime?.heatpump_power || 0;
|
||||
const load = realtime?.total_load || 0;
|
||||
const grid = realtime?.grid_power || 0;
|
||||
const hp = realtime?.heatpump_power || 0;
|
||||
|
||||
// Build sankey from realtime data as fallback if API has no flow data
|
||||
const pvToBuilding = Math.min(pv, load);
|
||||
// Calculate flows
|
||||
const pvToLoad = Math.min(pv, load);
|
||||
const pvToGrid = Math.max(0, pv - load);
|
||||
const gridToBuilding = Math.max(0, load - pv);
|
||||
const gridToHeatPump = hp;
|
||||
const gridToLoad = Math.max(0, grid);
|
||||
const gridExport = Math.max(0, -grid);
|
||||
|
||||
const links = flowData?.links || [
|
||||
{ source: '光伏发电', target: '建筑用电', value: pvToBuilding || 0.1 },
|
||||
{ source: '光伏发电', target: '电网输出', value: pvToGrid || 0.1 },
|
||||
{ source: '电网输入', target: '建筑用电', value: gridToBuilding || 0.1 },
|
||||
{ source: '电网输入', target: '热泵系统', value: gridToHeatPump || 0.1 },
|
||||
].filter((l: any) => l.value > 0.05);
|
||||
const selfUseRate = load > 0 ? ((pvToLoad / load) * 100).toFixed(1) : '0.0';
|
||||
|
||||
const nodes = flowData?.nodes || [
|
||||
{ name: '光伏发电', itemStyle: { color: '#faad14' } },
|
||||
{ name: '电网输入', itemStyle: { color: '#52c41a' } },
|
||||
{ name: '建筑用电', itemStyle: { color: '#1890ff' } },
|
||||
{ name: '电网输出', itemStyle: { color: '#13c2c2' } },
|
||||
{ name: '热泵系统', itemStyle: { color: '#f5222d' } },
|
||||
];
|
||||
// Determine which flows are active (> 0.1 kW threshold)
|
||||
const flows = useMemo(() => ({
|
||||
pvToLoad: pvToLoad > 0.1,
|
||||
pvToGrid: pvToGrid > 0.1 || gridExport > 0.1,
|
||||
gridToLoad: gridToLoad > 0.1,
|
||||
gridToHp: hp > 0.1,
|
||||
}), [pvToLoad, pvToGrid, gridExport, gridToLoad, hp]);
|
||||
|
||||
// Only show nodes that appear in links
|
||||
const usedNames = new Set<string>();
|
||||
links.forEach((l: any) => { usedNames.add(l.source); usedNames.add(l.target); });
|
||||
const filteredNodes = nodes.filter((n: any) => usedNames.has(n.name));
|
||||
// SVG layout constants
|
||||
const W = 560;
|
||||
const H = 340;
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
|
||||
series: [{
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
emphasis: { focus: 'adjacency' },
|
||||
nodeAlign: 'left',
|
||||
orient: 'horizontal',
|
||||
top: 10,
|
||||
bottom: 30,
|
||||
left: 10,
|
||||
right: 10,
|
||||
nodeWidth: 20,
|
||||
nodeGap: 16,
|
||||
data: filteredNodes,
|
||||
links: links,
|
||||
label: { fontSize: 12 },
|
||||
lineStyle: { color: 'gradient', curveness: 0.5 },
|
||||
}],
|
||||
// Node positions (center points)
|
||||
const nodes = {
|
||||
pv: { x: W / 2, y: 40 },
|
||||
load: { x: 100, y: 200 },
|
||||
grid: { x: W - 100, y: 200 },
|
||||
heatpump: { x: W / 2, y: 300 },
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactECharts option={option} style={{ height: 240 }} />
|
||||
<div style={{ textAlign: 'center', padding: '4px 8px', background: '#fafafa', borderRadius: 8 }}>
|
||||
<Space size={24}>
|
||||
<span><FireOutlined style={{ color: '#f5222d' }} /> 热泵: <Text strong>{hp.toFixed(1)} kW</Text></span>
|
||||
<span>自发自用率: <Text strong style={{ color: '#52c41a' }}>
|
||||
{load > 0 ? ((Math.min(pv, load) / load) * 100).toFixed(1) : 0}%
|
||||
</Text></span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ width: '100%', position: 'relative' }}>
|
||||
<style>{`
|
||||
@keyframes dashFlow {
|
||||
to { stroke-dashoffset: -24; }
|
||||
}
|
||||
@keyframes dashFlowReverse {
|
||||
to { stroke-dashoffset: 24; }
|
||||
}
|
||||
@keyframes nodeGlow {
|
||||
0%, 100% { filter: drop-shadow(0 0 4px var(--glow-color)); }
|
||||
50% { filter: drop-shadow(0 0 12px var(--glow-color)); }
|
||||
}
|
||||
@keyframes pulseValue {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
.ef-flow-line {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 12 12;
|
||||
animation: dashFlow 0.8s linear infinite;
|
||||
}
|
||||
.ef-flow-line-reverse {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 12 12;
|
||||
animation: dashFlowReverse 0.8s linear infinite;
|
||||
}
|
||||
.ef-flow-bg {
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.1;
|
||||
}
|
||||
.ef-node-rect {
|
||||
rx: 12;
|
||||
ry: 12;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.ef-node-group {
|
||||
animation: nodeGlow 3s ease-in-out infinite;
|
||||
}
|
||||
.ef-node-icon {
|
||||
font-size: 22px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
.ef-node-label {
|
||||
fill: rgba(255,255,255,0.65);
|
||||
font-size: 11px;
|
||||
text-anchor: middle;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ef-node-value {
|
||||
fill: #fff;
|
||||
font-size: 15px;
|
||||
text-anchor: middle;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ef-flow-value {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
.ef-active-value {
|
||||
animation: pulseValue 2s ease-in-out infinite;
|
||||
}
|
||||
.ef-inactive {
|
||||
opacity: 0.2;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', maxHeight: 340 }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
{/* Glow filters */}
|
||||
<filter id="glowGreen" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#52c41a" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="glowBlue" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#1890ff" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="glowOrange" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#ff8c00" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="glowCyan" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#13c2c2" floodOpacity="0.6" />
|
||||
</filter>
|
||||
|
||||
{/* Gradient for PV -> Load (green) */}
|
||||
<linearGradient id="gradPvLoad" x1={nodes.pv.x} y1={nodes.pv.y} x2={nodes.load.x} y2={nodes.load.y} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#52c41a" />
|
||||
<stop offset="100%" stopColor="#1890ff" />
|
||||
</linearGradient>
|
||||
{/* Gradient for PV -> Grid (green to orange) */}
|
||||
<linearGradient id="gradPvGrid" x1={nodes.pv.x} y1={nodes.pv.y} x2={nodes.grid.x} y2={nodes.grid.y} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#52c41a" />
|
||||
<stop offset="100%" stopColor="#ff8c00" />
|
||||
</linearGradient>
|
||||
{/* Gradient for Grid -> Load (orange to blue) */}
|
||||
<linearGradient id="gradGridLoad" x1={nodes.grid.x} y1={nodes.grid.y} x2={nodes.load.x} y2={nodes.load.y} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#ff8c00" />
|
||||
<stop offset="100%" stopColor="#1890ff" />
|
||||
</linearGradient>
|
||||
{/* Gradient for Grid -> HeatPump (orange to cyan) */}
|
||||
<linearGradient id="gradGridHp" x1={nodes.grid.x} y1={nodes.grid.y} x2={nodes.heatpump.x} y2={nodes.heatpump.y} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#ff8c00" />
|
||||
<stop offset="100%" stopColor="#13c2c2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* ===== FLOW LINES ===== */}
|
||||
|
||||
{/* PV -> Load */}
|
||||
<path
|
||||
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.load.x} ${nodes.load.y - 80}, ${nodes.load.x} ${nodes.load.y - 30}`}
|
||||
className="ef-flow-bg"
|
||||
stroke="url(#gradPvLoad)"
|
||||
/>
|
||||
<path
|
||||
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.load.x} ${nodes.load.y - 80}, ${nodes.load.x} ${nodes.load.y - 30}`}
|
||||
className={flows.pvToLoad ? 'ef-flow-line' : 'ef-flow-line ef-inactive'}
|
||||
stroke="url(#gradPvLoad)"
|
||||
/>
|
||||
{flows.pvToLoad && (
|
||||
<text
|
||||
x={(nodes.pv.x + nodes.load.x) / 2 - 40}
|
||||
y={(nodes.pv.y + nodes.load.y) / 2 - 10}
|
||||
className="ef-flow-value ef-active-value"
|
||||
fill="#52c41a"
|
||||
>
|
||||
{pvToLoad.toFixed(1)} kW
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* PV -> Grid (export) */}
|
||||
<path
|
||||
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.grid.x} ${nodes.grid.y - 80}, ${nodes.grid.x} ${nodes.grid.y - 30}`}
|
||||
className="ef-flow-bg"
|
||||
stroke="url(#gradPvGrid)"
|
||||
/>
|
||||
<path
|
||||
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.grid.x} ${nodes.grid.y - 80}, ${nodes.grid.x} ${nodes.grid.y - 30}`}
|
||||
className={flows.pvToGrid ? 'ef-flow-line' : 'ef-flow-line ef-inactive'}
|
||||
stroke="url(#gradPvGrid)"
|
||||
/>
|
||||
{flows.pvToGrid && (
|
||||
<text
|
||||
x={(nodes.pv.x + nodes.grid.x) / 2 + 40}
|
||||
y={(nodes.pv.y + nodes.grid.y) / 2 - 10}
|
||||
className="ef-flow-value ef-active-value"
|
||||
fill="#ff8c00"
|
||||
>
|
||||
{(pvToGrid || gridExport).toFixed(1)} kW
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Grid -> Load (import) */}
|
||||
<path
|
||||
d={`M ${nodes.grid.x} ${nodes.grid.y} L ${nodes.load.x + 70} ${nodes.load.y}`}
|
||||
className="ef-flow-bg"
|
||||
stroke="url(#gradGridLoad)"
|
||||
/>
|
||||
<path
|
||||
d={`M ${nodes.grid.x} ${nodes.grid.y} L ${nodes.load.x + 70} ${nodes.load.y}`}
|
||||
className={flows.gridToLoad ? 'ef-flow-line-reverse' : 'ef-flow-line-reverse ef-inactive'}
|
||||
stroke="url(#gradGridLoad)"
|
||||
/>
|
||||
{flows.gridToLoad && (
|
||||
<text
|
||||
x={(nodes.grid.x + nodes.load.x + 70) / 2}
|
||||
y={nodes.load.y - 14}
|
||||
className="ef-flow-value ef-active-value"
|
||||
fill="#ff8c00"
|
||||
>
|
||||
{gridToLoad.toFixed(1)} kW
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Load -> HeatPump */}
|
||||
<path
|
||||
d={`M ${nodes.load.x} ${nodes.load.y + 30} C ${nodes.load.x} ${nodes.load.y + 70}, ${nodes.heatpump.x} ${nodes.heatpump.y - 40}, ${nodes.heatpump.x} ${nodes.heatpump.y - 20}`}
|
||||
className="ef-flow-bg"
|
||||
stroke="url(#gradGridHp)"
|
||||
/>
|
||||
<path
|
||||
d={`M ${nodes.load.x} ${nodes.load.y + 30} C ${nodes.load.x} ${nodes.load.y + 70}, ${nodes.heatpump.x} ${nodes.heatpump.y - 40}, ${nodes.heatpump.x} ${nodes.heatpump.y - 20}`}
|
||||
className={flows.gridToHp ? 'ef-flow-line' : 'ef-flow-line ef-inactive'}
|
||||
stroke="#13c2c2"
|
||||
/>
|
||||
{flows.gridToHp && (
|
||||
<text
|
||||
x={(nodes.load.x + nodes.heatpump.x) / 2 - 20}
|
||||
y={nodes.heatpump.y - 30}
|
||||
className="ef-flow-value ef-active-value"
|
||||
fill="#13c2c2"
|
||||
>
|
||||
{hp.toFixed(1)} kW
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* ===== NODES ===== */}
|
||||
|
||||
{/* PV Solar Node */}
|
||||
<g className="ef-node-group" style={{ '--glow-color': '#52c41a' } as React.CSSProperties}>
|
||||
<rect
|
||||
x={nodes.pv.x - 55} y={nodes.pv.y - 28}
|
||||
width={110} height={56}
|
||||
className="ef-node-rect"
|
||||
fill="rgba(82, 196, 26, 0.12)"
|
||||
stroke="#52c41a"
|
||||
filter="url(#glowGreen)"
|
||||
/>
|
||||
<text x={nodes.pv.x - 30} y={nodes.pv.y - 4} className="ef-node-icon">☀️</text>
|
||||
<text x={nodes.pv.x + 10} y={nodes.pv.y - 6} className="ef-node-label">光伏发电</text>
|
||||
<text x={nodes.pv.x} y={nodes.pv.y + 16} className="ef-node-value" fill="#52c41a">
|
||||
{pv.toFixed(1)} kW
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Building Load Node */}
|
||||
<g className="ef-node-group" style={{ '--glow-color': '#1890ff' } as React.CSSProperties}>
|
||||
<rect
|
||||
x={nodes.load.x - 55} y={nodes.load.y - 28}
|
||||
width={110} height={56}
|
||||
className="ef-node-rect"
|
||||
fill="rgba(24, 144, 255, 0.12)"
|
||||
stroke="#1890ff"
|
||||
filter="url(#glowBlue)"
|
||||
/>
|
||||
<text x={nodes.load.x - 30} y={nodes.load.y - 4} className="ef-node-icon">🏢</text>
|
||||
<text x={nodes.load.x + 10} y={nodes.load.y - 6} className="ef-node-label">建筑负载</text>
|
||||
<text x={nodes.load.x} y={nodes.load.y + 16} className="ef-node-value" fill="#1890ff">
|
||||
{load.toFixed(1)} kW
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Grid Node */}
|
||||
<g className="ef-node-group" style={{ '--glow-color': '#ff8c00' } as React.CSSProperties}>
|
||||
<rect
|
||||
x={nodes.grid.x - 55} y={nodes.grid.y - 28}
|
||||
width={110} height={56}
|
||||
className="ef-node-rect"
|
||||
fill="rgba(255, 140, 0, 0.12)"
|
||||
stroke="#ff8c00"
|
||||
filter="url(#glowOrange)"
|
||||
/>
|
||||
<text x={nodes.grid.x - 30} y={nodes.grid.y - 4} className="ef-node-icon">⚡</text>
|
||||
<text x={nodes.grid.x + 10} y={nodes.grid.y - 6} className="ef-node-label">
|
||||
{grid >= 0 ? '电网购入' : '电网输出'}
|
||||
</text>
|
||||
<text x={nodes.grid.x} y={nodes.grid.y + 16} className="ef-node-value" fill="#ff8c00">
|
||||
{Math.abs(grid).toFixed(1)} kW
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* HeatPump Node */}
|
||||
<g className="ef-node-group" style={{ '--glow-color': '#13c2c2' } as React.CSSProperties}>
|
||||
<rect
|
||||
x={nodes.heatpump.x - 55} y={nodes.heatpump.y - 28}
|
||||
width={110} height={56}
|
||||
className="ef-node-rect"
|
||||
fill="rgba(19, 194, 194, 0.12)"
|
||||
stroke="#13c2c2"
|
||||
filter="url(#glowCyan)"
|
||||
/>
|
||||
<text x={nodes.heatpump.x - 30} y={nodes.heatpump.y - 4} className="ef-node-icon">🔥</text>
|
||||
<text x={nodes.heatpump.x + 10} y={nodes.heatpump.y - 6} className="ef-node-label">热泵系统</text>
|
||||
<text x={nodes.heatpump.x} y={nodes.heatpump.y + 16} className="ef-node-value" fill="#13c2c2">
|
||||
{hp.toFixed(1)} kW
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Self-consumption badge */}
|
||||
<rect x={W / 2 - 60} y={H / 2 - 14} width={120} height={28} rx={14}
|
||||
fill="rgba(82, 196, 26, 0.15)" stroke="#52c41a" strokeWidth={1} />
|
||||
<text x={W / 2} y={H / 2 + 4} textAnchor="middle" fill="#52c41a"
|
||||
fontSize={12} fontWeight={600}>
|
||||
自消纳 {selfUseRate}%
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
94
frontend/src/pages/Dashboard/components/WeatherWidget.tsx
Normal file
94
frontend/src/pages/Dashboard/components/WeatherWidget.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Space, Typography } from 'antd';
|
||||
import { getWeatherCurrent } from '../../../services/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface WeatherInfo {
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
condition: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function getTimeBasedWeather(): WeatherInfo {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 6 && hour < 18) {
|
||||
return {
|
||||
temperature: Math.round(20 + Math.random() * 8),
|
||||
humidity: Math.round(40 + Math.random() * 20),
|
||||
condition: '晴',
|
||||
icon: '\u2600\uFE0F',
|
||||
};
|
||||
}
|
||||
return {
|
||||
temperature: Math.round(12 + Math.random() * 6),
|
||||
humidity: Math.round(50 + Math.random() * 20),
|
||||
condition: '晴',
|
||||
icon: '\uD83C\uDF19',
|
||||
};
|
||||
}
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [weather, setWeather] = useState<WeatherInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
const res: any = await getWeatherCurrent();
|
||||
if (!mounted) return;
|
||||
const conditionMap: Record<string, { label: string; icon: string }> = {
|
||||
sunny: { label: '晴', icon: '\u2600\uFE0F' },
|
||||
cloudy: { label: '多云', icon: '\u26C5' },
|
||||
overcast: { label: '阴', icon: '\u2601\uFE0F' },
|
||||
rainy: { label: '雨', icon: '\uD83C\uDF27\uFE0F' },
|
||||
clear: { label: '晴', icon: '\uD83C\uDF19' },
|
||||
};
|
||||
const cond = conditionMap[res?.condition] || conditionMap['sunny']!;
|
||||
setWeather({
|
||||
temperature: Math.round(res?.temperature ?? 22),
|
||||
humidity: Math.round(res?.humidity ?? 50),
|
||||
condition: cond.label,
|
||||
icon: cond.icon,
|
||||
});
|
||||
} catch {
|
||||
if (!mounted) return;
|
||||
setWeather(getTimeBasedWeather());
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
const timer = setInterval(fetchWeather, 300000); // refresh every 5 min
|
||||
return () => { mounted = false; clearInterval(timer); };
|
||||
}, []);
|
||||
|
||||
if (!weather) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
bordered={false}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)',
|
||||
borderRadius: 8,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
bodyStyle={{ padding: '8px 16px' }}
|
||||
>
|
||||
<Space size={16} align="center">
|
||||
<span style={{ fontSize: 28, lineHeight: 1 }}>{weather.icon}</span>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 18 }}>{weather.temperature}°C</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>{weather.condition}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
湿度 {weather.humidity}%
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import PowerGeneration from './components/PowerGeneration';
|
||||
import LoadCurve from './components/LoadCurve';
|
||||
import DeviceStatus from './components/DeviceStatus';
|
||||
import EnergyFlow from './components/EnergyFlow';
|
||||
import WeatherWidget from './components/WeatherWidget';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -59,10 +60,13 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<ThunderboltOutlined style={{ color: '#1890ff', marginRight: 8 }} />
|
||||
能源总览
|
||||
</Title>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<ThunderboltOutlined style={{ color: '#1890ff', marginRight: 8 }} />
|
||||
能源总览
|
||||
</Title>
|
||||
<WeatherWidget />
|
||||
</div>
|
||||
|
||||
{/* 核心指标卡片 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Row, Col, Tree, Checkbox, DatePicker, Select, Button, Table, Space, message } from 'antd';
|
||||
import { SearchOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { Card, Row, Col, Tree, Checkbox, DatePicker, Select, Button, Table, Space, message, Modal, Input, Popconfirm } from 'antd';
|
||||
import { SearchOutlined, DownloadOutlined, SaveOutlined, FolderOpenOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getDeviceGroups, getDevices, queryElectricalParams, exportEnergyData } from '../../services/api';
|
||||
@@ -37,6 +37,30 @@ const GRANULARITY_OPTIONS = [
|
||||
{ label: '按天', value: 'day' },
|
||||
];
|
||||
|
||||
interface CurveTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceId: number | null;
|
||||
dataTypes: string[];
|
||||
timeRange: string;
|
||||
granularity: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'zpark-curve-templates';
|
||||
|
||||
function loadTemplates(): CurveTemplate[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveTemplates(templates: CurveTemplate[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
|
||||
}
|
||||
|
||||
export default function DataQuery() {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [deviceMap, setDeviceMap] = useState<Record<number, any>>({});
|
||||
@@ -50,6 +74,9 @@ export default function DataQuery() {
|
||||
const [chartData, setChartData] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [templates, setTemplates] = useState<CurveTemplate[]>(loadTemplates);
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
@@ -132,6 +159,44 @@ export default function DataQuery() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
if (!templateName.trim()) {
|
||||
message.warning('请输入模板名称');
|
||||
return;
|
||||
}
|
||||
const newTemplate: CurveTemplate = {
|
||||
id: Date.now().toString(),
|
||||
name: templateName.trim(),
|
||||
deviceId: selectedDeviceId,
|
||||
dataTypes: selectedParams,
|
||||
timeRange: granularity,
|
||||
granularity,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const updated = [...templates, newTemplate];
|
||||
setTemplates(updated);
|
||||
saveTemplates(updated);
|
||||
setSaveModalOpen(false);
|
||||
setTemplateName('');
|
||||
message.success('模板已保存');
|
||||
};
|
||||
|
||||
const handleLoadTemplate = (templateId: string) => {
|
||||
const tpl = templates.find(t => t.id === templateId);
|
||||
if (!tpl) return;
|
||||
if (tpl.deviceId) setSelectedDeviceId(tpl.deviceId);
|
||||
if (tpl.dataTypes?.length) setSelectedParams(tpl.dataTypes);
|
||||
if (tpl.granularity) setGranularity(tpl.granularity);
|
||||
message.success(`已加载模板: ${tpl.name}`);
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = (templateId: string) => {
|
||||
const updated = templates.filter(t => t.id !== templateId);
|
||||
setTemplates(updated);
|
||||
saveTemplates(updated);
|
||||
message.success('模板已删除');
|
||||
};
|
||||
|
||||
const handleQuery = useCallback(async () => {
|
||||
if (!selectedDeviceId) {
|
||||
message.warning('请先选择设备');
|
||||
@@ -339,6 +404,37 @@ export default function DataQuery() {
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('xlsx')}>
|
||||
导出Excel
|
||||
</Button>
|
||||
<Button icon={<SaveOutlined />} onClick={() => setSaveModalOpen(true)}>
|
||||
保存模板
|
||||
</Button>
|
||||
{templates.length > 0 && (
|
||||
<Select
|
||||
placeholder="我的模板"
|
||||
style={{ width: 180 }}
|
||||
value={undefined}
|
||||
onChange={handleLoadTemplate}
|
||||
suffixIcon={<FolderOpenOutlined />}
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{templates.map(t => (
|
||||
<Select.Option key={t.id} value={t.id} label={t.name}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t.name}</span>
|
||||
<Popconfirm
|
||||
title="确定删除此模板?"
|
||||
onConfirm={(e) => { e?.stopPropagation(); handleDeleteTemplate(t.id); }}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<DeleteOutlined
|
||||
style={{ color: '#ff4d4f', fontSize: 12 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -360,6 +456,26 @@ export default function DataQuery() {
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Modal
|
||||
title="保存查询模板"
|
||||
open={saveModalOpen}
|
||||
onOk={handleSaveTemplate}
|
||||
onCancel={() => { setSaveModalOpen(false); setTemplateName(''); }}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入模板名称"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
onPressEnter={handleSaveTemplate}
|
||||
maxLength={30}
|
||||
/>
|
||||
<div style={{ marginTop: 12, color: '#999', fontSize: 13 }}>
|
||||
将保存: 当前设备 {selectedDevice ? selectedDevice.name : '(未选择)'}、
|
||||
参数 [{selectedParams.join(', ')}]、粒度 {granularity}
|
||||
</div>
|
||||
</Modal>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
107
frontend/src/pages/IVDiagnosis/index.tsx
Normal file
107
frontend/src/pages/IVDiagnosis/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Alert, Card, Row, Col } from 'antd';
|
||||
import { ScanOutlined, BugOutlined, FileTextOutlined, LineChartOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
const featureCards = [
|
||||
{ icon: <ScanOutlined style={{ fontSize: 32, color: '#1890ff' }} />, title: '在线IV扫描', desc: 'Remote I-V curve scanning at string level' },
|
||||
{ icon: <BugOutlined style={{ fontSize: 32, color: '#fa541c' }} />, title: '23种故障诊断', desc: 'Automatic fault classification with 97% accuracy' },
|
||||
{ icon: <FileTextOutlined style={{ fontSize: 32, color: '#52c41a' }} />, title: '诊断报告', desc: 'Auto-generated diagnosis reports with repair recommendations' },
|
||||
{ icon: <LineChartOutlined style={{ fontSize: 32, color: '#722ed1' }} />, title: '趋势分析', desc: 'I-V curve degradation tracking over time' },
|
||||
];
|
||||
|
||||
// Generate a typical I-V curve: I = Isc * (1 - exp((V - Voc) / Vt))
|
||||
const generateIVData = () => {
|
||||
const Isc = 10.5;
|
||||
const Voc = 38;
|
||||
const Vt = 3.2;
|
||||
const points: { v: number; i: number; p: number }[] = [];
|
||||
for (let v = 0; v <= Voc; v += 0.5) {
|
||||
const i = Math.max(0, Isc * (1 - Math.exp((v - Voc) / Vt)));
|
||||
points.push({ v, i, p: v * i });
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export default function IVDiagnosis() {
|
||||
const ivData = useMemo(() => generateIVData(), []);
|
||||
|
||||
const chartOption = {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
legend: { data: ['电流 (A)', '功率 (W)'] },
|
||||
xAxis: { type: 'value' as const, name: '电压 (V)', min: 0, max: 40 },
|
||||
yAxis: [
|
||||
{ type: 'value' as const, name: '电流 (A)', min: 0, max: 12 },
|
||||
{ type: 'value' as const, name: '功率 (W)', min: 0, max: 400 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '电流 (A)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: ivData.map(p => [p.v, +p.i.toFixed(2)]),
|
||||
lineStyle: { width: 2.5, color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: '功率 (W)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: ivData.map(p => [p.v, +p.p.toFixed(1)]),
|
||||
lineStyle: { width: 2.5, color: '#fa541c' },
|
||||
itemStyle: { color: '#fa541c' },
|
||||
symbol: 'none',
|
||||
areaStyle: { color: 'rgba(250,84,28,0.08)' },
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
{
|
||||
type: 'text',
|
||||
left: '15%',
|
||||
top: '15%',
|
||||
style: { text: 'Isc = 10.5A', fontSize: 12, fill: '#1890ff' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
right: '18%',
|
||||
bottom: '22%',
|
||||
style: { text: 'Voc = 38V', fontSize: 12, fill: '#1890ff' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: '12%',
|
||||
style: { text: 'Pmax = 298W @ 30.5V', fontSize: 12, fill: '#fa541c', fontWeight: 'bold' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="智能IV曲线诊断 — 功能开发中"
|
||||
description="Smart I-V Curve Diagnosis requires inverter hardware support for online I-V scanning. This feature will support 23 fault types with 97% accuracy when hardware is available."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
{featureCards.map(card => (
|
||||
<Col span={6} key={card.title}>
|
||||
<Card hoverable style={{ textAlign: 'center', height: '100%' }}>
|
||||
<div style={{ marginBottom: 12 }}>{card.icon}</div>
|
||||
<Card.Meta title={card.title} description={card.desc} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="示例 I-V 曲线 (Sample I-V Curve)">
|
||||
<ReactECharts option={chartOption} style={{ height: 380 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
frontend/src/pages/KnowledgeBase/index.tsx
Normal file
252
frontend/src/pages/KnowledgeBase/index.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, Input, Tabs, List, Tag, Typography, Empty } from 'antd';
|
||||
import {
|
||||
BookOutlined, SearchOutlined, ToolOutlined, WarningOutlined,
|
||||
SafetyOutlined, FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface Article {
|
||||
id: number;
|
||||
category: string;
|
||||
title: string;
|
||||
date: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const CATEGORY_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
maintenance: { label: '设备维护', color: 'blue', icon: <ToolOutlined /> },
|
||||
troubleshooting: { label: '故障排查', color: 'orange', icon: <WarningOutlined /> },
|
||||
safety: { label: '安全规范', color: 'red', icon: <SafetyOutlined /> },
|
||||
operation: { label: '操作手册', color: 'green', icon: <FileTextOutlined /> },
|
||||
};
|
||||
|
||||
const articles: Article[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: 'maintenance',
|
||||
title: '逆变器日常巡检规程',
|
||||
date: '2026-04-01',
|
||||
content: `## 巡检频率
|
||||
每周一次
|
||||
|
||||
## 巡检项目
|
||||
1. 检查逆变器运行指示灯状态
|
||||
2. 检查散热风扇运转是否正常
|
||||
3. 检查接线端子是否松动
|
||||
4. 记录当前功率和日发电量
|
||||
5. 检查通信模块信号强度
|
||||
|
||||
## 注意事项
|
||||
- 巡检时不得打开逆变器外壳
|
||||
- 异常情况及时上报运维主管`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'troubleshooting',
|
||||
title: '逆变器常见故障代码及处理',
|
||||
date: '2026-04-01',
|
||||
content: `## 常见故障代码
|
||||
| 代码 | 含义 | 处理方法 |
|
||||
|------|------|----------|
|
||||
| F01 | 电网电压过高 | 检查电网侧电压,联系供电部门 |
|
||||
| F02 | 电网频率异常 | 等待电网恢复,自动并网 |
|
||||
| F03 | 直流过压 | 检查组串接线,确认组串电压 |
|
||||
| F04 | 绝缘阻抗低 | 检查组件和线缆绝缘 |
|
||||
| F05 | 温度过高 | 清洁散热器,检查环境温度 |`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: 'safety',
|
||||
title: '光伏电站安全操作规范',
|
||||
date: '2026-04-01',
|
||||
content: `## 基本安全要求
|
||||
1. 所有操作必须两人以上配合
|
||||
2. 必须穿戴绝缘手套和安全帽
|
||||
3. 雷雨天气禁止室外操作
|
||||
4. 操作前确认设备已断电
|
||||
5. 使用万用表确认无残余电压`,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: 'operation',
|
||||
title: 'iSolarCloud 数据导出操作指南',
|
||||
date: '2026-04-01',
|
||||
content: `## 导出步骤
|
||||
1. 登录 iSolarCloud 平台
|
||||
2. 进入电站详情 → 曲线
|
||||
3. 选择时间范围和参数
|
||||
4. 点击右上角导出按钮
|
||||
5. 选择 Excel 格式下载`,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: 'maintenance',
|
||||
title: '光伏组件清洗规程',
|
||||
date: '2026-04-01',
|
||||
content: `## 清洗频率
|
||||
每季度一次(春季花粉期加密)
|
||||
|
||||
## 清洗方法
|
||||
1. 使用纯净水或去离子水
|
||||
2. 软质刷子或海绵擦拭
|
||||
3. 从上到下顺序冲洗
|
||||
4. 避免使用化学清洁剂
|
||||
|
||||
## 最佳时间
|
||||
清晨或傍晚,避免组件高温时清洗`,
|
||||
},
|
||||
];
|
||||
|
||||
function renderMarkdown(text: string) {
|
||||
const lines = text.trim().split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let tableRows: string[][] = [];
|
||||
let inTable = false;
|
||||
|
||||
const flushTable = () => {
|
||||
if (tableRows.length === 0) return;
|
||||
const header = tableRows[0];
|
||||
const body = tableRows.slice(1);
|
||||
elements.push(
|
||||
<table key={`table-${elements.length}`} style={{
|
||||
width: '100%', borderCollapse: 'collapse', margin: '8px 0', fontSize: 13,
|
||||
}}>
|
||||
<thead>
|
||||
<tr>
|
||||
{header.map((h, i) => (
|
||||
<th key={i} style={{
|
||||
border: '1px solid #e8e8e8', padding: '6px 10px', background: '#fafafa', textAlign: 'left',
|
||||
}}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} style={{ border: '1px solid #e8e8e8', padding: '6px 10px' }}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
tableRows = [];
|
||||
inTable = false;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('|') && line.endsWith('|')) {
|
||||
const cells = line.split('|').slice(1, -1).map(c => c.trim());
|
||||
if (cells.every(c => /^[-:]+$/.test(c))) continue; // separator
|
||||
tableRows.push(cells);
|
||||
inTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inTable) flushTable();
|
||||
|
||||
if (line.startsWith('## ')) {
|
||||
elements.push(<Title level={5} key={i} style={{ marginTop: 12, marginBottom: 4 }}>{line.slice(3)}</Title>);
|
||||
} else if (/^\d+\.\s/.test(line)) {
|
||||
elements.push(<Paragraph key={i} style={{ margin: '2px 0 2px 16px' }}>{line}</Paragraph>);
|
||||
} else if (line.startsWith('- ')) {
|
||||
elements.push(<Paragraph key={i} style={{ margin: '2px 0 2px 16px' }}>{line}</Paragraph>);
|
||||
} else if (line.trim() === '') {
|
||||
// skip
|
||||
} else {
|
||||
elements.push(<Paragraph key={i} style={{ margin: '4px 0' }}>{line}</Paragraph>);
|
||||
}
|
||||
}
|
||||
if (inTable) flushTable();
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
export default function KnowledgeBase() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return articles.filter(a => {
|
||||
const matchCategory = activeTab === 'all' || a.category === activeTab;
|
||||
const matchSearch = !search || a.title.includes(search) || a.content.includes(search);
|
||||
return matchCategory && matchSearch;
|
||||
});
|
||||
}, [search, activeTab]);
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'all', label: '全部' },
|
||||
...Object.entries(CATEGORY_MAP).map(([key, val]) => ({
|
||||
key,
|
||||
label: <span>{val.icon} {val.label}</span>,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<BookOutlined style={{ marginRight: 8 }} />
|
||||
运维知识库
|
||||
</Title>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="搜索文章标题或内容..."
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Empty description="未找到相关文章" />
|
||||
) : (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 2 }}
|
||||
dataSource={filtered}
|
||||
renderItem={article => {
|
||||
const cat = CATEGORY_MAP[article.category];
|
||||
const isExpanded = expandedId === article.id;
|
||||
const preview = article.content.split('\n').filter(l => l.trim() && !l.startsWith('#')).slice(0, 2).join(' ');
|
||||
return (
|
||||
<List.Item>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => setExpandedId(isExpanded ? null : article.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag color={cat?.color}>{cat?.label}</Tag>
|
||||
<span>{article.title}</span>
|
||||
</div>
|
||||
}
|
||||
extra={<Text type="secondary" style={{ fontSize: 12 }}>{article.date}</Text>}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<div style={{ maxHeight: 400, overflow: 'auto' }}>
|
||||
{renderMarkdown(article.content)}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" ellipsis>
|
||||
{preview.length > 80 ? preview.slice(0, 80) + '...' : preview}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/pages/ROISimulator/index.tsx
Normal file
320
frontend/src/pages/ROISimulator/index.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, Form, InputNumber, Button, Row, Col, Statistic, Table, Typography, Divider } from 'antd';
|
||||
import { DollarOutlined, CalculatorOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { LineChart, BarChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent, LegendComponent, MarkLineComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, MarkLineComponent, CanvasRenderer]);
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface SimParams {
|
||||
capacity: number;
|
||||
costPerWp: number;
|
||||
degradationRate: number;
|
||||
sellPrice: number;
|
||||
selfConsumption: number;
|
||||
feedInTariff: number;
|
||||
omRate: number;
|
||||
period: number;
|
||||
peakHours: number;
|
||||
sunDays: number;
|
||||
}
|
||||
|
||||
interface YearData {
|
||||
year: number;
|
||||
generation: number;
|
||||
revenue: number;
|
||||
omCost: number;
|
||||
netIncome: number;
|
||||
cumulative: number;
|
||||
}
|
||||
|
||||
interface SimResult {
|
||||
totalInvestment: number;
|
||||
paybackYear: number | null;
|
||||
irr: number;
|
||||
npv: number;
|
||||
years: YearData[];
|
||||
}
|
||||
|
||||
const defaultParams: SimParams = {
|
||||
capacity: 2710,
|
||||
costPerWp: 3.5,
|
||||
degradationRate: 0.5,
|
||||
sellPrice: 0.65,
|
||||
selfConsumption: 80,
|
||||
feedInTariff: 0.35,
|
||||
omRate: 1.0,
|
||||
period: 25,
|
||||
peakHours: 4.5,
|
||||
sunDays: 260,
|
||||
};
|
||||
|
||||
function calculateIRR(cashFlows: number[]): number {
|
||||
let rate = 0.1;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const npv = cashFlows.reduce((sum, cf, t) => sum + cf / Math.pow(1 + rate, t), 0);
|
||||
const dnpv = cashFlows.reduce((sum, cf, t) => sum - t * cf / Math.pow(1 + rate, t + 1), 0);
|
||||
if (Math.abs(dnpv) < 1e-10) break;
|
||||
const newRate = rate - npv / dnpv;
|
||||
if (Math.abs(newRate - rate) < 1e-8) break;
|
||||
rate = newRate;
|
||||
}
|
||||
return rate;
|
||||
}
|
||||
|
||||
function calculateNPV(cashFlows: number[], discountRate: number): number {
|
||||
return cashFlows.reduce((sum, cf, t) => sum + cf / Math.pow(1 + discountRate, t), 0);
|
||||
}
|
||||
|
||||
function simulate(params: SimParams): SimResult {
|
||||
const totalInvestment = params.capacity * params.costPerWp;
|
||||
const years: YearData[] = [];
|
||||
let cumulative = -totalInvestment;
|
||||
let paybackYear: number | null = null;
|
||||
|
||||
for (let y = 1; y <= params.period; y++) {
|
||||
const degradation = Math.pow(1 - params.degradationRate / 100, y - 1);
|
||||
const annualGen = params.capacity * params.peakHours * params.sunDays * degradation;
|
||||
const selfConsumed = annualGen * params.selfConsumption / 100;
|
||||
const exported = annualGen - selfConsumed;
|
||||
const revenue = selfConsumed * params.sellPrice + exported * params.feedInTariff;
|
||||
const omCost = totalInvestment * params.omRate / 100;
|
||||
const netIncome = revenue - omCost;
|
||||
cumulative += netIncome;
|
||||
|
||||
if (cumulative >= 0 && paybackYear === null) paybackYear = y;
|
||||
|
||||
years.push({ year: y, generation: annualGen, revenue, omCost, netIncome, cumulative });
|
||||
}
|
||||
|
||||
const cashFlows = [-totalInvestment, ...years.map(y => y.netIncome)];
|
||||
const irr = calculateIRR(cashFlows);
|
||||
const npv = calculateNPV(cashFlows, 0.06);
|
||||
|
||||
return { totalInvestment, paybackYear, irr, npv, years };
|
||||
}
|
||||
|
||||
export default function ROISimulator() {
|
||||
const [form] = Form.useForm();
|
||||
const [params, setParams] = useState<SimParams>(defaultParams);
|
||||
|
||||
const result = useMemo(() => simulate(params), [params]);
|
||||
|
||||
const handleCalculate = () => {
|
||||
form.validateFields().then(values => {
|
||||
setParams(values as SimParams);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.setFieldsValue(defaultParams);
|
||||
setParams(defaultParams);
|
||||
};
|
||||
|
||||
const chartOption = useMemo(() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (p: any) => {
|
||||
const items = p.map((s: any) =>
|
||||
`${s.marker} ${s.seriesName}: ${(s.value / 10000).toFixed(2)} 万元`
|
||||
);
|
||||
return `第 ${p[0].axisValue} 年<br/>${items.join('<br/>')}`;
|
||||
},
|
||||
},
|
||||
legend: { data: ['年净收益', '累计现金流'] },
|
||||
grid: { left: 60, right: 30, bottom: 30, top: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: result.years.map(y => y.year),
|
||||
name: '年',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '万元',
|
||||
axisLabel: { formatter: (v: number) => (v / 10000).toFixed(0) },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '年净收益',
|
||||
type: 'bar',
|
||||
data: result.years.map(y => y.netIncome),
|
||||
itemStyle: { color: '#52c41a' },
|
||||
barMaxWidth: 20,
|
||||
},
|
||||
{
|
||||
name: '累计现金流',
|
||||
type: 'line',
|
||||
data: result.years.map(y => y.cumulative),
|
||||
itemStyle: { color: '#1890ff' },
|
||||
lineStyle: { width: 2 },
|
||||
markLine: {
|
||||
silent: true,
|
||||
data: [{ yAxis: 0, lineStyle: { color: '#ff4d4f', type: 'dashed' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), [result]);
|
||||
|
||||
const columns = [
|
||||
{ title: '年份', dataIndex: 'year', key: 'year', width: 70 },
|
||||
{
|
||||
title: '发电量 (kWh)', dataIndex: 'generation', key: 'generation',
|
||||
render: (v: number) => v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||
},
|
||||
{
|
||||
title: '收入 (元)', dataIndex: 'revenue', key: 'revenue',
|
||||
render: (v: number) => v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||
},
|
||||
{
|
||||
title: '运维成本 (元)', dataIndex: 'omCost', key: 'omCost',
|
||||
render: (v: number) => v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||
},
|
||||
{
|
||||
title: '净收益 (元)', dataIndex: 'netIncome', key: 'netIncome',
|
||||
render: (v: number) => <span style={{ color: v >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||
{v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
|
||||
</span>,
|
||||
},
|
||||
{
|
||||
title: '累计 (元)', dataIndex: 'cumulative', key: 'cumulative',
|
||||
render: (v: number) => <span style={{ color: v >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||
{v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
|
||||
</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<CalculatorOutlined style={{ marginRight: 8 }} />
|
||||
光伏投资回报模拟器
|
||||
</Title>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="输入参数" size="small">
|
||||
<Form form={form} layout="vertical" initialValues={defaultParams} size="small">
|
||||
<Form.Item label="装机容量 (kWp)" name="capacity" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={100000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="投资单价 (元/Wp)" name="costPerWp" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.1} max={20} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="年衰减率 (%)" name="degradationRate" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={5} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="自用电价 (元/kWh)" name="sellPrice" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={5} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="自消纳比例 (%)" name="selfConsumption" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="上网电价 (元/kWh)" name="feedInTariff" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={5} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="年运维费率 (% 总投资)" name="omRate" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="模拟年限" name="period" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="日均峰值日照 (小时)" name="peakHours" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="年日照天数" name="sunDays" rules={[{ required: true }]}>
|
||||
<InputNumber min={100} max={365} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Button type="primary" icon={<CalculatorOutlined />} block onClick={handleCalculate}>
|
||||
计算
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button icon={<ReloadOutlined />} block onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="总投资"
|
||||
value={result.totalInvestment / 10000}
|
||||
precision={1}
|
||||
suffix="万元"
|
||||
prefix={<DollarOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="回收期"
|
||||
value={result.paybackYear ?? '-'}
|
||||
suffix={result.paybackYear ? '年' : ''}
|
||||
valueStyle={{ color: result.paybackYear && result.paybackYear <= 8 ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="25年IRR"
|
||||
value={(result.irr * 100)}
|
||||
precision={2}
|
||||
suffix="%"
|
||||
valueStyle={{ color: result.irr > 0.08 ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="25年NPV"
|
||||
value={result.npv / 10000}
|
||||
precision={1}
|
||||
suffix="万元"
|
||||
valueStyle={{ color: result.npv > 0 ? '#52c41a' : '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="累计现金流" style={{ marginTop: 16 }}>
|
||||
<ReactEChartsCore
|
||||
echarts={echarts}
|
||||
option={chartOption}
|
||||
style={{ height: 320 }}
|
||||
notMerge
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Card size="small" title="年度明细">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={result.years}
|
||||
rowKey="year"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
scroll={{ x: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/pages/RemoteConfig/index.tsx
Normal file
104
frontend/src/pages/RemoteConfig/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Select, Card, Descriptions, Button, Tooltip, Timeline, Typography, Space, Row, Col } from 'antd';
|
||||
import { LockOutlined, SettingOutlined, ReadOutlined, UploadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons';
|
||||
import { getDevices } from '../../services/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const SAMPLE_PARAMS = [
|
||||
{ label: '并网电压范围', value: '185V - 265V' },
|
||||
{ label: '并网频率范围', value: '47.5Hz - 51.5Hz' },
|
||||
{ label: '功率因数', value: '1.0' },
|
||||
{ label: '无功功率模式', value: 'Disabled' },
|
||||
{ label: '孤岛保护', value: 'Enabled' },
|
||||
{ label: 'MPPT范围', value: '200V - 850V' },
|
||||
{ label: '最大交流电流', value: '63A' },
|
||||
];
|
||||
|
||||
export default function RemoteConfig() {
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
getDevices({ device_type: 'inverter' }).then((res: any) => {
|
||||
const list = Array.isArray(res) ? res : res?.items || [];
|
||||
setDevices(list);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ margin: '0 0 16px' }}>
|
||||
<SettingOutlined style={{ color: '#1890ff', marginRight: 8 }} />
|
||||
远程配置
|
||||
</Title>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="远程参数配置 — 功能开发中"
|
||||
description="Remote device configuration requires secure command channels to inverters. This feature is planned for v2.0 when hardware integration is available."
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="设备选择" size="small" style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="选择逆变器设备"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedDevice}
|
||||
onChange={setSelectedDevice}
|
||||
options={devices.map((d: any) => ({
|
||||
label: d.name || d.device_name || d.id,
|
||||
value: d.id || d.device_id,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={<><LockOutlined style={{ marginRight: 8 }} />参数模板预览</>} size="small">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
{SAMPLE_PARAMS.map((p) => (
|
||||
<Descriptions.Item
|
||||
key={p.label}
|
||||
label={<><LockOutlined style={{ color: '#d9d9d9', marginRight: 6 }} />{p.label}</>}
|
||||
>
|
||||
<Text type="secondary">{p.value}</Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
|
||||
<Space wrap style={{ marginTop: 16 }}>
|
||||
<Tooltip title="Coming Soon">
|
||||
<Button icon={<ReadOutlined />} disabled>读取参数</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Coming Soon">
|
||||
<Button icon={<UploadOutlined />} disabled>下发参数</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Coming Soon">
|
||||
<Button icon={<RocketOutlined />} disabled>固件升级</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Coming Soon">
|
||||
<Button icon={<ReloadOutlined />} disabled>重启设备</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="功能路线图" size="small">
|
||||
<Timeline
|
||||
items={[
|
||||
{ color: 'green', children: <><Text strong>v1.6</Text> — UI Preview (当前)</> },
|
||||
{ color: 'blue', children: <><Text strong>v2.0</Text> — 只读参数访问</> },
|
||||
{ color: 'blue', children: <><Text strong>v2.1</Text> — 参数下发(含安全检查)</> },
|
||||
{ color: 'gray', children: <><Text strong>v2.2</Text> — 固件 OTA 升级</> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/pages/StringMonitoring/index.tsx
Normal file
146
frontend/src/pages/StringMonitoring/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, Select, Table, Tag, Alert, Row, Col } from 'antd';
|
||||
import { ApartmentOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
const INVERTERS = [
|
||||
'AP101', 'AP102', 'AP103', 'AP104', 'AP105', 'AP106', 'AP107', 'AP108',
|
||||
'AP201', 'AP202', 'AP203', 'AP204', 'AP205', 'AP206', 'AP207', 'AP208',
|
||||
];
|
||||
|
||||
interface StringData {
|
||||
id: string;
|
||||
voltage: number;
|
||||
current: number;
|
||||
power: number;
|
||||
status: 'normal' | 'low' | 'alarm';
|
||||
}
|
||||
|
||||
const generateStringData = (inverterId: string): StringData[] => {
|
||||
const count = inverterId.startsWith('AP1') ? 8 : 16;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const voltage = 320 + Math.random() * 80;
|
||||
const current = 8 + Math.random() * 4;
|
||||
const isLow = Math.random() < 0.1;
|
||||
const isAlarm = Math.random() < 0.05;
|
||||
const v = isAlarm ? voltage * 0.3 : isLow ? voltage * 0.7 : voltage;
|
||||
const c = isAlarm ? current * 0.2 : isLow ? current * 0.6 : current;
|
||||
return {
|
||||
id: `STR-${String(i + 1).padStart(2, '0')}`,
|
||||
voltage: v,
|
||||
current: c,
|
||||
power: v * c,
|
||||
status: isAlarm ? 'alarm' : isLow ? 'low' : 'normal',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
normal: { color: 'green', text: '正常' },
|
||||
low: { color: 'orange', text: '低效' },
|
||||
alarm: { color: 'red', text: '告警' },
|
||||
};
|
||||
|
||||
const heatColor = (status: string, current: number, mean: number) => {
|
||||
if (status === 'alarm') return '#ff4d4f';
|
||||
if (status === 'low') return '#faad14';
|
||||
if (current >= mean) return '#52c41a';
|
||||
return '#95de64';
|
||||
};
|
||||
|
||||
export default function StringMonitoring() {
|
||||
const [inverterId, setInverterId] = useState(INVERTERS[0]);
|
||||
|
||||
const data = useMemo(() => generateStringData(inverterId), [inverterId]);
|
||||
const meanCurrent = useMemo(() => data.reduce((s, d) => s + d.current, 0) / data.length, [data]);
|
||||
|
||||
const columns = [
|
||||
{ title: '组串ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '电压 (V)', dataIndex: 'voltage', key: 'voltage', render: (v: number) => v.toFixed(1) },
|
||||
{ title: '电流 (A)', dataIndex: 'current', key: 'current', render: (v: number) => v.toFixed(2) },
|
||||
{ title: '功率 (W)', dataIndex: 'power', key: 'power', render: (v: number) => v.toFixed(0) },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {
|
||||
const st = statusMap[s] || { color: 'default', text: s };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
];
|
||||
|
||||
const barOption = {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
xAxis: { type: 'category' as const, data: data.map(d => d.id) },
|
||||
yAxis: { type: 'value' as const, name: '电流 (A)' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.map(d => ({
|
||||
value: +d.current.toFixed(2),
|
||||
itemStyle: { color: statusMap[d.status]?.color === 'green' ? '#52c41a' : statusMap[d.status]?.color === 'orange' ? '#faad14' : '#ff4d4f' },
|
||||
})),
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
data: [{ yAxis: +meanCurrent.toFixed(2), label: { formatter: `均值 ${meanCurrent.toFixed(2)}A` } }],
|
||||
lineStyle: { color: '#1890ff', type: 'dashed' as const },
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Heatmap grid
|
||||
const gridCols = data.length <= 8 ? 4 : 4;
|
||||
const gridRows = Math.ceil(data.length / gridCols);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="组串级监控 — 数据接入开发中"
|
||||
description="String-level monitoring data integration is under development. Currently showing simulated data for UI preview."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Card size="small" title={<><ApartmentOutlined /> 组串监控</>} extra={
|
||||
<Select value={inverterId} onChange={setInverterId} style={{ width: 140 }}
|
||||
options={INVERTERS.map(id => ({ label: `逆变器 ${id}`, value: id }))} />
|
||||
}>
|
||||
<Table columns={columns} dataSource={data} rowKey="id" size="small" pagination={false} />
|
||||
</Card>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={14}>
|
||||
<Card size="small" title="组串电流对比">
|
||||
<ReactECharts option={barOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Card size="small" title="组串性能热力图">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${gridCols}, 1fr)`, gap: 8, padding: 8 }}>
|
||||
{data.map(d => (
|
||||
<div key={d.id} style={{
|
||||
background: heatColor(d.status, d.current, meanCurrent),
|
||||
borderRadius: 8, padding: '12px 8px', textAlign: 'center', color: '#fff',
|
||||
fontWeight: 600, fontSize: 13, minHeight: 60,
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||
}}>
|
||||
<div>{d.id}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.9 }}>{d.current.toFixed(1)}A / {d.power.toFixed(0)}W</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, justifyContent: 'center', marginTop: 12, fontSize: 12 }}>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 12, background: '#52c41a', borderRadius: 2, marginRight: 4 }} />正常</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 12, background: '#faad14', borderRadius: 2, marginRight: 4 }} />低效</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 12, background: '#ff4d4f', borderRadius: 2, marginRight: 4 }} />告警</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user