[feature/frontend] admin panel (wip)
This commit is contained in:
parent
34ebb05808
commit
1526c27b49
18 changed files with 1130 additions and 22 deletions
|
@ -20,5 +20,42 @@
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "TSS Rocks. All rights reserved."
|
"copyright": "TSS Rocks. All rights reserved."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"common": {
|
||||||
|
"search": "Search",
|
||||||
|
"create": "Create",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"published": "Published",
|
||||||
|
"draft": "Draft",
|
||||||
|
"author": "Author",
|
||||||
|
"date": "Date",
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Description",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Role",
|
||||||
|
"bio": "Bio",
|
||||||
|
"articles": "Articles",
|
||||||
|
"lastLogin": "Last Login",
|
||||||
|
"joinDate": "Join Date",
|
||||||
|
"username": "Username",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"posts": "Posts",
|
||||||
|
"categories": "Categories",
|
||||||
|
"users": "Users",
|
||||||
|
"contributors": "Contributors"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "Administrator",
|
||||||
|
"user": "User",
|
||||||
|
"contributor": "Contributor"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,43 @@
|
||||||
"system": "跟随系统"
|
"system": "跟随系统"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "TSS.Rocks. 版权所有。"
|
"copyright": "TSS Rocks. 保留所有权利。"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"common": {
|
||||||
|
"search": "搜索",
|
||||||
|
"create": "新建",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"status": "状态",
|
||||||
|
"actions": "操作",
|
||||||
|
"published": "已发布",
|
||||||
|
"draft": "草稿",
|
||||||
|
"author": "作者",
|
||||||
|
"date": "日期",
|
||||||
|
"title": "标题",
|
||||||
|
"description": "描述",
|
||||||
|
"name": "名称",
|
||||||
|
"email": "邮箱",
|
||||||
|
"role": "角色",
|
||||||
|
"bio": "简介",
|
||||||
|
"articles": "文章数",
|
||||||
|
"lastLogin": "最后登录",
|
||||||
|
"joinDate": "加入时间",
|
||||||
|
"username": "用户名",
|
||||||
|
"logout": "退出"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "仪表盘",
|
||||||
|
"posts": "文章",
|
||||||
|
"categories": "分类",
|
||||||
|
"users": "用户",
|
||||||
|
"contributors": "作者"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "管理员",
|
||||||
|
"user": "普通用户",
|
||||||
|
"contributor": "作者"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,43 @@
|
||||||
"system": "跟隨系統"
|
"system": "跟隨系統"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "TSS.Rocks. 版權所有。"
|
"copyright": "TSS Rocks. 保留所有權利。"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"common": {
|
||||||
|
"search": "搜尋",
|
||||||
|
"create": "新建",
|
||||||
|
"edit": "編輯",
|
||||||
|
"delete": "刪除",
|
||||||
|
"status": "狀態",
|
||||||
|
"actions": "操作",
|
||||||
|
"published": "已發布",
|
||||||
|
"draft": "草稿",
|
||||||
|
"author": "作者",
|
||||||
|
"date": "日期",
|
||||||
|
"title": "標題",
|
||||||
|
"description": "描述",
|
||||||
|
"name": "名稱",
|
||||||
|
"email": "郵箱",
|
||||||
|
"role": "角色",
|
||||||
|
"bio": "簡介",
|
||||||
|
"articles": "文章數",
|
||||||
|
"lastLogin": "最後登入",
|
||||||
|
"joinDate": "加入時間",
|
||||||
|
"username": "用戶名",
|
||||||
|
"logout": "退出"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "儀表板",
|
||||||
|
"posts": "文章",
|
||||||
|
"categories": "分類",
|
||||||
|
"users": "用戶",
|
||||||
|
"contributors": "作者"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "管理員",
|
||||||
|
"user": "普通用戶",
|
||||||
|
"contributor": "作者"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,11 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tss-rocks/api": "workspace:*",
|
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
|
"@tss-rocks/api": "workspace:*",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
@ -27,6 +28,7 @@
|
||||||
"@tailwindcss/postcss": "^4.0.3",
|
"@tailwindcss/postcss": "^4.0.3",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.0.3",
|
"@tailwindcss/vite": "^4.0.3",
|
||||||
|
"@types/node": "^22.13.4",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|
|
@ -3,35 +3,61 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { Header } from './components/layout/Header';
|
import { Header } from './components/layout/Header';
|
||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
|
||||||
// Lazy load pages
|
// Lazy load pages
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const Daily = lazy(() => import('./pages/Daily'));
|
const Daily = lazy(() => import('./pages/Daily'));
|
||||||
const Article = lazy(() => import('./pages/Article'));
|
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'));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
|
<BrowserRouter>
|
||||||
<Header />
|
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
|
||||||
{/* 页眉分隔线 */}
|
|
||||||
<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">
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
{/* Admin routes */}
|
||||||
<Route path="/daily" element={<Daily />} />
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route path="/posts/:articleId" element={<Article />} />
|
<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>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
<Footer />
|
</AuthProvider>
|
||||||
</div>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
132
frontend/src/components/admin/Table.tsx
Normal file
132
frontend/src/components/admin/Table.tsx
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface Column<T> {
|
||||||
|
key: keyof T;
|
||||||
|
title: string;
|
||||||
|
render?: (value: T[keyof T], item: T) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
data: T[];
|
||||||
|
loading?: boolean;
|
||||||
|
onEdit?: (item: T) => void;
|
||||||
|
onDelete?: (item: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Table = <T extends Record<string, any>>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
loading = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: TableProps<T>) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Loading header */}
|
||||||
|
<div className="flex border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||||
|
{columns.map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 h-6 bg-slate-200 dark:bg-slate-700 rounded animate-pulse"
|
||||||
|
style={{ marginRight: index !== columns.length - 1 ? '2rem' : 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Loading rows */}
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
{[1, 2, 3].map((row) => (
|
||||||
|
<div key={row} className="flex items-center">
|
||||||
|
{columns.map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1"
|
||||||
|
style={{ marginRight: index !== columns.length - 1 ? '2rem' : 0 }}
|
||||||
|
>
|
||||||
|
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse"
|
||||||
|
style={{ width: index === columns.length - 1 ? '30%' : '60%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={String(column.key)}
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-4 text-left text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{column.title}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<th scope="col" className="relative px-6 py-4">
|
||||||
|
<span className="sr-only">{t('admin.common.actions')}</span>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
{data.map((item, itemIdx) => (
|
||||||
|
<tr
|
||||||
|
key={itemIdx}
|
||||||
|
className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td
|
||||||
|
key={String(column.key)}
|
||||||
|
className="px-6 py-4 text-sm text-slate-800 dark:text-white"
|
||||||
|
>
|
||||||
|
{column.render
|
||||||
|
? column.render(item[column.key], item)
|
||||||
|
: String(item[column.key])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<td className="px-6 py-4 text-right text-sm space-x-2">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
className="text-slate-600 dark:text-white hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{t('admin.common.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{t('admin.common.delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{data.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-slate-500 dark:text-white">{t('admin.common.noData')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Table;
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { FiSun, FiMoon, FiSearch, FiGlobe, FiMonitor } from 'react-icons/fi';
|
import { FiSun, FiMoon, FiSearch, FiGlobe, FiMonitor } from 'react-icons/fi';
|
||||||
import { Menu } from '@headlessui/react';
|
import { Menu } from '@headlessui/react';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import type { Theme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', nativeName: 'English' },
|
{ code: 'en', nativeName: 'English' },
|
||||||
|
|
82
frontend/src/contexts/AuthContext.tsx
Normal file
82
frontend/src/contexts/AuthContext.tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { AuthContextType, AuthState, User } from '../types/auth';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
|
const [state, setState] = useState<AuthState>(initialState);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('登录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
user: data.user,
|
||||||
|
token: data.token,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
} catch (error) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : '登录失败',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setState(initialState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
...state,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthContext;
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
|
|
@ -1,11 +1,27 @@
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
import en from '../data/i18n/en.json';
|
import en from '../data/i18n/en.json';
|
||||||
import zhHans from '../data/i18n/zh-Hans.json';
|
import zhHans from '../data/i18n/zh-Hans.json';
|
||||||
import zhHant from '../data/i18n/zh-Hant.json';
|
import zhHant from '../data/i18n/zh-Hant.json';
|
||||||
|
|
||||||
|
// 获取浏览器默认语言
|
||||||
|
const getBrowserLanguage = () => {
|
||||||
|
const lang = navigator.language;
|
||||||
|
if (lang.startsWith('zh')) {
|
||||||
|
return lang === 'zh-TW' || lang === 'zh-HK' ? 'zh-Hant' : 'zh-Hans';
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取存储的语言设置
|
||||||
|
const getStoredLanguage = () => {
|
||||||
|
return localStorage.getItem('language') || getBrowserLanguage();
|
||||||
|
};
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources: {
|
resources: {
|
||||||
|
@ -13,11 +29,21 @@ i18n
|
||||||
'zh-Hans': { translation: zhHans },
|
'zh-Hans': { translation: zhHans },
|
||||||
'zh-Hant': { translation: zhHant },
|
'zh-Hant': { translation: zhHant },
|
||||||
},
|
},
|
||||||
lng: 'zh-Hans',
|
lng: getStoredLanguage(),
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
lookupLocalStorage: 'language',
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听语言变化并保存到 localStorage
|
||||||
|
i18n.on('languageChanged', (lng) => {
|
||||||
|
localStorage.setItem('language', lng);
|
||||||
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|
129
frontend/src/pages/admin/categories/CategoriesManagement.tsx
Normal file
129
frontend/src/pages/admin/categories/CategoriesManagement.tsx
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
|
import Table from '../../../components/admin/Table';
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoriesManagement: FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 这里后续会通过 API 获取数据
|
||||||
|
const categories: Category[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '新闻',
|
||||||
|
description: '新闻分类',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleEdit = (category: Category) => {
|
||||||
|
console.log('Edit category:', category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (category: Category) => {
|
||||||
|
console.log('Delete category:', category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: t('admin.common.name'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
title: t('admin.common.description'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-stone-200 dark:divide-stone-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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={categories}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
>
|
||||||
|
{({ data, onEdit, onDelete }) => (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-stone-200 dark:border-stone-700">
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
className={`text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium ${
|
||||||
|
index === 0 ? 'rounded-tl-md' : ''
|
||||||
|
} ${index === columns.length - 1 ? 'rounded-tr-md' : ''}`}
|
||||||
|
>
|
||||||
|
{column.title}
|
||||||
|
</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>
|
||||||
|
<tbody>
|
||||||
|
{data.map((category) => (
|
||||||
|
<tr key={category.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className={`py-4 px-6 ${
|
||||||
|
column.key === 'description' ? 'text-left' : 'text-left'
|
||||||
|
} text-stone-800 dark:text-stone-200`}
|
||||||
|
>
|
||||||
|
{category[column.key]}
|
||||||
|
</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"
|
||||||
|
onClick={() => onEdit(category)}
|
||||||
|
>
|
||||||
|
{t('admin.common.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||||
|
onClick={() => onDelete(category)}
|
||||||
|
>
|
||||||
|
{t('admin.common.delete')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoriesManagement;
|
138
frontend/src/pages/admin/contributors/ContributorsManagement.tsx
Normal file
138
frontend/src/pages/admin/contributors/ContributorsManagement.tsx
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
|
import Table from '../../../components/admin/Table';
|
||||||
|
|
||||||
|
interface Contributor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bio: string;
|
||||||
|
articles: number;
|
||||||
|
joinDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContributorsManagement: FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 这里后续会通过 API 获取数据
|
||||||
|
const contributors: Contributor[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '李四',
|
||||||
|
bio: '这是李四的简介',
|
||||||
|
articles: 5,
|
||||||
|
joinDate: '2024-02-20',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleEdit = (contributor: Contributor) => {
|
||||||
|
console.log('Edit contributor:', contributor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (contributor: Contributor) => {
|
||||||
|
console.log('Delete contributor:', contributor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: t('admin.common.name'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bio',
|
||||||
|
title: t('admin.common.bio'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'joinDate',
|
||||||
|
title: t('admin.common.joinDate'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-stone-200 dark:divide-stone-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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={contributors}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
>
|
||||||
|
{({ data, onEdit, onDelete }) => (
|
||||||
|
<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-right py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.joinDate}</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"
|
||||||
|
onClick={() => onEdit(contributor)}
|
||||||
|
>
|
||||||
|
{t('admin.common.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||||
|
onClick={() => onDelete(contributor)}
|
||||||
|
>
|
||||||
|
{t('admin.common.delete')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContributorsManagement;
|
168
frontend/src/pages/admin/layout/AdminLayout.tsx
Normal file
168
frontend/src/pages/admin/layout/AdminLayout.tsx
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
RiFileTextLine,
|
||||||
|
RiFolderLine,
|
||||||
|
RiUserLine,
|
||||||
|
RiTeamLine,
|
||||||
|
RiLogoutBoxRLine,
|
||||||
|
RiSunLine,
|
||||||
|
RiMoonLine,
|
||||||
|
RiComputerLine,
|
||||||
|
RiGlobalLine
|
||||||
|
} from 'react-icons/ri';
|
||||||
|
import { useTheme } from '../../../hooks/useTheme';
|
||||||
|
import type { Theme } from '../../../hooks/useTheme';
|
||||||
|
|
||||||
|
interface AdminLayoutProps {}
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/admin/posts', icon: RiFileTextLine, label: 'admin.nav.posts' },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 AdminLayout: FC<AdminLayoutProps> = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 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={() => {/* TODO: Implement logout */}}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{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">A</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>
|
||||||
|
</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 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminLayout;
|
148
frontend/src/pages/admin/posts/PostsManagement.tsx
Normal file
148
frontend/src/pages/admin/posts/PostsManagement.tsx
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Table from '../../../components/admin/Table';
|
||||||
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
publishDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostsManagement: FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 这里后续会通过 API 获取数据
|
||||||
|
const posts: Post[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: '示例文章标题',
|
||||||
|
author: '张三',
|
||||||
|
status: 'published',
|
||||||
|
publishDate: '2024-02-20',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleEdit = (post: Post) => {
|
||||||
|
console.log('Edit post:', post);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (post: Post) => {
|
||||||
|
console.log('Delete post:', post);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
title: t('admin.common.title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'author',
|
||||||
|
title: t('admin.common.author'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: t('admin.common.status'),
|
||||||
|
render: (value: Post['status']) => (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-3 py-1 text-xs rounded-md ${
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.common.${value}`)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publishDate',
|
||||||
|
title: t('admin.common.date'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={posts}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
>
|
||||||
|
{({ data, onEdit, onDelete }) => (
|
||||||
|
<table className="w-full">
|
||||||
|
<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.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>
|
||||||
|
<tbody>
|
||||||
|
{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">
|
||||||
|
<span className={`px-3 py-1 text-sm rounded-md ${
|
||||||
|
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'
|
||||||
|
}`}>
|
||||||
|
{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"
|
||||||
|
onClick={() => onEdit(post)}
|
||||||
|
>
|
||||||
|
{t('admin.common.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||||
|
onClick={() => onDelete(post)}
|
||||||
|
>
|
||||||
|
{t('admin.common.delete')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostsManagement;
|
100
frontend/src/pages/admin/users/UsersManagement.tsx
Normal file
100
frontend/src/pages/admin/users/UsersManagement.tsx
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||||
|
import Table from '../../../components/admin/Table';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
lastLogin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsersManagement: FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 这里后续会通过 API 获取数据
|
||||||
|
const users: User[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
username: '张三',
|
||||||
|
email: 'zhangsan@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
lastLogin: '2024-02-20',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleEdit = (user: User) => {
|
||||||
|
console.log('Edit user:', user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (user: User) => {
|
||||||
|
console.log('Delete user:', user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'username',
|
||||||
|
title: t('admin.common.username'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
title: t('admin.common.role'),
|
||||||
|
render: (value: User['role']) => (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-3 py-1 text-xs rounded-md ${
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.roles.${value}`)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'joinDate',
|
||||||
|
title: t('admin.common.joinDate'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastLogin',
|
||||||
|
title: t('admin.common.lastLogin'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={users}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersManagement;
|
13
frontend/src/router.tsx
Normal file
13
frontend/src/router.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { createBrowserRouter } from 'react-router-dom';
|
||||||
|
import AdminLayout from './pages/admin/layout/AdminLayout';
|
||||||
|
import Dashboard from './pages/admin/Dashboard';
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
element: <AdminLayout><Dashboard /></AdminLayout>,
|
||||||
|
},
|
||||||
|
// Add more routes here as we develop them
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default router;
|
19
frontend/src/types/auth.ts
Normal file
19
frontend/src/types/auth.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'contributor' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType extends AuthState {
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
|
@ -28,6 +28,9 @@ importers:
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^24.2.2
|
specifier: ^24.2.2
|
||||||
version: 24.2.2(typescript@5.7.3)
|
version: 24.2.2(typescript@5.7.3)
|
||||||
|
i18next-browser-languagedetector:
|
||||||
|
specifier: ^8.0.4
|
||||||
|
version: 8.0.4
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.474.0
|
specifier: ^0.474.0
|
||||||
version: 0.474.0(react@19.0.0)
|
version: 0.474.0(react@19.0.0)
|
||||||
|
@ -62,6 +65,9 @@ importers:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.0.3
|
specifier: ^4.0.3
|
||||||
version: 4.0.7(vite@6.1.1(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1))
|
version: 4.0.7(vite@6.1.1(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1))
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.13.4
|
||||||
|
version: 22.13.4
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.8
|
specifier: ^19.0.8
|
||||||
version: 19.0.10
|
version: 19.0.10
|
||||||
|
@ -1358,6 +1364,9 @@ packages:
|
||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
i18next-browser-languagedetector@8.0.4:
|
||||||
|
resolution: {integrity: sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==}
|
||||||
|
|
||||||
i18next@24.2.2:
|
i18next@24.2.2:
|
||||||
resolution: {integrity: sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==}
|
resolution: {integrity: sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -3466,6 +3475,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
i18next-browser-languagedetector@8.0.4:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.26.9
|
||||||
|
|
||||||
i18next@24.2.2(typescript@5.7.3):
|
i18next@24.2.2(typescript@5.7.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.9
|
'@babel/runtime': 7.26.9
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue