Files
jiang13-forum/frontend/src/pages/PostDetailPage.tsx

1423 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, 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,
} 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, PollView, PostLotteryView } 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 PostPollCard from '../components/PostPollCard';
import PostBountyBanner from '../components/PostBountyBanner';
import { findCommentFloor } from '../utils/bounty';
import PostLotteryCard from '../components/PostLotteryCard';
import PostRevisionPanel from '../components/PostRevisionPanel';
import PostManageMenu from '../components/PostManageMenu';
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, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
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 { extractHeadingsFromHtml } from '../utils/postHeadings';
import { renderPostContentHtml } from '../utils/postContent';
import { InFlowSiteFooter } from '../components/SiteFooter';
import NotFoundPage from './NotFoundPage';
type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
function postDetailCacheKey(id: number) {
return `post:${id}`;
}
/** 格式化剩余可编辑时间 */
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 navType = useNavigationType();
const { user, refresh } = useAuth();
const { limits } = useForumLimits();
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
const initialSnap = (postId && !Number.isNaN(postId))
? getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId))
: undefined;
const [post, setPost] = useState<PostItem | null>(initialSnap?.post ?? null);
const [poll, setPoll] = useState<PollView | null>(initialSnap?.poll ?? null);
const [lottery, setLottery] = useState<PostLotteryView | null>(initialSnap?.lottery ?? null);
const [comments, setComments] = useState<Comment[]>(initialSnap?.comments ?? []);
const [liked, setLiked] = useState(initialSnap?.liked ?? false);
const [favorited, setFavorited] = useState(initialSnap?.favorited ?? false);
const [replyTo, setReplyTo] = useState<Comment | null>(null);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(!initialSnap);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0);
const [canEdit, setCanEdit] = useState(initialSnap?.canEdit ?? false);
const [isEdited, setIsEdited] = useState(initialSnap?.isEdited ?? false);
const [editBlockReason, setEditBlockReason] = useState(initialSnap?.editBlockReason ?? '');
const [editWindowHours, setEditWindowHours] = useState(initialSnap?.editWindowHours ?? 0);
const [showRevisions, setShowRevisions] = useState(false);
const [deletingPost, setDeletingPost] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [composerOpen, setComposerOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState<ReportReason>('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 [bountyAwardTarget, setBountyAwardTarget] = useState<number | null>(null);
const [bountyAwarding, setBountyAwarding] = useState(false);
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
/** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => {
if (!initialSnap) return null;
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
return 0;
});
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
const postRef = useRef<PostItem | null>(null);
const scrollTopRef = useRef(
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
);
postRef.current = post;
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 isLoggedIn = !!user;
// 同步从正文派生目录,避免预取缓存命中后 setHeadings([]) 盖掉子组件上报且不再回调
const headings = useMemo(() => {
if (!postContent.trim()) return [];
const rendered = renderPostContentHtml(postContent, isLoggedIn, {
openLinksInNewTab: limits.open_content_links_in_new_tab,
});
return extractHeadingsFromHtml(rendered);
}, [postContent, isLoggedIn, limits.open_content_links_in_new_tab]);
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);
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);
const applySnapshot = useCallback((snap: PostDetailSnapshot, opts?: { restoreScroll?: boolean }) => {
setPost(snap.post);
setPoll(snap.poll);
setLottery(snap.lottery);
setLiked(snap.liked);
setFavorited(snap.favorited);
setCanEdit(snap.canEdit);
setIsEdited(snap.isEdited);
setEditBlockReason(snap.editBlockReason);
setEditWindowHours(snap.editWindowHours);
setBountyCanRefund(snap.bountyCanRefund);
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
setComments(snap.comments);
if (opts?.restoreScroll) {
scrollTopRef.current = snap.scrollTop;
setRestoreScrollTop(snap.scrollTop);
} else {
scrollTopRef.current = 0;
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
const el = pageRef.current;
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
}
}, []);
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
loadPostRef.current = (mode: 'auto' | 'force') => {
if (!postId || Number.isNaN(postId)) {
setPost(null);
setLoading(false);
return;
}
if (mode === 'force') {
deleteSessionSnapshot(postDetailCacheKey(postId));
}
const cached = mode === 'force'
? undefined
: getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId));
if (cached) {
loadSeq.current += 1;
applySnapshot(cached, { restoreScroll: navType === 'POP' && !location.hash });
setReplyTo(null);
setEditingCommentId(null);
setComposerOpen(false);
setLoading(false);
return;
}
const seq = ++loadSeq.current;
const keep = !!postRef.current;
setReplyTo(null);
setEditingCommentId(null);
setComposerOpen(false);
if (!keep) {
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;
const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
const snap: PostDetailSnapshot = {
post: detail.post,
comments: commentsList,
poll: detail.poll ?? null,
lottery: detail.lottery ?? null,
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
setSessionSnapshot(postDetailCacheKey(postId), snap);
applySnapshot(snap, { restoreScroll: false });
if (mode === 'force') {
const el = pageRef.current;
if (el) el.scrollTop = 0;
scrollTopRef.current = 0;
}
void refresh();
} catch {
if (seq !== loadSeq.current) return;
if (!keep) setPost(null);
} finally {
if (seq === loadSeq.current) setLoading(false);
}
})();
};
useEffect(() => {
loadPostRef.current('auto');
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
}, [postId]);
useEffect(() => {
// 下拉已预热则应用快照;否则强制重拉
const onForce = () => {
if (!postId || Number.isNaN(postId)) return;
const warm = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId));
if (warm) {
loadSeq.current += 1;
applySnapshot(warm, { restoreScroll: false });
setLoading(false);
const el = pageRef.current;
if (el) el.scrollTop = 0;
scrollTopRef.current = 0;
return;
}
loadPostRef.current('force');
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
};
}, [postId, applySnapshot]);
useEffect(() => {
const el = pageRef.current;
if (!el || loading || !post) return;
const onScroll = () => {
scrollTopRef.current = el.scrollTop;
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [loading, post]);
useLayoutEffect(() => {
if (restoreScrollTop == null || !pageRef.current || loading || !post) return;
pageRef.current.scrollTop = restoreScrollTop;
scrollTopRef.current = restoreScrollTop;
setRestoreScrollTop(null);
}, [restoreScrollTop, loading, post]);
useEffect(() => {
if (!post || post.id !== postId || loading) return;
setSessionSnapshot(postDetailCacheKey(post.id), {
post,
comments,
poll,
lottery,
liked,
favorited,
canEdit,
isEdited,
editBlockReason,
editWindowHours,
bountyCanRefund,
bountyRefundBlockReason,
bountyEligibleReplyCount,
scrollTop: scrollTopRef.current,
});
}, [
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason,
bountyEligibleReplyCount, postId, loading,
]);
useEffect(() => () => {
const p = postRef.current;
if (!p) return;
const prev = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(p.id));
if (!prev) return;
setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current });
}, []);
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);
setPoll(detail.poll ?? null);
setLottery(detail.lottery ?? null);
setBountyCanRefund(detail.bounty_can_refund ?? true);
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
} catch {
// 评论已成功,正文刷新失败不阻断
}
}, [postId]);
const focusCommentEditor = useCallback(() => {
const root = commentBoxRef.current;
if (!root) return;
const editable = root.querySelector<HTMLElement>('.ProseMirror, [contenteditable="true"]');
editable?.focus({ preventScroll: true });
}, []);
const scrollToCommentBox = useCallback(() => {
setComposerOpen(true);
// 等折叠条展开后再滚入视口并聚焦 TipTap
window.setTimeout(() => {
const target = commentBoxRef.current || commentSectionRef.current;
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(focusCommentEditor, 80);
}, 50);
}, [focusCommentEditor]);
// 手机端展开输入框后聚焦编辑器
useEffect(() => {
if (!composerOpen || !isMobile) return;
const timer = window.setTimeout(focusCommentEditor, 60);
return () => window.clearTimeout(timer);
}, [composerOpen, isMobile, focusCommentEditor]);
// 手机端:焦点离开评论输入区(含工具栏)后收起为「说点什么…」
useEffect(() => {
if (!isMobile || !composerOpen) return;
const root = commentBoxRef.current;
if (!root) return;
const onFocusOut = (e: FocusEvent) => {
const next = e.relatedTarget as Node | null;
if (next && root.contains(next)) return;
// 等焦点真正落定(工具栏 / Dialog / 系统弹窗)后再判断是否收起
window.setTimeout(() => {
const active = document.activeElement;
if (active && root.contains(active)) return;
// 代码块 Dialog 等浮层打开时不收起
if (document.querySelector('[role="dialog"][data-state="open"]')) return;
setComposerOpen(false);
}, 50);
};
root.addEventListener('focusout', onFocusOut);
return () => root.removeEventListener('focusout', onFocusOut);
}, [isMobile, composerOpen]);
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return false;
const root = pageRef.current;
if (!root) return false;
// 直接计算目标滚动位置
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const relativeTop = elRect.top - rootRect.top;
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
const targetTop = root.scrollTop + relativeTop - 24;
// 如果 relativeTop 非常异常(如 NaN 或 Infinity说明布局可能还没完成
if (!isFinite(targetTop) || targetTop < -500) return false;
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
// 直接设置滚动位置(不使用平滑滚动,避免动画干扰)
root.scrollTop = clampedTop;
// 验证滚动结果,如果不准确则再次调整
// 注意:使用动态计算的 currentMaxScrollTop因为评论可能在之后才渲染完成
const verifyAndFix = () => {
const currentElRect = el.getBoundingClientRect();
const currentRootRect = root.getBoundingClientRect();
const currentRelativeTop = currentElRect.top - currentRootRect.top;
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
// 如果元素在视口内但位置不准确,进行修正
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 24) > 10) {
const correction = currentRelativeTop - 24;
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
root.scrollTop = newTarget;
}
};
// 在多个时间点验证和调整
requestAnimationFrame(verifyAndFix);
setTimeout(verifyAndFix, 50);
setTimeout(verifyAndFix, 150);
setHighlightFloor(floor);
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
return true;
}, []);
/** 正文标题锚点:滚动容器是 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;
if (!root) return false;
// 直接计算目标滚动位置
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const relativeTop = elRect.top - rootRect.top;
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
const targetTop = root.scrollTop + relativeTop - 12;
// 如果 relativeTop 非常异常(如 NaN 或 Infinity说明布局可能还没完成
if (!isFinite(targetTop) || targetTop < -500) return false;
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
// 直接设置滚动位置
root.scrollTop = clampedTop;
// 验证滚动结果,如果不准确则再次调整
const verifyAndFix = () => {
const currentElRect = el.getBoundingClientRect();
const currentRootRect = root.getBoundingClientRect();
const currentRelativeTop = currentElRect.top - currentRootRect.top;
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 12) > 10) {
const correction = currentRelativeTop - 12;
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
root.scrollTop = newTarget;
}
};
requestAnimationFrame(verifyAndFix);
if (smooth) {
setTimeout(verifyAndFix, 50);
setTimeout(verifyAndFix, 150);
}
return true;
}, []);
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
useEffect(() => {
if (loading || !post) return;
const m = location.hash.match(/^#floor-(\d+)$/);
if (!m) return;
const floor = Number(m[1]);
if (!floor) return;
let cancelled = false;
let attempts = 0;
let timer = 0;
const tryJump = () => {
if (cancelled) return;
if (jumpToFloor(floor)) return;
attempts += 1;
if (attempts < 30) {
timer = window.setTimeout(tryJump, 50);
}
};
timer = window.setTimeout(tryJump, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [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);
if (isMobile) setComposerOpen(false);
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);
deleteSessionSnapshot(postDetailCacheKey(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 && !post) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) {
return (
<NotFoundPage
title="帖子不存在"
description="该帖子不存在,或已被删除。"
/>
);
}
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 handleBountyAward = (commentId: number) => {
setBountyAwardTarget(commentId);
};
const confirmBountyAward = async () => {
if (bountyAwardTarget == null) return;
setBountyAwarding(true);
try {
await api.bountyAward(post.id, bountyAwardTarget);
notify.success('悬赏已发放');
setBountyAwardTarget(null);
await reloadPostContent();
await reloadComments();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setBountyAwarding(false);
}
};
const awardedCommentFloor = findCommentFloor(comments, post.bounty_comment_id);
const canJumpToAwarded = awardedCommentFloor != null;
const handleJumpToAwarded = () => {
if (awardedCommentFloor != null) jumpToFloor(awardedCommentFloor);
};
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 : '操作失败');
}
};
const handleCommentsLock = async () => {
if (!post) return;
try {
const r = await api.adminCommentsLockPost(postId, !post.comments_locked);
setPost(p => p ? { ...p, comments_locked: r.comments_locked } : p);
if (r.comments_locked) {
setReplyTo(null);
setEditingCommentId(null);
}
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
return (
<article className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header">
<div className="post-detail-nav">
<div className="post-detail-nav-left">
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
{post.board && (
<BoardBadge board={post.board} className="post-detail-board-tag" />
)}
</div>
{(isOwnerOrAdmin || canEdit || isAdmin) && (
<PostManageMenu
post={post}
isAdmin={isAdmin}
isOwnerOrAdmin={isOwnerOrAdmin}
canEdit={canEdit}
isEdited={isEdited}
isMobile={isMobile}
editRemaining={editRemaining}
editBlockReason={editBlockReason}
deleting={deletingPost}
onEdit={() => nav(`/post/${postId}/edit`)}
onShowRevisions={() => setShowRevisions(true)}
onToggleResolved={handleToggleResolved}
onApprove={handleApprove}
onReject={() => setRejectOpen(true)}
onFeature={handleFeature}
onPin={handlePin}
onBoardPin={handleBoardPin}
onLock={handleLock}
onCommentsLock={handleCommentsLock}
onDelete={() => setDeleteOpen(true)}
/>
)}
</div>
{post.status === 'pending' && (
<div className="post-moderation-banner post-moderation-banner--pending">
</div>
)}
{post.status === 'rejected' && (
<div className="post-moderation-banner post-moderation-banner--rejected">
</div>
)}
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && (
<span className="post-pin-badge post-pin-badge--detail" title="全局置顶"></span>
)}
{post.board_pinned && (
<span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶"></span>
)}
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle"></Badge>}
{post.status === 'rejected' && <Badge variant="destructive" className="mr-2 align-middle"></Badge>}
{post.post_type === 'question' && (
<span
className={`post-qa-badge post-qa-badge--detail${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll"></span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open post-bounty-badge--detail"> {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded post-bounty-badge--detail"></span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
{post.title}
</h1>
<div className="post-detail-author-row">
<UserLink
user={post.user}
showAvatar={false}
showName={false}
className="post-avatar post-avatar-lg user-link--avatar-only"
>
{post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: authorInitial}
</UserLink>
<div className="post-detail-author-info">
<UserLink user={post.user} className="post-detail-author-name" showBadges />
<span className="post-detail-meta-line">
{formatDateTime(post.created_at)}
{showEdited && (
<> · {formatDateTime(post.updated_at!)}</>
)}
{' · '}{post.view_count}
{post.edit_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
<Lock size={12} />
</span>
)}
{post.comments_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定讨论">
<MessageSquareOff size={12} />
</span>
)}
</span>
</div>
</div>
</div>
{tags.length > 0 && (
<div className="post-detail-tags">
{tags.map(t => (
<button
key={t}
type="button"
className="post-detail-tag-btn"
onClick={() => nav(`/?tag=${encodeURIComponent(t)}`)}
>
<Badge variant="secondary">{t}</Badge>
</button>
))}
</div>
)}
{isMobile && headings.length > 0 && (
<details className="post-detail-toc-mobile">
<summary>{headings.length}</summary>
<ArticleOutline
headings={headings}
scrollRoot={pageRef.current}
title="目录"
/>
</details>
)}
{poll && (
<PostPollCard
postId={post.id}
poll={poll}
isOwnerOrAdmin={isOwnerOrAdmin}
onUpdate={setPoll}
/>
)}
<PostBountyBanner
post={post}
isOwnerOrAdmin={isOwnerOrAdmin}
isAdmin={isAdmin}
canRefund={bountyCanRefund}
refundBlockReason={bountyRefundBlockReason}
eligibleReplyCount={bountyEligibleReplyCount}
onUpdate={reloadPostContent}
onJumpToAwarded={handleJumpToAwarded}
canJumpToAwarded={canJumpToAwarded}
/>
{lottery && (
<PostLotteryCard
postId={post.id}
lottery={lottery}
isOwnerOrAdmin={isOwnerOrAdmin}
onUpdate={v => { setLottery(v); void reloadPostContent(); }}
/>
)}
<PostContent
html={post.content || ''}
isLoggedIn={isLoggedIn}
postId={post.id}
onRequestReply={scrollToCommentBox}
onUnlocked={() => { void reloadPostContent(); }}
/>
<div className="post-detail-actions">
<div className="post-detail-actions-primary">
<Button
variant={liked ? 'default' : 'outline'}
size="sm"
onClick={handleLike}
title={!user ? '登录后即可点赞' : undefined}
>
<ThumbsUp />
{post.like_count}
</Button>
<Button
variant={favorited ? 'default' : 'outline'}
size="sm"
onClick={handleFavorite}
title={!user ? '登录后即可收藏' : undefined}
>
<Star />
{favorited ? '已收藏' : '收藏'}
</Button>
{!user ? (
<Button
variant="outline"
size="sm"
className="post-detail-more-btn"
aria-label="更多操作"
onClick={() => requireLogin('举报')}
>
<MoreHorizontal />
</Button>
) : user.id !== post.user_id ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="post-detail-more-btn"
aria-label="更多操作"
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="report-more-menu">
<DropdownMenuItem
className="report-more-menu__item"
onSelect={() => setReportOpen(true)}
>
<Flag size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletingPost}></AlertDialogCancel>
<AlertDialogAction
disabled={deletingPost}
onClick={(e) => {
e.preventDefault();
void handleDeletePost();
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="pm-compose-fields">
<label className="pm-field">
<span></span>
<select
value={reportReason}
onChange={(e) => setReportReason(e.target.value as ReportReason)}
>
{REPORT_REASON_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="pm-field">
<span></span>
<textarea
value={reportDetail}
onChange={(e) => setReportDetail(e.target.value)}
rows={4}
maxLength={500}
placeholder="补充更多细节…"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReportOpen(false)}></Button>
<Button loading={reporting} onClick={handleReport}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="pm-compose-fields">
<label className="pm-field">
<span></span>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={5}
maxLength={1000}
placeholder="请说明未通过的原因…"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectOpen(false)}></Button>
<Button variant="destructive" loading={rejecting} onClick={handleReject}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PostRevisionPanel
postId={postId}
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
open={showRevisions}
onClose={() => setShowRevisions(false)}
isLoggedIn={!!user}
/>
<div className="comment-section" ref={commentSectionRef}>
<div className="comment-section-bar">
<span className="comment-section-title"></span>
<span className="comment-section-count">{comments.length} </span>
</div>
{post.comments_locked ? (
<div className="comment-locked-banner" role="status">
<MessageSquareOff size={16} aria-hidden />
</div>
) : !replyTo && (
<div className="comment-box-wrap" ref={commentBoxRef}>
{isMobile && !composerOpen && (
<button
type="button"
className="comment-composer-collapsed"
onClick={() => {
if (!user) {
requireLogin('评论');
return;
}
setComposerOpen(true);
window.requestAnimationFrame(() => {
window.setTimeout(focusCommentEditor, 50);
});
}}
>
{user ? (
<>
<span className="comment-composer-collapsed-avatar" aria-hidden>
{user.avatar
? <img src={user.avatar} alt="" loading="lazy" decoding="async" />
: (user.nickname?.[0] || '?')}
</span>
<span className="comment-composer-collapsed-placeholder"></span>
</>
) : (
<span className="comment-composer-collapsed-placeholder"></span>
)}
</button>
)}
<div
className={
isMobile && !composerOpen
? 'comment-box-expanded-slot is-collapsed'
: 'comment-box-expanded-slot'
}
aria-hidden={isMobile && !composerOpen}
>
<CommentBox {...commentBoxProps} />
</div>
</div>
)}
<div className="comment-list-area">
{comments.length === 0 && !replyTo ? (
<div className="comment-empty">
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
<p>
{post.comments_locked
? '暂无评论'
: user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}
</p>
</div>
) : (
<CommentThreadList
comments={comments}
highlightFloor={highlightFloor}
replyToId={post.comments_locked ? null : (replyTo?.id ?? null)}
editingId={editingCommentId}
currentUser={user}
onReply={post.comments_locked ? () => undefined : handleReplyTo}
onCancelReply={() => setReplyTo(null)}
onStartEdit={(c) => {
setReplyTo(null);
setEditingCommentId(c.id);
}}
onCancelEdit={() => setEditingCommentId(null)}
onSaveEdit={handleSaveComment}
onDelete={handleDeleteComment}
onApprove={user?.role === 'admin' ? handleApproveComment : undefined}
onRequireLogin={requireLogin}
onLikeUpdate={(commentId, liked, likeCount) => {
setComments(list => list.map(item => (
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
)));
}}
renderReplyBox={post.comments_locked ? undefined : (c) => (
<CommentBox
key={c.id}
{...commentBoxProps}
replyTo={c}
inline
/>
)}
bountyAward={post.post_type === 'bounty' ? {
open: post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0,
awardedCommentId: post.bounty_comment_id,
postAuthorId: post.user_id,
canAward: isOwnerOrAdmin,
onAward: handleBountyAward,
} : undefined}
/>
)}
</div>
</div>
<AlertDialog
open={bountyAwardTarget != null}
onOpenChange={(open) => { if (!open && !bountyAwarding) setBountyAwardTarget(null); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{post.bounty_points ?? 0}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bountyAwarding}></AlertDialogCancel>
<AlertDialogAction
disabled={bountyAwarding}
onClick={(e) => {
e.preventDefault();
void confirmBountyAward();
}}
>
{bountyAwarding ? '发放中…' : '确认采纳'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<InFlowSiteFooter />
</article>
);
}