281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
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; |