feat: customer frontend, Sungrow collector fixes, real data (v1.2.0)
- Add frontend/ at root (no Three.js, no Charging, green #52c41a theme) - Fix Sungrow collector: add curPage/size params, unit conversion - Fix station-level dedup to prevent double-counting - Add shared token cache for API rate limit protection - Add .githooks/pre-commit, CLAUDE.md, .gitignore - Update docker-compose.override.yml frontend -> ./frontend - Pin bcrypt in requirements.txt - Add BUYOFF_RESULTS_2026-04-05.md (39/43 pass) - Data accuracy: 0.0% diff vs iSolarCloud Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
frontend/src/pages/Dashboard/components/DeviceStatus.tsx
Normal file
26
frontend/src/pages/Dashboard/components/DeviceStatus.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface Props {
|
||||
stats: { online?: number; offline?: number; alarm?: number; total?: number };
|
||||
}
|
||||
|
||||
export default function DeviceStatus({ stats }: Props) {
|
||||
const option = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, textStyle: { fontSize: 12 } },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['50%', '45%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: true, position: 'outside', fontSize: 12 },
|
||||
data: [
|
||||
{ value: stats.online || 0, name: '在线', itemStyle: { color: '#52c41a' } },
|
||||
{ value: stats.offline || 0, name: '离线', itemStyle: { color: '#d9d9d9' } },
|
||||
{ value: stats.alarm || 0, name: '告警', itemStyle: { color: '#f5222d' } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 280 }} />;
|
||||
}
|
||||
96
frontend/src/pages/Dashboard/components/EnergyFlow.tsx
Normal file
96
frontend/src/pages/Dashboard/components/EnergyFlow.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
|
||||
interface Props {
|
||||
realtime?: {
|
||||
pv_power: number;
|
||||
heatpump_power: number;
|
||||
total_load: number;
|
||||
grid_power: number;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Build sankey from realtime data as fallback if API has no flow data
|
||||
const pvToBuilding = Math.min(pv, load);
|
||||
const pvToGrid = Math.max(0, pv - load);
|
||||
const gridToBuilding = Math.max(0, load - pv);
|
||||
const gridToHeatPump = hp;
|
||||
|
||||
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 nodes = flowData?.nodes || [
|
||||
{ name: '光伏发电', itemStyle: { color: '#faad14' } },
|
||||
{ name: '电网输入', itemStyle: { color: '#52c41a' } },
|
||||
{ name: '建筑用电', itemStyle: { color: '#1890ff' } },
|
||||
{ name: '电网输出', itemStyle: { color: '#13c2c2' } },
|
||||
{ name: '热泵系统', itemStyle: { color: '#f5222d' } },
|
||||
];
|
||||
|
||||
// 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));
|
||||
|
||||
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 },
|
||||
}],
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/pages/Dashboard/components/EnergyOverview.tsx
Normal file
33
frontend/src/pages/Dashboard/components/EnergyOverview.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface Props {
|
||||
energyToday?: Record<string, { consumption: number; generation: number }>;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
electricity: '用电',
|
||||
heat: '供热',
|
||||
water: '用水',
|
||||
gas: '用气',
|
||||
};
|
||||
|
||||
export default function EnergyOverview({ energyToday }: Props) {
|
||||
const data = energyToday || {};
|
||||
const categories = Object.keys(data).map(k => LABELS[k] || k);
|
||||
const consumption = Object.values(data).map(v => v.consumption);
|
||||
const generation = Object.values(data).map(v => v.generation);
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['消耗', '产出'], bottom: 0 },
|
||||
grid: { top: 20, right: 20, bottom: 40, left: 50 },
|
||||
xAxis: { type: 'category', data: categories },
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{ name: '消耗', type: 'bar', data: consumption, itemStyle: { color: '#1890ff' } },
|
||||
{ name: '产出', type: 'bar', data: generation, itemStyle: { color: '#52c41a' } },
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 250 }} />;
|
||||
}
|
||||
40
frontend/src/pages/Dashboard/components/LoadCurve.tsx
Normal file
40
frontend/src/pages/Dashboard/components/LoadCurve.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface Props {
|
||||
data: { time: string; power: number }[];
|
||||
}
|
||||
|
||||
export default function LoadCurve({ data }: Props) {
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 30, right: 20, bottom: 30, left: 50 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => {
|
||||
const t = new Date(d.time);
|
||||
return `${t.getHours().toString().padStart(2, '0')}:00`;
|
||||
}),
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', name: 'kW', axisLabel: { fontSize: 11 } },
|
||||
series: [{
|
||||
name: '负荷功率',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data.map(d => d.power),
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(24,144,255,0.3)' },
|
||||
{ offset: 1, color: 'rgba(24,144,255,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
lineStyle: { width: 2, color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
}],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 280 }} />;
|
||||
}
|
||||
45
frontend/src/pages/Dashboard/components/PowerGeneration.tsx
Normal file
45
frontend/src/pages/Dashboard/components/PowerGeneration.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Row, Col, Statistic, Progress, Typography } from 'antd';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
realtime?: { pv_power: number; total_load: number };
|
||||
energyToday?: { consumption: number; generation: number };
|
||||
}
|
||||
|
||||
export default function PowerGeneration({ realtime, energyToday }: Props) {
|
||||
const pvPower = realtime?.pv_power || 0;
|
||||
const ratedPower = 375.035; // 总装机容量 kW
|
||||
const utilization = ratedPower > 0 ? (pvPower / ratedPower) * 100 : 0;
|
||||
const generation = energyToday?.generation || 0;
|
||||
const selfUseRate = energyToday && energyToday.generation > 0
|
||||
? Math.min(100, (energyToday.consumption / energyToday.generation) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic title="实时发电功率" value={pvPower} suffix="kW" precision={1}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#faad14' }} />} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="今日发电量" value={generation} suffix="kWh" precision={1} />
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">装机利用率</Text>
|
||||
<Progress percent={Number(utilization.toFixed(1))} strokeColor="#faad14" />
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">自消纳率</Text>
|
||||
<Progress percent={Number(selfUseRate.toFixed(1))} strokeColor="#52c41a" />
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
装机容量: {ratedPower} kW | 3台华为SUN2000-110KTL-M0
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user