Files
jiang13-forum/frontend/src/pages/PostDetailPage.tsx
freefire b24f6c23ea 优化帖子详情与列表展示,并新增问答帖类型与已解决状态。
统一标题锚点定位、编辑底栏固定与操作栏分层,列表徽章前置并区分置顶/问答配色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:19:32 +08:00

888 lines
32 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 } 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 } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon';
import PinnedIcon from '@/components/PinnedIcon';
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 {
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 { 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<LayoutCtx>();
const [post, setPost] = useState<PostItem | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
const [replyTo, setReplyTo] = useState<Comment | null>(null);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(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<PostHeading[]>([]);
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 pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
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 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 reloadComments();
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);
setComments(list => list.filter(c => c.id !== comment.id));
if (replyTo?.id === comment.id) setReplyTo(null);
if (editingCommentId === comment.id) 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 <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 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 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 (
<article className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header">
<div className="post-detail-nav">
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
{post.board && (
<BoardBadge board={post.board} className="post-detail-board-tag" />
)}
</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--icon post-pin-badge--detail" title="置顶">
<PinnedIcon size={16} />
</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.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" />
<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>
)}
</span>
</div>
</div>
</div>
{tags.length > 0 && (
<div className="post-detail-tags">
{tags.map(t => <Badge key={t} variant="secondary">{t}</Badge>)}
</div>
)}
{isMobile && headings.length > 0 && (
<details className="post-detail-toc-mobile">
<summary>{headings.length}</summary>
<ArticleOutline
headings={headings}
scrollRoot={pageRef.current}
title="目录"
/>
</details>
)}
<PostContent
html={post.content || ''}
isLoggedIn={!!user}
onHeadingsChange={handleHeadingsChange}
/>
<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 && user.id !== post.user_id && (
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
<Flag />
</Button>
)}
{!user && (
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
<Flag />
</Button>
)}
</div>
{(isOwnerOrAdmin || canEdit || isAdmin) && (
<div className="post-detail-actions-manage">
{isOwnerOrAdmin && post.post_type === 'question' && (
<Button
variant={post.question_resolved ? 'outline' : 'default'}
size="sm"
onClick={handleToggleResolved}
>
{post.question_resolved ? <CircleHelp /> : <CircleCheck />}
{post.question_resolved ? '标为未解决' : '标为已解决'}
</Button>
)}
{canEdit && (
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
<Pencil />
</Button>
)}
{isOwnerOrAdmin && isEdited && (
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
<History />
</Button>
)}
{isAdmin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deletingPost}>
<Trash2 />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeletePost}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{editRemaining && (
<span className="post-detail-edit-hint">{editRemaining}</span>
)}
{isOwnerOrAdmin && !canEdit && editBlockReason && (
<span className="post-detail-edit-hint" title={editBlockReason}>
{editBlockReason}
</span>
)}
{isAdmin && (
<>
{(post.status === 'pending' || post.status === 'rejected') && (
<Button variant="default" size="sm" onClick={handleApprove}>
</Button>
)}
<Button variant="outline" size="sm" onClick={handleFeature}>
<Sparkles />
{post.featured ? '取消精华' : '设为精华'}
</Button>
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
</Button>
<Button variant="outline" size="sm" onClick={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
{post.status !== 'rejected' && (
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
<Ban />
</Button>
)}
</>
)}
</div>
)}
</div>
</div>
<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>
{!replyTo && (
<div className="comment-box-wrap" ref={commentBoxRef}>
<CommentBox {...commentBoxProps} />
</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>{user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}</p>
</div>
) : (
<CommentThreadList
comments={comments}
highlightFloor={highlightFloor}
replyToId={replyTo?.id ?? null}
editingId={editingCommentId}
currentUser={user}
onReply={handleReplyTo}
onCancelReply={() => setReplyTo(null)}
onStartEdit={(c) => {
setReplyTo(null);
setEditingCommentId(c.id);
}}
onCancelEdit={() => setEditingCommentId(null)}
onSaveEdit={handleSaveComment}
onDelete={handleDeleteComment}
onApprove={user?.role === 'admin' ? handleApproveComment : undefined}
renderReplyBox={(c) => (
<CommentBox
key={c.id}
{...commentBoxProps}
replyTo={c}
inline
/>
)}
/>
)}
</div>
</div>
<InFlowSiteFooter />
</article>
);
}