Files
web-Iot/src/components/Layout.tsx
2026-08-03 13:29:16 +08:00

363 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
Layout, Menu, Button, Space, ConfigProvider, theme, Select, Avatar, Card, Tag
} from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DashboardOutlined, ExperimentOutlined, VideoCameraOutlined,
BellOutlined, UserOutlined, LogoutOutlined,
AppstoreOutlined, SettingOutlined, MenuUnfoldOutlined, MenuFoldOutlined,
CompressOutlined,
ExpandOutlined
} from '@ant-design/icons';
import logo from '../assets/logo.png';
import { useSelector } from 'react-redux';
import { RootState, useAppDispatch } from '../store';
import { logout } from '../store/userSlice';
import { setCurrentStation, clearStation } from '../store/stationSlice';
import { getMenuList } from '../api/mune';
import { getStationByUserId } from '../api/stationManage';
import envConfig from '../../env';
const { Header, Sider, Content } = Layout;
export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false);
const [time, setTime] = useState(new Date().toLocaleString());
const [menuList, setMenuList] = useState([]);
const [stationList, setStationList] = useState([]);
const [isFullScreen, setIsFullScreen] = useState(false); // 新增全屏标识
const navigate = useNavigate();
const location = useLocation();
const dispatch = useAppDispatch();
const { userInfo } = useSelector((state: RootState) => state.user);
const { currentStation, stationId } = useSelector((state: RootState) => state.station);
const getStationData = () => {
getStationByUserId().then(res => {
if (res.code === 200) {
const list = res.data || [];
setStationList(list);
if (!currentStation && list.length > 0) {
dispatch(setCurrentStation(list[0]));
}
}
});
};
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
setIsFullScreen(true);
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
setIsFullScreen(false);
}
}
};
const handleStationChange = (id: number) => {
const station = stationList.find(s => s.id === id);
if (station) {
dispatch(setCurrentStation(station));
}
};
const getIcon = (path) => {
if (path.includes('overview')) return <DashboardOutlined />;
if (path.includes('device')) return <ExperimentOutlined />;
if (path.includes('monitor')) return <VideoCameraOutlined />;
if (path.includes('alert')) return <BellOutlined />;
if (path.includes('user')) return <UserOutlined />;
if (path.includes('system')) return <SettingOutlined />;
if (path.includes('menu')) return <AppstoreOutlined />;
return <AppstoreOutlined />;
};
const buildMenuTree = (list: any[]) => {
const map = new Map();
const tree: any[] = [];
// 建立映射
list.forEach(item => {
map.set(item.menuId, {
...item,
key: item.path,
label: item.menuName,
icon: getIcon(item.path),
children: [],
});
});
// 建树
list.forEach(item => {
const node = map.get(item.menuId);
if (item.parentId === 0) {
tree.push(node);
} else {
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(node);
}
}
});
// 排序
const sortMenu = (menus: any[]) => {
menus.sort((a, b) => a.orderNum - b.orderNum);
menus.forEach(menu => {
if (menu.children.length) {
sortMenu(menu.children);
} else {
delete menu.children;
}
});
};
sortMenu(tree);
return tree;
};
const renderMenuItems = (treeList, isChild = false) => {
return treeList.map(item => {
const hasChild = item.children && item.children.length > 0;
const key = item.path || `/menu/${item.menuId}`;
return {
key,
icon: isChild ? null : getIcon(item.path), // 子菜单无图标
label: item.menuName,
...(hasChild && {
children: renderMenuItems(item.children, true),
}),
};
});
};
const handleLogout = async () => {
await dispatch(logout());
dispatch(clearStation());
navigate('/login', { replace: true });
};
const getMenu = () => {
getMenuList({
pageNum: 1,
pageSize: 10000,
}).then(res => {
if (res.code === 200) {
const menus = buildMenuTree(res.rows || []);
setMenuList(menus);
}
});
};
useEffect(() => {
const timer = setInterval(() => setTime(new Date().toLocaleString()), 1000);
getMenu();
getStationData();
return () => clearInterval(timer);
}, [userInfo]);
useEffect(() => {
const fullscreenChange = () => {
setIsFullScreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', fullscreenChange);
return () => document.removeEventListener('fullscreenchange', fullscreenChange);
}, []);
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: '#1890ff',
borderRadius: 2,
padding: 12,
paddingLG: 16,
margin: 12,
marginLG: 16,
},
components: {
Layout: { headerBg: '#fff', siderBg: '#fff' },
Menu: { itemBg: '#fff', itemSelectedBg: '#e6f4ff' },
Card: {
paddingLG: 12,
},
Table: {
padding: 8,
paddingXS: 4,
},
Form: {
itemMarginBottom: 12,
},
Space: {
gap: 8,
}
}
}}
>
<Layout style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Header style={{
background: '#0837a8',
color: '#fff',
minHeight: "40px",
height: "auto",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '2px 12px',
flexWrap: 'wrap',
gap: '4px'
}}>
<Space size="large" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', height: "100%", justifyContent: "center" }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '16px',
color: '#fff',
width: 40,
height: 40,
}}
/>
<div style={{ height: "100%", display: "flex", gap: "10px", justifyContent: "center", alignItems: "center" }}>
<img src={logo} alt="Logo" style={{ width: 24, height: 24 }} />
{!collapsed && (
<span style={{ fontWeight: 'bold', fontSize: 'clamp(14px, 1.2vw, 16px)', color: '#fff', whiteSpace: 'nowrap' }}>
光伏电站智能监控与运维系统
</span>
)}
</div>
<Select
value={stationId}
placeholder="请选择电站"
onChange={handleStationChange}
options={stationList.map(s => ({ label: s.siteName, value: s.id }))}
style={{
width: 'clamp(160px, 15vw, 220px)',
backgroundColor: '#0837a8',
color: '#fff',
border: 'none',
}}
/>
</Space>
<Space size="middle" style={{ flexWrap: 'wrap' }}>
<Button
type="text"
icon={isFullScreen ? <CompressOutlined /> : <ExpandOutlined />}
onClick={toggleFullScreen}
style={{ color: '#fff', fontSize: 'clamp(12px, 1vw, 14px)' }}
>
{isFullScreen ? '退出全屏' : '全屏'}
</Button>
<span style={{ fontSize: 'clamp(12px, 1vw, 13px)' }}>{time}</span>
<Space
style={{ cursor: 'pointer', padding: '0 4px' }}
onClick={() => navigate('/profile')}
className="hover:opacity-80"
>
{userInfo?.avatar && (
<Avatar
size="small"
src={userInfo.avatar.startsWith('http') ? userInfo.avatar : `${envConfig.baseURL}${userInfo.avatar}`}
/>
)}
<span style={{ fontSize: 'clamp(12px, 1vw, 14px)' }}>{userInfo?.nickName || userInfo?.username}</span>
</Space>
<Button type="text" danger icon={<LogoutOutlined />} onClick={handleLogout} style={{ fontSize: 'clamp(12px, 1vw, 14px)' }}>
退出登录
</Button>
</Space>
</Header>
<Layout style={{ flex: 1, overflow: 'hidden', background: '#fff' }}>
<Sider
collapsible
collapsed={collapsed}
trigger={null}
width={180}
theme="light"
style={{ borderRight: '1px solid #e5e7eb' }}
>
<Layout
style={{
height: '100%',
flexDirection: 'column',
background: '#fff',
overflow: 'hidden'
}}
>
{/* 菜单滚动区域:弹性占满剩余空间,超出滚动 */}
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
<Menu
mode="inline"
theme="light"
items={menuList}
selectedKeys={[location.pathname]}
onClick={({ key }) => navigate(key)}
style={{ borderRight: 0 }}
/>
</div>
{/* 底部场站卡片,固定在底部,不被菜单挤压 */}
{!collapsed && currentStation && (
<Card
size="small"
style={{
border: '1px solid #e5e7eb',
borderRadius: 8,
margin: 4,
marginBottom: 8
}}
styles={{ body: { padding: '12px 4px' } }}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>
{currentStation.siteName || '1MW 光伏电站'}
</div>
<img
style={{ width: '100%', borderRadius: "8px", marginBottom: 8 }}
src="https://images.unsplash.com/photo-1509391366360-2e959784a276?ixlib=rb-1.2.1&auto=format&fit=crop&w=1200&q=80"
/>
<div style={{ fontSize: 11, color: '#666', lineHeight: 1.8 }}>
<div>电站地址:{currentStation.address || '河南省林州'}</div>
<div>电站类型:{currentStation.type || '地面电站'}</div>
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', color: '#41ad0a' }}>
<span>运行状态:</span>
<span style={{ marginLeft: 4 }}>●</span>
<span style={{ marginLeft: 4 }}>正常运行</span>
</div>
</div>
</div>
</Card>
)}
</Layout>
</Sider>
<Content style={{ background: '#f4f7f9', overflowY: 'auto', padding: '4px 8px' }}>
<Outlet />
</Content>
</Layout>
</Layout>
</ConfigProvider>
);
}