diff --git a/VERSIONS.json b/VERSIONS.json
index 97633c5..5ece7b5 100644
--- a/VERSIONS.json
+++ b/VERSIONS.json
@@ -1,9 +1,9 @@
{
"project": "zpark-ems",
- "project_version": "1.4.0",
+ "project_version": "1.5.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": "Energy flow diagram, weather widget, curve templates, device comparison, dispersion analysis, PWA, alarm subscriptions"
}
diff --git a/frontend/index.html b/frontend/index.html
index 4b389ef..a8a3a5f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,11 @@
+
+
+
+
中关村医疗器械园智慧能源管理平台
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 0000000..6212a1d
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -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"
+ }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..fee4f29
--- /dev/null
+++ b/frontend/public/sw.js
@@ -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))
+ );
+ }
+});
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index bef5202..94a09d3 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -8,3 +8,10 @@ createRoot(document.getElementById('root')!).render(
,
)
+
+// Register PWA service worker
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+ });
+}
diff --git a/frontend/src/pages/Alarms/components/AlarmSubscription.tsx b/frontend/src/pages/Alarms/components/AlarmSubscription.tsx
new file mode 100644
index 0000000..8f6c87f
--- /dev/null
+++ b/frontend/src/pages/Alarms/components/AlarmSubscription.tsx
@@ -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 = {
+ critical: 'red',
+ warning: 'gold',
+ info: 'blue',
+};
+
+const severityTextMap: Record = {
+ critical: '紧急',
+ warning: '一般',
+ info: '信息',
+};
+
+const deviceTypeTextMap: Record = {
+ 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([]);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingId, setEditingId] = useState(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 => (
+ {severityTextMap[v] || v}
+ )),
+ },
+ {
+ title: '设备类型', dataIndex: 'deviceTypes',
+ render: (vals: string[]) => vals?.map(v => deviceTypeTextMap[v] || v).join(', '),
+ },
+ {
+ title: '通知方式', dataIndex: 'notifyMethod',
+ render: (vals: string[]) => vals?.map(v => (
+ {v === 'email' ? '邮件' : 'Webhook'}
+ )),
+ },
+ {
+ title: '启用', dataIndex: 'enabled', width: 80,
+ render: (v: boolean, r: AlarmSubscription) => (
+ handleToggle(r.id)} size="small" />
+ ),
+ },
+ {
+ title: '操作', key: 'action', width: 120,
+ render: (_: any, r: AlarmSubscription) => (
+
+ } onClick={() => openEdit(r)} />
+ handleDelete(r.id)} okText="删除" cancelText="取消">
+ } />
+
+
+ ),
+ },
+ ];
+
+ return (
+ } onClick={openCreate}>新建订阅
+ }>
+
+
+ { setModalOpen(false); form.resetFields(); }}
+ onOk={() => form.submit()}
+ okText={editingId ? '保存' : '创建'}
+ cancelText="取消"
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {notifyMethod?.includes('email') && (
+
+
+
+ )}
+ {notifyMethod?.includes('webhook') && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Alarms/index.tsx b/frontend/src/pages/Alarms/index.tsx
index b34d30a..8ba869b 100644
--- a/frontend/src/pages/Alarms/index.tsx
+++ b/frontend/src/pages/Alarms/index.tsx
@@ -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() {
)},
{ key: 'analytics', label: '分析', children: },
+ { key: 'subscription', label: 订阅设置, children: },
]} />
setShowRuleModal(false)}
diff --git a/frontend/src/pages/Analysis/components/DeviceComparison.tsx b/frontend/src/pages/Analysis/components/DeviceComparison.tsx
new file mode 100644
index 0000000..9189c61
--- /dev/null
+++ b/frontend/src/pages/Analysis/components/DeviceComparison.tsx
@@ -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([]);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [date, setDate] = useState(dayjs().subtract(1, 'day'));
+ const [loading, setLoading] = useState(false);
+ const [deviceData, setDeviceData] = useState([]);
+
+ 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 += `
${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 (
+
+
+
+ 选择设备 (2-5台):
+
+
+
+ {deviceData.length > 0 ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (
+ !loading && selectedIds.length >= 2 &&
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Analysis/components/DispersionAnalysis.tsx b/frontend/src/pages/Analysis/components/DispersionAnalysis.tsx
new file mode 100644
index 0000000..8cd1f33
--- /dev/null
+++ b/frontend/src/pages/Analysis/components/DispersionAnalysis.tsx
@@ -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().subtract(1, 'day'));
+ const [loading, setLoading] = useState(false);
+ const [stats, setStats] = useState(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}
发电量: ${p.value.toFixed(1)} kWh
偏差: ${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 (
+
+ {diff >= 0 ? '+' : ''}{diff.toFixed(1)} kWh ({pct}%)
+
+ );
+ },
+ },
+ {
+ title: '状态', key: 'status',
+ render: (_: any, record: DeviceEnergy) => {
+ if (!stats) return '-';
+ const diff = record.daily_energy - stats.mean;
+ return diff < 0
+ ? 低于均值
+ : 高于均值;
+ },
+ },
+ ];
+
+ return (
+
+
+
+ 分析日期:
+ d && setDate(d)}
+ disabledDate={(current) => current && current > dayjs()}
+ />
+
+
+
+ {stats ? (
+ <>
+
+
+
+ 离散率
+
+ {stats.dispersionRate.toFixed(1)}%
+
+ {getDispersionLabel(stats.dispersionRate)}
+
+
+
+
+
+
+ 平均发电量
+ {stats.mean.toFixed(1)} kWh
+
+
+
+
+ 标准差
+ {stats.stdDev.toFixed(2)} kWh
+
+
+
+
+
+
+
+
+ {stats.outliers.length > 0 && (
+ 2σ) — ${stats.outliers.length} 台`} size="small">
+
+
+ )}
+ >
+ ) : (
+ !loading &&
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Analysis/index.tsx b/frontend/src/pages/Analysis/index.tsx
index b24a1ca..5bc5895 100644
--- a/frontend/src/pages/Analysis/index.tsx
+++ b/frontend/src/pages/Analysis/index.tsx
@@ -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)}
-
+
+
+
+
+
);
}
@@ -324,6 +330,7 @@ export default function Analysis() {
items={[
{ key: 'overview', label: '能耗概览', children: overviewContent },
{ key: 'comparison', label: '数据对比', children: },
+ { key: 'dispersion', label: '离散率分析', children: },
{ key: 'loss', label: '损耗分析', children: },
{ key: 'yoy', label: '同比分析', children: },
{ key: 'mom', label: '环比分析', children: },
diff --git a/frontend/src/pages/Dashboard/components/EnergyFlow.tsx b/frontend/src/pages/Dashboard/components/EnergyFlow.tsx
index e24d089..4536433 100644
--- a/frontend/src/pages/Dashboard/components/EnergyFlow.tsx
+++ b/frontend/src/pages/Dashboard/components/EnergyFlow.tsx
@@ -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(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();
- 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 ;
-
return (
-
-
-
-
- 热泵: {hp.toFixed(1)} kW
- 自发自用率:
- {load > 0 ? ((Math.min(pv, load) / load) * 100).toFixed(1) : 0}%
-
-
-
+
+
+
+
);
}
diff --git a/frontend/src/pages/Dashboard/components/WeatherWidget.tsx b/frontend/src/pages/Dashboard/components/WeatherWidget.tsx
new file mode 100644
index 0000000..13986d0
--- /dev/null
+++ b/frontend/src/pages/Dashboard/components/WeatherWidget.tsx
@@ -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
(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const fetchWeather = async () => {
+ try {
+ const res: any = await getWeatherCurrent();
+ if (!mounted) return;
+ const conditionMap: Record = {
+ 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 (
+
+
+ {weather.icon}
+
+ {weather.temperature}°C
+ {weather.condition}
+
+
+ 湿度 {weather.humidity}%
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx
index 6e4b1c2..802a18f 100644
--- a/frontend/src/pages/Dashboard/index.tsx
+++ b/frontend/src/pages/Dashboard/index.tsx
@@ -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 (
-
-
- 能源总览
-
+
+
+
+ 能源总览
+
+
+
{/* 核心指标卡片 */}
diff --git a/frontend/src/pages/DataQuery/index.tsx b/frontend/src/pages/DataQuery/index.tsx
index acd7506..37e356d 100644
--- a/frontend/src/pages/DataQuery/index.tsx
+++ b/frontend/src/pages/DataQuery/index.tsx
@@ -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([]);
const [deviceMap, setDeviceMap] = useState>({});
@@ -50,6 +74,9 @@ export default function DataQuery() {
const [chartData, setChartData] = useState>({});
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
+ const [templates, setTemplates] = useState(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() {
} loading={exporting} onClick={() => handleExport('xlsx')}>
导出Excel
+ } onClick={() => setSaveModalOpen(true)}>
+ 保存模板
+
+ {templates.length > 0 && (
+ }
+ optionLabelProp="label"
+ >
+ {templates.map(t => (
+
+
+
{t.name}
+
{ e?.stopPropagation(); handleDeleteTemplate(t.id); }}
+ onCancel={(e) => e?.stopPropagation()}
+ >
+ e.stopPropagation()}
+ />
+
+
+
+ ))}
+
+ )}
@@ -360,6 +456,26 @@ export default function DataQuery() {
>
)}
+ { setSaveModalOpen(false); setTemplateName(''); }}
+ okText="保存"
+ cancelText="取消"
+ >
+ setTemplateName(e.target.value)}
+ onPressEnter={handleSaveTemplate}
+ maxLength={30}
+ />
+
+ 将保存: 当前设备 {selectedDevice ? selectedDevice.name : '(未选择)'}、
+ 参数 [{selectedParams.join(', ')}]、粒度 {granularity}
+
+
);
}