From 93af4bc16b148acafd0e6bb7c5b62edf0d975423 Mon Sep 17 00:00:00 2001 From: Du Wenbo Date: Mon, 6 Apr 2026 22:37:09 +0800 Subject: [PATCH] feat: solar KPIs, version display, feature flags (v1.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New dashboard KPI cards: - Performance Ratio (PR) with color thresholds - Equivalent Utilization Hours - Daily Revenue (¥) - Self-Consumption Rate Version display for field engineers: - Login page footer: "v1.4.0 | Core: v1.4.0" - Sidebar footer: version when expanded - System Settings: full version breakdown Backend (core sync): - GET /api/v1/version (no auth) — reads VERSIONS.json - GET /api/v1/kpi/solar — PR, revenue, equiv hours calculations - Dashboard energy_today fallback from raw energy_data - PV device filter includes sungrow_inverter type Feature flags: - Sidebar hides disabled features (charging, bigscreen_3d) Co-Authored-By: Claude Opus 4.6 (1M context) --- VERSIONS.json | 8 +-- core/backend/VERSIONS.json | 9 +++ core/backend/app/api/router.py | 4 +- core/backend/app/api/v1/dashboard.py | 36 +++++++++- core/backend/app/api/v1/kpi.py | 93 ++++++++++++++++++++++++++ core/backend/app/api/v1/version.py | 32 +++++++++ frontend/src/layouts/MainLayout.tsx | 12 +++- frontend/src/pages/Dashboard/index.tsx | 56 +++++++++++++++- frontend/src/pages/Login/index.tsx | 14 +++- frontend/src/pages/System/Settings.tsx | 18 ++++- frontend/src/services/api.ts | 6 ++ 11 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 core/backend/VERSIONS.json create mode 100644 core/backend/app/api/v1/kpi.py create mode 100644 core/backend/app/api/v1/version.py diff --git a/VERSIONS.json b/VERSIONS.json index 8660933..97633c5 100644 --- a/VERSIONS.json +++ b/VERSIONS.json @@ -1,9 +1,9 @@ { "project": "zpark-ems", - "project_version": "1.3.0", + "project_version": "1.4.0", "customer": "Z-Park 中关村医疗器械园", - "core_version": "1.1.0", - "frontend_template_version": "1.1.0", + "core_version": "1.4.0", + "frontend_template_version": "1.4.0", "last_updated": "2026-04-06", - "notes": "Z-Park branding, ps_id fix, feature flags, buyoff CONDITIONAL PASS" + "notes": "Solar KPIs (PR, revenue, equiv hours), version display, feature flag filtering" } diff --git a/core/backend/VERSIONS.json b/core/backend/VERSIONS.json new file mode 100644 index 0000000..8660933 --- /dev/null +++ b/core/backend/VERSIONS.json @@ -0,0 +1,9 @@ +{ + "project": "zpark-ems", + "project_version": "1.3.0", + "customer": "Z-Park 中关村医疗器械园", + "core_version": "1.1.0", + "frontend_template_version": "1.1.0", + "last_updated": "2026-04-06", + "notes": "Z-Park branding, ps_id fix, feature flags, buyoff CONDITIONAL PASS" +} diff --git a/core/backend/app/api/router.py b/core/backend/app/api/router.py index 366f61b..5d6ab5f 100644 --- a/core/backend/app/api/router.py +++ b/core/backend/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding +from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi api_router = APIRouter(prefix="/api/v1") @@ -26,3 +26,5 @@ api_router.include_router(energy_strategy.router) api_router.include_router(weather.router) api_router.include_router(ai_ops.router) api_router.include_router(branding.router) +api_router.include_router(version.router) +api_router.include_router(kpi.router) diff --git a/core/backend/app/api/v1/dashboard.py b/core/backend/app/api/v1/dashboard.py index c46fd64..0df1e3d 100644 --- a/core/backend/app/api/v1/dashboard.py +++ b/core/backend/app/api/v1/dashboard.py @@ -26,7 +26,7 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends( ) device_stats = {row[0]: row[1] for row in device_stats_q.all()} - # 今日能耗汇总 + # 今日能耗汇总 (from daily summary table) daily_q = await db.execute( select( EnergyDailySummary.energy_type, @@ -38,6 +38,35 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends( for row in daily_q.all(): energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0} + # Fallback: if daily summary is empty, compute from raw energy_data + if not energy_summary: + from sqlalchemy import distinct + fallback_q = await db.execute( + select( + func.sum(EnergyData.value), + ).where( + and_( + EnergyData.timestamp >= today_start, + EnergyData.data_type == "daily_energy", + ) + ).group_by(EnergyData.device_id).order_by(EnergyData.device_id) + ) + # Get the latest daily_energy per device (avoid double-counting) + latest_energy_q = await db.execute( + select( + EnergyData.device_id, + func.max(EnergyData.value).label("max_energy"), + ).where( + and_( + EnergyData.timestamp >= today_start, + EnergyData.data_type == "daily_energy", + ) + ).group_by(EnergyData.device_id) + ) + total_gen = sum(row[1] or 0 for row in latest_energy_q.all()) + if total_gen > 0: + energy_summary["electricity"] = {"consumption": 0, "generation": round(total_gen, 2)} + # 今日碳排放 carbon_q = await db.execute( select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction)) @@ -134,7 +163,10 @@ async def get_load_curve( async def _get_pv_device_ids(db: AsyncSession): result = await db.execute( - select(Device.id).where(Device.device_type == "pv_inverter", Device.is_active == True) + select(Device.id).where( + Device.device_type.in_(["pv_inverter", "sungrow_inverter"]), + Device.is_active == True, + ) ) return [r[0] for r in result.fetchall()] diff --git a/core/backend/app/api/v1/kpi.py b/core/backend/app/api/v1/kpi.py new file mode 100644 index 0000000..1bd4522 --- /dev/null +++ b/core/backend/app/api/v1/kpi.py @@ -0,0 +1,93 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.device import Device +from app.models.energy import EnergyData +from app.models.user import User + +router = APIRouter(prefix="/kpi", tags=["关键指标"]) + + +@router.get("/solar") +async def get_solar_kpis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """Solar performance KPIs - PR, self-consumption, equivalent hours, revenue""" + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Get PV devices and their rated power + pv_q = await db.execute( + select(Device.id, Device.rated_power).where( + Device.device_type.in_(["pv_inverter", "sungrow_inverter"]), + Device.is_active == True, + ) + ) + pv_devices = pv_q.all() + pv_ids = [d[0] for d in pv_devices] + total_rated_kw = sum(d[1] or 0 for d in pv_devices) # kW + + if not pv_ids or total_rated_kw == 0: + return { + "pr": 0, "self_consumption_rate": 0, + "equivalent_hours": 0, "revenue_today": 0, + "total_rated_kw": 0, "daily_generation_kwh": 0, + } + + # Get latest daily_energy per PV device for today + daily_gen_q = await db.execute( + select( + EnergyData.device_id, + func.max(EnergyData.value).label("max_energy"), + ).where( + and_( + EnergyData.timestamp >= today_start, + EnergyData.data_type == "daily_energy", + EnergyData.device_id.in_(pv_ids), + ) + ).group_by(EnergyData.device_id) + ) + + # Check if values are station-level (all identical) or device-level + daily_values = daily_gen_q.all() + if not daily_values: + daily_generation_kwh = 0 + else: + values = [row[1] or 0 for row in daily_values] + # If all values are identical, it's station-level data — use max (not sum) + if len(set(values)) == 1 and len(values) > 1: + daily_generation_kwh = values[0] + else: + daily_generation_kwh = sum(values) + + # Performance Ratio (PR) = actual output / (rated capacity * peak sun hours) + # Approximate peak sun hours from time of day (simplified) + hours_since_sunrise = max(0, min(12, (now.hour + now.minute / 60) - 6)) # approx 6am sunrise + theoretical_kwh = total_rated_kw * hours_since_sunrise * 0.8 # 0.8 = typical irradiance factor + pr = (daily_generation_kwh / theoretical_kwh * 100) if theoretical_kwh > 0 else 0 + pr = min(100, round(pr, 1)) # Cap at 100% + + # Self-consumption rate (without grid export meter, assume 100% self-consumed for now) + # TODO: integrate grid export meter data when available + self_consumption_rate = 100.0 + + # Equivalent utilization hours = daily generation / rated capacity + equivalent_hours = round(daily_generation_kwh / total_rated_kw, 2) if total_rated_kw > 0 else 0 + + # Revenue = daily generation * electricity price + # TODO: get actual price from electricity_pricing table + # Default industrial TOU average price in Beijing: ~0.65 CNY/kWh + avg_price = 0.65 + revenue_today = round(daily_generation_kwh * avg_price, 2) + + return { + "pr": pr, + "self_consumption_rate": round(self_consumption_rate, 1), + "equivalent_hours": equivalent_hours, + "revenue_today": revenue_today, + "total_rated_kw": total_rated_kw, + "daily_generation_kwh": round(daily_generation_kwh, 2), + "avg_price_per_kwh": avg_price, + "pv_device_count": len(pv_ids), + } diff --git a/core/backend/app/api/v1/version.py b/core/backend/app/api/v1/version.py new file mode 100644 index 0000000..005dc17 --- /dev/null +++ b/core/backend/app/api/v1/version.py @@ -0,0 +1,32 @@ +import os +import json +from fastapi import APIRouter + +router = APIRouter(prefix="/version", tags=["版本信息"]) + + +@router.get("") +async def get_version(): + """Return platform version information for display on login/dashboard""" + # Read VERSIONS.json from project root (2 levels up from backend/) + backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + + # Try multiple paths for VERSIONS.json + for path in [ + os.path.join(backend_dir, "VERSIONS.json"), # standalone + os.path.join(backend_dir, "..", "VERSIONS.json"), # inside core/ subtree + os.path.join(backend_dir, "..", "..", "VERSIONS.json"), # customer project root + ]: + if os.path.exists(path): + with open(path, 'r', encoding='utf-8') as f: + versions = json.load(f) + return versions + + # Fallback: read VERSION file + version_file = os.path.join(backend_dir, "VERSION") + version = "unknown" + if os.path.exists(version_file): + with open(version_file, 'r') as f: + version = f.read().strip() + + return {"project_version": version, "project": "ems-core"} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index d7440fc..e0fe292 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -12,7 +12,7 @@ import { import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { getUser, removeToken } from '../utils/auth'; -import { getAlarmStats, getAlarmEvents, getBranding } from '../services/api'; +import { getAlarmStats, getAlarmEvents, getBranding, getVersion } from '../services/api'; import { useTheme } from '../contexts/ThemeContext'; const { Header, Sider, Content } = Layout; @@ -29,6 +29,7 @@ export default function MainLayout() { const [alarmCount, setAlarmCount] = useState(0); const [recentAlarms, setRecentAlarms] = useState([]); const [features, setFeatures] = useState>({}); + const [versionInfo, setVersionInfo] = useState(null); const navigate = useNavigate(); const location = useLocation(); const user = getUser(); @@ -39,6 +40,7 @@ export default function MainLayout() { getBranding().then((res: any) => { setFeatures(res?.features || {}); }).catch(() => {}); + getVersion().then(setVersionInfo).catch(() => {}); }, []); // Map feature flags to menu keys that should be hidden when the feature is disabled @@ -172,6 +174,14 @@ export default function MainLayout() { } }} /> + {!collapsed && versionInfo && ( +
+ v{versionInfo.project_version} +
+ )}
(null); const [loadData, setLoadData] = useState([]); const [loading, setLoading] = useState(true); + const [kpis, setKpis] = useState(null); const fetchData = async () => { try { @@ -42,6 +43,14 @@ export default function Dashboard() { return () => clearInterval(timer); }, []); + useEffect(() => { + getSolarKpis().then(setKpis).catch(() => {}); + const timer = setInterval(() => { + getSolarKpis().then(setKpis).catch(() => {}); + }, 60000); + return () => clearInterval(timer); + }, []); + if (loading) return ; const ds = overview?.device_stats || {}; @@ -84,6 +93,51 @@ export default function Dashboard() { + {/* 光伏 KPI */} + + + + 75 ? '#52c41a' : (kpis?.pr || 0) > 50 ? '#faad14' : '#f5222d' }} + /> + + + + + + + + + + + + + + + + + + + {/* 图表区域 */} diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index 7ae6bfb..4c20105 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Form, Input, Button, Card, message, Typography } from 'antd'; import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; -import { login } from '../../services/api'; +import { login, getVersion } from '../../services/api'; import { setToken, setUser } from '../../utils/auth'; const { Title, Text } = Typography; @@ -10,8 +10,13 @@ const { Title, Text } = Typography; export default function LoginPage() { const [loading, setLoading] = useState(false); const [guestLoading, setGuestLoading] = useState(false); + const [versionInfo, setVersionInfo] = useState(null); const navigate = useNavigate(); + useEffect(() => { + getVersion().then(setVersionInfo).catch(() => {}); + }, []); + const doLogin = async (username: string, password: string) => { const res: any = await login(username, password); setToken(res.access_token); @@ -82,6 +87,11 @@ export default function LoginPage() { + {versionInfo && ( +
+ v{versionInfo.project_version} | Core: v{versionInfo.core_version || '—'} +
+ )} ); diff --git a/frontend/src/pages/System/Settings.tsx b/frontend/src/pages/System/Settings.tsx index 5c9e014..6e10284 100644 --- a/frontend/src/pages/System/Settings.tsx +++ b/frontend/src/pages/System/Settings.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { Card, Form, Input, InputNumber, Switch, Select, Button, message, Spin, Row, Col } from 'antd'; +import { Card, Form, Input, InputNumber, Switch, Select, Button, message, Spin, Row, Col, Descriptions } from 'antd'; import { SaveOutlined } from '@ant-design/icons'; -import { getSettings, updateSettings } from '../../services/api'; +import { getSettings, updateSettings, getVersion } from '../../services/api'; import { getUser } from '../../utils/auth'; const TIMEZONE_OPTIONS = [ @@ -17,11 +17,13 @@ export default function SystemSettings() { const [form] = Form.useForm(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [versionInfo, setVersionInfo] = useState(null); const user = getUser(); const isAdmin = user?.role === 'admin'; useEffect(() => { loadSettings(); + getVersion().then(setVersionInfo).catch(() => {}); }, []); const loadSettings = async () => { @@ -105,6 +107,18 @@ export default function SystemSettings() { )} + + {versionInfo && ( + + + zpark-ems + {versionInfo.project_version || '—'} + {versionInfo.core_version || '—'} + {versionInfo.frontend_template_version || '—'} + {versionInfo.last_updated || '—'} + + + )} ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0718275..28be230 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -24,6 +24,12 @@ api.interceptors.response.use( } ); +// Version info (no auth needed) +export const getVersion = () => api.get('/version'); + +// Solar KPIs +export const getSolarKpis = () => api.get('/kpi/solar'); + // Branding export const getBranding = () => api.get('/branding');