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>
20
frontend/Dockerfile.prod
Normal 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;"]
|
||||
19
frontend/public/devices/default.svg
Normal 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 |
30
frontend/public/devices/heat_meter.svg
Normal 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 |
46
frontend/public/devices/heat_pump.svg
Normal 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 |
41
frontend/public/devices/meter.svg
Normal 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 |
42
frontend/public/devices/pv_inverter.svg
Normal 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 |
39
frontend/public/devices/sensor.svg
Normal 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 & Humidity Sensor</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
41
frontend/public/devices/water_meter.svg
Normal 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 |
196
frontend/src/hooks/useRealtimeWebSocket.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }} />
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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> : '-' },
|
||||
|
||||
@@ -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="实时数据">
|
||||
|
||||
@@ -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>
|
||||
)},
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
21
frontend/src/utils/devicePhoto.ts
Normal 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';
|
||||
}
|
||||