[feature/frontend] update person info
Some checks failed
Build Backend / Build Docker Image (push) Failing after 2m24s
Test Backend / test (push) Successful in 2m58s

This commit is contained in:
CDN 2025-02-21 20:04:30 +08:00
parent 79912925db
commit 7a33038af8
Signed by: CDN
GPG key ID: 0C656827F9F80080
21 changed files with 1436 additions and 217 deletions

View file

@ -1,63 +1,19 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Header } from './components/layout/Header';
import { Suspense, lazy } from 'react';
import Footer from './components/Footer';
import { RouterProvider } from 'react-router-dom';
import { Suspense } from 'react';
import { AuthProvider } from './contexts/AuthContext';
// Lazy load pages
const Home = lazy(() => import('./pages/Home'));
const Daily = lazy(() => import('./pages/Daily'));
const Article = lazy(() => import('./pages/Article'));
const AdminLayout = lazy(() => import('./pages/admin/layout/AdminLayout'));
// 管理页面懒加载
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
const UsersManagement = lazy(() => import('./pages/admin/users/UsersManagement'));
const ContributorsManagement = lazy(() => import('./pages/admin/contributors/ContributorsManagement'));
import { UserProvider } from './contexts/UserContext';
import router from './router';
import LoadingSpinner from './components/LoadingSpinner';
function App() {
return (
<AuthProvider>
<BrowserRouter>
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
<Suspense fallback={<div>Loading...</div>}>
<Routes>
{/* Admin routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route path="posts" element={<PostsManagement />} />
<Route path="categories" element={<CategoriesManagement />} />
<Route path="users" element={<UsersManagement />} />
<Route path="contributors" element={<ContributorsManagement />} />
</Route>
{/* Public routes */}
<Route
path="/"
element={
<>
<Header />
{/* 页眉分隔线 */}
<div className="w-[95%] mx-auto">
<div className="border-t-2 border-gray-900 dark:border-gray-100 w-full mb-2" />
</div>
<main className="flex-1 w-[95%] mx-auto py-8">
<Routes>
<Route index element={<Home />} />
<Route path="/daily" element={<Daily />} />
<Route path="/posts/:articleId" element={<Article />} />
</Routes>
</main>
<Footer />
</>
}
/>
</Routes>
</Suspense>
</div>
</BrowserRouter>
</AuthProvider>
<Suspense fallback={<LoadingSpinner fullScreen />}>
<AuthProvider>
<UserProvider>
<RouterProvider router={router} />
</UserProvider>
</AuthProvider>
</Suspense>
);
}

View file

@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
interface LoadingSpinnerProps {
fullScreen?: boolean;
}
export default function LoadingSpinner({ fullScreen = false }: LoadingSpinnerProps) {
const { t } = useTranslation();
const content = (
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-indigo-200 dark:border-indigo-900 border-t-indigo-500 dark:border-t-indigo-400 rounded-full animate-spin" />
<div className="text-slate-600 dark:text-slate-300 text-sm font-medium">
{t('admin.common.loading')}
</div>
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm z-50">
{content}
</div>
);
}
return (
<div className="flex items-center justify-center p-8">
{content}
</div>
);
}

View file

@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next';
interface TableActionsProps {
/**
* 'create'
*/
actionKey?: 'create' | 'upload';
/**
*
*/
onClick?: () => void;
}
export default function TableActions({ actionKey = 'create', onClick }: TableActionsProps) {
const { t } = useTranslation();
return (
<div className="mb-6 flex justify-end">
<button
onClick={onClick}
className="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
>
{t(`admin.common.${actionKey}`)}
</button>
</div>
);
}

View file

@ -12,7 +12,7 @@ const LANGUAGES = [
{ code: 'zh-Hant', nativeName: '繁體中文' },
];
export function Header() {
function Header() {
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
const [isSearchOpen, setIsSearchOpen] = useState(false);
@ -238,3 +238,5 @@ export function Header() {
</header>
);
}
export default Header;

View file

@ -0,0 +1,81 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: number;
username: string;
display_name?: string;
email: string;
status: string;
created_at: string;
updated_at: string;
edges: Record<string, any>;
}
interface UserContextType {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: () => Promise<void>;
}
const UserContext = createContext<UserContextType>({
user: null,
loading: false,
error: null,
fetchUser: async () => {},
});
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
console.log('Fetching current user info...');
const response = await fetch('/api/v1/users/me', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('API Response:', result);
if (result) {
console.log('Setting user data:', result);
setUser(result);
} else {
throw new Error('No user data received');
}
} catch (err) {
console.error('Error fetching user info:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch user info');
setUser(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (localStorage.getItem('token')) {
fetchUser();
}
}, []);
return (
<UserContext.Provider value={{ user, loading, error, fetchUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
return useContext(UserContext);
}

View file

@ -1,6 +1,7 @@
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react';
import LoadingSpinner from '../components/LoadingSpinner';
export default function Article() {
const { articleId } = useParams();
@ -9,13 +10,19 @@ export default function Article() {
content: string;
metadata: any;
} | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// In a real application, we would fetch the article content here
// based on the articleId and current language
console.log(`Fetching article ${articleId} in ${i18n.language}`);
setLoading(false);
}, [articleId, i18n.language]);
if (loading) {
return <LoadingSpinner />;
}
if (!article) {
return <div>Loading...</div>;
}

View file

@ -1,17 +1,19 @@
import { FC, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
interface Category {
id: string;
name: string;
description: string;
slug: string;
postCount: number;
}
const CategoriesManagement: FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [loading] = useState(false);
const { t } = useTranslation();
// 这里后续会通过 API 获取数据
@ -19,7 +21,8 @@ const CategoriesManagement: FC = () => {
{
id: '1',
name: '新闻',
description: '新闻分类',
slug: 'news',
postCount: 10,
},
];
@ -31,14 +34,22 @@ const CategoriesManagement: FC = () => {
console.log('Delete category:', category);
};
const handleCreate = () => {
// TODO: 实现创建分类的逻辑
};
const columns = [
{
key: 'name',
key: 'name' as keyof Category,
title: t('admin.common.name'),
},
{
key: 'description',
title: t('admin.common.description'),
key: 'slug' as keyof Category,
title: t('admin.common.slug'),
},
{
key: 'postCount' as keyof Category,
title: t('admin.common.postCount'),
},
];
@ -56,15 +67,12 @@ const CategoriesManagement: FC = () => {
/>
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div>
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
<TableActions onClick={handleCreate} />
</div>
</div>
<div>
<Table
<Table<Category>
columns={columns}
data={categories}
loading={loading}

View file

@ -2,13 +2,15 @@ import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
interface Contributor {
id: string;
name: string;
bio: string;
articles: number;
joinDate: string;
email: string;
role: 'editor' | 'contributor';
status: 'active' | 'inactive' | 'banned';
postCount: number;
}
const ContributorsManagement: FC = () => {
@ -21,9 +23,10 @@ const ContributorsManagement: FC = () => {
{
id: '1',
name: '李四',
bio: '这是李四的简介',
articles: 5,
joinDate: '2024-02-20',
email: 'lisi@example.com',
role: 'contributor',
status: 'active',
postCount: 5,
},
];
@ -37,28 +40,55 @@ const ContributorsManagement: FC = () => {
const columns = [
{
key: 'name',
title: t('admin.common.name'),
key: 'name' as keyof Contributor,
title: t('admin.contributors.name'),
},
{
key: 'bio',
title: t('admin.common.bio'),
key: 'email' as keyof Contributor,
title: t('admin.contributors.email'),
},
{
key: 'articles',
title: t('admin.common.articles'),
render: (value: number) => (
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm">
{value}
key: 'role' as keyof Contributor,
title: t('admin.contributors.role'),
render: (value: Contributor['role']) => (
<span
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
value === 'editor'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300'
}`}
>
{t(`admin.roles.${value}`)}
</span>
),
},
{
key: 'joinDate',
title: t('admin.common.joinDate'),
key: 'status' as keyof Contributor,
title: t('admin.contributors.status'),
render: (value: Contributor['status']) => (
<span
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
value === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: value === 'inactive'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}
>
{t(`admin.common.${value}`)}
</span>
),
},
{
key: 'postCount' as keyof Contributor,
title: t('admin.contributors.postCount'),
},
];
const handleCreate = () => {
// TODO: 实现创建贡献者的逻辑
};
return (
<div className="divide-y divide-stone-200 dark:divide-stone-700">
<div className="p-6">
@ -73,15 +103,12 @@ const ContributorsManagement: FC = () => {
/>
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div>
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
<TableActions onClick={handleCreate} />
</div>
</div>
<div>
<Table
<Table<Contributor>
columns={columns}
data={contributors}
loading={loading}
@ -92,10 +119,11 @@ const ContributorsManagement: FC = () => {
<table className="w-full">
<thead>
<tr className="border-b border-stone-200 dark:border-stone-700">
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tl-md">{t('admin.common.name')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.bio')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.articles')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.joinDate')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tl-md">{t('admin.contributors.name')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.email')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.role')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.status')}</th>
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.contributors.postCount')}</th>
<th className="text-right py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
</tr>
</thead>
@ -103,13 +131,32 @@ const ContributorsManagement: FC = () => {
{data.map((contributor) => (
<tr key={contributor.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0">
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.name}</td>
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.bio}</td>
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.email}</td>
<td className="py-4 px-6">
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm">
{contributor.articles}
<span
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
contributor.role === 'editor'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300'
}`}
>
{t(`admin.roles.${contributor.role}`)}
</span>
</td>
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.joinDate}</td>
<td className="py-4 px-6">
<span
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
contributor.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: contributor.status === 'inactive'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}
>
{t(`admin.common.${contributor.status}`)}
</span>
</td>
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.postCount}</td>
<td className="py-4 px-6 text-right">
<button
className="text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 px-2 py-1 rounded-md transition-colors"

View file

@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
interface Daily {
id: string;
title: string;
publishDate: string;
status: string;
}
export default function DailyManagement() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const columns = [
{
title: t('admin.daily.title'),
key: 'title',
},
{
title: t('admin.daily.publishDate'),
key: 'publishDate',
},
{
title: t('admin.daily.status'),
key: 'status',
},
];
const handleCreate = () => {
// TODO: 实现创建每日一句的逻辑
};
return (
<div className="p-6">
<TableActions onClick={handleCreate} />
<Table
columns={columns}
data={[]}
loading={loading}
/>
</div>
);
}

View file

@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
export default function Dashboard() {
const { t } = useTranslation();
return (
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 文章统计 */}
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
{t('admin.dashboard.totalPosts')}
</h3>
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
0
</p>
</div>
{/* 分类统计 */}
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
{t('admin.dashboard.totalCategories')}
</h3>
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
0
</p>
</div>
{/* 用户统计 */}
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
{t('admin.dashboard.totalUsers')}
</h3>
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
0
</p>
</div>
{/* 贡献者统计 */}
<div className="bg-white dark:bg-slate-700/50 rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
{t('admin.dashboard.totalContributors')}
</h3>
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
0
</p>
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { FC } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { FC, useState, useEffect } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
RiFileTextLine,
@ -10,18 +10,26 @@ import {
RiSunLine,
RiMoonLine,
RiComputerLine,
RiGlobalLine
RiGlobalLine,
RiCalendarLine,
RiImageLine,
RiSettings3Line,
} from 'react-icons/ri';
import { useTheme } from '../../../hooks/useTheme';
import type { Theme } from '../../../hooks/useTheme';
import { Suspense } from 'react';
import LoadingSpinner from '../../../components/LoadingSpinner';
import { useUser } from '../../../contexts/UserContext';
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 = [
@ -49,16 +57,29 @@ const languageMap: LanguageMap = {
};
const AdminLayout: FC<AdminLayoutProps> = () => {
const location = useLocation();
const { t, i18n } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { theme, setTheme } = useTheme();
const { user, loading, error } = useUser();
useEffect(() => {
console.log('AdminLayout user:', user);
console.log('AdminLayout loading:', loading);
console.log('AdminLayout error:', error);
}, [user, loading, error]);
const handleLogout = () => {
localStorage.removeItem('token');
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="w-full max-w-[98%] mx-auto flex gap-4">
<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">
@ -103,7 +124,7 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
</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={() => {/* TODO: Implement logout */}}
onClick={handleLogout}
>
<RiLogoutBoxRLine className="text-xl flex-shrink-0" />
<span className="text-sm">{t('admin.common.logout')}</span>
@ -145,18 +166,21 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
</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">A</span>
<span className="text-lg">{(user?.display_name?.[0] || user?.username?.[0] || '?').toUpperCase()}</span>
</div>
<div>
<div className="text-slate-800 dark:text-white"></div>
<div className="text-sm text-slate-500 dark:text-slate-400">Administrator</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">
<Outlet />
<Suspense fallback={<LoadingSpinner />}>
<Outlet />
</Suspense>
</div>
</div>
</main>

View file

@ -0,0 +1,243 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiUser, FiLock, FiSun, FiMoon, FiMonitor, FiGlobe } from 'react-icons/fi';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../hooks/useTheme';
import { Menu } from '@headlessui/react';
interface LoginFormData {
username: string;
password: string;
remember: boolean;
}
const LANGUAGES = [
{ code: 'en', nativeName: 'English' },
{ code: 'zh-Hans', nativeName: '简体中文' },
{ code: 'zh-Hant', nativeName: '繁體中文' },
];
const THEMES = [
{ value: 'light' as const, icon: FiSun, label: 'theme.light' },
{ value: 'dark' as const, icon: FiMoon, label: 'theme.dark' },
{ value: 'system' as const, icon: FiMonitor, label: 'theme.system' }
];
export default function Login() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
const [formData, setFormData] = useState<LoginFormData>({
username: '',
password: '',
remember: false,
});
const [error, setError] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: formData.username,
password: formData.password,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || t('admin.login.error.failed'));
}
// 保存 token 和用户信息
localStorage.setItem('token', data.token);
if (formData.remember) {
localStorage.setItem('username', formData.username);
} else {
localStorage.removeItem('username');
}
// 跳转到管理面板
navigate('/admin');
} catch (err) {
setError(err instanceof Error ? err.message : t('admin.login.error.retry'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 relative overflow-hidden">
{/* Decorative Background */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -inset-[10px] bg-gradient-to-br from-indigo-50 via-slate-50 to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 blur-3xl opacity-80" />
<div className="absolute right-0 top-0 -mt-16 -mr-16 h-96 w-96 rounded-full bg-gradient-to-br from-indigo-100 to-indigo-50 dark:from-indigo-900/20 dark:to-indigo-900/10 blur-2xl" />
<div className="absolute left-0 bottom-0 -mb-16 -ml-16 h-96 w-96 rounded-full bg-gradient-to-tr from-indigo-100 to-indigo-50 dark:from-indigo-900/20 dark:to-indigo-900/10 blur-2xl" />
</div>
{/* Theme & Language Switcher */}
<div className="fixed top-4 right-4 flex items-center gap-2 z-50">
{/* Theme Switcher */}
<Menu as="div" className="relative">
<Menu.Button className="flex items-center justify-center w-10 h-10 rounded-lg text-slate-600 dark:text-slate-400 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-colors backdrop-blur-sm">
{THEMES.find(t => t.value === theme)?.icon({ className: 'w-5 h-5' })}
</Menu.Button>
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm rounded-lg shadow-lg ring-1 ring-black/5 focus:outline-none">
<div className="py-1">
{THEMES.map(({ value, icon: Icon, label }) => (
<Menu.Item key={value}>
{({ active }) => (
<button
className={`${
active ? 'bg-slate-100/80 dark:bg-slate-700/50' : ''
} ${
theme === value ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-700 dark:text-slate-300'
} group flex items-center w-full px-4 py-2.5 text-sm transition-colors`}
onClick={() => setTheme(value)}
>
<Icon className="mr-3 h-5 w-5" />
{t(label)}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Menu>
{/* Language Switcher */}
<Menu as="div" className="relative">
<Menu.Button className="flex items-center justify-center w-10 h-10 rounded-lg text-slate-600 dark:text-slate-400 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-colors backdrop-blur-sm">
<FiGlobe className="w-5 h-5" />
</Menu.Button>
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm rounded-lg shadow-lg ring-1 ring-black/5 focus:outline-none">
<div className="py-1">
{LANGUAGES.map(({ code, nativeName }) => (
<Menu.Item key={code}>
{({ active }) => (
<button
className={`${
active ? 'bg-slate-100/80 dark:bg-slate-700/50' : ''
} ${
i18n.language === code ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-700 dark:text-slate-300'
} group flex items-center w-full px-4 py-2.5 text-sm transition-colors`}
onClick={() => i18n.changeLanguage(code)}
>
{nativeName}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Menu>
</div>
<div className="relative w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white/70 dark:bg-slate-800/70 backdrop-blur-xl shadow-xl shadow-slate-200/20 dark:shadow-slate-900/30 rounded-2xl p-8">
<div className="text-center mb-8">
{/* Logo */}
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-indigo-600 shadow-lg shadow-indigo-500/30 dark:shadow-indigo-800/30 mb-4">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
{t('admin.login.title')}
</h2>
</div>
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-500/10 p-4 mb-6">
<div className="text-sm text-red-600 dark:text-red-400">{error}</div>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('admin.login.username')}
</label>
<div className="relative">
<input
id="username"
name="username"
type="text"
required
className="block w-full px-3 py-2 border-0 text-slate-900 dark:text-white bg-white dark:bg-slate-800 rounded-lg shadow-sm ring-1 ring-slate-200 dark:ring-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 sm:text-sm"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('admin.login.password')}
</label>
<div className="relative">
<input
id="password"
name="password"
type="password"
required
className="block w-full px-3 py-2 border-0 text-slate-900 dark:text-white bg-white dark:bg-slate-800 rounded-lg shadow-sm ring-1 ring-slate-200 dark:ring-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 sm:text-sm"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-6">
<div className="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-600 rounded transition-colors"
checked={formData.remember}
onChange={(e) =>
setFormData({ ...formData, remember: e.target.checked })
}
/>
<label
htmlFor="remember"
className="ml-2 block text-sm text-slate-700 dark:text-slate-300"
>
{t('admin.login.remember')}
</label>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-indigo-600 hover:from-indigo-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-slate-800 transition-all duration-150 ease-in-out transform hover:scale-[1.02] active:scale-[0.98]"
>
{isLoading ? t('admin.login.loading') : t('admin.login.submit')}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
interface Media {
id: string;
name: string;
type: string;
size: number;
uploadDate: string;
}
export default function MediasManagement() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const columns = [
{
title: t('admin.medias.name'),
key: 'name',
},
{
title: t('admin.medias.type'),
key: 'type',
},
{
title: t('admin.medias.size'),
key: 'size',
},
{
title: t('admin.medias.uploadDate'),
key: 'uploadDate',
},
];
const handleUpload = () => {
// TODO: 实现上传媒体文件的逻辑
};
return (
<div className="p-6">
<TableActions actionKey="upload" onClick={handleUpload} />
<Table
columns={columns}
data={[]}
loading={loading}
/>
</div>
);
}

View file

@ -1,14 +1,15 @@
import { FC, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
interface Post {
id: string;
title: string;
author: string;
status: 'draft' | 'published';
category: string;
publishDate: string;
status: 'draft' | 'published';
}
const PostsManagement: FC = () => {
@ -21,9 +22,9 @@ const PostsManagement: FC = () => {
{
id: '1',
title: '示例文章标题',
author: '张三',
status: 'published',
category: '示例分类',
publishDate: '2024-02-20',
status: 'published',
},
];
@ -37,34 +38,38 @@ const PostsManagement: FC = () => {
const columns = [
{
key: 'title',
title: t('admin.common.title'),
key: 'title' as keyof Post,
title: t('admin.posts.title'),
},
{
key: 'author',
title: t('admin.common.author'),
key: 'category' as keyof Post,
title: t('admin.posts.category'),
},
{
key: 'status',
title: t('admin.common.status'),
key: 'publishDate' as keyof Post,
title: t('admin.posts.publishDate'),
},
{
key: 'status' as keyof Post,
title: t('admin.posts.status'),
render: (value: Post['status']) => (
<span
className={`inline-block px-3 py-1 text-xs rounded-md ${
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
value === 'published'
? 'bg-slate-900 dark:bg-slate-700 text-white'
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}`}
>
{t(`admin.common.${value}`)}
</span>
),
},
{
key: 'publishDate',
title: t('admin.common.date'),
},
];
const handleCreate = () => {
// TODO: 实现创建文章的逻辑
};
return (
<div className="divide-y divide-slate-200 dark:divide-slate-700">
<div className="p-6">
@ -79,15 +84,12 @@ const PostsManagement: FC = () => {
/>
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div>
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
<TableActions onClick={handleCreate} />
</div>
</div>
<div>
<Table
<Table<Post>
columns={columns}
data={posts}
loading={loading}
@ -99,9 +101,9 @@ const PostsManagement: FC = () => {
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tl-md">{t('admin.common.title')}</th>
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.author')}</th>
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.category')}</th>
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.publishDate')}</th>
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.status')}</th>
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.date')}</th>
<th className="text-right py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
</tr>
</thead>
@ -109,17 +111,17 @@ const PostsManagement: FC = () => {
{data.map((post) => (
<tr key={post.id} className="border-b border-slate-200 dark:border-slate-700 last:border-0">
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.title}</td>
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.author}</td>
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.category}</td>
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
<td className="py-4 px-6">
<span className={`px-3 py-1 text-sm rounded-md ${
<span className={`px-2 py-1 text-sm font-medium rounded-full ${
post.status === 'published'
? 'bg-slate-900 dark:bg-slate-700 text-white'
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}`}>
{t(`admin.common.${post.status}`)}
</span>
</td>
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
<td className="py-4 px-6 text-right">
<button
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"

View file

@ -0,0 +1,224 @@
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react';
import { useUser } from '../../../contexts/UserContext';
interface UserSettings {
username: string;
display_name: string;
email: string;
}
interface PasswordSettings {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export default function Settings() {
const { t } = useTranslation();
const { user } = useUser();
const [loading, setLoading] = useState(false);
const [settings, setSettings] = useState<UserSettings>({
username: '',
display_name: '',
email: '',
});
const [passwords, setPasswords] = useState<PasswordSettings>({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
if (user) {
setSettings({
username: user.username,
display_name: user.display_name || '',
email: user.email,
});
}
}, [user]);
const validatePasswords = () => {
if (passwords.newPassword && passwords.newPassword.length < 8) {
setPasswordError(t('admin.settings.passwordTooShort'));
return false;
}
if (passwords.newPassword !== passwords.confirmPassword) {
setPasswordError(t('admin.settings.passwordMismatch'));
return false;
}
setPasswordError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (passwords.newPassword && !validatePasswords()) {
return;
}
setLoading(true);
try {
const response = await fetch('/api/v1/users/me', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
username: settings.username,
display_name: settings.display_name || undefined,
email: settings.email,
...(passwords.newPassword ? {
current_password: passwords.currentPassword,
new_password: passwords.newPassword,
} : {}),
}),
});
if (!response.ok) {
throw new Error('Failed to update settings');
}
// 清空密码字段
setPasswords({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
} catch (error) {
console.error('Failed to save settings:', error);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{t('admin.settings.username')}
</label>
<input
type="text"
id="username"
value={settings.username}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
minLength={3}
maxLength={32}
required
/>
</div>
<div>
<label
htmlFor="display_name"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{t('admin.settings.displayName')}
</label>
<input
type="text"
id="display_name"
value={settings.display_name}
onChange={(e) => setSettings({ ...settings, display_name: e.target.value })}
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
maxLength={64}
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{t('admin.settings.email')}
</label>
<input
type="email"
id="email"
value={settings.email}
onChange={(e) => setSettings({ ...settings, email: e.target.value })}
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
required
/>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-slate-900 dark:text-white">
{t('admin.settings.changePassword')}
</h3>
<div>
<label
htmlFor="current_password"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{t('admin.settings.currentPassword')}
</label>
<input
type="password"
id="current_password"
value={passwords.currentPassword}
onChange={(e) => setPasswords({ ...passwords, currentPassword: e.target.value })}
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
</div>
<div>
<label
htmlFor="new_password"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{t('admin.settings.newPassword')}
</label>
<input
type="password"
id="new_password"
value={passwords.newPassword}
onChange={(e) => setPasswords({ ...passwords, newPassword: e.target.value })}
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
minLength={8}
/>
</div>
<div>
<label
htmlFor="confirm_password"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{t('admin.settings.confirmPassword')}
</label>
<input
type="password"
id="confirm_password"
value={passwords.confirmPassword}
onChange={(e) => setPasswords({ ...passwords, confirmPassword: e.target.value })}
className="block w-full rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
</div>
{passwordError && (
<div className="text-sm text-red-600 dark:text-red-400">{passwordError}</div>
)}
</div>
<div className="pt-4">
<button
type="submit"
disabled={loading}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? t('admin.common.saving') : t('admin.common.save')}
</button>
</div>
</form>
</div>
);
}

View file

@ -1,54 +1,104 @@
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
import { useState, useEffect } from 'react';
import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
import { RiSearchLine } from 'react-icons/ri';
interface User {
id: string;
id: number;
username: string;
email: string;
role: 'admin' | 'user';
lastLogin: string;
role: 'admin' | 'editor' | 'contributor';
status: 'active' | 'inactive' | 'banned';
}
const UsersManagement: FC = () => {
export default function UsersManagement() {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation();
// 这里后续会通过 API 获取数据
const users: User[] = [
{
id: '1',
username: '张三',
email: 'zhangsan@example.com',
role: 'admin',
lastLogin: '2024-02-20',
},
];
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
console.log('Fetching users list...');
const response = await fetch('/api/v1/users', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch users list: ${response.status} ${response.statusText}`);
}
const { data } = await response.json();
console.log('Users list:', data);
setUsers(data || []);
} catch (err) {
console.error('Error fetching users list:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch users list');
setUsers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleEdit = (user: User) => {
console.log('Edit user:', user);
};
const handleDelete = (user: User) => {
const handleDelete = async (user: User) => {
console.log('Delete user:', user);
try {
const response = await fetch(`/api/v1/users/${user.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
await fetchUsers();
} catch (err) {
console.error('Error deleting user:', err);
}
};
const handleCreate = () => {
// TODO: 实现创建用户的逻辑
console.log('Create user clicked');
};
const columns = [
{
key: 'username',
title: t('admin.common.username'),
key: 'username' as keyof User,
title: t('admin.users.username'),
},
{
key: 'role',
title: t('admin.common.role'),
key: 'email' as keyof User,
title: t('admin.users.email'),
},
{
key: 'role' as keyof User,
title: t('admin.users.role'),
render: (value: User['role']) => (
<span
className={`inline-block px-3 py-1 text-xs rounded-md ${
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
value === 'admin'
? 'bg-slate-900 dark:bg-slate-700 text-white'
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'
: value === 'editor'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-300'
}`}
>
{t(`admin.roles.${value}`)}
@ -56,37 +106,49 @@ const UsersManagement: FC = () => {
),
},
{
key: 'joinDate',
title: t('admin.common.joinDate'),
},
{
key: 'lastLogin',
title: t('admin.common.lastLogin'),
key: 'status' as keyof User,
title: t('admin.users.status'),
render: (value: User['status']) => (
<span
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
value === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: value === 'inactive'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}
>
{t(`admin.common.${value}`)}
</span>
),
},
];
if (error) {
return (
<div className="p-6 text-red-600 dark:text-red-400">
Error: {error}
</div>
);
}
return (
<div className="divide-y divide-slate-200 dark:divide-slate-700">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="relative">
<input
type="text"
placeholder={t('admin.common.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 border-b-2 border-slate-200 dark:border-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-transparent outline-none w-64 text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
/>
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div>
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
<div className="p-6">
<div className="mb-6 flex justify-between items-center">
<div className="relative">
<input
type="text"
className="pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
placeholder={t('admin.common.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
</div>
<TableActions onClick={handleCreate} />
</div>
<Table
<Table<User>
columns={columns}
data={users}
loading={loading}
@ -95,6 +157,4 @@ const UsersManagement: FC = () => {
/>
</div>
);
};
export default UsersManagement;
}

View file

@ -1,13 +1,207 @@
import { createBrowserRouter } from 'react-router-dom';
import AdminLayout from './pages/admin/layout/AdminLayout';
import Dashboard from './pages/admin/Dashboard';
import { createBrowserRouter, Navigate, useLocation, Outlet } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import LoadingSpinner from './components/LoadingSpinner';
// 懒加载组件
const Home = lazy(() => import('./pages/Home'));
const Daily = lazy(() => import('./pages/Daily'));
const Article = lazy(() => import('./pages/Article'));
const AdminLayout = lazy(() => import('./pages/admin/layout/AdminLayout'));
const Login = lazy(() => import('./pages/admin/login'));
const Header = lazy(() => import('./components/layout/Header'));
const Footer = lazy(() => import('./components/Footer'));
// 管理页面组件
const Dashboard = lazy(() => import('./pages/admin/dashboard/Dashboard'));
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
const DailyManagement = lazy(() => import('./pages/admin/daily/DailyManagement'));
const MediasManagement = lazy(() => import('./pages/admin/medias/MediasManagement'));
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
const UsersManagement = lazy(() => import('./pages/admin/users/UsersManagement'));
const ContributorsManagement = lazy(() => import('./pages/admin/contributors/ContributorsManagement'));
const Settings = lazy(() => import('./pages/admin/settings/Settings'));
// 检查是否已登录
const checkAuth = () => {
const token = localStorage.getItem('token');
console.debug('[Auth] Token exists:', !!token);
return !!token;
};
// 受保护的路由组件
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const location = useLocation();
const isAuthenticated = checkAuth();
console.debug('[Router] Current location:', location.pathname);
console.debug('[Router] Is authenticated:', isAuthenticated);
if (!isAuthenticated) {
console.debug('[Router] Redirecting to login');
return <Navigate to="/admin/login" replace />;
}
return <>{children}</>;
};
// 登录路由组件
const LoginRoute = () => {
const isAuthenticated = checkAuth();
console.debug('[Login] Checking auth status');
if (isAuthenticated) {
console.debug('[Login] Already authenticated, redirecting to admin');
return <Navigate to="/admin" replace />;
}
return (
<Suspense fallback={<LoadingSpinner />}>
<Login />
</Suspense>
);
};
// 页面布局组件
const PageLayout = () => (
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
<Suspense fallback={<LoadingSpinner />}>
<Header />
<div className="w-[95%] mx-auto">
<div className="border-t-2 border-gray-900 dark:border-gray-100 w-full mb-2" />
</div>
<main className="flex-1 w-[95%] mx-auto py-8">
<Outlet />
</main>
<Footer />
</Suspense>
</div>
);
const router = createBrowserRouter([
{
path: '/admin',
element: <AdminLayout><Dashboard /></AdminLayout>,
path: '/',
element: (
<Suspense fallback={<LoadingSpinner />}>
<PageLayout />
</Suspense>
),
children: [
{
index: true,
element: (
<Suspense fallback={<LoadingSpinner />}>
<Home />
</Suspense>
),
},
{
path: 'daily',
element: (
<Suspense fallback={<LoadingSpinner />}>
<Daily />
</Suspense>
),
},
{
path: 'posts/:articleId',
element: (
<Suspense fallback={<LoadingSpinner />}>
<Article />
</Suspense>
),
},
],
},
{
path: '/admin',
children: [
{
path: 'login',
element: <LoginRoute />,
},
{
path: '',
element: (
<ProtectedRoute>
<Suspense fallback={<LoadingSpinner fullScreen />}>
<AdminLayout />
</Suspense>
</ProtectedRoute>
),
children: [
{
index: true,
element: (
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
),
},
{
path: 'posts',
element: (
<Suspense fallback={<LoadingSpinner />}>
<PostsManagement />
</Suspense>
),
},
{
path: 'daily',
element: (
<Suspense fallback={<LoadingSpinner />}>
<DailyManagement />
</Suspense>
),
},
{
path: 'medias',
element: (
<Suspense fallback={<LoadingSpinner />}>
<MediasManagement />
</Suspense>
),
},
{
path: 'categories',
element: (
<Suspense fallback={<LoadingSpinner />}>
<CategoriesManagement />
</Suspense>
),
},
{
path: 'users',
element: (
<Suspense fallback={<LoadingSpinner />}>
<UsersManagement />
</Suspense>
),
},
{
path: 'contributors',
element: (
<Suspense fallback={<LoadingSpinner />}>
<ContributorsManagement />
</Suspense>
),
},
{
path: 'settings',
element: (
<Suspense fallback={<LoadingSpinner />}>
<Settings />
</Suspense>
),
},
],
},
],
},
{
path: '*',
element: <Navigate to="/" />,
},
// Add more routes here as we develop them
]);
export default router;