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:
@@ -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"
|
||||
}
|
||||
|
||||
9
core/backend/VERSIONS.json
Normal file
9
core/backend/VERSIONS.json
Normal 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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()]
|
||||
|
||||
|
||||
93
core/backend/app/api/v1/kpi.py
Normal file
93
core/backend/app/api/v1/kpi.py
Normal 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),
|
||||
}
|
||||
32
core/backend/app/api/v1/version.py
Normal file
32
core/backend/app/api/v1/version.py
Normal 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"}
|
||||
@@ -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<any[]>([]);
|
||||
const [features, setFeatures] = useState<Record<string, boolean>>({});
|
||||
const [versionInfo, setVersionInfo] = useState<any>(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 && (
|
||||
<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>
|
||||
<Layout>
|
||||
<Header style={{
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ThunderboltOutlined, FireOutlined, CloudOutlined, AlertOutlined,
|
||||
WarningOutlined, CloseCircleOutlined,
|
||||
} 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 PowerGeneration from './components/PowerGeneration';
|
||||
import LoadCurve from './components/LoadCurve';
|
||||
@@ -18,6 +18,7 @@ export default function Dashboard() {
|
||||
const [realtime, setRealtime] = useState<any>(null);
|
||||
const [loadData, setLoadData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [kpis, setKpis] = useState<any>(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 <Spin size="large" style={{ display: 'block', margin: '200px auto' }} />;
|
||||
|
||||
const ds = overview?.device_stats || {};
|
||||
@@ -84,6 +93,51 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</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 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
|
||||
@@ -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<any>(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() {
|
||||
</Text>
|
||||
</div>
|
||||
</Form>
|
||||
{versionInfo && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16, opacity: 0.4, fontSize: 11 }}>
|
||||
v{versionInfo.project_version} | Core: v{versionInfo.core_version || '—'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<any>(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() {
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user