2026-04-02 18:46:42 +08:00
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import { Layout, Menu, Avatar, Dropdown, Typography, Badge, Popover, List, Tag, Empty } from 'antd';
|
2026-04-01 13:36:06 +08:00
|
|
|
import {
|
|
|
|
|
DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined,
|
|
|
|
|
FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined,
|
|
|
|
|
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
|
2026-04-02 18:46:42 +08:00
|
|
|
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
|
2026-04-02 19:28:05 +08:00
|
|
|
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
|
2026-04-01 13:36:06 +08:00
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|
|
|
|
import { getUser, removeToken } from '../utils/auth';
|
2026-04-02 18:46:42 +08:00
|
|
|
import { getAlarmStats, getAlarmEvents } from '../services/api';
|
2026-04-01 13:36:06 +08:00
|
|
|
|
|
|
|
|
const { Header, Sider, Content } = Layout;
|
|
|
|
|
const { Text } = Typography;
|
|
|
|
|
|
|
|
|
|
const menuItems = [
|
|
|
|
|
{ key: '/', icon: <DashboardOutlined />, label: '能源总览' },
|
|
|
|
|
{ key: '/monitoring', icon: <MonitorOutlined />, label: '实时监控' },
|
feat: add 3D interactive dashboard and 2D BigScreen pages
- New /bigscreen-3d route: React Three Fiber 3D campus with buildings,
PV panels, heat pumps, meters, and sensors — all procedural geometry
- Interactive: hover highlight, click to select, camera fly-in to
device detail views (PV inverter, heat pump, meter, heat meter, sensor)
- Real-time data: 15s polling for overview, 5s for selected device
- Energy flow particles along PV→Building, Grid→Building, Building→HP paths
- HUD overlay with date/clock, bottom metrics bar, device list panel
- New /bigscreen route: 2D dashboard with energy flow diagram, charts
- New /devices route: device management page
- Vite config: optimizeDeps.force for R3F dep consistency
- Data backfill script for testing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:43:48 +08:00
|
|
|
{ key: '/devices', icon: <AppstoreOutlined />, label: '设备管理' },
|
2026-04-01 13:36:06 +08:00
|
|
|
{ key: '/analysis', icon: <BarChartOutlined />, label: '能耗分析' },
|
|
|
|
|
{ key: '/alarms', icon: <AlertOutlined />, label: '告警管理' },
|
|
|
|
|
{ key: '/carbon', icon: <CloudOutlined />, label: '碳排放管理' },
|
|
|
|
|
{ key: '/reports', icon: <FileTextOutlined />, label: '报表管理' },
|
2026-04-02 19:28:05 +08:00
|
|
|
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: '可视化大屏',
|
|
|
|
|
children: [
|
|
|
|
|
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: '2D 能源大屏' },
|
|
|
|
|
{ key: '/bigscreen-3d', icon: <GlobalOutlined />, label: '3D 园区大屏' },
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-04-01 13:36:06 +08:00
|
|
|
{ key: '/system', icon: <SettingOutlined />, label: '系统管理',
|
|
|
|
|
children: [
|
|
|
|
|
{ key: '/system/users', label: '用户管理' },
|
|
|
|
|
{ key: '/system/roles', label: '角色权限' },
|
|
|
|
|
{ key: '/system/settings', label: '系统设置' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-02 18:46:42 +08:00
|
|
|
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' },
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-01 13:36:06 +08:00
|
|
|
export default function MainLayout() {
|
|
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
2026-04-02 18:46:42 +08:00
|
|
|
const [alarmCount, setAlarmCount] = useState(0);
|
|
|
|
|
const [recentAlarms, setRecentAlarms] = useState<any[]>([]);
|
2026-04-01 13:36:06 +08:00
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const location = useLocation();
|
|
|
|
|
const user = getUser();
|
|
|
|
|
|
2026-04-02 18:46:42 +08:00
|
|
|
const fetchAlarms = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const [stats, events] = await Promise.all([
|
|
|
|
|
getAlarmStats(),
|
|
|
|
|
getAlarmEvents({ status: 'active', page_size: 5 }),
|
|
|
|
|
]);
|
|
|
|
|
// Stats shape: { severity: { status: count } } — sum all "active" counts
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-04-01 13:36:06 +08:00
|
|
|
const handleLogout = () => {
|
|
|
|
|
removeToken();
|
|
|
|
|
localStorage.removeItem('user');
|
|
|
|
|
navigate('/login');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const userMenu = {
|
|
|
|
|
items: [
|
|
|
|
|
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
|
|
|
|
|
{ type: 'divider' as const },
|
|
|
|
|
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
|
|
|
<Sider trigger={null} collapsible collapsed={collapsed} width={220}
|
|
|
|
|
style={{ background: '#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 }}>天普EMS</Text>}
|
|
|
|
|
</div>
|
|
|
|
|
<Menu
|
|
|
|
|
theme="dark" mode="inline"
|
|
|
|
|
selectedKeys={[location.pathname]}
|
|
|
|
|
defaultOpenKeys={['/system']}
|
|
|
|
|
items={menuItems}
|
2026-04-02 19:28:05 +08:00
|
|
|
onClick={({ key }) => {
|
|
|
|
|
if (key === '/bigscreen' || key === '/bigscreen-3d') {
|
|
|
|
|
window.open(key, '_blank');
|
|
|
|
|
} else {
|
|
|
|
|
navigate(key);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-04-01 13:36:06 +08:00
|
|
|
/>
|
|
|
|
|
</Sider>
|
|
|
|
|
<Layout>
|
|
|
|
|
<Header style={{
|
|
|
|
|
padding: '0 24px', background: '#fff', display: 'flex',
|
|
|
|
|
alignItems: 'center', justifyContent: 'space-between',
|
|
|
|
|
boxShadow: '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 }}>
|
2026-04-02 18:46:42 +08:00
|
|
|
<Popover
|
|
|
|
|
trigger="click"
|
|
|
|
|
placement="bottomRight"
|
|
|
|
|
title={<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
|
|
<span>告警通知</span>
|
|
|
|
|
{alarmCount > 0 && <Tag color="red">{alarmCount} 条活跃</Tag>}
|
|
|
|
|
</div>}
|
|
|
|
|
content={
|
|
|
|
|
<div style={{ width: 320, maxHeight: 360, overflow: 'auto' }}>
|
|
|
|
|
{recentAlarms.length === 0 ? (
|
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无活跃告警" />
|
|
|
|
|
) : (
|
|
|
|
|
<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 }}>查看全部告警</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Badge count={alarmCount} size="small" overflowCount={99}>
|
|
|
|
|
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
|
|
|
|
</Badge>
|
|
|
|
|
</Popover>
|
2026-04-01 13:36:06 +08:00
|
|
|
<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: '#f5f5f5', minHeight: 280 }}>
|
|
|
|
|
<Outlet />
|
|
|
|
|
</Content>
|
|
|
|
|
</Layout>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
}
|