移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Lock, LockOpen } from 'lucide-react';
import { Search, Lock, LockOpen, Trash2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -11,12 +11,16 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
import { clearAllFeedCache } from '../../utils/feedCache';
import { isTimeDiffSignificant } from '../../utils/content';
type Tab = 'pending' | 'active' | 'trash';
type TrashPost = PostItem & { deleted_at: string };
function formatAdminTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
@@ -29,28 +33,71 @@ function formatAdminTime(iso: string) {
export default function AdminPostsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [tab, setTab] = useState<Tab>('pending');
const [posts, setPosts] = useState<PostItem[]>([]);
const [trash, setTrash] = useState<TrashPost[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [pendingCount, setPendingCount] = useState(0);
const [keyword, setKeyword] = useState('');
const [search, setSearch] = useState('');
const load = (p = page, kw = search) => {
const loadActive = (p = page, kw = search, status = tab === 'pending' ? 'pending' : 'all') => {
setLoading(true);
api.adminPosts({ page: p, keyword: kw })
api.adminPosts({ page: p, keyword: kw, status })
.then(d => {
setPosts(d.posts ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
setPendingCount(d.pending_count ?? 0);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
const loadTrash = (p = page, kw = search) => {
setLoading(true);
api.adminTrashPosts({ page: p, keyword: kw })
.then(d => {
setTrash(d.posts ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
const load = (p = 1, kw = search) => {
if (tab === 'trash') loadTrash(p, kw);
else loadActive(p, kw, tab === 'pending' ? 'pending' : 'all');
};
const approvePost = async (post: PostItem) => {
try {
const r = await api.adminApprovePost(post.id);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
useEffect(() => {
if (ready) load(1, search);
}, [ready, search]);
if (!ready) return;
setPage(1);
load(1, search);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随 tab/search/ready 刷新
}, [ready, search, tab]);
const switchTab = (next: Tab) => {
if (next === tab) return;
setTab(next);
setKeyword('');
setSearch('');
};
const togglePin = async (post: PostItem) => {
try {
@@ -58,7 +105,37 @@ export default function AdminPostsPage() {
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load();
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const toggleFeature = async (post: PostItem) => {
try {
const r = await api.adminFeaturePost(post.id, !post.featured);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const rejectPost = async (post: PostItem) => {
const reason = window.prompt(`拒绝《${post.title}》并私信通知作者,请填写原因:`);
if (reason == null) return;
if (!reason.trim()) {
notify.warning('请填写拒绝原因');
return;
}
try {
const r = await api.adminRejectPost(post.id, reason.trim());
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
@@ -68,7 +145,7 @@ export default function AdminPostsPage() {
try {
const r = await api.adminLockPost(post.id, !post.edit_locked);
notify.success(r.message);
load();
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
@@ -77,20 +154,81 @@ export default function AdminPostsPage() {
const remove = async (id: number) => {
try {
await api.adminDeletePost(id);
notify.success('帖子已删除');
load();
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success('帖子已移入回收站');
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
const restore = async (id: number) => {
try {
await api.adminRestorePost(id);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success('帖子已恢复');
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '恢复失败');
}
};
const purge = async (id: number) => {
try {
await api.adminPurgePost(id);
notify.success('帖子已永久删除');
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '彻底删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
<p>
{tab === 'trash'
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
: tab === 'pending'
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
: '精华、置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
</p>
</div>
<div className="admin-tabs" role="tablist" aria-label="帖子视图">
<button
type="button"
role="tab"
aria-selected={tab === 'pending'}
className={cn('admin-tab', tab === 'pending' && 'active')}
onClick={() => switchTab('pending')}
>
{pendingCount > 0 ? ` (${pendingCount})` : ''}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'active'}
className={cn('admin-tab', tab === 'active' && 'active')}
onClick={() => switchTab('active')}
>
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'trash'}
className={cn('admin-tab', tab === 'trash' && 'active')}
onClick={() => switchTab('trash')}
>
<Trash2 size={14} aria-hidden />
</button>
</div>
<form
@@ -113,6 +251,59 @@ export default function AdminPostsPage() {
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : tab === 'trash' ? (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{trash.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td className="max-w-[220px] truncate">{p.title}</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td className="text-sm whitespace-nowrap">{formatAdminTime(p.deleted_at)}</td>
<td>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => restore(p.id)}>
<RotateCcw size={14} />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => purge(p.id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
{trash.length === 0 && <div className="admin-empty"></div>}
</>
) : (
<>
<table className="admin-table">
@@ -124,6 +315,7 @@ export default function AdminPostsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -154,7 +346,8 @@ export default function AdminPostsPage() {
</td>
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.featured ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.pinned ? <Badge variant="green"></Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
@@ -167,7 +360,18 @@ export default function AdminPostsPage() {
)}
</td>
<td>
<div className="flex gap-1">
<div className="flex gap-1 flex-wrap">
{(p.status === 'pending' || p.status === 'rejected') && (
<Button size="sm" onClick={() => approvePost(p)}></Button>
)}
{p.status !== 'rejected' && (
<Button size="sm" variant="outline" onClick={() => rejectPost(p)}>
</Button>
)}
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
{p.featured ? '取消精华' : '精华'}
</Button>
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消置顶' : '置顶'}
</Button>
@@ -180,12 +384,14 @@ export default function AdminPostsPage() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => remove(p.id)}></AlertDialogAction>
<AlertDialogAction onClick={() => remove(p.id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -197,15 +403,15 @@ export default function AdminPostsPage() {
</tbody>
</table>
{posts.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
{totalPages > 1 && !loading && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</div>
</div>
);