[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

@ -14,19 +14,23 @@
"search": "Search" "search": "Search"
}, },
"theme": { "theme": {
"light": "Light Mode", "light": "Light",
"dark": "Dark Mode", "dark": "Dark",
"system": "System Mode" "system": "System"
}, },
"footer": { "footer": {
"copyright": "TSS Rocks. All rights reserved." "copyright": "TSS Rocks. All rights reserved."
}, },
"admin": { "admin": {
"common": { "common": {
"loading": "Loading...",
"search": "Search", "search": "Search",
"create": "Create", "create": "Create",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"upload": "Upload",
"save": "Save",
"saving": "Saving...",
"status": "Status", "status": "Status",
"actions": "Actions", "actions": "Actions",
"published": "Published", "published": "Published",
@ -43,19 +47,68 @@
"lastLogin": "Last Login", "lastLogin": "Last Login",
"joinDate": "Join Date", "joinDate": "Join Date",
"username": "Username", "username": "Username",
"logout": "Logout" "logout": "Logout",
"language": "Language",
"theme": {
"light": "Light Mode",
"dark": "Dark Mode",
"system": "System"
}
},
"dashboard": {
"totalPosts": "Total Posts",
"totalCategories": "Total Categories",
"totalUsers": "Total Users",
"totalContributors": "Total Contributors"
},
"login": {
"title": "Admin Login",
"username": "Username",
"password": "Password",
"remember": "Remember me",
"submit": "Sign in",
"loading": "Signing in...",
"error": {
"failed": "Login failed",
"retry": "Login failed, please try again later"
}
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"posts": "Posts", "posts": "Posts",
"daily": "Daily Quotes",
"medias": "Media",
"categories": "Categories", "categories": "Categories",
"users": "Users", "users": "Users",
"contributors": "Contributors" "contributors": "Contributors",
"settings": "Settings"
},
"daily": {
"title": "Title",
"publishDate": "Publish Date",
"status": "Status"
},
"medias": {
"name": "File Name",
"type": "Type",
"size": "Size",
"uploadDate": "Upload Date"
}, },
"roles": { "roles": {
"admin": "Administrator", "admin": "Administrator",
"user": "User", "user": "User",
"contributor": "Contributor" "contributor": "Contributor"
},
"settings": {
"username": "Username",
"displayName": "Display Name",
"email": "Email",
"changePassword": "Change Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long"
} }
} }
} }

View file

@ -14,8 +14,8 @@
"search": "搜索" "search": "搜索"
}, },
"theme": { "theme": {
"light": "浅色模式", "light": "浅色",
"dark": "深色模式", "dark": "深色",
"system": "跟随系统" "system": "跟随系统"
}, },
"footer": { "footer": {
@ -23,10 +23,12 @@
}, },
"admin": { "admin": {
"common": { "common": {
"loading": "正在加载...",
"search": "搜索", "search": "搜索",
"create": "新建", "create": "新建",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"upload": "上传",
"status": "状态", "status": "状态",
"actions": "操作", "actions": "操作",
"published": "已发布", "published": "已发布",
@ -43,19 +45,64 @@
"lastLogin": "最后登录", "lastLogin": "最后登录",
"joinDate": "加入时间", "joinDate": "加入时间",
"username": "用户名", "username": "用户名",
"logout": "退出" "logout": "退出登录",
"save": "保存",
"saving": "保存中..."
},
"dashboard": {
"totalPosts": "文章总数",
"totalCategories": "分类总数",
"totalUsers": "用户总数",
"totalContributors": "贡献者总数"
},
"login": {
"title": "管理员登录",
"username": "用户名",
"password": "密码",
"remember": "记住我",
"submit": "登录",
"loading": "登录中...",
"error": {
"failed": "登录失败",
"retry": "登录失败,请稍后重试"
}
}, },
"nav": { "nav": {
"dashboard": "仪表盘", "dashboard": "仪表板",
"posts": "文章", "posts": "文章管理",
"categories": "分类", "daily": "每日一句",
"users": "用户", "medias": "媒体管理",
"contributors": "作者" "categories": "分类管理",
"users": "用户管理",
"contributors": "贡献者管理",
"settings": "个人设置"
},
"daily": {
"title": "标题",
"publishDate": "发布日期",
"status": "状态"
},
"medias": {
"name": "文件名",
"type": "类型",
"size": "大小",
"uploadDate": "上传日期"
}, },
"roles": { "roles": {
"admin": "管理员", "admin": "管理员",
"user": "普通用户", "user": "普通用户",
"contributor": "作者" "contributor": "作者"
},
"settings": {
"username": "用户名",
"displayName": "显示名称",
"email": "邮箱",
"changePassword": "修改密码",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"passwordMismatch": "两次输入的密码不一致",
"passwordTooShort": "密码长度不能少于8个字符"
} }
} }
} }

View file

@ -14,8 +14,8 @@
"search": "搜尋" "search": "搜尋"
}, },
"theme": { "theme": {
"light": "淺色模式", "light": "淺色",
"dark": "深色模式", "dark": "深色",
"system": "跟隨系統" "system": "跟隨系統"
}, },
"footer": { "footer": {
@ -23,10 +23,12 @@
}, },
"admin": { "admin": {
"common": { "common": {
"loading": "正在載入...",
"search": "搜尋", "search": "搜尋",
"create": "新建", "create": "新建",
"edit": "編輯", "edit": "編輯",
"delete": "刪除", "delete": "刪除",
"upload": "上傳",
"status": "狀態", "status": "狀態",
"actions": "操作", "actions": "操作",
"published": "已發布", "published": "已發布",
@ -43,19 +45,70 @@
"lastLogin": "最後登入", "lastLogin": "最後登入",
"joinDate": "加入時間", "joinDate": "加入時間",
"username": "用戶名", "username": "用戶名",
"logout": "退出" "logout": "退出登錄",
"language": "語言",
"theme": {
"light": "淺色模式",
"dark": "深色模式",
"system": "跟隨系統"
},
"save": "保存",
"saving": "保存中..."
}, },
"nav": { "nav": {
"dashboard": "儀表板", "dashboard": "儀表板",
"posts": "文章", "posts": "文章管理",
"categories": "分類", "daily": "每日一句",
"users": "用戶", "medias": "媒體管理",
"contributors": "作者" "categories": "分類管理",
"users": "用戶管理",
"contributors": "貢獻者管理",
"settings": "個人設置"
},
"dashboard": {
"totalPosts": "文章總數",
"totalCategories": "分類總數",
"totalUsers": "用戶總數",
"totalContributors": "貢獻者總數"
},
"daily": {
"title": "標題",
"publishDate": "發布日期",
"status": "狀態"
},
"medias": {
"name": "檔案名",
"type": "類型",
"size": "大小",
"uploadDate": "上傳日期"
},
"login": {
"title": "管理員登錄",
"username": "用戶名",
"password": "密碼",
"remember": "記住我",
"submit": "登錄",
"loading": "登錄中...",
"error": {
"failed": "登錄失敗",
"retry": "登錄失敗,請稍後重試"
}
}, },
"roles": { "roles": {
"admin": "管理員", "admin": "管理員",
"user": "普通用戶", "user": "普通用戶",
"contributor": "作者" "contributor": "作者"
},
"settings": {
"username": "用戶名",
"displayName": "顯示名稱",
"email": "郵箱",
"changePassword": "修改密碼",
"currentPassword": "當前密碼",
"newPassword": "新密碼",
"confirmPassword": "確認密碼",
"passwordMismatch": "兩次輸入的密碼不一致",
"passwordTooShort": "密碼長度不能少於8個字符"
} }
} }
} }

View file

@ -1,63 +1,19 @@
import React from 'react'; import { RouterProvider } from 'react-router-dom';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Suspense } from 'react';
import { Header } from './components/layout/Header';
import { Suspense, lazy } from 'react';
import Footer from './components/Footer';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { UserProvider } from './contexts/UserContext';
// Lazy load pages import router from './router';
const Home = lazy(() => import('./pages/Home')); import LoadingSpinner from './components/LoadingSpinner';
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'));
function App() { function App() {
return ( return (
<Suspense fallback={<LoadingSpinner fullScreen />}>
<AuthProvider> <AuthProvider>
<BrowserRouter> <UserProvider>
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100"> <RouterProvider router={router} />
<Suspense fallback={<div>Loading...</div>}> </UserProvider>
<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> </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: '繁體中文' }, { code: 'zh-Hant', nativeName: '繁體中文' },
]; ];
export function Header() { function Header() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
@ -238,3 +238,5 @@ export function Header() {
</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 { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import LoadingSpinner from '../components/LoadingSpinner';
export default function Article() { export default function Article() {
const { articleId } = useParams(); const { articleId } = useParams();
@ -9,13 +10,19 @@ export default function Article() {
content: string; content: string;
metadata: any; metadata: any;
} | null>(null); } | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
// In a real application, we would fetch the article content here // In a real application, we would fetch the article content here
// based on the articleId and current language // based on the articleId and current language
console.log(`Fetching article ${articleId} in ${i18n.language}`); console.log(`Fetching article ${articleId} in ${i18n.language}`);
setLoading(false);
}, [articleId, i18n.language]); }, [articleId, i18n.language]);
if (loading) {
return <LoadingSpinner />;
}
if (!article) { if (!article) {
return <div>Loading...</div>; 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 { useTranslation } from 'react-i18next';
import { RiAddLine, RiSearchLine } from 'react-icons/ri'; import { RiAddLine, RiSearchLine } from 'react-icons/ri';
import Table from '../../../components/admin/Table'; import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
interface Category { interface Category {
id: string; id: string;
name: string; name: string;
description: string; slug: string;
postCount: number;
} }
const CategoriesManagement: FC = () => { const CategoriesManagement: FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false); const [loading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
// 这里后续会通过 API 获取数据 // 这里后续会通过 API 获取数据
@ -19,7 +21,8 @@ const CategoriesManagement: FC = () => {
{ {
id: '1', id: '1',
name: '新闻', name: '新闻',
description: '新闻分类', slug: 'news',
postCount: 10,
}, },
]; ];
@ -31,14 +34,22 @@ const CategoriesManagement: FC = () => {
console.log('Delete category:', category); console.log('Delete category:', category);
}; };
const handleCreate = () => {
// TODO: 实现创建分类的逻辑
};
const columns = [ const columns = [
{ {
key: 'name', key: 'name' as keyof Category,
title: t('admin.common.name'), title: t('admin.common.name'),
}, },
{ {
key: 'description', key: 'slug' as keyof Category,
title: t('admin.common.description'), 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" /> <RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div> </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"> <TableActions onClick={handleCreate} />
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
</div> </div>
</div> </div>
<div> <div>
<Table <Table<Category>
columns={columns} columns={columns}
data={categories} data={categories}
loading={loading} loading={loading}

View file

@ -2,13 +2,15 @@ import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RiAddLine, RiSearchLine } from 'react-icons/ri'; import { RiAddLine, RiSearchLine } from 'react-icons/ri';
import Table from '../../../components/admin/Table'; import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
interface Contributor { interface Contributor {
id: string; id: string;
name: string; name: string;
bio: string; email: string;
articles: number; role: 'editor' | 'contributor';
joinDate: string; status: 'active' | 'inactive' | 'banned';
postCount: number;
} }
const ContributorsManagement: FC = () => { const ContributorsManagement: FC = () => {
@ -21,9 +23,10 @@ const ContributorsManagement: FC = () => {
{ {
id: '1', id: '1',
name: '李四', name: '李四',
bio: '这是李四的简介', email: 'lisi@example.com',
articles: 5, role: 'contributor',
joinDate: '2024-02-20', status: 'active',
postCount: 5,
}, },
]; ];
@ -37,28 +40,55 @@ const ContributorsManagement: FC = () => {
const columns = [ const columns = [
{ {
key: 'name', key: 'name' as keyof Contributor,
title: t('admin.common.name'), title: t('admin.contributors.name'),
}, },
{ {
key: 'bio', key: 'email' as keyof Contributor,
title: t('admin.common.bio'), title: t('admin.contributors.email'),
}, },
{ {
key: 'articles', key: 'role' as keyof Contributor,
title: t('admin.common.articles'), title: t('admin.contributors.role'),
render: (value: number) => ( render: (value: Contributor['role']) => (
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm"> <span
{value} 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> </span>
), ),
}, },
{ {
key: 'joinDate', key: 'status' as keyof Contributor,
title: t('admin.common.joinDate'), 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 ( return (
<div className="divide-y divide-stone-200 dark:divide-stone-700"> <div className="divide-y divide-stone-200 dark:divide-stone-700">
<div className="p-6"> <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" /> <RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div> </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"> <TableActions onClick={handleCreate} />
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
</div> </div>
</div> </div>
<div> <div>
<Table <Table<Contributor>
columns={columns} columns={columns}
data={contributors} data={contributors}
loading={loading} loading={loading}
@ -92,10 +119,11 @@ const ContributorsManagement: FC = () => {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-stone-200 dark:border-stone-700"> <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 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.common.bio')}</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.common.articles')}</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.common.joinDate')}</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> <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> </tr>
</thead> </thead>
@ -103,13 +131,32 @@ const ContributorsManagement: FC = () => {
{data.map((contributor) => ( {data.map((contributor) => (
<tr key={contributor.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0"> <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.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"> <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"> <span
{contributor.articles} 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> </span>
</td> </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"> <td className="py-4 px-6 text-right">
<button <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" 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 { FC, useState, useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
RiFileTextLine, RiFileTextLine,
@ -10,18 +10,26 @@ import {
RiSunLine, RiSunLine,
RiMoonLine, RiMoonLine,
RiComputerLine, RiComputerLine,
RiGlobalLine RiGlobalLine,
RiCalendarLine,
RiImageLine,
RiSettings3Line,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { useTheme } from '../../../hooks/useTheme'; 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 {} interface AdminLayoutProps {}
const menuItems = [ const menuItems = [
{ path: '/admin/posts', icon: RiFileTextLine, label: 'admin.nav.posts' }, { 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/categories', icon: RiFolderLine, label: 'admin.nav.categories' },
{ path: '/admin/users', icon: RiUserLine, label: 'admin.nav.users' }, { path: '/admin/users', icon: RiUserLine, label: 'admin.nav.users' },
{ path: '/admin/contributors', icon: RiTeamLine, label: 'admin.nav.contributors' }, { path: '/admin/contributors', icon: RiTeamLine, label: 'admin.nav.contributors' },
{ path: '/admin/settings', icon: RiSettings3Line, label: 'admin.nav.settings' },
]; ];
const themeOptions = [ const themeOptions = [
@ -49,16 +57,29 @@ const languageMap: LanguageMap = {
}; };
const AdminLayout: FC<AdminLayoutProps> = () => { const AdminLayout: FC<AdminLayoutProps> = () => {
const location = useLocation();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { theme, setTheme } = useTheme(); 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 ( return (
<div className="min-h-screen bg-slate-100 dark:bg-slate-900 py-6 flex"> <div className="min-h-screen bg-slate-100 dark:bg-slate-900 py-6 flex">
{/* Background Overlay */} {/* 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="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 */} {/* Sidebar */}
<aside className="w-64 bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg rounded-lg shadow-lg flex flex-col"> <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"> <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>
<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" 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" /> <RiLogoutBoxRLine className="text-xl flex-shrink-0" />
<span className="text-sm">{t('admin.common.logout')}</span> <span className="text-sm">{t('admin.common.logout')}</span>
@ -145,18 +166,21 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
</div> </div>
<div className="flex items-center gap-3"> <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"> <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> <div>
<div className="text-slate-800 dark:text-white"></div> <div className="text-slate-800 dark:text-white">
<div className="text-sm text-slate-500 dark:text-slate-400">Administrator</div> {loading ? 'Loading...' : user?.display_name || user?.username || 'Guest'}
</div>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
<div className="flex-1 p-6"> <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"> <div className="h-full bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200/60 dark:border-slate-700/60">
<Suspense fallback={<LoadingSpinner />}>
<Outlet /> <Outlet />
</Suspense>
</div> </div>
</div> </div>
</main> </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 { useTranslation } from 'react-i18next';
import Table from '../../../components/admin/Table'; import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
import { RiAddLine, RiSearchLine } from 'react-icons/ri'; import { RiAddLine, RiSearchLine } from 'react-icons/ri';
interface Post { interface Post {
id: string; id: string;
title: string; title: string;
author: string; category: string;
status: 'draft' | 'published';
publishDate: string; publishDate: string;
status: 'draft' | 'published';
} }
const PostsManagement: FC = () => { const PostsManagement: FC = () => {
@ -21,9 +22,9 @@ const PostsManagement: FC = () => {
{ {
id: '1', id: '1',
title: '示例文章标题', title: '示例文章标题',
author: '张三', category: '示例分类',
status: 'published',
publishDate: '2024-02-20', publishDate: '2024-02-20',
status: 'published',
}, },
]; ];
@ -37,34 +38,38 @@ const PostsManagement: FC = () => {
const columns = [ const columns = [
{ {
key: 'title', key: 'title' as keyof Post,
title: t('admin.common.title'), title: t('admin.posts.title'),
}, },
{ {
key: 'author', key: 'category' as keyof Post,
title: t('admin.common.author'), title: t('admin.posts.category'),
}, },
{ {
key: 'status', key: 'publishDate' as keyof Post,
title: t('admin.common.status'), title: t('admin.posts.publishDate'),
},
{
key: 'status' as keyof Post,
title: t('admin.posts.status'),
render: (value: Post['status']) => ( render: (value: Post['status']) => (
<span <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' value === 'published'
? 'bg-slate-900 dark:bg-slate-700 text-white' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}`} }`}
> >
{t(`admin.common.${value}`)} {t(`admin.common.${value}`)}
</span> </span>
), ),
}, },
{
key: 'publishDate',
title: t('admin.common.date'),
},
]; ];
const handleCreate = () => {
// TODO: 实现创建文章的逻辑
};
return ( return (
<div className="divide-y divide-slate-200 dark:divide-slate-700"> <div className="divide-y divide-slate-200 dark:divide-slate-700">
<div className="p-6"> <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" /> <RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
</div> </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"> <TableActions onClick={handleCreate} />
<RiAddLine className="text-xl" />
<span>{t('admin.common.create')}</span>
</button>
</div> </div>
</div> </div>
<div> <div>
<Table <Table<Post>
columns={columns} columns={columns}
data={posts} data={posts}
loading={loading} loading={loading}
@ -99,9 +101,9 @@ const PostsManagement: FC = () => {
<thead> <thead>
<tr className="border-b border-slate-200 dark:border-slate-700"> <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 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.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> <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> </tr>
</thead> </thead>
@ -109,17 +111,17 @@ const PostsManagement: FC = () => {
{data.map((post) => ( {data.map((post) => (
<tr key={post.id} className="border-b border-slate-200 dark:border-slate-700 last:border-0"> <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.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"> <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' post.status === 'published'
? 'bg-slate-900 dark:bg-slate-700 text-white' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}`}> }`}>
{t(`admin.common.${post.status}`)} {t(`admin.common.${post.status}`)}
</span> </span>
</td> </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"> <td className="py-4 px-6 text-right">
<button <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" 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 { useTranslation } from 'react-i18next';
import { RiAddLine, RiSearchLine } from 'react-icons/ri'; import { useState, useEffect } from 'react';
import Table from '../../../components/admin/Table'; import Table from '../../../components/admin/Table';
import TableActions from '../../../components/admin/TableActions';
import { RiSearchLine } from 'react-icons/ri';
interface User { interface User {
id: string; id: number;
username: string; username: string;
email: string; email: string;
role: 'admin' | 'user'; role: 'admin' | 'editor' | 'contributor';
lastLogin: string; status: 'active' | 'inactive' | 'banned';
} }
const UsersManagement: FC = () => { export default function UsersManagement() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
// 这里后续会通过 API 获取数据 const fetchUsers = async () => {
const users: User[] = [ setLoading(true);
{ setError(null);
id: '1', try {
username: '张三', console.log('Fetching users list...');
email: 'zhangsan@example.com', const response = await fetch('/api/v1/users', {
role: 'admin', headers: {
lastLogin: '2024-02-20', '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) => { const handleEdit = (user: User) => {
console.log('Edit user:', user); console.log('Edit user:', user);
}; };
const handleDelete = (user: User) => { const handleDelete = async (user: User) => {
console.log('Delete 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 = [ const columns = [
{ {
key: 'username', key: 'username' as keyof User,
title: t('admin.common.username'), title: t('admin.users.username'),
}, },
{ {
key: 'role', key: 'email' as keyof User,
title: t('admin.common.role'), title: t('admin.users.email'),
},
{
key: 'role' as keyof User,
title: t('admin.users.role'),
render: (value: User['role']) => ( render: (value: User['role']) => (
<span <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' value === 'admin'
? 'bg-slate-900 dark:bg-slate-700 text-white' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200' : 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}`)} {t(`admin.roles.${value}`)}
@ -56,37 +106,49 @@ const UsersManagement: FC = () => {
), ),
}, },
{ {
key: 'joinDate', key: 'status' as keyof User,
title: t('admin.common.joinDate'), title: t('admin.users.status'),
}, render: (value: User['status']) => (
{ <span
key: 'lastLogin', className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
title: t('admin.common.lastLogin'), 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 ( return (
<div className="divide-y divide-slate-200 dark:divide-slate-700">
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between"> <div className="mb-6 flex justify-between items-center">
<div className="relative"> <div className="relative">
<input <input
type="text" 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')} placeholder={t('admin.common.search')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} 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" /> <RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
</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>
<TableActions onClick={handleCreate} />
</div> </div>
<Table <Table<User>
columns={columns} columns={columns}
data={users} data={users}
loading={loading} loading={loading}
@ -95,6 +157,4 @@ const UsersManagement: FC = () => {
/> />
</div> </div>
); );
}; }
export default UsersManagement;

View file

@ -1,13 +1,207 @@
import { createBrowserRouter } from 'react-router-dom'; import { createBrowserRouter, Navigate, useLocation, Outlet } from 'react-router-dom';
import AdminLayout from './pages/admin/layout/AdminLayout'; import { lazy, Suspense } from 'react';
import Dashboard from './pages/admin/Dashboard'; 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([ const router = createBrowserRouter([
{ {
path: '/admin', path: '/',
element: <AdminLayout><Dashboard /></AdminLayout>, 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; export default router;

View file

@ -11,4 +11,13 @@ export default defineConfig({
optimizeDeps: { optimizeDeps: {
exclude: ['lucide-react'], exclude: ['lucide-react'],
}, },
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
},
},
}); });