237 lines
9 KiB
TypeScript
237 lines
9 KiB
TypeScript
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;
|