import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'; import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react'; import FeaturedIcon from '@/components/FeaturedIcon'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import BoardBadge from '@/components/BoardBadge'; import UserLink from '@/components/UserLink'; import { Spinner } from '@/components/ui/spinner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { notify } from '@/lib/notify'; import { api } from '../api/client'; import type { PostItem, Comment, ReportReason } from '../api/types'; import { REPORT_REASON_OPTIONS } from '../utils/report'; import CommentThreadList from '../components/CommentThreadList'; import CommentBox, { type CommentSubmitData } from '../components/CommentBox'; import PostContent from '../components/PostContent'; import PostRevisionPanel from '../components/PostRevisionPanel'; import ArticleOutline from '../components/ArticleOutline'; import { useAuth } from '../hooks/useAuth'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { getCachedSiteBranding } from '../hooks/useSiteBranding'; import { formatDateTime, isTimeDiffSignificant } from '../utils/content'; import { collectCommentSubtreeIds } from '../utils/comment'; import { loadMyCommentIds } from '../utils/guest'; import { clearAllFeedCache } from '../utils/feedCache'; import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll'; import { loginPath } from '../utils/authRedirect'; import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText'; import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink'; import { useForumLimits } from '../hooks/useForumLimits'; import type { LayoutCtx } from '../layouts/MainLayout'; import type { PostHeading } from '../utils/postHeadings'; import { InFlowSiteFooter } from '../components/SiteFooter'; import NotFoundPage from './NotFoundPage'; /** 格式化剩余可编辑时间 */ function formatEditRemaining(createdAt: string, windowHours: number): string { if (windowHours <= 0) return ''; const deadline = new Date(createdAt).getTime() + windowHours * 3600_000; const ms = deadline - Date.now(); if (ms <= 0) return ''; const hours = Math.floor(ms / 3600_000); const mins = Math.floor((ms % 3600_000) / 60_000); if (hours >= 24) return `还可编辑约 ${Math.floor(hours / 24)} 天`; if (hours > 0) return `还可编辑约 ${hours} 小时`; return `还可编辑约 ${mins} 分钟`; } export default function PostDetailPage() { const { id } = useParams(); const postId = parsePermalinkID(id); const nav = useNavigate(); const location = useLocation(); const { user, refresh } = useAuth(); const { limits } = useForumLimits(); const { setPostOutline, isMobile } = useOutletContext(); const [post, setPost] = useState(null); const [comments, setComments] = useState([]); const [liked, setLiked] = useState(false); const [favorited, setFavorited] = useState(false); const [replyTo, setReplyTo] = useState(null); const [editingCommentId, setEditingCommentId] = useState(null); const [submitting, setSubmitting] = useState(false); const [loading, setLoading] = useState(true); const [highlightFloor, setHighlightFloor] = useState(null); const [submitCount, setSubmitCount] = useState(0); const [canEdit, setCanEdit] = useState(false); const [isEdited, setIsEdited] = useState(false); const [editBlockReason, setEditBlockReason] = useState(''); const [editWindowHours, setEditWindowHours] = useState(0); const [showRevisions, setShowRevisions] = useState(false); const [deletingPost, setDeletingPost] = useState(false); const [headings, setHeadings] = useState([]); const [reportOpen, setReportOpen] = useState(false); const [reportReason, setReportReason] = useState('spam'); const [reportDetail, setReportDetail] = useState(''); const [reporting, setReporting] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [rejecting, setRejecting] = useState(false); const pageRef = useRef(null); const commentSectionRef = useRef(null); const commentBoxRef = useRef(null); const highlightTimer = useRef>(); useGlobalWheelScroll(pageRef, !loading && !!post); // SPA 内跳转时纠正非规范伪静态路径 useEffect(() => { if (!postId || Number.isNaN(postId)) return; const target = canonicalRedirectPath('post', postId, location.pathname, limits); if (target) nav(target + location.search + location.hash, { replace: true }); }, [postId, location.pathname, location.search, location.hash, limits, nav]); const brand = getCachedSiteBranding(); const postContent = post?.content ?? ''; const postSEO = post ? { title: post.title, description: excerptFromHTML(postContent), keywords: joinSEOKeywords(post.board?.name, brand.keywords), canonicalPath: postPath(post.id, limits), ogType: 'article', ogImage: firstImageFromHTML(postContent) || post.user?.avatar || brand.og_image || '', jsonLd: { '@context': 'https://schema.org', '@type': 'DiscussionForumPosting', headline: post.title, description: excerptFromHTML(postContent), datePublished: post.created_at, dateModified: post.updated_at || post.created_at, url: postPath(post.id, limits), author: { '@type': 'Person', name: post.user?.nickname || post.user?.username || '', }, }, } : null; usePageSEO(postSEO); const handleHeadingsChange = useCallback((next: PostHeading[]) => { setHeadings(next); }, []); useEffect(() => { if (loading || !post) { setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' }); return () => setPostOutline(null); } setPostOutline({ headings, scrollRoot: pageRef.current, title: '文章目录', author: post.user ?? null, publishedAt: post.created_at, viewCount: post.view_count, }); return () => setPostOutline(null); }, [headings, loading, post, setPostOutline]); const loadSeq = useRef(0); const detailPath = postPath(postId, limits); useEffect(() => { if (!postId || Number.isNaN(postId)) { setPost(null); setLoading(false); return; } setReplyTo(null); setEditingCommentId(null); setHeadings([]); const seq = ++loadSeq.current; setLoading(true); setPost(null); (async () => { try { const myIds = user ? [] : loadMyCommentIds(); const [detail, comm] = await Promise.all([ api.post(postId), api.comments(postId, myIds), ]); if (seq !== loadSeq.current) return; setPost(detail.post); setLiked(detail.liked); setFavorited(detail.favorited); setCanEdit(detail.can_edit ?? false); setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at)); setEditBlockReason(detail.edit_block_reason ?? ''); setEditWindowHours(detail.post_edit_window_hours ?? 0); setComments(Array.isArray(comm.comments) ? comm.comments : []); void refresh(); } catch { if (seq !== loadSeq.current) return; setPost(null); } finally { if (seq === loadSeq.current) setLoading(false); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载 }, [postId]); const reloadComments = useCallback(async () => { const myIds = user ? [] : loadMyCommentIds(); const comm = await api.comments(postId, myIds); setComments(Array.isArray(comm.comments) ? comm.comments : []); }, [postId, user]); /** 发评后重拉正文,解锁「回复可见」区块(跳过浏览计数) */ const reloadPostContent = useCallback(async () => { try { const detail = await api.post(postId, { skipView: true }); setPost(detail.post); } catch { // 评论已成功,正文刷新失败不阻断 } }, [postId]); const scrollToCommentBox = useCallback(() => { const target = commentBoxRef.current || commentSectionRef.current; target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); window.setTimeout(() => { const ta = commentBoxRef.current?.querySelector('textarea'); ta?.focus({ preventScroll: true }); }, 320); }, []); const jumpToFloor = useCallback((floor: number) => { const el = document.getElementById(`floor-${floor}`); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightFloor(floor); clearTimeout(highlightTimer.current); highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000); }, []); /** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */ const jumpToHeadingHash = useCallback((hash: string, smooth = false) => { const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim(); if (!id || /^floor-\d+$/.test(id)) return false; const el = document.getElementById(id); if (!el) return false; const root = pageRef.current; const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto'; if (root) { const rootRect = root.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); const top = root.scrollTop + (elRect.top - rootRect.top) - 12; root.scrollTo({ top: Math.max(0, top), behavior }); } else { el.scrollIntoView({ behavior, block: 'start' }); } return true; }, []); // 从 #floor-N 定位到对应评论(右栏最新评论等入口) useEffect(() => { if (loading || !post) return; const m = location.hash.match(/^#floor-(\d+)$/); if (!m) return; const floor = Number(m[1]); if (!floor) return; const t = window.setTimeout(() => jumpToFloor(floor), 80); return () => clearTimeout(t); }, [loading, post, comments, location.hash, jumpToFloor]); // 从 #heading-N(或任意标题 id)定位;等正文进 DOM 后重试,避免首屏 hash 失效 useEffect(() => { if (loading || !post) return; const hash = location.hash; if (!hash || /^#floor-\d+$/.test(hash)) return; let cancelled = false; let attempts = 0; let timer = 0; const tryJump = () => { if (cancelled) return; if (jumpToHeadingHash(hash, false)) return; attempts += 1; if (attempts < 30) { timer = window.setTimeout(tryJump, 50); } }; timer = window.setTimeout(tryJump, 0); return () => { cancelled = true; window.clearTimeout(timer); }; }, [loading, post, location.hash, jumpToHeadingHash, headings]); const requireLogin = (actionLabel: string) => { notify.warning(`登录后即可${actionLabel}`); nav(loginPath(detailPath)); }; const handleReplyTo = (comment: Comment) => { if (!user) { requireLogin('回复'); return; } setEditingCommentId(null); if (replyTo?.id === comment.id) { setReplyTo(null); return; } setReplyTo(comment); }; useLayoutEffect(() => { if (!replyTo) return; const el = document.getElementById(`reply-box-${replyTo.id}`); el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [replyTo?.id]); useEffect(() => { return () => clearTimeout(highlightTimer.current); }, []); const handleLike = async () => { if (!user) { requireLogin('点赞'); return; } try { const r = await api.like(postId); setLiked(r.liked); setPost(p => p ? { ...p, like_count: r.like_count } : p); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleFavorite = async () => { if (!user) { requireLogin('收藏'); return; } try { const r = await api.favorite(postId); setFavorited(r.favorited); notify.success(r.favorited ? '已收藏' : '已取消收藏'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleSubmitComment = async (data: CommentSubmitData) => { if (!user) { requireLogin('评论'); return; } setSubmitting(true); try { const r = await api.addComment(postId, { content: data.content, replyTo: replyTo?.id, isPrivate: data.isPrivate, }); setReplyTo(null); setSubmitCount(c => c + 1); notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功')); await Promise.all([reloadComments(), reloadPostContent()]); setTimeout(() => jumpToFloor(r.floor), 100); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '评论失败'); } finally { setSubmitting(false); } }; const handleSaveComment = async (comment: Comment, content: string) => { try { const r = await api.updateComment(comment.id, content); setComments(list => list.map(c => ( c.id === comment.id ? { ...c, content: r.content || content, updated_at: new Date().toISOString(), status: r.status || c.status, } : c ))); setEditingCommentId(null); notify.success(r.message || '评论已更新'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '保存失败'); throw e; } }; const handleDeleteComment = async (comment: Comment) => { try { await api.deleteComment(comment.id); const removeIds = collectCommentSubtreeIds(comments, comment.id); setComments(list => list.filter(c => !removeIds.has(c.id))); if (replyTo && removeIds.has(replyTo.id)) setReplyTo(null); if (editingCommentId != null && removeIds.has(editingCommentId)) setEditingCommentId(null); notify.success('评论已移入回收站'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '删除失败'); throw e; } }; const handleApproveComment = async (comment: Comment) => { try { const r = await api.adminApproveComment(comment.id); setComments(list => list.map(c => ( c.id === comment.id ? { ...c, status: r.status } : c ))); notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '审核失败'); throw e; } }; const handleDeletePost = async () => { setDeletingPost(true); try { await api.deletePost(postId); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success('帖子已删除'); nav('/', { replace: true }); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '删除失败'); } finally { setDeletingPost(false); } }; const commentBoxProps = { user, submitting, submitCount, onSubmit: handleSubmitComment, onCancelReply: () => setReplyTo(null), }; if (loading) return
; if (!post) { return ( ); } const authorInitial = post.user?.nickname?.[0] || '?'; const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? []; const isOwnerOrAdmin = !!(user && (user.role === 'admin' || user.id === post.user_id)); const isAdmin = user?.role === 'admin'; const showEdited = isEdited && post.updated_at; const editRemaining = canEdit && user?.role !== 'admin' ? formatEditRemaining(post.created_at, editWindowHours) : ''; const handlePin = async () => { if (!post) return; try { const r = await api.adminPinPost(postId, !post.pinned); setPost(p => p ? { ...p, pinned: r.pinned } : p); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleBoardPin = async () => { if (!post) return; try { const r = await api.adminBoardPinPost(postId, !post.board_pinned); setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleFeature = async () => { if (!post) return; try { const r = await api.adminFeaturePost(postId, !post.featured); setPost(p => p ? { ...p, featured: r.featured } : p); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleToggleResolved = async () => { if (!post || post.post_type !== 'question') return; const next = !post.question_resolved; try { const r = await api.setQuestionResolved(postId, next); setPost(p => p ? { ...p, question_resolved: r.question_resolved } : p); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleApprove = async () => { if (!post) return; try { const r = await api.adminApprovePost(postId); setPost(p => p ? { ...p, status: r.status } : p); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; const handleReport = async () => { if (!user) { requireLogin('举报'); return; } setReporting(true); try { const r = await api.reportPost(postId, { reason: reportReason, detail: reportDetail.trim() || undefined, }); notify.success(r.message); setReportOpen(false); setReportDetail(''); setReportReason('spam'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '举报失败'); } finally { setReporting(false); } }; const handleReject = async () => { if (!rejectReason.trim()) { notify.warning('请填写拒绝原因'); return; } setRejecting(true); try { const r = await api.adminRejectPost(postId, rejectReason.trim()); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success(r.message); setRejectOpen(false); nav('/'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } finally { setRejecting(false); } }; const handleLock = async () => { if (!post) return; try { const r = await api.adminLockPost(postId, !post.edit_locked); setPost(p => p ? { ...p, edit_locked: r.edit_locked } : p); if (user?.role === 'admin') { setCanEdit(true); } else if (user?.id === post.user_id && r.edit_locked) { setCanEdit(false); setEditBlockReason('帖子已被管理员锁定,无法编辑'); } notify.success(r.message); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; return (
{post.board && ( )}
{post.status === 'pending' && (
该帖子审核中,仅你与管理员可见;通过后将公开显示。
)} {post.status === 'rejected' && (
该帖子未通过审核,仅你与管理员可见。可修改后重新提交,或查看站内私信中的拒绝原因。
)}

{post.pinned && ( 全局置顶 )} {post.board_pinned && ( 板块置顶 )} {post.featured && } {post.status === 'pending' && 审核中} {post.status === 'rejected' && 未通过} {post.post_type === 'question' && ( {post.question_resolved ? '已解决' : '未解决'} )} {post.title}

{post.user?.avatar ? : authorInitial}
发布于 {formatDateTime(post.created_at)} {showEdited && ( <> · 编辑于 {formatDateTime(post.updated_at!)} )} {' · '}{post.view_count} 次浏览 {post.edit_locked && ( 已锁定 )}
{tags.length > 0 && (
{tags.map(t => {t})}
)} {isMobile && headings.length > 0 && (
文章目录({headings.length})
)} { void reloadPostContent(); }} />
{!user ? ( ) : user.id !== post.user_id ? ( setReportOpen(true)} > 举报 ) : null}
{(isOwnerOrAdmin || canEdit || isAdmin) && (
{isOwnerOrAdmin && post.post_type === 'question' && ( )} {canEdit && ( )} {isOwnerOrAdmin && isEdited && ( )} {isAdmin && ( 确定删除该帖子? 帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。 取消 删除 )} {editRemaining && ( {editRemaining} )} {isOwnerOrAdmin && !canEdit && editBlockReason && ( {editBlockReason} )} {isAdmin && ( <> {(post.status === 'pending' || post.status === 'rejected') && ( )} {post.status !== 'rejected' && ( )} )}
)}
举报帖子 请选择原因,管理员将尽快处理。