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:
Du Wenbo
2026-04-05 23:43:24 +08:00
parent ed30ac31e4
commit d3f47d664c
121 changed files with 21784 additions and 23 deletions

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

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

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

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

View 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 | 3SUN2000-110KTL-M0
</Text>
</div>
</div>
);
}