tss-rocks/frontend/src/pages/admin/layout/AdminLayout.tsx

237 lines
9 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 { FC, useState, useEffect } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
RiFileTextLine,
RiFolderLine,
RiUserLine,
RiTeamLine,
RiLogoutBoxRLine,
RiSunLine,
RiMoonLine,
RiComputerLine,
RiGlobalLine,
RiCalendarLine,
RiImageLine,
RiSettings3Line,
} from 'react-icons/ri';
import { useTheme } from '../../../hooks/useTheme';
import { Suspense } from 'react';
import LoadingSpinner from '../../../components/LoadingSpinner';
import { useUser } from '../../../contexts/UserContext';
import { PageTitleProvider, usePageTitle } from '../../../contexts/PageTitleContext';
interface AdminLayoutProps {}
const menuItems = [
{ path: '/admin/posts', icon: RiFileTextLine, label: 'admin.nav.posts' },
{ path: '/admin/daily', icon: RiCalendarLine, label: 'admin.nav.daily' },
{ path: '/admin/medias', icon: RiImageLine, label: 'admin.nav.medias' },
{ path: '/admin/categories', icon: RiFolderLine, label: 'admin.nav.categories' },
{ path: '/admin/users', icon: RiUserLine, label: 'admin.nav.users' },
{ path: '/admin/contributors', icon: RiTeamLine, label: 'admin.nav.contributors' },
{ path: '/admin/settings', icon: RiSettings3Line, label: 'admin.nav.settings' },
];
const themeOptions = [
{ value: 'light' as const, icon: RiSunLine, label: 'theme.light' },
{ value: 'dark' as const, icon: RiMoonLine, label: 'theme.dark' },
{ value: 'system' as const, icon: RiComputerLine, label: 'theme.system' }
];
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'zh-Hans', label: '简体中文' },
{ value: 'zh-Hant', label: '繁體中文' }
];
type LanguageMap = {
'en': 'zh-Hans';
'zh-Hans': 'zh-Hant';
'zh-Hant': 'en';
};
const languageMap: LanguageMap = {
'en': 'zh-Hans',
'zh-Hans': 'zh-Hant',
'zh-Hant': 'en'
};
const AdminLayoutContent: FC = () => {
const { t, i18n } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { theme, setTheme } = useTheme();
const { user, loading, error, fetchUser } = useUser();
const { title } = usePageTitle();
useEffect(() => {
// 如果没有 token重定向到登录页
if (!localStorage.getItem('token')) {
navigate('/admin/login');
return;
}
// 如果没有用户信息且没有在加载中,尝试获取用户信息
if (!user && !loading && !error) {
fetchUser();
}
// 如果获取用户信息出错,可能是 token 过期,重定向到登录页
if (error) {
localStorage.removeItem('token');
navigate('/admin/login');
}
}, [user, loading, error, navigate, fetchUser]);
const handleLogout = async () => {
try {
// 调用后端登出接口
const token = localStorage.getItem('token');
if (token) {
await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
}
} catch (err) {
console.error('Logout error:', err);
} finally {
// 清除所有认证相关的存储数据
localStorage.removeItem('token');
localStorage.removeItem('username');
// 重置用户状态
await fetchUser();
// 重定向到登录页
navigate('/admin/login');
}
};
return (
<div className="min-h-screen bg-slate-100 dark:bg-slate-900 py-6 flex">
{/* Background Overlay */}
<div className="fixed inset-0 bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-900 backdrop-blur-xl -z-10" />
<div className="container max-w-[1920px] mx-auto px-2 flex gap-2">
{/* Sidebar */}
<aside className="w-64 bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg rounded-lg shadow-lg flex flex-col">
<div className="h-16 px-6 border-b border-slate-200/80 dark:border-slate-700/80 flex items-center justify-center">
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">
TSS Rocks
</h1>
</div>
<nav className="flex-1 p-4">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 mb-2 rounded-lg transition-colors ${
isActive
? 'bg-slate-900 dark:bg-slate-700 text-white'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50'
}`}
>
<Icon className="text-xl" />
<span className="tracking-wide">{t(item.label)}</span>
</Link>
);
})}
</nav>
<div className="p-4 space-y-2">
{/* Language and Logout Buttons */}
<div className="flex gap-2">
<button
onClick={() => {
const currentLang = i18n.language as keyof LanguageMap;
const nextLang = languageMap[currentLang] || 'en';
i18n.changeLanguage(nextLang);
}}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors whitespace-nowrap min-w-[100px]"
title={t('admin.common.switchLanguage')}
>
<RiGlobalLine className="text-xl flex-shrink-0" />
<span className="text-sm truncate">{languageOptions.find(lang => lang.value === i18n.language)?.label || 'English'}</span>
</button>
<button
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors whitespace-nowrap"
onClick={handleLogout}
>
<RiLogoutBoxRLine className="text-xl flex-shrink-0" />
<span className="text-sm">{t('admin.common.logout')}</span>
</button>
</div>
{/* Theme Buttons */}
<div className="border-t border-slate-200/80 dark:border-slate-700/80 pt-2">
<div className="flex items-center gap-1">
{themeOptions.map((option) => {
const Icon = option.icon;
return (
<button
key={option.value}
onClick={() => setTheme(option.value)}
className={`flex-1 p-2 rounded-md transition-colors ${
theme === option.value
? 'bg-slate-900 dark:bg-slate-700 text-white'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50'
}`}
title={t(option.label)}
>
<Icon className="text-xl mx-auto" />
</button>
);
})}
</div>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col rounded-lg overflow-hidden bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-lg">
<header className="border-b border-slate-200/80 dark:border-slate-700/80">
<div className="h-16 px-8 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">
{title || t(menuItems.find(item => item.path === location.pathname)?.label || 'admin.nav.dashboard')}
</h2>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-full flex items-center justify-center text-slate-600 dark:text-slate-300">
<span className="text-lg">{(user?.display_name?.[0] || user?.username?.[0] || '?').toUpperCase()}</span>
</div>
<div>
<div className="text-slate-800 dark:text-white">
{loading ? 'Loading...' : user?.display_name || user?.username || 'Guest'}
</div>
</div>
</div>
</div>
</header>
<div className="flex-1 p-6">
<div className="h-full bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200/60 dark:border-slate-700/60">
<Suspense fallback={<LoadingSpinner fullScreen />}>
<Outlet />
</Suspense>
</div>
</div>
</main>
</div>
</div>
);
};
const AdminLayout: FC<AdminLayoutProps> = () => {
return (
<PageTitleProvider>
<AdminLayoutContent />
</PageTitleProvider>
);
};
export default AdminLayout;