import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Search, Lock, LockOpen, MessageSquareOff, Trash2, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Spinner } from '@/components/ui/spinner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, 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; return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } export default function AdminPostsPage() { const nav = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const focusId = Number(searchParams.get('id') || 0) || 0; const { ready } = useAdminGuard(); const [tab, setTab] = useState('pending'); const [posts, setPosts] = useState([]); const [trash, setTrash] = useState([]); 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 [highlightId, setHighlightId] = useState(focusId > 0 ? focusId : null); const focusTriedRef = useRef(false); const highlightTimer = useRef>(); const loadActive = (p = page, kw = search, status = tab === 'pending' ? 'pending' : 'all') => { setLoading(true); 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) return; setPage(1); load(1, search); // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随 tab/search/ready 刷新 }, [ready, search, tab]); // 从通知深链 ?id= 定位并高亮 useEffect(() => { if (!ready || loading || focusId <= 0 || focusTriedRef.current) return; if (tab === 'trash') return; const found = posts.find((p) => p.id === focusId); if (found) { focusTriedRef.current = true; setHighlightId(focusId); requestAnimationFrame(() => { document.getElementById(`admin-post-row-${focusId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center', }); }); clearTimeout(highlightTimer.current); highlightTimer.current = setTimeout(() => setHighlightId(null), 2800); const next = new URLSearchParams(searchParams); next.delete('id'); setSearchParams(next, { replace: true }); return; } if (tab === 'pending') { setTab('active'); return; } focusTriedRef.current = true; notify.warning('该帖子可能已审核或不在当前列表'); const next = new URLSearchParams(searchParams); next.delete('id'); setSearchParams(next, { replace: true }); }, [ready, loading, posts, focusId, tab, searchParams, setSearchParams]); useEffect(() => () => clearTimeout(highlightTimer.current), []); const switchTab = (next: Tab) => { if (next === tab) return; setTab(next); setKeyword(''); setSearch(''); }; const togglePin = async (post: PostItem) => { try { const r = await api.adminPinPost(post.id, !post.pinned); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); load(page); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const toggleBoardPin = async (post: PostItem) => { try { const r = await api.adminBoardPinPost(post.id, !post.board_pinned); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); 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 : '操作失败'); } }; const toggleLock = async (post: PostItem) => { try { const r = await api.adminLockPost(post.id, !post.edit_locked); notify.success(r.message); load(page); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const toggleCommentsLock = async (post: PostItem) => { try { const r = await api.adminCommentsLockPost(post.id, !post.comments_locked); notify.success(r.message); load(page); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const remove = async (id: number) => { try { await api.adminDeletePost(id); 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 (

帖子管理

{tab === 'trash' ? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销' : tab === 'pending' ? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知' : '推荐、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}

{ e.preventDefault(); setSearch(keyword.trim()); }} > setKeyword(e.target.value)} placeholder="搜索标题、标签或正文…" /> {search && ( )}
{loading ? (
) : tab === 'trash' ? ( <> {trash.map(p => ( ))}
ID 标题 板块 作者 评论 删除时间 操作
{p.id} {p.title} {p.board?.name ?? '—'} {p.user?.nickname ?? '—'} {p.comment_count ?? 0} {formatAdminTime(p.deleted_at)}
永久删除该帖子? 将彻底清除帖子、评论、点赞、收藏与修订历史,此操作不可恢复。 取消 purge(p.id)}>永久删除
{trash.length === 0 &&
回收站为空
} ) : ( <> {posts.map(p => { const edited = p.updated_at && isTimeDiffSignificant(p.created_at, p.updated_at); return ( ); })}
ID 标题 板块 作者 标签 评论 推荐 全局置顶 板块置顶 编辑锁 讨论锁 点赞 浏览 时间 操作
{p.id} {edited && 已编辑} {p.board?.name ?? '—'} {p.user?.id ? ( ) : '—'} {p.tags || '—'} {p.comment_count ?? 0} {p.featured ? : '—'} {p.pinned ? : '—'} {p.board_pinned ? : '—'} {p.edit_locked ? : '—'} {p.comments_locked ? : '—'} {p.like_count} {p.view_count} {formatAdminTime(p.created_at)} {edited && p.updated_at && ( 改于 {formatAdminTime(p.updated_at)} )}
{(p.status === 'pending' || p.status === 'rejected') && ( )} {p.status !== 'rejected' && ( )} 移入回收站? 帖子与评论将移入回收站,可随时恢复;永久删除请到回收站操作。 取消 remove(p.id)}>移入回收站
{posts.length === 0 &&
没有找到帖子
} )} {totalPages > 1 && !loading && (
第 {page} / {totalPages} 页
)}
); }