Files
EventHubFrontAdmin/src/layouts/AdminLayout.tsx
T
2026-05-23 22:31:14 +03:00

281 lines
12 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, { useEffect, useState } from 'react';
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useAdminWebSocket } from '../hooks/useAdminWebSocket';
import {
DashboardOutlined,
UserOutlined,
CalendarOutlined,
WarningOutlined,
StarOutlined,
StopOutlined,
BugOutlined,
DollarOutlined,
TeamOutlined,
AuditOutlined,
LogoutOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const AdminLayout: React.FC = () => {
useAdminWebSocket();
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { token: { colorBgContainer } } = theme.useToken();
const [collapsed, setCollapsed] = useState(false);
// Обработка WebSocket-уведомлений
useEffect(() => {
const handler = (event: CustomEvent) => {
const msg = event.detail;
if (msg.type === 'report_created') {
const { report_id, target_type, target_id, reason } = msg.data || {};
notification.info({
title: 'Новая жалоба',
description: (
<span>
Жалоба{' '}
{report_id ? (
<a
href={`/reports/${report_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/reports/${report_id}`);
}}
style={{ fontWeight: 600 }}
>
#{report_id}
</a>
) : (
'#?'
)}{' '}
на {target_type || 'неизвестный тип'}{' '}
{target_id ? (
<a
href={`/${target_type}s/${target_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/${target_type}s/${target_id}`);
}}
style={{ fontWeight: 600 }}
>
{target_id}
</a>
) : (
'?'
)}{' '}
{reason ? `(${reason})` : ''}
</span>
),
placement: 'topRight',
});
} else if (msg.type === 'ticket_created') {
const { ticket_id } = msg.data || {};
notification.info({
title: 'Новый тикет',
description: (
<span>
Тикет{' '}
{ticket_id ? (
<a
href={`/tickets/${ticket_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/tickets/${ticket_id}`);
}}
style={{ fontWeight: 600 }}
>
#{ticket_id}
</a>
) : (
'без ID'
)}{' '}
создан
</span>
),
placement: 'topRight',
});
}
};
window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate]);
const menuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: 'Дашборд' },
{ key: '/users', icon: <UserOutlined />, label: 'Пользователи', roles: ['superadmin', 'admin'] },
{ key: '/events', icon: <CalendarOutlined />, label: 'События', roles: ['superadmin', 'admin'] },
{ key: '/reports', icon: <WarningOutlined />, label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/reviews', icon: <StarOutlined />, label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/banned-words', icon: <StopOutlined />, label: 'Бан-слова', roles: ['superadmin', 'admin'] },
{ key: '/tickets', icon: <BugOutlined />, label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] },
{ key: '/subscriptions', icon: <DollarOutlined />, label: 'Подписки', roles: ['superadmin', 'admin'] },
{ key: '/admins', icon: <TeamOutlined />, label: 'Администраторы', roles: ['superadmin'] },
{ key: '/audit', icon: <AuditOutlined />, label: 'Аудит', roles: ['superadmin'] },
];
const filteredMenu = menuItems.filter(
(item) => !item.roles || (user && item.roles.includes(user.role))
);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const getDisplayName = () => {
const nick = user?.nickname;
const email = user?.email;
if (nick && nick !== '-' && nick !== 'undefined') return nick;
if (email && email !== '-' && email !== 'undefined') return email;
return user?.id ?? '—';
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
style={{
position: 'relative', // чтобы абсолютный блок считался от него
display: 'flex',
flexDirection: 'column',
height: '100vh',
// position: 'sticky',
top: 0,
left: 0,
}}
>
<div style={{ height: 32, margin: 16, color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
{collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={filteredMenu}
onClick={({ key }) => navigate(key)}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
marginBottom: 60, // отступ, чтобы меню не заходило под нижний блок
}}
/>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
padding: '12px 24px',
borderTop: '1px solid rgba(255,255,255,0.1)',
background: '#001529', // цвет сайдбара Ant Design
flexShrink: 0,
}}
>
<Dropdown
menu={{
items: [
{
key: 'profile',
icon: <SettingOutlined />,
label: 'Мой профиль',
onClick: () => navigate('/profile'),
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Выйти',
onClick: handleLogout,
},
],
}}
trigger={['click']}
placement="topLeft"
>
<Button
type="text"
style={{
color: 'white',
display: 'flex',
alignItems: 'center',
padding: '4px 0',
border: 'none',
width: collapsed ? 'auto' : '100%',
}}
>
<Avatar icon={<UserOutlined />} src={user?.avatar_url} size="small" />
{!collapsed && (
<Text
style={{
color: 'white',
marginLeft: 8,
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
ellipsis
>
{getDisplayName()}
</Text>
)}
</Button>
</Dropdown>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
color: 'white',
fontSize: '16px',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</div>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space size="large">
<Badge status="success" text="Онлайн: 12" />
<Badge status="processing" text="Заказов сегодня: 5" />
<Badge status="warning" text="Тикетов: 2" />
</Space>
<div>{/* Резерв */}</div>
</Header>
<Content style={{ margin: 24 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;