feat: customer frontend, Sungrow collector fixes, real data (v1.2.0)
- Add frontend/ at root (no Three.js, no Charging, green #52c41a theme) - Fix Sungrow collector: add curPage/size params, unit conversion - Fix station-level dedup to prevent double-counting - Add shared token cache for API rate limit protection - Add .githooks/pre-commit, CLAUDE.md, .gitignore - Update docker-compose.override.yml frontend -> ./frontend - Pin bcrypt in requirements.txt - Add BUYOFF_RESULTS_2026-04-05.md (39/43 pass) - Data accuracy: 0.0% diff vs iSolarCloud Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
224
frontend/src/layouts/MainLayout.tsx
Normal file
224
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty, Select } from 'antd';
|
||||
import {
|
||||
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
|
||||
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
|
||||
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
|
||||
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
|
||||
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
|
||||
BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined,
|
||||
SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
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 { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||
critical: { icon: <CloseCircleOutlined style={{ color: '#f5222d' }} />, color: 'red' },
|
||||
warning: { icon: <WarningOutlined style={{ color: '#faad14' }} />, color: 'orange' },
|
||||
info: { icon: <InfoCircleOutlined style={{ color: '#1890ff' }} />, color: 'blue' },
|
||||
};
|
||||
|
||||
export default function MainLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [alarmCount, setAlarmCount] = useState(0);
|
||||
const [recentAlarms, setRecentAlarms] = useState<any[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = getUser();
|
||||
const { darkMode, toggleDarkMode } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: t('menu.dashboard') },
|
||||
{ key: '/monitoring', icon: <MonitorOutlined />, label: t('menu.monitoring') },
|
||||
{ key: '/devices', icon: <AppstoreOutlined />, label: t('menu.devices') },
|
||||
{ key: '/analysis', icon: <BarChartOutlined />, label: t('menu.analysis') },
|
||||
{ key: '/alarms', icon: <AlertOutlined />, label: t('menu.alarms') },
|
||||
{ key: '/carbon', icon: <CloudOutlined />, label: t('menu.carbon') },
|
||||
{ key: '/reports', icon: <FileTextOutlined />, label: t('menu.reports') },
|
||||
{ key: '/quota', icon: <FundOutlined />, label: t('menu.quota', '定额管理') },
|
||||
{ key: '/charging', icon: <CarOutlined />, label: t('menu.charging', '充电管理') },
|
||||
{ key: '/maintenance', icon: <ToolOutlined />, label: t('menu.maintenance', '运维管理') },
|
||||
{ key: '/data-query', icon: <SearchOutlined />, label: t('menu.dataQuery', '数据查询') },
|
||||
{ key: '/prediction', icon: <RobotOutlined />, label: t('menu.prediction', 'AI预测') },
|
||||
{ key: '/management', icon: <SolutionOutlined />, label: t('menu.management', '管理体系') },
|
||||
{ key: '/energy-strategy', icon: <ThunderboltOutlined />, label: t('menu.energyStrategy', '策略优化') },
|
||||
{ key: '/ai-operations', icon: <ExperimentOutlined />, label: t('menu.aiOperations', 'AI运维') },
|
||||
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
|
||||
children: [
|
||||
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },
|
||||
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: t('menu.bigscreen3d') },
|
||||
],
|
||||
},
|
||||
{ key: '/system', icon: <SettingOutlined />, label: t('menu.system'),
|
||||
children: [
|
||||
{ key: '/system/users', label: t('menu.users') },
|
||||
{ key: '/system/roles', label: t('menu.roles') },
|
||||
{ key: '/system/settings', label: t('menu.settings') },
|
||||
{ key: '/system/audit', label: t('menu.audit', '审计日志') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchAlarms = useCallback(async () => {
|
||||
try {
|
||||
const [stats, events] = await Promise.all([
|
||||
getAlarmStats(),
|
||||
getAlarmEvents({ status: 'active', page_size: 5 }),
|
||||
]);
|
||||
const statsData = (stats as any) || {};
|
||||
let activeTotal = 0;
|
||||
for (const severity of Object.values(statsData)) {
|
||||
if (severity && typeof severity === 'object') {
|
||||
activeTotal += (severity as any).active || 0;
|
||||
}
|
||||
}
|
||||
setAlarmCount(activeTotal);
|
||||
const items = (events as any)?.items || (events as any) || [];
|
||||
setRecentAlarms(Array.isArray(items) ? items : []);
|
||||
} catch {
|
||||
// silently ignore - notifications are non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlarms();
|
||||
const timer = setInterval(fetchAlarms, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchAlarms]);
|
||||
|
||||
const handleLogout = () => {
|
||||
removeToken();
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem('tianpu-lang', lang);
|
||||
};
|
||||
|
||||
const userMenu = {
|
||||
items: [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: t('header.profile') },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: t('header.logout'), onClick: handleLogout },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} width={220}
|
||||
style={{ background: darkMode ? '#141414' : '#001529' }}>
|
||||
<div style={{
|
||||
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 }} />
|
||||
{!collapsed && <Text strong style={{ color: '#fff', fontSize: 16 }}>{t('header.brandName')}</Text>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark" mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={['/system']}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key === '/bigscreen' || key === '/bigscreen-3d') {
|
||||
window.open(key, '_blank');
|
||||
} else {
|
||||
navigate(key);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{
|
||||
padding: '0 24px', background: darkMode ? '#141414' : '#fff', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'space-between',
|
||||
boxShadow: darkMode ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <MenuUnfoldOutlined style={{ fontSize: 18 }} /> :
|
||||
<MenuFoldOutlined style={{ fontSize: 18 }} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
size="small"
|
||||
style={{ width: 90 }}
|
||||
options={[
|
||||
{ label: '中文', value: 'zh' },
|
||||
{ label: 'English', value: 'en' },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center' }}
|
||||
onClick={toggleDarkMode}
|
||||
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{darkMode ? <BulbFilled style={{ color: '#faad14' }} /> : <BulbOutlined />}
|
||||
</div>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t('header.alarmNotification')}</span>
|
||||
{alarmCount > 0 && <Tag color="red">{alarmCount} {t('header.activeAlarms')}</Tag>}
|
||||
</div>}
|
||||
content={
|
||||
<div style={{ width: 320, maxHeight: 360, overflow: 'auto' }}>
|
||||
{recentAlarms.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('header.noActiveAlarms')} />
|
||||
) : (
|
||||
<List size="small" dataSource={recentAlarms} renderItem={(alarm: any) => {
|
||||
const sev = SEVERITY_CONFIG[alarm.severity] || SEVERITY_CONFIG.info;
|
||||
return (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer', padding: '8px 0' }}
|
||||
onClick={() => navigate('/alarms')}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={sev.icon}
|
||||
title={<span style={{ fontSize: 13 }}>{alarm.device_name || alarm.title || '未知设备'}</span>}
|
||||
description={<>
|
||||
<div style={{ fontSize: 12 }}>{alarm.message || alarm.title}</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>{alarm.triggered_at}</div>
|
||||
</>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}} />
|
||||
)}
|
||||
<div style={{ textAlign: 'center', padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<a onClick={() => navigate('/alarms')} style={{ fontSize: 13 }}>{t('header.viewAllAlarms')}</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge count={alarmCount} size="small" overflowCount={99}>
|
||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
||||
</Badge>
|
||||
</Popover>
|
||||
<Dropdown menu={userMenu} placement="bottomRight">
|
||||
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#1890ff' }} />
|
||||
<Text>{user?.full_name || user?.username || '用户'}</Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: 16, padding: 24, background: darkMode ? '#1f1f1f' : '#f5f5f5', minHeight: 280 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user