[feature/frontend] create posts (wip)
This commit is contained in:
parent
e86d8c1576
commit
086c9761a9
10 changed files with 598 additions and 115 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"categories": {
|
||||
"man": "Man",
|
||||
"man": "Human",
|
||||
"machine": "Machine",
|
||||
"earth": "Earth",
|
||||
"space": "Space",
|
||||
|
@ -29,8 +29,6 @@
|
|||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"upload": "Upload",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"published": "Published",
|
||||
|
@ -48,12 +46,10 @@
|
|||
"joinDate": "Join Date",
|
||||
"username": "Username",
|
||||
"logout": "Logout",
|
||||
"language": "Language",
|
||||
"theme": {
|
||||
"light": "Light Mode",
|
||||
"dark": "Dark Mode",
|
||||
"system": "System"
|
||||
}
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"noData": "No data available",
|
||||
"unsavedChanges": "You have unsaved changes. Are you sure you want to leave?"
|
||||
},
|
||||
"dashboard": {
|
||||
"totalPosts": "Total Posts",
|
||||
|
@ -61,13 +57,33 @@
|
|||
"totalUsers": "Total Users",
|
||||
"totalContributors": "Total Contributors"
|
||||
},
|
||||
"posts": {
|
||||
"title": "Title",
|
||||
"categories": "Categories",
|
||||
"createdAt": "Created At",
|
||||
"status": "Status",
|
||||
"noTitle": "No Title",
|
||||
"deleteConfirm": "Are you sure you want to delete this post?",
|
||||
"create": "Create Post",
|
||||
"edit": "Edit Post",
|
||||
"slug": "Slug",
|
||||
"content": "Content",
|
||||
"summary": "Summary",
|
||||
"metaKeywords": "Meta Keywords",
|
||||
"metaDescription": "Meta Description",
|
||||
"selectCategories": "Select Categories",
|
||||
"saving": "Saving...",
|
||||
"publishing": "Publishing...",
|
||||
"saveDraft": "Save Draft",
|
||||
"publish": "Publish"
|
||||
},
|
||||
"login": {
|
||||
"title": "Admin Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"remember": "Remember me",
|
||||
"submit": "Sign in",
|
||||
"loading": "Signing in...",
|
||||
"submit": "Login",
|
||||
"loading": "Logging in...",
|
||||
"error": {
|
||||
"failed": "Login failed",
|
||||
"retry": "Login failed, please try again later"
|
||||
|
@ -76,7 +92,7 @@
|
|||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"posts": "Posts",
|
||||
"daily": "Daily Quotes",
|
||||
"daily": "Daily",
|
||||
"medias": "Media",
|
||||
"categories": "Categories",
|
||||
"users": "Users",
|
||||
|
|
|
@ -47,7 +47,9 @@
|
|||
"username": "用户名",
|
||||
"logout": "退出登录",
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
"saving": "保存中...",
|
||||
"noData": "暂无数据",
|
||||
"unsavedChanges": "你有未保存的更改,确定要离开吗?"
|
||||
},
|
||||
"dashboard": {
|
||||
"totalPosts": "文章总数",
|
||||
|
@ -55,6 +57,26 @@
|
|||
"totalUsers": "用户总数",
|
||||
"totalContributors": "贡献者总数"
|
||||
},
|
||||
"posts": {
|
||||
"title": "标题",
|
||||
"categories": "分类",
|
||||
"createdAt": "创建时间",
|
||||
"status": "状态",
|
||||
"noTitle": "无标题",
|
||||
"deleteConfirm": "确定要删除这篇文章吗?",
|
||||
"create": "创建文章",
|
||||
"edit": "编辑文章",
|
||||
"slug": "文章链接",
|
||||
"content": "内容",
|
||||
"summary": "摘要",
|
||||
"metaKeywords": "关键词",
|
||||
"metaDescription": "描述",
|
||||
"selectCategories": "选择分类",
|
||||
"saving": "保存中...",
|
||||
"publishing": "发布中...",
|
||||
"saveDraft": "保存草稿",
|
||||
"publish": "发布文章"
|
||||
},
|
||||
"login": {
|
||||
"title": "管理员登录",
|
||||
"username": "用户名",
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
"lastLogin": "最後登入",
|
||||
"joinDate": "加入時間",
|
||||
"username": "用戶名",
|
||||
"logout": "退出登錄",
|
||||
"logout": "退出登入",
|
||||
"language": "語言",
|
||||
"theme": {
|
||||
"light": "淺色模式",
|
||||
|
@ -53,7 +53,9 @@
|
|||
"system": "跟隨系統"
|
||||
},
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
"saving": "保存中...",
|
||||
"noData": "暫無數據",
|
||||
"unsavedChanges": "你有未保存的更改,確定要離開嗎?"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "儀表板",
|
||||
|
@ -83,15 +85,15 @@
|
|||
"uploadDate": "上傳日期"
|
||||
},
|
||||
"login": {
|
||||
"title": "管理員登錄",
|
||||
"title": "管理員登入",
|
||||
"username": "用戶名",
|
||||
"password": "密碼",
|
||||
"remember": "記住我",
|
||||
"submit": "登錄",
|
||||
"loading": "登錄中...",
|
||||
"submit": "登入",
|
||||
"loading": "登入中...",
|
||||
"error": {
|
||||
"failed": "登錄失敗",
|
||||
"retry": "登錄失敗,請稍後重試"
|
||||
"failed": "登入失敗",
|
||||
"retry": "登入失敗,請稍後重試"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
|
|
|
@ -12,7 +12,10 @@
|
|||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@tss-rocks/api": "workspace:*",
|
||||
"@types/classnames": "^2.3.4",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lucide-react": "^0.474.0",
|
||||
|
|
26
frontend/src/contexts/PageTitleContext.tsx
Normal file
26
frontend/src/contexts/PageTitleContext.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { createContext, useContext, FC, ReactNode, useState } from 'react';
|
||||
|
||||
interface PageTitleContextType {
|
||||
title: string;
|
||||
setTitle: (title: string) => void;
|
||||
}
|
||||
|
||||
const PageTitleContext = createContext<PageTitleContextType | undefined>(undefined);
|
||||
|
||||
export const PageTitleProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
return (
|
||||
<PageTitleContext.Provider value={{ title, setTitle }}>
|
||||
{children}
|
||||
</PageTitleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePageTitle = () => {
|
||||
const context = useContext(PageTitleContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePageTitle must be used within a PageTitleProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -19,6 +19,7 @@ import { useTheme } from '../../../hooks/useTheme';
|
|||
import { Suspense } from 'react';
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner';
|
||||
import { useUser } from '../../../contexts/UserContext';
|
||||
import { PageTitleProvider, usePageTitle } from '../../../contexts/PageTitleContext';
|
||||
|
||||
interface AdminLayoutProps {}
|
||||
|
||||
|
@ -56,12 +57,13 @@ const languageMap: LanguageMap = {
|
|||
'zh-Hant': 'en'
|
||||
};
|
||||
|
||||
const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||
const AdminLayoutContent: FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { user, loading, error, fetchUser } = useUser();
|
||||
const { title } = usePageTitle();
|
||||
|
||||
useEffect(() => {
|
||||
// 如果没有 token,重定向到登录页
|
||||
|
@ -196,7 +198,7 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
|
|||
<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')}
|
||||
{title || t(menuItems.find(item => item.path === location.pathname)?.label || 'admin.nav.dashboard')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
@ -224,4 +226,12 @@ const AdminLayout: FC<AdminLayoutProps> = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||
return (
|
||||
<PageTitleProvider>
|
||||
<AdminLayoutContent />
|
||||
</PageTitleProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
|
|
296
frontend/src/pages/admin/posts/PostEditor.tsx
Normal file
296
frontend/src/pages/admin/posts/PostEditor.tsx
Normal file
|
@ -0,0 +1,296 @@
|
|||
import { useState, useEffect, FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useBlocker } from 'react-router-dom';
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner';
|
||||
import { usePageTitle } from '../../../contexts/PageTitleContext';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface PostContent {
|
||||
language_code: 'en' | 'zh-Hans' | 'zh-Hant';
|
||||
title: string;
|
||||
content_markdown: string;
|
||||
summary?: string;
|
||||
meta_keywords?: string;
|
||||
meta_description?: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
contents: Array<{
|
||||
language_code: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id?: number;
|
||||
slug: string;
|
||||
status: 'draft' | 'published';
|
||||
contents: PostContent[];
|
||||
categories: Category[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh-Hans', label: '简体中文' },
|
||||
{ code: 'zh-Hant', label: '繁體中文' }
|
||||
] as const;
|
||||
|
||||
const PostEditor: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { postId } = useParams<{ postId: string }>();
|
||||
const isEditing = !!postId;
|
||||
const { setTitle } = usePageTitle();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'en' | 'zh-Hans' | 'zh-Hant'>('en');
|
||||
const [post, setPost] = useState<Post>({
|
||||
slug: '',
|
||||
status: 'draft',
|
||||
contents: [
|
||||
{
|
||||
language_code: 'en',
|
||||
title: '',
|
||||
content_markdown: ''
|
||||
},
|
||||
{
|
||||
language_code: 'zh-Hans',
|
||||
title: '',
|
||||
content_markdown: ''
|
||||
},
|
||||
{
|
||||
language_code: 'zh-Hant',
|
||||
title: '',
|
||||
content_markdown: ''
|
||||
}
|
||||
],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
const confirmed = window.confirm(t('admin.common.unsavedChanges'));
|
||||
if (confirmed) {
|
||||
blocker.proceed();
|
||||
} else {
|
||||
blocker.reset();
|
||||
}
|
||||
}
|
||||
}, [blocker, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(isEditing ? t('admin.posts.edit') : t('admin.posts.create'));
|
||||
}, [isEditing, t, setTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
fetchPost();
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/v1/posts/${postId}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch post');
|
||||
const data = await response.json();
|
||||
setPost(data.data);
|
||||
setIsDirty(false);
|
||||
} catch (error) {
|
||||
console.error('Error fetching post:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (publish: boolean = false) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
const url = isEditing ? `/api/v1/posts/${postId}` : '/api/v1/posts';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...post,
|
||||
status: publish ? 'published' : 'draft'
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save post');
|
||||
|
||||
setIsDirty(false);
|
||||
navigate('/admin/posts');
|
||||
} catch (error) {
|
||||
console.error('Error saving post:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeContent = post.contents.find(c => c.language_code === activeTab)!;
|
||||
const updateContent = (updates: Partial<PostContent>) => {
|
||||
setIsDirty(true);
|
||||
const newContents = post.contents.map(content =>
|
||||
content.language_code === activeTab
|
||||
? { ...content, ...updates }
|
||||
: content
|
||||
);
|
||||
setPost(prev => ({ ...prev, contents: newContents }));
|
||||
};
|
||||
|
||||
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsDirty(true);
|
||||
setPost(prev => ({ ...prev, slug: e.target.value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.slug')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={post.slug}
|
||||
onChange={handleSlugChange}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Language Tabs */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-700">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Language">
|
||||
{LANGUAGES.map(({ code, label }) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => setActiveTab(code)}
|
||||
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === code
|
||||
? 'border-slate-900 dark:border-white text-slate-900 dark:text-white'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Fields */}
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.title')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={activeContent.title}
|
||||
onChange={e => updateContent({ title: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.content')}
|
||||
</label>
|
||||
<textarea
|
||||
value={activeContent.content_markdown}
|
||||
onChange={e => updateContent({ content_markdown: e.target.value })}
|
||||
rows={10}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.summary')}
|
||||
</label>
|
||||
<textarea
|
||||
value={activeContent.summary || ''}
|
||||
onChange={e => updateContent({ summary: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meta Keywords */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.metaKeywords')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={activeContent.meta_keywords || ''}
|
||||
onChange={e => updateContent({ meta_keywords: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meta Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||
{t('admin.posts.metaDescription')}
|
||||
</label>
|
||||
<textarea
|
||||
value={activeContent.meta_description || ''}
|
||||
onChange={e => updateContent({ meta_description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{/* TODO: Add category selection */}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-md hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400 disabled:opacity-50"
|
||||
>
|
||||
{saving ? t('admin.posts.saving') : t('admin.posts.saveDraft')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-slate-900 dark:bg-slate-700 rounded-md hover:bg-slate-800 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400 disabled:opacity-50"
|
||||
>
|
||||
{saving ? t('admin.posts.publishing') : t('admin.posts.publish')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostEditor;
|
|
@ -1,73 +1,183 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useEffect, FC, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Table from '../../../components/admin/Table';
|
||||
import TableActions from '../../../components/admin/TableActions';
|
||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||
import { RiSearchLine } from 'react-icons/ri';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePageTitle } from '../../../contexts/PageTitleContext';
|
||||
|
||||
// 初始化 dayjs 插件
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
interface PostContent {
|
||||
language_code: 'en' | 'zh-Hans' | 'zh-Hant';
|
||||
title: string;
|
||||
content_markdown: string;
|
||||
summary?: string;
|
||||
meta_keywords?: string;
|
||||
meta_description?: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
contents: Array<{
|
||||
language_code: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
publishDate: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
status: 'draft' | 'published';
|
||||
contents: PostContent[];
|
||||
categories: Category[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type TablePost = {
|
||||
id: number;
|
||||
slug: string;
|
||||
status: 'draft' | 'published';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
displayTitle: string;
|
||||
displayCategories: string;
|
||||
};
|
||||
|
||||
const PostsManagement: FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [posts, setPosts] = useState<TablePost[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setTitle } = usePageTitle();
|
||||
|
||||
// 这里后续会通过 API 获取数据
|
||||
const posts: Post[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '示例文章标题',
|
||||
category: '示例分类',
|
||||
publishDate: '2024-02-20',
|
||||
status: 'published',
|
||||
},
|
||||
];
|
||||
useEffect(() => {
|
||||
setTitle(t('admin.nav.posts'));
|
||||
return () => setTitle('');
|
||||
}, [setTitle, t]);
|
||||
|
||||
const handleEdit = (post: Post) => {
|
||||
console.log('Edit post:', post);
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/v1/posts?sort=-created_at');
|
||||
if (!response.ok) throw new Error('Failed to fetch posts');
|
||||
const data = await response.json();
|
||||
|
||||
// 处理数据,添加显示字段
|
||||
const processedPosts = data.data.map((post: Post): TablePost => ({
|
||||
id: post.id,
|
||||
slug: post.slug,
|
||||
status: post.status,
|
||||
created_at: post.created_at,
|
||||
updated_at: post.updated_at,
|
||||
displayTitle: getPostTitle(post),
|
||||
displayCategories: getCategoryNames(post)
|
||||
}));
|
||||
|
||||
setPosts(processedPosts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (post: Post) => {
|
||||
console.log('Delete post:', post);
|
||||
const getPostTitle = (post: Post) => {
|
||||
// Try to get English title first
|
||||
const englishContent = post.contents.find(c => c.language_code === 'en');
|
||||
if (englishContent?.title) return englishContent.title;
|
||||
|
||||
// Fallback to the first available title
|
||||
const firstContent = post.contents[0];
|
||||
return firstContent?.title || t('admin.posts.noTitle');
|
||||
};
|
||||
|
||||
const getCategoryNames = (post: Post) => {
|
||||
return post.categories
|
||||
.map(category => {
|
||||
const englishContent = category.contents.find(c => c.language_code === 'en');
|
||||
if (englishContent?.name) return englishContent.name;
|
||||
return category.contents[0]?.name || '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const handleEdit = async (post: TablePost) => {
|
||||
navigate(post.slug);
|
||||
};
|
||||
|
||||
const handleDelete = async (post: TablePost) => {
|
||||
if (!window.confirm(t('admin.posts.deleteConfirm'))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/posts/${post.slug}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete post');
|
||||
|
||||
await fetchPosts();
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title' as keyof Post,
|
||||
key: 'displayTitle' as keyof TablePost,
|
||||
title: t('admin.posts.title'),
|
||||
render: (value: string): ReactNode => value
|
||||
},
|
||||
{
|
||||
key: 'category' as keyof Post,
|
||||
title: t('admin.posts.category'),
|
||||
key: 'displayCategories' as keyof TablePost,
|
||||
title: t('admin.posts.categories'),
|
||||
render: (value: string): ReactNode => value
|
||||
},
|
||||
{
|
||||
key: 'publishDate' as keyof Post,
|
||||
title: t('admin.posts.publishDate'),
|
||||
},
|
||||
{
|
||||
key: 'status' as keyof Post,
|
||||
title: t('admin.posts.status'),
|
||||
render: (value: Post['status']) => (
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||
value === 'published'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.common.${value}`)}
|
||||
key: 'created_at' as keyof TablePost,
|
||||
title: t('admin.posts.createdAt'),
|
||||
render: (value: string): ReactNode => (
|
||||
<span title={dayjs(value).format('YYYY-MM-DD HH:mm:ss')}>
|
||||
{dayjs(value).fromNow()}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'status' as keyof TablePost,
|
||||
title: t('admin.posts.status'),
|
||||
render: (value: string | number): ReactNode => {
|
||||
const status = value as TablePost['status'];
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs font-medium rounded-full ${
|
||||
status === 'published'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.common.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handleCreate = () => {
|
||||
// TODO: 实现创建文章的逻辑
|
||||
navigate('new');
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -89,59 +199,13 @@ const PostsManagement: FC = () => {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Table<Post>
|
||||
<Table<TablePost>
|
||||
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.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-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.category}</td>
|
||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`px-2 py-1 text-sm font-medium rounded-full ${
|
||||
post.status === 'published'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
}`}>
|
||||
{t(`admin.common.${post.status}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-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>
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ const Footer = lazy(() => import('./components/Footer'));
|
|||
// 管理页面组件
|
||||
const Dashboard = lazy(() => import('./pages/admin/dashboard/Dashboard'));
|
||||
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
|
||||
const PostEditor = lazy(() => import('./pages/admin/posts/PostEditor'));
|
||||
const DailyManagement = lazy(() => import('./pages/admin/daily/DailyManagement'));
|
||||
const MediasManagement = lazy(() => import('./pages/admin/medias/MediasManagement'));
|
||||
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
|
||||
|
@ -140,11 +141,32 @@ const router = createBrowserRouter([
|
|||
},
|
||||
{
|
||||
path: 'posts',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||
<PostsManagement />
|
||||
</Suspense>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||
<PostsManagement />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||
<PostEditor />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':postId',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||
<PostEditor />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'daily',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue