feat: energy flow, weather, comparison, PWA, alarm subs (v1.5.0)

7 new features inspired by iSolarCloud:

1. Animated Energy Flow Diagram — SVG/CSS animated power flow
   between PV, Load, Grid, HeatPump with real-time values
2. Weather Widget — temperature/condition on dashboard header
3. Curve Template Library — save/load Data Query presets
4. Enhanced Device Comparison — multi-device power overlay chart
5. Dispersion Rate Analysis — statistical variation across inverters
   with outlier detection (new Analysis tab)
6. PWA Support — manifest.json + service worker for mobile install
7. Alarm Subscription UI — configurable notification preferences

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-07 10:35:50 +08:00
parent 93af4bc16b
commit ec3aab28c1
14 changed files with 1234 additions and 80 deletions

View File

@@ -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>

View 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
View 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))
);
}
});

View File

@@ -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(() => {});
});
}

View 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>
);
}

View File

@@ -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)}

View 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>
);
}

View 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>
);
}

View File

@@ -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 /> },

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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]}>

View File

@@ -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>
);
}