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:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>天普智慧能源管理平台</title>
|
||||
<title>中关村医疗器械园智慧能源管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -12,12 +12,12 @@ const ThemeContext = createContext<ThemeContextType>({
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
const saved = localStorage.getItem('tianpu-dark-mode');
|
||||
const saved = localStorage.getItem('zpark-dark-mode');
|
||||
return saved === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('tianpu-dark-mode', String(darkMode));
|
||||
localStorage.setItem('zpark-dark-mode', String(darkMode));
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ i18n.use(initReactI18next).init({
|
||||
zh: { translation: zh },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: localStorage.getItem('tianpu-lang') || 'zh',
|
||||
lng: localStorage.getItem('zpark-lang') || 'zh',
|
||||
fallbackLng: 'zh',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"viewAllAlarms": "View all alarms",
|
||||
"profile": "Profile",
|
||||
"logout": "Sign Out",
|
||||
"brandName": "Tianpu EMS"
|
||||
"brandName": "Z-Park EMS"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"viewAllAlarms": "查看全部告警",
|
||||
"profile": "个人信息",
|
||||
"logout": "退出登录",
|
||||
"brandName": "天普EMS"
|
||||
"brandName": "Z-Park EMS"
|
||||
},
|
||||
"common": {
|
||||
"save": "保存",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function BigScreen() {
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerDate}>{formatDate(clock)}</span>
|
||||
<h1 className={styles.headerTitle}>天普零碳园区智慧能源管理平台</h1>
|
||||
<h1 className={styles.headerTitle}>中关村医疗器械园智慧能源管理平台</h1>
|
||||
<span className={styles.headerTime}>{formatTime(clock)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function HUDOverlay({ overview, realtimeData }: HUDOverlayProps)
|
||||
<div className={styles.hudOverlay}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerDate}>{formatDate(now)}</span>
|
||||
<span className={styles.headerTitle}>天普零碳园区 3D智慧能源管理平台</span>
|
||||
<span className={styles.headerTitle}>中关村医疗器械园 3D智慧能源管理平台</span>
|
||||
<span className={styles.headerClock}>{formatTime(now)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function BigScreen3D() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.placeholder}>
|
||||
<h2 className={styles.placeholderTitle}>天普零碳园区 3D智慧能源管理平台</h2>
|
||||
<h2 className={styles.placeholderTitle}>中关村医疗器械园 3D智慧能源管理平台</h2>
|
||||
<p style={{ color: '#8899aa' }}>正在加载设备数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
|
||||
export default function PowerGeneration({ realtime, energyToday }: Props) {
|
||||
const pvPower = realtime?.pv_power || 0;
|
||||
const ratedPower = 375.035; // 总装机容量 kW
|
||||
const ratedPower = 2200; // 总装机容量 kW (10台阳光电源逆变器)
|
||||
const utilization = ratedPower > 0 ? (pvPower / ratedPower) * 100 : 0;
|
||||
const generation = energyToday?.generation || 0;
|
||||
const selfUseRate = energyToday && energyToday.generation > 0
|
||||
@@ -37,7 +37,7 @@ export default function PowerGeneration({ realtime, energyToday }: Props) {
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
装机容量: {ratedPower} kW | 3台华为SUN2000-110KTL-M0
|
||||
装机容量: {ratedPower} kW | 10台阳光电源组串式逆变器
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,11 +43,21 @@ export default function Devices() {
|
||||
Object.entries(query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getDevices(cleanQuery);
|
||||
setData(res as any);
|
||||
const res = await getDevices(cleanQuery) as any;
|
||||
// Enrich items with type name and group name from cached meta
|
||||
if (res?.items) {
|
||||
const typeMap = new Map(deviceTypes.map((t: any) => [t.code || t.id, t.name]));
|
||||
const groupMap = new Map(deviceGroups.map((g: any) => [g.id, g.name]));
|
||||
res.items = res.items.map((d: any) => ({
|
||||
...d,
|
||||
device_type_name: d.device_type_name || typeMap.get(d.device_type) || typeMap.get(d.device_type_id) || '-',
|
||||
device_group_name: d.device_group_name || groupMap.get(d.group_id) || '-',
|
||||
}));
|
||||
}
|
||||
setData(res);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
}, [filters, deviceTypes, deviceGroups]);
|
||||
|
||||
const loadMeta = async () => {
|
||||
try {
|
||||
@@ -56,7 +66,9 @@ export default function Devices() {
|
||||
]);
|
||||
setDeviceTypes(types as any[]);
|
||||
setDeviceGroups(groups as any[]);
|
||||
setStats(st as any);
|
||||
const stData = st as any;
|
||||
stData.total = (stData.online || 0) + (stData.offline || 0) + (stData.alarm || 0) + (stData.maintenance || 0);
|
||||
setStats(stData);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ export default function LoginPage() {
|
||||
}}>
|
||||
<Card style={{ width: 400, borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<ThunderboltOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
<ThunderboltOutlined style={{ fontSize: 48, color: '#52c41a' }} />
|
||||
<Title level={3} style={{ marginTop: 12, marginBottom: 4 }}>
|
||||
天普智慧能源管理平台
|
||||
中关村医疗器械园智慧能源管理平台
|
||||
</Title>
|
||||
<Text type="secondary">零碳园区 · 智慧运维</Text>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@ export default function LoginPage() {
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 8 }}>
|
||||
<Button block loading={guestLoading} onClick={onGuestLogin}
|
||||
style={{ borderColor: '#1890ff', color: '#1890ff' }}>
|
||||
style={{ borderColor: '#52c41a', color: '#52c41a' }}>
|
||||
访客体验入口
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
@@ -24,6 +24,9 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Branding
|
||||
export const getBranding = () => api.get('/branding');
|
||||
|
||||
// Auth
|
||||
export const login = (username: string, password: string) =>
|
||||
api.post('/auth/login', new URLSearchParams({ username, password }), {
|
||||
|
||||
Reference in New Issue
Block a user