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) => ( + + + }> + + + { setModalOpen(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editingId ? '保存' : '创建'} + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + )} + {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台): +
+ + + ) : ( + !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}% - - -
+
+ + + + + {/* Glow filters */} + + + + + + + + + + + + + + {/* Gradient for PV -> Load (green) */} + + + + + {/* Gradient for PV -> Grid (green to orange) */} + + + + + {/* Gradient for Grid -> Load (orange to blue) */} + + + + + {/* Gradient for Grid -> HeatPump (orange to cyan) */} + + + + + + + {/* ===== FLOW LINES ===== */} + + {/* PV -> Load */} + + + {flows.pvToLoad && ( + + {pvToLoad.toFixed(1)} kW + + )} + + {/* PV -> Grid (export) */} + + + {flows.pvToGrid && ( + + {(pvToGrid || gridExport).toFixed(1)} kW + + )} + + {/* Grid -> Load (import) */} + + + {flows.gridToLoad && ( + + {gridToLoad.toFixed(1)} kW + + )} + + {/* Load -> HeatPump */} + + + {flows.gridToHp && ( + + {hp.toFixed(1)} kW + + )} + + {/* ===== NODES ===== */} + + {/* PV Solar Node */} + + + ☀️ + 光伏发电 + + {pv.toFixed(1)} kW + + + + {/* Building Load Node */} + + + 🏢 + 建筑负载 + + {load.toFixed(1)} kW + + + + {/* Grid Node */} + + + + + {grid >= 0 ? '电网购入' : '电网输出'} + + + {Math.abs(grid).toFixed(1)} kW + + + + {/* HeatPump Node */} + + + 🔥 + 热泵系统 + + {hp.toFixed(1)} kW + + + + {/* Self-consumption badge */} + + + 自消纳 {selfUseRate}% + +
); } 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 (
- - <ThunderboltOutlined style={{ color: '#1890ff', marginRight: 8 }} /> - 能源总览 - +
+ + <ThunderboltOutlined style={{ color: '#1890ff', marginRight: 8 }} /> + 能源总览 + + +
{/* 核心指标卡片 */} 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() { + + {templates.length > 0 && ( + + )} @@ -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} +
+
); }