feat: Z-Park branding, data collection fix, buyoff pass (v1.3.0)

Branding:
- Replace all Tianpu text/colors with Z-Park (green #52c41a)
- Update login, sidebar, BigScreen, localStorage keys

Data collection:
- Populate ps_id for all 10 inverters (Phase1: 2226182, Phase2: 2226188)
- Fix docker-compose volume mount for customer config.yaml

Buyoff warning fixes:
- Installed capacity: 2200 kW / 10 Sungrow inverters (was wrong Huawei data)
- Feature flags: hide charging menu when features.charging=false
- Device total count: compute client-side from stats
- Device groups: enrich group names from metadata

Buyoff result: CONDITIONAL PASS (21/21 critical, 54/63 total)
Data accuracy: <3% deviation from iSolarCloud reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-06 22:05:06 +08:00
parent b3bb33a638
commit 1274e77cb4
17 changed files with 298 additions and 36 deletions

View File

@@ -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 } from '../services/api';
import { getAlarmStats, getAlarmEvents, getBranding } from '../services/api';
import { useTheme } from '../contexts/ThemeContext';
const { Header, Sider, Content } = Layout;
@@ -28,13 +28,27 @@ export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false);
const [alarmCount, setAlarmCount] = useState(0);
const [recentAlarms, setRecentAlarms] = useState<any[]>([]);
const [features, setFeatures] = useState<Record<string, boolean>>({});
const navigate = useNavigate();
const location = useLocation();
const user = getUser();
const { darkMode, toggleDarkMode } = useTheme();
const { t, i18n } = useTranslation();
const menuItems = [
useEffect(() => {
getBranding().then((res: any) => {
setFeatures(res?.features || {});
}).catch(() => {});
}, []);
// Map feature flags to menu keys that should be hidden when the feature is disabled
const featureMenuMap: Record<string, string> = {
charging: '/charging',
carbon: '/carbon',
bigscreen_3d: '/bigscreen-3d',
};
const allMenuItems = [
{ key: '/', icon: <DashboardOutlined />, label: t('menu.dashboard') },
{ key: '/monitoring', icon: <MonitorOutlined />, label: t('menu.monitoring') },
{ key: '/devices', icon: <AppstoreOutlined />, label: t('menu.devices') },
@@ -66,6 +80,28 @@ export default function MainLayout() {
},
];
// Filter menu items based on feature flags
const hiddenKeys = new Set(
Object.entries(featureMenuMap)
.filter(([flag]) => features[flag] === false)
.map(([, key]) => key)
);
const filterMenuItems = (items: typeof allMenuItems): typeof allMenuItems =>
items
.filter(item => !hiddenKeys.has(item.key))
.map(item => {
if ('children' in item && item.children) {
const filtered = item.children.filter((c: any) => !hiddenKeys.has(c.key));
if (filtered.length === 0) return null;
return { ...item, children: filtered };
}
return item;
})
.filter(Boolean) as typeof allMenuItems;
const menuItems = filterMenuItems(allMenuItems);
const fetchAlarms = useCallback(async () => {
try {
const [stats, events] = await Promise.all([
@@ -101,7 +137,7 @@ export default function MainLayout() {
const handleLanguageChange = (lang: string) => {
i18n.changeLanguage(lang);
localStorage.setItem('tianpu-lang', lang);
localStorage.setItem('zpark-lang', lang);
};
const userMenu = {
@@ -120,7 +156,7 @@ export default function MainLayout() {
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
<ThunderboltOutlined style={{ fontSize: 24, color: '#1890ff', marginRight: collapsed ? 0 : 8 }} />
<ThunderboltOutlined style={{ fontSize: 24, color: '#52c41a', marginRight: collapsed ? 0 : 8 }} />
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>{t('header.brandName')}</Text>}
</div>
<Menu
@@ -209,7 +245,7 @@ export default function MainLayout() {
</Popover>
<Dropdown menu={userMenu} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#1890ff' }} />
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#52c41a' }} />
<Text>{user?.full_name || user?.username || '用户'}</Text>
</div>
</Dropdown>