2025-11-29 15:10:00 +00:00
|
|
|
|
/**
|
2026-09-20 05:48:43 +00:00
|
|
|
|
* 管理后台布局
|
|
|
|
|
|
*
|
|
|
|
|
|
* 结构:左侧深色栏(品牌=收起/展开按钮 → 菜单 → 底部固定用户胶囊)+
|
|
|
|
|
|
* 右侧内容区(顶部只有语言与明暗开关,页面信息统一由页面自己的 Header 卡片承载)。
|
|
|
|
|
|
* 管理员与普通用户共用同一套布局。
|
2025-11-29 15:10:00 +00:00
|
|
|
|
*/
|
2026-09-20 05:48:43 +00:00
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
|
|
|
|
import { Avatar, ConfigProvider, Layout, Menu, Popover, Spin } from 'antd';
|
|
|
|
|
|
import zhCN from 'antd/locale/zh_CN';
|
|
|
|
|
|
import enUS from 'antd/locale/en_US';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
import {
|
2026-09-20 05:48:43 +00:00
|
|
|
|
CalendarOutlined,
|
|
|
|
|
|
ClockCircleOutlined,
|
|
|
|
|
|
ControlOutlined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
DashboardOutlined,
|
|
|
|
|
|
DatabaseOutlined,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
DownOutlined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
DownloadOutlined,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
GlobalOutlined,
|
|
|
|
|
|
HomeOutlined,
|
|
|
|
|
|
IdcardOutlined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
LogoutOutlined,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
MenuFoldOutlined,
|
|
|
|
|
|
MenuUnfoldOutlined,
|
|
|
|
|
|
MoonOutlined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
RocketOutlined,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
ScheduleOutlined,
|
2025-11-30 02:43:47 +00:00
|
|
|
|
SettingOutlined,
|
2025-12-26 01:21:15 +00:00
|
|
|
|
StarOutlined,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
SunOutlined,
|
|
|
|
|
|
TeamOutlined,
|
|
|
|
|
|
UserOutlined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
|
import type { MenuProps } from 'antd';
|
2026-09-20 05:48:43 +00:00
|
|
|
|
|
2026-07-21 12:09:51 +00:00
|
|
|
|
import { authAPI } from '../../utils/request';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
import { auth } from '../../utils/auth';
|
2025-12-01 08:52:04 +00:00
|
|
|
|
import { useToast } from '../../contexts/ToastContext';
|
2026-09-20 05:48:43 +00:00
|
|
|
|
import { adminThemeDark, adminThemeLight } from './adminTheme';
|
|
|
|
|
|
import { AdminPrefsProvider, useAdminPrefs } from './AdminPrefsContext';
|
|
|
|
|
|
import './admin.css';
|
|
|
|
|
|
|
|
|
|
|
|
const { Sider, Content } = Layout;
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
interface MenuNode {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
parent_id: number | null;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
icon?: string | null;
|
|
|
|
|
|
path?: string | null;
|
|
|
|
|
|
children?: MenuNode[];
|
|
|
|
|
|
}
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const iconMap: Record<string, React.ReactNode> = {
|
2025-11-29 15:10:00 +00:00
|
|
|
|
dashboard: <DashboardOutlined />,
|
|
|
|
|
|
database: <DatabaseOutlined />,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
planet: <GlobalOutlined />,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
data: <DatabaseOutlined />,
|
|
|
|
|
|
download: <DownloadOutlined />,
|
2025-11-30 02:43:47 +00:00
|
|
|
|
settings: <SettingOutlined />,
|
|
|
|
|
|
users: <TeamOutlined />,
|
|
|
|
|
|
sliders: <ControlOutlined />,
|
2025-12-26 01:21:15 +00:00
|
|
|
|
profile: <IdcardOutlined />,
|
|
|
|
|
|
star: <StarOutlined />,
|
2026-07-21 12:09:51 +00:00
|
|
|
|
rocket: <RocketOutlined />,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
schedule: <ScheduleOutlined />,
|
|
|
|
|
|
// 后端菜单里也直接存了组件名,这里一并兼容
|
|
|
|
|
|
DashboardOutlined: <DashboardOutlined />,
|
|
|
|
|
|
DatabaseOutlined: <DatabaseOutlined />,
|
|
|
|
|
|
DownloadOutlined: <DownloadOutlined />,
|
|
|
|
|
|
SettingOutlined: <SettingOutlined />,
|
|
|
|
|
|
TeamOutlined: <TeamOutlined />,
|
|
|
|
|
|
ControlOutlined: <ControlOutlined />,
|
|
|
|
|
|
IdcardOutlined: <IdcardOutlined />,
|
|
|
|
|
|
StarOutlined: <StarOutlined />,
|
|
|
|
|
|
RocketOutlined: <RocketOutlined />,
|
|
|
|
|
|
CalendarOutlined: <CalendarOutlined />,
|
|
|
|
|
|
ClockCircleOutlined: <ClockCircleOutlined />,
|
|
|
|
|
|
ScheduleOutlined: <ScheduleOutlined />,
|
|
|
|
|
|
GlobalOutlined: <GlobalOutlined />,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
function roleLabelOf(user: ReturnType<typeof auth.getUser>): string {
|
|
|
|
|
|
const roles = (user?.roles as string[] | undefined) ?? [];
|
|
|
|
|
|
if (roles.includes('admin')) return '管理员';
|
|
|
|
|
|
if (roles.length > 0) return '普通用户';
|
|
|
|
|
|
return '已登录用户';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AdminShell() {
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const [menus, setMenus] = useState<MenuNode[]>([]);
|
|
|
|
|
|
const [loadingMenus, setLoadingMenus] = useState(true);
|
|
|
|
|
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
|
|
|
|
const [user, setUser] = useState(() => auth.getUser());
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const location = useLocation();
|
2025-12-01 08:52:04 +00:00
|
|
|
|
const toast = useToast();
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const { lang, themeMode, setLang, setThemeMode, t } = useAdminPrefs();
|
|
|
|
|
|
const locale = lang === 'en' ? enUS : zhCN;
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const isAdmin = ((user?.roles as string[] | undefined) ?? []).includes('admin');
|
|
|
|
|
|
|
|
|
|
|
|
// 用户信息可能在其它标签页或个人信息页被更新,这里保持同步。
|
2026-01-18 02:54:03 +00:00
|
|
|
|
useEffect(() => {
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const handleStorageChange = (event: StorageEvent) => {
|
|
|
|
|
|
if (event.key === 'cosmo_user') {
|
|
|
|
|
|
setUser(event.newValue ? JSON.parse(event.newValue) : null);
|
2026-01-18 02:54:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('storage', handleStorageChange);
|
|
|
|
|
|
return () => window.removeEventListener('storage', handleStorageChange);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const loadMenus = useCallback(async () => {
|
|
|
|
|
|
setLoadingMenus(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await authAPI.getMenus();
|
|
|
|
|
|
setMenus(Array.isArray(data) ? data : []);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setMenus([]);
|
|
|
|
|
|
toast.error('加载菜单失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingMenus(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [toast]);
|
2026-01-18 02:54:03 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
useEffect(() => {
|
2026-09-20 05:48:43 +00:00
|
|
|
|
void loadMenus();
|
|
|
|
|
|
}, [loadMenus]);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
// 切换页面时收起用户二级菜单,避免菜单悬停在旧页面上。
|
2025-12-26 01:21:15 +00:00
|
|
|
|
useEffect(() => {
|
2026-09-20 05:48:43 +00:00
|
|
|
|
setUserMenuOpen(false);
|
|
|
|
|
|
}, [location.pathname]);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
// 访问 /admin 或 /user 根路径时跳到第一个可用菜单。
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (menus.length === 0) return;
|
|
|
|
|
|
if (location.pathname === '/admin' || location.pathname === '/user') {
|
|
|
|
|
|
const firstPath = menus.find((menu) => menu.path)?.path;
|
|
|
|
|
|
if (firstPath) navigate(firstPath, { replace: true });
|
2025-11-29 15:10:00 +00:00
|
|
|
|
}
|
2026-09-20 05:48:43 +00:00
|
|
|
|
}, [menus, location.pathname, navigate]);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const menuItems = useMemo<MenuProps['items']>(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
menus.map((menu) => ({
|
|
|
|
|
|
key: menu.path || `menu-${menu.id}`,
|
|
|
|
|
|
icon: iconMap[menu.icon || ''] ?? undefined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
label: menu.title,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
children: menu.children?.length
|
|
|
|
|
|
? menu.children.map((child) => ({
|
|
|
|
|
|
key: child.path || `menu-${child.id}`,
|
|
|
|
|
|
label: child.title,
|
|
|
|
|
|
}))
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
})),
|
|
|
|
|
|
[menus],
|
|
|
|
|
|
);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
/** 顶栏左侧显示当前所属分组(页面标题由页面自己的 Header 卡片展示)。 */
|
|
|
|
|
|
const headerSection = useMemo(() => {
|
|
|
|
|
|
for (const menu of menus) {
|
|
|
|
|
|
if (menu.children?.some((child) => child.path === location.pathname)) return menu.title;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (location.pathname.endsWith('/profile')) return '账号';
|
|
|
|
|
|
if (location.pathname.startsWith('/user')) return '用户中心';
|
|
|
|
|
|
return 'COSMO 管理后台';
|
|
|
|
|
|
}, [menus, location.pathname]);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const handleLogout = useCallback(async () => {
|
|
|
|
|
|
setUserMenuOpen(false);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await authAPI.logout();
|
2026-09-20 05:48:43 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
// 后端不可用时也要清理本地登录态。
|
2025-11-29 15:10:00 +00:00
|
|
|
|
}
|
2026-09-20 05:48:43 +00:00
|
|
|
|
auth.logout();
|
|
|
|
|
|
toast.success('已退出登录');
|
|
|
|
|
|
navigate('/login', { replace: true });
|
|
|
|
|
|
}, [navigate, toast]);
|
|
|
|
|
|
|
|
|
|
|
|
const go = (path: string) => {
|
|
|
|
|
|
setUserMenuOpen(false);
|
|
|
|
|
|
navigate(path);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const avatarUrl = user?.avatar_url ? `/upload/${user.avatar_url}` : undefined;
|
|
|
|
|
|
const displayName = (user?.full_name as string) || (user?.username as string) || '未登录';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const userMenuContent = (
|
|
|
|
|
|
<div style={{ width: 232 }}>
|
|
|
|
|
|
<div className="adm-user-menu-head">
|
|
|
|
|
|
<Avatar size={38} src={avatarUrl} icon={<UserOutlined />} />
|
|
|
|
|
|
<div className="adm-user-menu-head-meta">
|
|
|
|
|
|
<strong>{displayName}</strong>
|
|
|
|
|
|
<span>{user?.email || t(roleLabelOf(user))}</span>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
</div>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="adm-user-menu-group">{t('个人资料')}</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`adm-user-menu-item ${location.pathname.endsWith('/profile') ? 'is-active' : ''}`}
|
|
|
|
|
|
onClick={() => go('/user/profile')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<IdcardOutlined />
|
|
|
|
|
|
{t('个人资料')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="adm-user-menu-divider" />
|
|
|
|
|
|
<button type="button" className="adm-user-menu-item" onClick={() => go('/')}>
|
|
|
|
|
|
<HomeOutlined />
|
|
|
|
|
|
{t('返回可视化首页')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="adm-user-menu-item is-danger" onClick={() => void handleLogout()}>
|
|
|
|
|
|
<LogoutOutlined />
|
|
|
|
|
|
{t('退出登录')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigProvider theme={themeMode === 'dark' ? adminThemeDark : adminThemeLight} locale={locale}>
|
|
|
|
|
|
<Layout className="adm-shell" data-adm-theme={themeMode}>
|
|
|
|
|
|
<Sider
|
|
|
|
|
|
width={236}
|
|
|
|
|
|
theme={themeMode === 'dark' ? 'dark' : 'light'}
|
|
|
|
|
|
trigger={null}
|
|
|
|
|
|
collapsible
|
|
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
|
className={`adm-sider ${collapsed ? 'adm-sider--collapsed' : ''}`}
|
2025-11-29 15:10:00 +00:00
|
|
|
|
>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
{/* 品牌区 = 收起 / 展开按钮:收起后悬停显示展开图标 */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="adm-brand"
|
|
|
|
|
|
onClick={() => setCollapsed((value) => !value)}
|
|
|
|
|
|
aria-label={collapsed ? t('展开菜单') : t('收起菜单')}
|
|
|
|
|
|
title={collapsed ? t('展开菜单') : t('收起菜单')}
|
2025-11-29 15:10:00 +00:00
|
|
|
|
>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<span className="adm-brand-mark">🌌</span>
|
|
|
|
|
|
<MenuUnfoldOutlined className="adm-brand-hover-icon" />
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="adm-brand-text">
|
|
|
|
|
|
<strong>COSMO</strong>
|
|
|
|
|
|
<span>{isAdmin ? t('管理后台') : t('用户中心')}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<MenuFoldOutlined className="adm-brand-toggle" />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="adm-menu">
|
|
|
|
|
|
{loadingMenus ? (
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '24px 0' }}>
|
|
|
|
|
|
<Spin size="small" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
theme={themeMode === 'dark' ? 'dark' : 'light'}
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
selectedKeys={[location.pathname]}
|
|
|
|
|
|
defaultOpenKeys={menus.filter((menu) => menu.children?.length).map((menu) => `menu-${menu.id}`)}
|
|
|
|
|
|
items={menuItems}
|
|
|
|
|
|
onClick={({ key }) => navigate(key)}
|
|
|
|
|
|
style={{ background: 'transparent', borderInlineEnd: 'none' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 用户胶囊:位于菜单下方,点击展开二级菜单 */}
|
|
|
|
|
|
<div className={`adm-user-dock ${collapsed ? 'adm-user-dock--collapsed' : ''}`}>
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
open={userMenuOpen}
|
|
|
|
|
|
onOpenChange={setUserMenuOpen}
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
placement="topLeft"
|
|
|
|
|
|
arrow={false}
|
|
|
|
|
|
overlayClassName="adm-user-menu"
|
|
|
|
|
|
content={userMenuContent}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button type="button" className="adm-user-pill" aria-expanded={userMenuOpen}>
|
|
|
|
|
|
<Avatar size={30} src={avatarUrl} icon={<UserOutlined />} />
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="adm-user-pill-meta">
|
|
|
|
|
|
<strong>{displayName}</strong>
|
|
|
|
|
|
<span>{t(roleLabelOf(user))}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<DownOutlined className="adm-user-pill-arrow" />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Popover>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
</div>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
</Sider>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<Layout>
|
|
|
|
|
|
{/* 统一框架 Header:左侧当前分组,右侧全局开关 */}
|
|
|
|
|
|
<header className="adm-header">
|
|
|
|
|
|
<div className="adm-header-left">
|
|
|
|
|
|
<span className="adm-header-section">{t(headerSection)}</span>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
</div>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<div className="adm-header-right">
|
|
|
|
|
|
<div className="adm-switch-group" role="group" aria-label={t('语言切换')}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={lang === 'zh' ? 'is-active' : ''}
|
|
|
|
|
|
onClick={() => setLang('zh')}
|
|
|
|
|
|
title="中文"
|
|
|
|
|
|
>
|
|
|
|
|
|
中文
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={lang === 'en' ? 'is-active' : ''}
|
|
|
|
|
|
onClick={() => setLang('en')}
|
|
|
|
|
|
title="English"
|
|
|
|
|
|
>
|
|
|
|
|
|
EN
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="adm-icon-switch"
|
|
|
|
|
|
onClick={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
|
|
|
|
|
|
aria-label={t('主题切换')}
|
|
|
|
|
|
title={themeMode === 'dark' ? t('浅色') : t('深色')}
|
|
|
|
|
|
>
|
|
|
|
|
|
{themeMode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<Content className="adm-content">
|
|
|
|
|
|
<Outlet />
|
|
|
|
|
|
</Content>
|
|
|
|
|
|
</Layout>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
</Layout>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
</ConfigProvider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function AdminLayout() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<AdminPrefsProvider>
|
|
|
|
|
|
<AdminShell />
|
|
|
|
|
|
</AdminPrefsProvider>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|