feat: solar KPIs, version display, feature flags (v1.4.0)

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) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-06 22:37:09 +08:00
parent 1274e77cb4
commit 93af4bc16b
11 changed files with 275 additions and 13 deletions

View File

@@ -1,9 +1,9 @@
{ {
"project": "zpark-ems", "project": "zpark-ems",
"project_version": "1.3.0", "project_version": "1.4.0",
"customer": "Z-Park 中关村医疗器械园", "customer": "Z-Park 中关村医疗器械园",
"core_version": "1.1.0", "core_version": "1.4.0",
"frontend_template_version": "1.1.0", "frontend_template_version": "1.4.0",
"last_updated": "2026-04-06", "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"
} }

View File

@@ -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"
}

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter 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") 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(weather.router)
api_router.include_router(ai_ops.router) api_router.include_router(ai_ops.router)
api_router.include_router(branding.router) api_router.include_router(branding.router)
api_router.include_router(version.router)
api_router.include_router(kpi.router)

View File

@@ -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()} device_stats = {row[0]: row[1] for row in device_stats_q.all()}
# 今日能耗汇总 # 今日能耗汇总 (from daily summary table)
daily_q = await db.execute( daily_q = await db.execute(
select( select(
EnergyDailySummary.energy_type, 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(): for row in daily_q.all():
energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0} 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( carbon_q = await db.execute(
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction)) 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): async def _get_pv_device_ids(db: AsyncSession):
result = await db.execute( 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()] return [r[0] for r in result.fetchall()]

View File

@@ -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),
}

View File

@@ -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"}

View File

@@ -12,7 +12,7 @@ import {
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getUser, removeToken } from '../utils/auth'; 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'; import { useTheme } from '../contexts/ThemeContext';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -29,6 +29,7 @@ export default function MainLayout() {
const [alarmCount, setAlarmCount] = useState(0); const [alarmCount, setAlarmCount] = useState(0);
const [recentAlarms, setRecentAlarms] = useState<any[]>([]); const [recentAlarms, setRecentAlarms] = useState<any[]>([]);
const [features, setFeatures] = useState<Record<string, boolean>>({}); const [features, setFeatures] = useState<Record<string, boolean>>({});
const [versionInfo, setVersionInfo] = useState<any>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = getUser(); const user = getUser();
@@ -39,6 +40,7 @@ export default function MainLayout() {
getBranding().then((res: any) => { getBranding().then((res: any) => {
setFeatures(res?.features || {}); setFeatures(res?.features || {});
}).catch(() => {}); }).catch(() => {});
getVersion().then(setVersionInfo).catch(() => {});
}, []); }, []);
// Map feature flags to menu keys that should be hidden when the feature is disabled // 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 && (
<div style={{
padding: '8px 16px', fontSize: 11, color: 'rgba(255,255,255,0.3)',
borderTop: '1px solid rgba(255,255,255,0.06)', textAlign: 'center',
}}>
v{versionInfo.project_version}
</div>
)}
</Sider> </Sider>
<Layout> <Layout>
<Header style={{ <Header style={{

View File

@@ -4,7 +4,7 @@ import {
ThunderboltOutlined, FireOutlined, CloudOutlined, AlertOutlined, ThunderboltOutlined, FireOutlined, CloudOutlined, AlertOutlined,
WarningOutlined, CloseCircleOutlined, WarningOutlined, CloseCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getDashboardOverview, getRealtimeData, getLoadCurve } from '../../services/api'; import { getDashboardOverview, getRealtimeData, getLoadCurve, getSolarKpis } from '../../services/api';
import EnergyOverview from './components/EnergyOverview'; import EnergyOverview from './components/EnergyOverview';
import PowerGeneration from './components/PowerGeneration'; import PowerGeneration from './components/PowerGeneration';
import LoadCurve from './components/LoadCurve'; import LoadCurve from './components/LoadCurve';
@@ -18,6 +18,7 @@ export default function Dashboard() {
const [realtime, setRealtime] = useState<any>(null); const [realtime, setRealtime] = useState<any>(null);
const [loadData, setLoadData] = useState<any[]>([]); const [loadData, setLoadData] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [kpis, setKpis] = useState<any>(null);
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -42,6 +43,14 @@ export default function Dashboard() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
useEffect(() => {
getSolarKpis().then(setKpis).catch(() => {});
const timer = setInterval(() => {
getSolarKpis().then(setKpis).catch(() => {});
}, 60000);
return () => clearInterval(timer);
}, []);
if (loading) return <Spin size="large" style={{ display: 'block', margin: '200px auto' }} />; if (loading) return <Spin size="large" style={{ display: 'block', margin: '200px auto' }} />;
const ds = overview?.device_stats || {}; const ds = overview?.device_stats || {};
@@ -84,6 +93,51 @@ export default function Dashboard() {
</Col> </Col>
</Row> </Row>
{/* 光伏 KPI */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="性能比 (PR)"
value={kpis?.pr || 0}
suffix="%"
valueStyle={{ color: (kpis?.pr || 0) > 75 ? '#52c41a' : (kpis?.pr || 0) > 50 ? '#faad14' : '#f5222d' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="等效利用小时"
value={kpis?.equivalent_hours || 0}
suffix="h"
precision={1}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="今日收益"
value={kpis?.revenue_today || 0}
prefix="¥"
precision={0}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="自消纳率"
value={kpis?.self_consumption_rate || 0}
suffix="%"
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
{/* 图表区域 */} {/* 图表区域 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={16}> <Col xs={24} lg={16}>

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Form, Input, Button, Card, message, Typography } from 'antd'; import { Form, Input, Button, Card, message, Typography } from 'antd';
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { login } from '../../services/api'; import { login, getVersion } from '../../services/api';
import { setToken, setUser } from '../../utils/auth'; import { setToken, setUser } from '../../utils/auth';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -10,8 +10,13 @@ const { Title, Text } = Typography;
export default function LoginPage() { export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [guestLoading, setGuestLoading] = useState(false); const [guestLoading, setGuestLoading] = useState(false);
const [versionInfo, setVersionInfo] = useState<any>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
getVersion().then(setVersionInfo).catch(() => {});
}, []);
const doLogin = async (username: string, password: string) => { const doLogin = async (username: string, password: string) => {
const res: any = await login(username, password); const res: any = await login(username, password);
setToken(res.access_token); setToken(res.access_token);
@@ -82,6 +87,11 @@ export default function LoginPage() {
</Text> </Text>
</div> </div>
</Form> </Form>
{versionInfo && (
<div style={{ textAlign: 'center', marginTop: 16, opacity: 0.4, fontSize: 11 }}>
v{versionInfo.project_version} | Core: v{versionInfo.core_version || '—'}
</div>
)}
</Card> </Card>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; 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 { SaveOutlined } from '@ant-design/icons';
import { getSettings, updateSettings } from '../../services/api'; import { getSettings, updateSettings, getVersion } from '../../services/api';
import { getUser } from '../../utils/auth'; import { getUser } from '../../utils/auth';
const TIMEZONE_OPTIONS = [ const TIMEZONE_OPTIONS = [
@@ -17,11 +17,13 @@ export default function SystemSettings() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [versionInfo, setVersionInfo] = useState<any>(null);
const user = getUser(); const user = getUser();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
getVersion().then(setVersionInfo).catch(() => {});
}, []); }, []);
const loadSettings = async () => { const loadSettings = async () => {
@@ -105,6 +107,18 @@ export default function SystemSettings() {
</Button> </Button>
</div> </div>
)} )}
{versionInfo && (
<Card title="平台版本信息" size="small" style={{ marginTop: 16 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="项目">zpark-ems</Descriptions.Item>
<Descriptions.Item label="版本">{versionInfo.project_version || '—'}</Descriptions.Item>
<Descriptions.Item label="Core 版本">{versionInfo.core_version || '—'}</Descriptions.Item>
<Descriptions.Item label="前端模板">{versionInfo.frontend_template_version || '—'}</Descriptions.Item>
<Descriptions.Item label="最后更新">{versionInfo.last_updated || '—'}</Descriptions.Item>
</Descriptions>
</Card>
)}
</Form> </Form>
); );
} }

View File

@@ -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 // Branding
export const getBranding = () => api.get('/branding'); export const getBranding = () => api.get('/branding');