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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
94
frontend/src/pages/Dashboard/components/WeatherWidget.tsx
Normal file
94
frontend/src/pages/Dashboard/components/WeatherWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user