feat: complete platform build-out to 95% benchmark-ready

Major additions across backend, frontend, and infrastructure:

Backend:
- IoT collector framework (Modbus TCP, MQTT, HTTP) with manager
- Realistic Beijing solar/weather simulator with cloud transients
- Alarm auto-checker with demo anomaly injection (3-4 events/hour)
- Report generation (PDF/Excel) with sync fallback and E2E testing
- Energy data CSV/XLSX export endpoint
- WebSocket real-time broadcast at /ws/realtime
- Alembic initial migration for all 14 tables
- 77 pytest tests across 9 API routers

Frontend:
- Live notification badge with alarm count (was hardcoded 0)
- Sankey energy flow diagram on dashboard
- Device photos (SVG illustrations) on all device pages
- Report download with status icons
- Energy data export buttons (CSV/Excel)
- WebSocket hook with auto-reconnect and polling fallback
- BigScreen 2D responsive CSS (tablet/mobile)
- Error handling improvements across pages

Infrastructure:
- PostgreSQL + TimescaleDB as primary database
- Production docker-compose with nginx reverse proxy
- Comprehensive Chinese README
- .env.example with documentation
- quick-start.sh deployment script
- nginx config with gzip, caching, security headers

Data:
- 30-day realistic backfill (47K rows, weather-correlated)
- 18 devices, 6 alarm rules, 15 historical alarm events
- Beijing solar position model with seasonal variation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-02 18:46:42 +08:00
parent 6a59f9af76
commit 36c53e0e7c
72 changed files with 7284 additions and 392 deletions

20
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,20 @@
# Multi-stage production build for standalone use
# In docker-compose.prod.yml, the nginx Dockerfile handles frontend building directly
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# SPA fallback
RUN echo 'server { listen 80; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Device icon -->
<rect x="150" y="60" width="100" height="120" rx="8" fill="none" stroke="#64748b" stroke-width="3"/>
<circle cx="200" cy="110" r="25" fill="none" stroke="#64748b" stroke-width="2"/>
<path d="M 185 110 L 200 95 L 215 110 L 200 125 Z" fill="#64748b" opacity="0.5"/>
<!-- Signal -->
<line x1="200" y1="60" x2="200" y2="40" stroke="#64748b" stroke-width="2"/>
<circle cx="200" cy="36" r="4" fill="#64748b"/>
<!-- Label -->
<text x="200" y="228" text-anchor="middle" fill="#94a3b8" font-family="Arial, sans-serif" font-size="20" font-weight="bold">IoT Device</text>
<text x="200" y="248" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">通用设备</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2d1b1b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1a0f0f;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Meter body -->
<rect x="120" y="50" width="160" height="140" rx="10" fill="#f3f4f6" stroke="#d1d5db" stroke-width="2"/>
<!-- Screen -->
<rect x="138" y="65" width="124" height="50" rx="4" fill="#1f2937"/>
<text x="200" y="92" text-anchor="middle" fill="#ef4444" font-family="monospace" font-size="20" font-weight="bold">256.8</text>
<text x="255" y="92" fill="#ef4444" font-family="monospace" font-size="10">GJ</text>
<text x="145" y="78" fill="#9ca3af" font-family="monospace" font-size="9">累计热量</text>
<!-- Flow rate -->
<text x="200" y="135" text-anchor="middle" fill="#6b7280" font-family="Arial" font-size="11">流量: 2.4 m³/h</text>
<text x="200" y="150" text-anchor="middle" fill="#6b7280" font-family="Arial" font-size="11">温差: 8.2°C</text>
<!-- Pipes -->
<rect x="70" y="155" width="55" height="16" rx="8" fill="#ef4444" opacity="0.7"/>
<rect x="275" y="155" width="55" height="16" rx="8" fill="#3b82f6" opacity="0.7"/>
<text x="97" y="166" text-anchor="middle" fill="white" font-size="9" font-family="Arial">供水</text>
<text x="302" y="166" text-anchor="middle" fill="white" font-size="9" font-family="Arial">回水</text>
<!-- Flow arrows in pipes -->
<text x="80" y="166" fill="white" font-size="10"></text>
<text x="315" y="166" fill="white" font-size="10"></text>
<!-- Label -->
<text x="200" y="238" text-anchor="middle" fill="#f87171" font-family="Arial, sans-serif" font-size="20" font-weight="bold">热量表</text>
<text x="200" y="258" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Ultrasonic Heat Meter</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a2332;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
</linearGradient>
<linearGradient id="body" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#e5e7eb;stop-opacity:1" />
<stop offset="100%" style="stop-color:#9ca3af;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Heat pump body -->
<rect x="80" y="50" width="240" height="170" rx="12" fill="url(#body)" stroke="#6b7280" stroke-width="2"/>
<!-- Fan grille -->
<circle cx="200" cy="120" r="55" fill="#374151" stroke="#4b5563" stroke-width="3"/>
<circle cx="200" cy="120" r="45" fill="none" stroke="#6b7280" stroke-width="1"/>
<circle cx="200" cy="120" r="35" fill="none" stroke="#6b7280" stroke-width="1"/>
<circle cx="200" cy="120" r="25" fill="none" stroke="#6b7280" stroke-width="1"/>
<circle cx="200" cy="120" r="8" fill="#1f2937"/>
<!-- Fan blades -->
<g transform="translate(200, 120)" fill="#4b5563" opacity="0.8">
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(0)"/>
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(90)"/>
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(180)"/>
<ellipse cx="0" cy="-22" rx="8" ry="22" transform="rotate(270)"/>
</g>
<!-- Side vents -->
<g fill="#9ca3af">
<rect x="90" y="170" width="40" height="3" rx="1.5"/>
<rect x="90" y="178" width="40" height="3" rx="1.5"/>
<rect x="90" y="186" width="40" height="3" rx="1.5"/>
<rect x="90" y="194" width="40" height="3" rx="1.5"/>
<rect x="90" y="202" width="40" height="3" rx="1.5"/>
</g>
<!-- Pipes -->
<rect x="280" y="140" width="30" height="12" rx="6" fill="#ef4444" opacity="0.8"/>
<rect x="280" y="165" width="30" height="12" rx="6" fill="#3b82f6" opacity="0.8"/>
<text x="295" y="149" text-anchor="middle" fill="white" font-size="8" font-family="Arial"></text>
<text x="295" y="174" text-anchor="middle" fill="white" font-size="8" font-family="Arial"></text>
<!-- Status LED -->
<circle cx="100" cy="68" r="4" fill="#22c55e"/>
<!-- Label -->
<text x="200" y="258" text-anchor="middle" fill="#ff8c00" font-family="Arial, sans-serif" font-size="20" font-weight="bold">空气源热泵</text>
<text x="200" y="278" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Air Source Heat Pump</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Meter body -->
<rect x="110" y="30" width="180" height="220" rx="10" fill="#f3f4f6" stroke="#d1d5db" stroke-width="2"/>
<!-- Screen -->
<rect x="130" y="50" width="140" height="60" rx="6" fill="#1f2937" stroke="#374151" stroke-width="1"/>
<!-- LCD display -->
<text x="200" y="82" text-anchor="middle" fill="#22c55e" font-family="monospace" font-size="24" font-weight="bold">1847.5</text>
<text x="260" y="82" text-anchor="end" fill="#22c55e" font-family="monospace" font-size="12">kWh</text>
<text x="140" y="65" fill="#6b7280" font-family="monospace" font-size="10">正向有功总</text>
<!-- Status LEDs -->
<circle cx="145" cy="125" r="4" fill="#22c55e"/>
<circle cx="160" cy="125" r="4" fill="#ef4444" opacity="0.3"/>
<circle cx="175" cy="125" r="4" fill="#eab308" opacity="0.3"/>
<text x="190" y="128" fill="#6b7280" font-family="Arial" font-size="9">运行</text>
<!-- Buttons -->
<circle cx="150" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
<circle cx="200" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
<circle cx="250" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
<text x="150" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial"></text>
<text x="200" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">OK</text>
<text x="250" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial"></text>
<!-- Terminal block -->
<rect x="130" y="190" width="140" height="40" rx="4" fill="#e5e7eb" stroke="#d1d5db"/>
<g fill="#374151">
<circle cx="150" cy="210" r="6" />
<circle cx="175" cy="210" r="6" />
<circle cx="200" cy="210" r="6" />
<circle cx="225" cy="210" r="6" />
<circle cx="250" cy="210" r="6" />
</g>
<!-- Label -->
<text x="200" y="272" text-anchor="middle" fill="#00d4ff" font-family="Arial, sans-serif" font-size="20" font-weight="bold">智能电表</text>
<text x="200" y="292" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Smart Power Meter</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a3a2a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0d2818;stop-opacity:1" />
</linearGradient>
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Solar panels -->
<g transform="translate(80, 40)">
<!-- Panel frame -->
<rect x="0" y="0" width="240" height="160" rx="6" fill="url(#panel)" stroke="#3b82f6" stroke-width="2"/>
<!-- Grid lines -->
<line x1="80" y1="0" x2="80" y2="160" stroke="#1e3a5f" stroke-width="1.5"/>
<line x1="160" y1="0" x2="160" y2="160" stroke="#1e3a5f" stroke-width="1.5"/>
<line x1="0" y1="40" x2="240" y2="40" stroke="#1e3a5f" stroke-width="1.5"/>
<line x1="0" y1="80" x2="240" y2="80" stroke="#1e3a5f" stroke-width="1.5"/>
<line x1="0" y1="120" x2="240" y2="120" stroke="#1e3a5f" stroke-width="1.5"/>
<!-- Cells shine -->
<rect x="4" y="4" width="72" height="34" rx="2" fill="#3b82f6" opacity="0.6"/>
<rect x="84" y="4" width="72" height="34" rx="2" fill="#60a5fa" opacity="0.4"/>
<rect x="164" y="4" width="72" height="34" rx="2" fill="#3b82f6" opacity="0.5"/>
<!-- Sun icon -->
<circle cx="220" cy="20" r="12" fill="#fbbf24" opacity="0.8"/>
<g stroke="#fbbf24" stroke-width="2" opacity="0.6">
<line x1="220" y1="2" x2="220" y2="8"/>
<line x1="220" y1="32" x2="220" y2="38"/>
<line x1="202" y1="20" x2="208" y2="20"/>
<line x1="232" y1="20" x2="238" y2="20"/>
</g>
<!-- Stand -->
<rect x="110" y="160" width="20" height="40" fill="#374151"/>
<rect x="80" y="195" width="80" height="8" rx="4" fill="#4b5563"/>
</g>
<!-- Label -->
<text x="200" y="270" text-anchor="middle" fill="#00ff88" font-family="Arial, sans-serif" font-size="20" font-weight="bold">光伏逆变器</text>
<text x="200" y="290" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Huawei SUN2000-110KTL</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a2332;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0c1524;stop-opacity:1" />
</linearGradient>
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:0.3" />
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:0" />
</radialGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Glow effect -->
<circle cx="200" cy="130" r="80" fill="url(#glow)"/>
<!-- Sensor body -->
<rect x="160" y="60" width="80" height="120" rx="12" fill="#f9fafb" stroke="#e5e7eb" stroke-width="2"/>
<!-- Display -->
<rect x="172" y="75" width="56" height="35" rx="4" fill="#1f2937"/>
<text x="200" y="96" text-anchor="middle" fill="#22c55e" font-family="monospace" font-size="18" font-weight="bold">23.5</text>
<text x="200" y="106" text-anchor="middle" fill="#6b7280" font-family="monospace" font-size="8">°C</text>
<!-- Humidity bar -->
<rect x="175" y="120" width="50" height="6" rx="3" fill="#1f2937"/>
<rect x="175" y="120" width="32" height="6" rx="3" fill="#3b82f6"/>
<text x="200" y="138" text-anchor="middle" fill="#6b7280" font-family="Arial" font-size="9">湿度 64%</text>
<!-- Antenna -->
<line x1="200" y1="60" x2="200" y2="30" stroke="#9ca3af" stroke-width="2"/>
<circle cx="200" cy="26" r="4" fill="#22c55e"/>
<!-- Signal waves -->
<path d="M 210 35 Q 218 26, 210 17" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.6"/>
<path d="M 214 39 Q 226 26, 214 13" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.4"/>
<path d="M 218 43 Q 234 26, 218 9" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.2"/>
<!-- Wall mount -->
<rect x="185" y="180" width="30" height="20" rx="4" fill="#d1d5db"/>
<circle cx="195" cy="190" r="3" fill="#9ca3af"/>
<circle cx="205" cy="190" r="3" fill="#9ca3af"/>
<!-- Label -->
<text x="200" y="238" text-anchor="middle" fill="#a78bfa" font-family="Arial, sans-serif" font-size="20" font-weight="bold">温湿度传感器</text>
<text x="200" y="258" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Temperature &amp; Humidity Sensor</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg)" rx="12"/>
<!-- Meter body -->
<rect x="110" y="30" width="180" height="220" rx="10" fill="#f3f4f6" stroke="#d1d5db" stroke-width="2"/>
<!-- Screen -->
<rect x="130" y="50" width="140" height="60" rx="6" fill="#1f2937" stroke="#374151" stroke-width="1"/>
<!-- LCD display -->
<text x="200" y="82" text-anchor="middle" fill="#22c55e" font-family="monospace" font-size="24" font-weight="bold">1847.5</text>
<text x="260" y="82" text-anchor="end" fill="#22c55e" font-family="monospace" font-size="12">kWh</text>
<text x="140" y="65" fill="#6b7280" font-family="monospace" font-size="10">正向有功总</text>
<!-- Status LEDs -->
<circle cx="145" cy="125" r="4" fill="#22c55e"/>
<circle cx="160" cy="125" r="4" fill="#ef4444" opacity="0.3"/>
<circle cx="175" cy="125" r="4" fill="#eab308" opacity="0.3"/>
<text x="190" y="128" fill="#6b7280" font-family="Arial" font-size="9">运行</text>
<!-- Buttons -->
<circle cx="150" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
<circle cx="200" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
<circle cx="250" cy="155" r="10" fill="#e5e7eb" stroke="#9ca3af" stroke-width="1"/>
<text x="150" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial"></text>
<text x="200" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial">OK</text>
<text x="250" y="158" text-anchor="middle" fill="#6b7280" font-size="8" font-family="Arial"></text>
<!-- Terminal block -->
<rect x="130" y="190" width="140" height="40" rx="4" fill="#e5e7eb" stroke="#d1d5db"/>
<g fill="#374151">
<circle cx="150" cy="210" r="6" />
<circle cx="175" cy="210" r="6" />
<circle cx="200" cy="210" r="6" />
<circle cx="225" cy="210" r="6" />
<circle cx="250" cy="210" r="6" />
</g>
<!-- Label -->
<text x="200" y="272" text-anchor="middle" fill="#00d4ff" font-family="Arial, sans-serif" font-size="20" font-weight="bold">智能电表</text>
<text x="200" y="292" text-anchor="middle" fill="#6b7280" font-family="Arial, sans-serif" font-size="12">Smart Power Meter</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { getToken } from '../utils/auth';
export interface RealtimeData {
pv_power: number;
heatpump_power: number;
total_load: number;
grid_power: number;
active_alarms: number;
timestamp: string;
}
export interface AlarmEventData {
id: number;
title: string;
severity: string;
message?: string;
device_name?: string;
triggered_at?: string;
}
interface WebSocketMessage {
type: 'realtime_update' | 'alarm_event' | 'pong';
data?: RealtimeData | AlarmEventData;
}
interface UseRealtimeWebSocketOptions {
/** Called when a new alarm event arrives */
onAlarmEvent?: (alarm: AlarmEventData) => void;
/** Polling interval in ms when WS is unavailable (default: 15000) */
fallbackInterval?: number;
/** Whether the hook is enabled (default: true) */
enabled?: boolean;
}
interface UseRealtimeWebSocketResult {
/** Latest realtime data from WebSocket */
data: RealtimeData | null;
/** Whether WebSocket is currently connected */
connected: boolean;
/** Whether we are using fallback polling */
usingFallback: boolean;
}
const MAX_RECONNECT_DELAY = 30000;
const INITIAL_RECONNECT_DELAY = 1000;
export default function useRealtimeWebSocket(
options: UseRealtimeWebSocketOptions = {}
): UseRealtimeWebSocketResult {
const { onAlarmEvent, fallbackInterval = 15000, enabled = true } = options;
const [data, setData] = useState<RealtimeData | null>(null);
const [connected, setConnected] = useState(false);
const [usingFallback, setUsingFallback] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectDelayRef = useRef(INITIAL_RECONNECT_DELAY);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fallbackTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onAlarmEventRef = useRef(onAlarmEvent);
const mountedRef = useRef(true);
// Keep callback ref up to date
useEffect(() => {
onAlarmEventRef.current = onAlarmEvent;
}, [onAlarmEvent]);
const cleanup = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.onerror = null;
wsRef.current.onmessage = null;
wsRef.current.close();
wsRef.current = null;
}
}, []);
const startFallbackPolling = useCallback(() => {
if (fallbackTimerRef.current) return;
setUsingFallback(true);
// We don't do actual polling here - the parent component's
// existing polling handles data fetch. This flag signals the
// parent to keep its polling active.
}, []);
const stopFallbackPolling = useCallback(() => {
if (fallbackTimerRef.current) {
clearInterval(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
setUsingFallback(false);
}, []);
const connect = useCallback(() => {
if (!mountedRef.current || !enabled) return;
const token = getToken();
if (!token) {
startFallbackPolling();
return;
}
cleanup();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/v1/ws/realtime?token=${encodeURIComponent(token)}`;
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) return;
setConnected(true);
stopFallbackPolling();
reconnectDelayRef.current = INITIAL_RECONNECT_DELAY;
// Ping every 30s to keep alive
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 30000);
};
ws.onmessage = (event) => {
if (!mountedRef.current) return;
try {
const msg: WebSocketMessage = JSON.parse(event.data);
if (msg.type === 'realtime_update' && msg.data) {
setData(msg.data as RealtimeData);
} else if (msg.type === 'alarm_event' && msg.data) {
onAlarmEventRef.current?.(msg.data as AlarmEventData);
}
// pong is just a keepalive ack, ignore
} catch {
// ignore parse errors
}
};
ws.onclose = (event) => {
if (!mountedRef.current) return;
setConnected(false);
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
// Don't reconnect if closed intentionally (4001 = auth error)
if (event.code === 4001) {
startFallbackPolling();
return;
}
// Reconnect with exponential backoff
startFallbackPolling();
const delay = reconnectDelayRef.current;
reconnectDelayRef.current = Math.min(delay * 2, MAX_RECONNECT_DELAY);
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
// onclose will fire after this, which handles reconnection
};
} catch {
startFallbackPolling();
const delay = reconnectDelayRef.current;
reconnectDelayRef.current = Math.min(delay * 2, MAX_RECONNECT_DELAY);
reconnectTimerRef.current = setTimeout(connect, delay);
}
}, [enabled, cleanup, startFallbackPolling, stopFallbackPolling]);
useEffect(() => {
mountedRef.current = true;
if (enabled) {
connect();
}
return () => {
mountedRef.current = false;
cleanup();
stopFallbackPolling();
};
}, [enabled, connect, cleanup, stopFallbackPolling]);
return { data, connected, usingFallback };
}

View File

@@ -14,3 +14,47 @@ body {
width: 100%;
min-height: 100vh;
}
/* ============================================
MainLayout responsive styles
============================================ */
/* Tablet: collapse sidebar by default */
@media (max-width: 768px) {
.ant-layout-sider {
position: fixed !important;
z-index: 1000;
height: 100vh;
}
.ant-layout-sider-collapsed {
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
flex: 0 0 0 !important;
overflow: hidden;
}
.ant-layout-header {
padding: 0 12px !important;
}
.ant-layout-content {
margin: 8px !important;
padding: 12px !important;
}
}
/* Mobile: tighter spacing */
@media (max-width: 375px) {
.ant-layout-header {
padding: 0 8px !important;
height: 48px !important;
line-height: 48px !important;
}
.ant-layout-content {
margin: 4px !important;
padding: 8px !important;
}
}

View File

@@ -1,13 +1,15 @@
import { useState } from 'react';
import { Layout, Menu, Avatar, Dropdown, Typography, Badge } from 'antd';
import { useState, useEffect, useCallback } from 'react';
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty } from 'antd';
import {
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
ThunderboltOutlined, AppstoreOutlined,
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { getUser, removeToken } from '../utils/auth';
import { getAlarmStats, getAlarmEvents } from '../services/api';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
@@ -29,12 +31,48 @@ const menuItems = [
},
];
const SEVERITY_CONFIG: Record<string, { icon: React.ReactNode; color: string }> = {
critical: { icon: <CloseCircleOutlined style={{ color: '#f5222d' }} />, color: 'red' },
warning: { icon: <WarningOutlined style={{ color: '#faad14' }} />, color: 'orange' },
info: { icon: <InfoCircleOutlined style={{ color: '#1890ff' }} />, color: 'blue' },
};
export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false);
const [alarmCount, setAlarmCount] = useState(0);
const [recentAlarms, setRecentAlarms] = useState<any[]>([]);
const navigate = useNavigate();
const location = useLocation();
const user = getUser();
const fetchAlarms = useCallback(async () => {
try {
const [stats, events] = await Promise.all([
getAlarmStats(),
getAlarmEvents({ status: 'active', page_size: 5 }),
]);
// Stats shape: { severity: { status: count } } — sum all "active" counts
const statsData = (stats as any) || {};
let activeTotal = 0;
for (const severity of Object.values(statsData)) {
if (severity && typeof severity === 'object') {
activeTotal += (severity as any).active || 0;
}
}
setAlarmCount(activeTotal);
const items = (events as any)?.items || (events as any) || [];
setRecentAlarms(Array.isArray(items) ? items : []);
} catch {
// silently ignore - notifications are non-critical
}
}, []);
useEffect(() => {
fetchAlarms();
const timer = setInterval(fetchAlarms, 30000);
return () => clearInterval(timer);
}, [fetchAlarms]);
const handleLogout = () => {
removeToken();
localStorage.removeItem('user');
@@ -80,10 +118,47 @@ export default function MainLayout() {
<MenuFoldOutlined style={{ fontSize: 18 }} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{/* TODO: fetch notification count from API */}
<Badge count={0} size="small">
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
</Badge>
<Popover
trigger="click"
placement="bottomRight"
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span></span>
{alarmCount > 0 && <Tag color="red">{alarmCount} </Tag>}
</div>}
content={
<div style={{ width: 320, maxHeight: 360, overflow: 'auto' }}>
{recentAlarms.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无活跃告警" />
) : (
<List size="small" dataSource={recentAlarms} renderItem={(alarm: any) => {
const sev = SEVERITY_CONFIG[alarm.severity] || SEVERITY_CONFIG.info;
return (
<List.Item
style={{ cursor: 'pointer', padding: '8px 0' }}
onClick={() => navigate('/alarms')}
>
<List.Item.Meta
avatar={sev.icon}
title={<span style={{ fontSize: 13 }}>{alarm.device_name || alarm.title || '未知设备'}</span>}
description={<>
<div style={{ fontSize: 12 }}>{alarm.message || alarm.title}</div>
<div style={{ fontSize: 11, color: '#999' }}>{alarm.triggered_at}</div>
</>}
/>
</List.Item>
);
}} />
)}
<div style={{ textAlign: 'center', padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}></a>
</div>
</div>
}
>
<Badge count={alarmCount} size="small" overflowCount={99}>
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
</Badge>
</Popover>
<Dropdown menu={userMenu} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#1890ff' }} />

View File

@@ -30,28 +30,34 @@ export default function Alarms() {
const [ev, ru] = await Promise.all([getAlarmEvents({}), getAlarmRules()]);
setEvents(ev);
setRules(ru as any[]);
} catch (e) { console.error(e); }
} catch { message.error('加载告警数据失败'); }
finally { setLoading(false); }
};
const handleAcknowledge = async (id: number) => {
await acknowledgeAlarm(id);
message.success('已确认');
loadData();
try {
await acknowledgeAlarm(id);
message.success('已确认');
loadData();
} catch { message.error('确认操作失败'); }
};
const handleResolve = async (id: number) => {
await resolveAlarm(id);
message.success('已解决');
loadData();
try {
await resolveAlarm(id);
message.success('已解决');
loadData();
} catch { message.error('解决操作失败'); }
};
const handleCreateRule = async (values: any) => {
await createAlarmRule(values);
message.success('规则创建成功');
setShowRuleModal(false);
form.resetFields();
loadData();
try {
await createAlarmRule(values);
message.success('规则创建成功');
setShowRuleModal(false);
form.resetFields();
loadData();
} catch { message.error('规则创建失败'); }
};
const eventColumns = [

View File

@@ -1,15 +1,16 @@
import { useEffect, useState } from 'react';
import { Card, Row, Col, DatePicker, Select, Statistic, Table } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { Card, Row, Col, DatePicker, Select, Statistic, Table, Button, Space, message } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary } from '../../services/api';
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary, exportEnergyData } from '../../services/api';
export default function Analysis() {
const [historyData, setHistoryData] = useState<any[]>([]);
const [comparison, setComparison] = useState<any>(null);
const [dailySummary, setDailySummary] = useState<any[]>([]);
const [granularity, setGranularity] = useState('hour');
const [exporting, setExporting] = useState(false);
useEffect(() => {
loadData();
@@ -28,6 +29,25 @@ export default function Analysis() {
} catch (e) { console.error(e); }
};
const handleExport = async (format: 'csv' | 'xlsx' = 'csv') => {
setExporting(true);
try {
const end = dayjs().format('YYYY-MM-DD');
const start = dayjs().subtract(30, 'day').format('YYYY-MM-DD');
await exportEnergyData({
start_time: start,
end_time: end,
format,
});
message.success('导出成功');
} catch (e) {
message.error('导出失败,请重试');
console.error(e);
} finally {
setExporting(false);
}
};
const historyChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['平均', '最大', '最小'] },
@@ -58,6 +78,18 @@ export default function Analysis() {
return (
<div>
<Row justify="end" style={{ marginBottom: 16 }}>
<Col>
<Space>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('csv')}>
CSV
</Button>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('xlsx')}>
Excel
</Button>
</Space>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card>

View File

@@ -8,6 +8,7 @@ import LoadCurveCard from './components/LoadCurveCard';
import AlarmCard from './components/AlarmCard';
import CarbonCard from './components/CarbonCard';
import AnimatedNumber from './components/AnimatedNumber';
import useRealtimeWebSocket from '../../hooks/useRealtimeWebSocket';
import {
getDashboardOverview,
getRealtimeData,
@@ -31,6 +32,29 @@ export default function BigScreen() {
const [deviceStats, setDeviceStats] = useState<any>(null);
const timerRef = useRef<any>(null);
// WebSocket for real-time updates
const { data: wsData, connected: wsConnected, usingFallback } = useRealtimeWebSocket({
onAlarmEvent: (alarm) => {
// Prepend new alarm to events list
setAlarmEvents((prev) => [alarm as any, ...prev].slice(0, 5));
// Increment active alarm count
setAlarmStats((prev: any) => prev ? { ...prev, active_count: (prev.active_count ?? 0) + 1 } : prev);
},
});
// Merge WebSocket realtime data into state
useEffect(() => {
if (wsData) {
setRealtime((prev: any) => ({
...prev,
pv_power: wsData.pv_power,
heatpump_power: wsData.heatpump_power,
total_power: wsData.total_load,
grid_power: wsData.grid_power,
}));
}
}, [wsData]);
// Clock update every second
useEffect(() => {
const t = setInterval(() => setClock(new Date()), 1000);
@@ -76,12 +100,14 @@ export default function BigScreen() {
}
}, []);
// Initial fetch + polling every 15s
// Initial fetch always. Polling at 15s only if WS is disconnected (fallback).
// When WS is connected, poll at 60s for non-realtime data (overview, load curve, carbon, etc.)
useEffect(() => {
fetchAll();
timerRef.current = setInterval(fetchAll, 15000);
const interval = wsConnected && !usingFallback ? 60000 : 15000;
timerRef.current = setInterval(fetchAll, interval);
return () => clearInterval(timerRef.current);
}, [fetchAll]);
}, [fetchAll, wsConnected, usingFallback]);
const formatDate = (d: Date) => {
const y = d.getFullYear();
@@ -109,6 +135,12 @@ export default function BigScreen() {
<span className={styles.headerTime}>{formatTime(clock)}</span>
</div>
{/* WebSocket connection indicator */}
<div className={styles.wsIndicator}>
<span className={wsConnected ? styles.wsIndicatorDotConnected : styles.wsIndicatorDotDisconnected} />
<span>{wsConnected ? '实时' : '轮询'}</span>
</div>
{/* Main 3-column grid */}
<div className={styles.mainGrid}>
{/* Left Column */}

View File

@@ -424,3 +424,235 @@
font-size: 12px;
color: rgba(224, 232, 240, 0.5);
}
/* WebSocket connection indicator */
.wsIndicator {
position: fixed;
bottom: 8px;
right: 8px;
font-size: 11px;
color: rgba(224, 232, 240, 0.4);
z-index: 100;
display: flex;
align-items: center;
gap: 4px;
}
.wsIndicatorDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.wsIndicatorDotConnected {
composes: wsIndicatorDot;
background: #00ff88;
box-shadow: 0 0 6px rgba(0, 255, 136, 0.5);
}
.wsIndicatorDotDisconnected {
composes: wsIndicatorDot;
background: #ff8c00;
box-shadow: 0 0 6px rgba(255, 140, 0, 0.5);
}
/* ============================================
Responsive: Tablet (768px and below)
============================================ */
@media (max-width: 768px) {
.container {
overflow-y: auto;
overflow-x: hidden;
height: auto;
min-height: 100vh;
}
.header {
height: 56px;
flex-wrap: wrap;
padding: 0 12px;
}
.headerTitle {
font-size: 18px;
letter-spacing: 2px;
}
.headerDate {
position: static;
font-size: 12px;
order: 2;
}
.headerTime {
position: static;
font-size: 14px;
order: 3;
}
.mainGrid {
grid-template-columns: 1fr;
gap: 10px;
padding: 10px;
}
.column {
gap: 10px;
}
.card {
padding: 12px;
}
.centerCard {
padding: 12px;
min-height: 300px;
}
.cardTitle {
font-size: 14px;
margin-bottom: 8px;
}
.bigNumber,
.bigNumberCyan,
.bigNumberOrange {
font-size: 24px;
}
.bigNumberRed {
font-size: 20px;
}
.statValue,
.statValueCyan,
.statValueGreen,
.statValueOrange {
font-size: 16px;
}
.deviceStatusBar {
flex-wrap: wrap;
gap: 12px;
justify-content: space-around;
}
.statusCount {
font-size: 16px;
}
.flowNode {
width: 110px;
height: 80px;
}
.flowNodeValue {
font-size: 18px;
}
.flowNodeLabel {
font-size: 11px;
}
}
/* ============================================
Responsive: Mobile (375px and below)
============================================ */
@media (max-width: 375px) {
.header {
height: 48px;
padding: 0 8px;
}
.headerTitle {
font-size: 14px;
letter-spacing: 1px;
}
.headerDate {
display: none;
}
.headerTime {
font-size: 12px;
}
.mainGrid {
padding: 6px;
gap: 8px;
}
.column {
gap: 8px;
}
.card {
padding: 10px 12px;
}
.centerCard {
min-height: 250px;
padding: 10px 12px;
}
.cardTitle {
font-size: 13px;
margin-bottom: 6px;
padding-left: 8px;
}
.bigNumber,
.bigNumberCyan,
.bigNumberOrange {
font-size: 20px;
}
.bigNumberRed {
font-size: 18px;
}
.statRow {
grid-template-columns: 1fr;
gap: 6px;
}
.statValue,
.statValueCyan,
.statValueGreen,
.statValueOrange {
font-size: 14px;
}
.statLabel {
font-size: 11px;
}
.deviceStatusBar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 4px 8px;
}
.alarmItem {
font-size: 11px;
padding: 4px 0;
}
.flowNode {
width: 90px;
height: 64px;
}
.flowNodeValue {
font-size: 14px;
}
.flowNodeLabel {
font-size: 10px;
}
.unit {
font-size: 11px;
}
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import styles from '../styles.module.css';
import { getDeviceRealtime } from '../../../services/api';
import { getDevicePhoto } from '../../../utils/devicePhoto';
interface Device {
id: number;
@@ -115,6 +116,11 @@ export default function DeviceInfoPanel({ device, onClose, onViewDetail }: Devic
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
<div style={{ padding: '0 12px 8px', textAlign: 'center' }}>
<img src={getDevicePhoto(device.device_type)} alt={device.name}
style={{ width: '100%', height: 120, borderRadius: 8, objectFit: 'cover', border: '1px solid rgba(0,212,255,0.2)' }} />
</div>
<div>
<div className={styles.paramRow}>
<span className={styles.paramLabel}></span>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import styles from '../styles.module.css';
import { getDevicePhoto } from '../../../utils/devicePhoto';
interface Device {
id: number;
@@ -64,6 +65,8 @@ export default function DeviceListPanel({ devices, selectedDeviceId, onDeviceSel
className={`${styles.deviceItem} ${selectedDeviceId === device.id ? styles.deviceItemActive : ''}`}
onClick={() => onDeviceSelect(device)}
>
<img src={getDevicePhoto(device.device_type)} alt=""
style={{ width: 28, height: 28, borderRadius: 6, objectFit: 'cover', flexShrink: 0 }} />
<span
className={styles.statusDot}
style={{ backgroundColor: STATUS_COLORS[device.status] || '#666666' }}

View File

@@ -1,5 +1,8 @@
import { Typography, Space } from 'antd';
import { ThunderboltOutlined, HomeOutlined, CloudServerOutlined, FireOutlined } from '@ant-design/icons';
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;
@@ -13,71 +16,74 @@ 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;
// 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 style={{ padding: '16px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'center', minHeight: 200 }}>
{/* 光伏 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: '#fff7e6',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px',
border: '2px solid #faad14',
}}>
<ThunderboltOutlined style={{ fontSize: 32, color: '#faad14' }} />
</div>
<Text strong></Text>
<br />
<Text style={{ fontSize: 20, color: '#faad14' }}>{pv.toFixed(1)}</Text>
<Text type="secondary"> kW</Text>
</div>
{/* 箭头 */}
<div style={{ textAlign: 'center' }}>
<Text type="secondary"></Text>
</div>
{/* 建筑负荷 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: '#e6f7ff',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px',
border: '2px solid #1890ff',
}}>
<HomeOutlined style={{ fontSize: 32, color: '#1890ff' }} />
</div>
<Text strong></Text>
<br />
<Text style={{ fontSize: 20, color: '#1890ff' }}>{load.toFixed(1)}</Text>
<Text type="secondary"> kW</Text>
</div>
{/* 箭头 */}
<div style={{ textAlign: 'center' }}>
<Text type="secondary"></Text>
</div>
{/* 电网 */}
<div style={{ textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: '50%', background: '#f6ffed',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px',
border: '2px solid #52c41a',
}}>
<CloudServerOutlined style={{ fontSize: 32, color: '#52c41a' }} />
</div>
<Text strong></Text>
<br />
<Text style={{ fontSize: 20, color: '#52c41a' }}>{grid.toFixed(1)}</Text>
<Text type="secondary"> kW</Text>
</div>
</div>
<div style={{ textAlign: 'center', marginTop: 16, padding: '8px', background: '#fafafa', borderRadius: 8 }}>
<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' }}>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons';
import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api';
import { getDevicePhoto } from '../../utils/devicePhoto';
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
@@ -114,6 +115,9 @@ export default function Devices() {
};
const columns = [
{ title: '', dataIndex: 'device_type', width: 50, render: (_: any, record: any) => (
<img src={getDevicePhoto(record.device_type || record.device_type_id)} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover' }} />
)},
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '设备编号', dataIndex: 'code', width: 130 },
{ title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? <Tag icon={<AppstoreOutlined />} color="blue">{v}</Tag> : '-' },

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Input, Select, Descriptions, Modal, Badge } from 'antd';
import { Card, Table, Tag, Input, Select, Descriptions, Modal, Badge, message } from 'antd';
import { getDevices, getDeviceRealtime } from '../../services/api';
import { getDevicePhoto } from '../../utils/devicePhoto';
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
@@ -31,7 +32,7 @@ export default function Monitoring() {
try {
const res: any = await getDevices({ page_size: 100 });
setDevices(res.items || []);
} catch (e) { console.error(e); }
} catch { message.error('加载设备数据失败'); }
finally { setLoading(false); }
};
@@ -50,6 +51,9 @@ export default function Monitoring() {
});
const columns = [
{ title: '', dataIndex: 'device_type', key: 'photo', width: 50, render: (t: string) => (
<img src={getDevicePhoto(t)} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover' }} />
)},
{ title: '设备名称', dataIndex: 'name', key: 'name' },
{ title: '设备编号', dataIndex: 'code', key: 'code' },
{ title: '类型', dataIndex: 'device_type', key: 'type', render: (t: string) => typeMap[t] || t },
@@ -81,6 +85,11 @@ export default function Monitoring() {
<Modal title={selectedDevice?.name} open={!!selectedDevice} onCancel={() => setSelectedDevice(null)}
footer={null} width={700}>
{deviceData?.device && (
<>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<img src={getDevicePhoto(deviceData.device.device_type)} alt={deviceData.device.name}
style={{ width: 200, height: 150, borderRadius: 12, objectFit: 'cover', border: '1px solid #f0f0f0' }} />
</div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="编号">{deviceData.device.code}</Descriptions.Item>
<Descriptions.Item label="类型">{typeMap[deviceData.device.device_type]}</Descriptions.Item>
@@ -90,6 +99,7 @@ export default function Monitoring() {
text={statusMap[deviceData.device.status]?.text} />
</Descriptions.Item>
</Descriptions>
</>
)}
{deviceData?.data && (
<Descriptions column={2} size="small" bordered title="实时数据">

View File

@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { Card, Table, Button, Tabs, Tag, Modal, Form, Select, Input, message, Space } from 'antd';
import { PlusOutlined, PlayCircleOutlined, DownloadOutlined } from '@ant-design/icons';
import { getReportTemplates, getReportTasks, createReportTask, runReportTask } from '../../services/api';
import {
PlusOutlined, PlayCircleOutlined, DownloadOutlined,
ClockCircleOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import { getReportTemplates, getReportTasks, createReportTask, runReportTask, downloadReport } from '../../services/api';
export default function Reports() {
const [templates, setTemplates] = useState<any[]>([]);
@@ -18,22 +21,39 @@ export default function Reports() {
const [t, ts] = await Promise.all([getReportTemplates(), getReportTasks()]);
setTemplates(t as any[]);
setTasks(ts as any[]);
} catch (e) { console.error(e); }
} catch { message.error('加载报表数据失败'); }
finally { setLoading(false); }
};
const handleRun = async (id: number) => {
await runReportTask(id);
message.success('报表生成中');
loadData();
try {
await runReportTask(id);
message.success('报表生成中');
loadData();
} catch {
message.error('报表生成失败');
}
};
const handleDownload = async (id: number) => {
try {
await downloadReport(id);
message.success('下载成功');
} catch {
message.error('下载失败,请确认报表已生成完成');
}
};
const handleCreate = async (values: any) => {
await createReportTask(values);
message.success('任务创建成功');
setShowModal(false);
form.resetFields();
loadData();
try {
await createReportTask(values);
message.success('任务创建成功');
setShowModal(false);
form.resetFields();
loadData();
} catch {
message.error('任务创建失败');
}
};
const templateColumns = [
@@ -49,14 +69,24 @@ export default function Reports() {
{ title: '报表格式', dataIndex: 'export_format', render: (v: string) => v?.toUpperCase() },
{ title: '定时计划', dataIndex: 'schedule', render: (v: string) => v || '手动' },
{ title: '状态', dataIndex: 'status', render: (s: string) => {
const colors: Record<string, string> = { pending: 'default', running: 'blue', completed: 'green', failed: 'red' };
return <Tag color={colors[s]}>{s}</Tag>;
const config: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
pending: { color: 'default', icon: <ClockCircleOutlined />, label: '等待中' },
running: { color: 'processing', icon: <SyncOutlined spin />, label: '生成中' },
completed: { color: 'success', icon: <CheckCircleOutlined />, label: '已完成' },
failed: { color: 'error', icon: <CloseCircleOutlined />, label: '失败' },
};
const c = config[s] || config.pending;
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
}},
{ title: '上次执行', dataIndex: 'last_run', render: (v: string) => v || '-' },
{ title: '操作', key: 'action', render: (_: any, r: any) => (
<Space>
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(r.id)}></Button>
{r.file_path && <Button size="small" icon={<DownloadOutlined />}></Button>}
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(r.id)}
disabled={r.status === 'running'}></Button>
{r.status === 'completed' && (
<Button size="small" type="primary" icon={<DownloadOutlined />}
onClick={() => handleDownload(r.id)}></Button>
)}
</Space>
)},
];

View File

@@ -73,6 +73,57 @@ export const createReportTemplate = (data: any) => api.post('/reports/templates'
export const getReportTasks = () => api.get('/reports/tasks');
export const createReportTask = (data: any) => api.post('/reports/tasks', data);
export const runReportTask = (id: number) => api.post(`/reports/tasks/${id}/run`);
export const downloadReport = async (taskId: number) => {
const response = await axios.get(`/api/v1/reports/tasks/${taskId}/download`, {
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const contentDisposition = response.headers['content-disposition'];
let filename = `report_${taskId}`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match?.[1]) filename = match[1].replace(/['"]/g, '');
}
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
// Energy Export
export const exportEnergyData = async (params: {
start_time: string;
end_time: string;
device_id?: number;
data_type?: string;
format?: 'csv' | 'xlsx';
}) => {
const response = await axios.get('/api/v1/energy/export', {
params,
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const format = params.format || 'csv';
const ext = format;
const contentDisposition = response.headers['content-disposition'];
let filename = `energy_export.${ext}`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match?.[1]) filename = match[1].replace(/['"]/g, '');
}
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
// Users
export const getUsers = (params?: Record<string, any>) => api.get('/users', { params });

View File

@@ -0,0 +1,21 @@
/**
* Device type to photo mapping.
* Photos are bundled as SVG illustrations in /public/devices/.
*/
const DEVICE_PHOTOS: Record<string, string> = {
pv_inverter: '/devices/pv_inverter.svg',
heat_pump: '/devices/heat_pump.svg',
meter: '/devices/meter.svg',
smart_meter: '/devices/meter.svg',
sensor: '/devices/sensor.svg',
heat_meter: '/devices/heat_meter.svg',
water_meter: '/devices/water_meter.svg',
};
/**
* Get the photo URL for a device type.
* Falls back to a generic device illustration.
*/
export function getDevicePhoto(deviceType: string): string {
return DEVICE_PHOTOS[deviceType] || '/devices/default.svg';
}