import { useState, useEffect } from 'react'; import { Check, Award, Clock, History, MessageSquare, X, Pencil, Trash2, ThumbsUp, MoreHorizontal, Flag, } from 'lucide-react'; import type { ReactNode } from 'react'; import type { Comment, ReportReason, User } from '../api/types'; import { api } from '../api/client'; import CommentContent from './CommentContent'; import CommentEditor from './CommentEditor'; import CommentRevisionDialog from './CommentRevisionDialog'; 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { notify } from '@/lib/notify'; import { commentNick, commentInitial, formatCommentDate, isGuestComment, buildCommentTree, type CommentNode, } from '../utils/comment'; import { isTimeDiffSignificant } from '../utils/content'; import { REPORT_REASON_OPTIONS } from '../utils/report'; import { useForumLimits } from '../hooks/useForumLimits'; import { isHtmlEmpty } from '../utils/postContent'; import { Tooltip } from './ui/Tooltip'; import UserLink from './UserLink'; import { cn } from '@/lib/utils'; import { pinAwardedCommentTree } from '../utils/bounty'; function isCommentAuthor(c: Comment, user?: User | null): boolean { return !!user && c.user_id > 0 && c.user_id === user.id; } function canEditComment(c: Comment, user: User | null | undefined, windowMinutes: number): boolean { if (!user) return false; if (user.role === 'admin') return true; if (!isCommentAuthor(c, user)) return false; if (windowMinutes <= 0) return true; const created = new Date(c.created_at).getTime(); if (Number.isNaN(created)) return false; return Date.now() - created <= windowMinutes * 60_000; } interface ItemProps { node: CommentNode; nested?: boolean; highlightFloor?: number | null; replyToId?: number | null; editingId?: number | null; currentUser?: User | null; onReply: (comment: Comment) => void; onCancelReply: () => void; onStartEdit: (comment: Comment) => void; onCancelEdit: () => void; onSaveEdit: (comment: Comment, content: string) => Promise; onDelete: (comment: Comment) => Promise; onApprove?: (comment: Comment) => Promise; onRequireLogin?: (actionLabel: string) => void; onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void; renderReplyBox?: (comment: Comment) => ReactNode; bountyAward?: { open: boolean; awardedCommentId?: number; postAuthorId: number; canAward: boolean; onAward: (commentId: number) => void; }; } /** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */ function CommentItem({ node, nested, highlightFloor, replyToId, editingId, currentUser, onReply, onCancelReply, onStartEdit, onCancelEdit, onSaveEdit, onDelete, onApprove, onRequireLogin, onLikeUpdate, renderReplyBox, bountyAward, }: ItemProps) { const { limits } = useForumLimits(); const c = node.comment; const nick = commentNick(c); const guest = isGuestComment(c); const isHighlighted = highlightFloor === c.floor; const isBountyAwarded = bountyAward?.awardedCommentId === c.id; const hidden = !!c.content_hidden; const isReplying = replyToId === c.id; const isEditing = editingId === c.id; const isAdmin = currentUser?.role === 'admin'; const editWindowMinutes = limits.comment_edit_window_minutes ?? 3; // 到期后强制重渲染,使「编辑」按钮自动消失 const [, setEditExpireTick] = useState(0); useEffect(() => { if (!currentUser || currentUser.role === 'admin') return; if (!isCommentAuthor(c, currentUser)) return; if (editWindowMinutes <= 0) return; const created = new Date(c.created_at).getTime(); if (Number.isNaN(created)) return; const remaining = created + editWindowMinutes * 60_000 - Date.now(); if (remaining <= 0) return; const timer = window.setTimeout(() => setEditExpireTick(n => n + 1), remaining + 30); return () => window.clearTimeout(timer); }, [c.created_at, c.id, c.user_id, currentUser, editWindowMinutes]); const canEdit = canEditComment(c, currentUser, editWindowMinutes); const canDelete = isAdmin; const canApprove = isAdmin && (c.status === 'pending' || c.status === 'rejected') && !!onApprove; const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at); const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser); const [editText, setEditText] = useState(c.content); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [approving, setApproving] = useState(false); const [liking, setLiking] = useState(false); const [liked, setLiked] = useState(!!c.liked); const [likeCount, setLikeCount] = useState(c.like_count ?? 0); const [revOpen, setRevOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [reportReason, setReportReason] = useState('spam'); const [reportDetail, setReportDetail] = useState(''); const [reporting, setReporting] = useState(false); useEffect(() => { if (isEditing) setEditText(c.content); }, [isEditing, c.content, c.id]); useEffect(() => { setLiked(!!c.liked); setLikeCount(c.like_count ?? 0); }, [c.id, c.liked, c.like_count]); const handleSave = async () => { const next = editText.trim(); if (!next) return; setSaving(true); try { await onSaveEdit(c, next); } finally { setSaving(false); } }; const handleLike = async () => { if (!currentUser) { onRequireLogin?.('点赞'); return; } if (liking) return; setLiking(true); try { const r = await api.likeComment(c.id); setLiked(r.liked); setLikeCount(r.like_count); onLikeUpdate?.(c.id, r.liked, r.like_count); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '点赞失败'); } finally { setLiking(false); } }; const openReport = () => { if (!currentUser) { onRequireLogin?.('举报'); return; } setReportReason('spam'); setReportDetail(''); setReportOpen(true); }; const handleReport = async () => { setReporting(true); try { const r = await api.reportComment(c.id, { reason: reportReason, detail: reportDetail.trim() || undefined, }); notify.success(r.message); setReportOpen(false); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '举报失败'); } finally { setReporting(false); } }; return (
{!guest && c.user_id ? ( {c.user?.avatar ? ( ) : ( commentInitial(c) )} ) : (
{c.user?.avatar ? ( ) : ( commentInitial(c) )}
)}
{guest && c.guest_url ? ( {nick} ) : !guest && c.user_id ? ( ) : ( {nick} )} {isBountyAwarded && ( 已采纳 )} {!hidden && ( )}
{hidden ? (
该评论为私密评论,仅文章作者与评论发起者可见!
) : isEditing ? (
) : (
)}
{formatCommentDate(c.created_at)} {isAdmin && showEdited && · 已编辑} {c.status === 'pending' && · 审核中} {c.status === 'rejected' && · 未通过} {c.reply_target && ( @{commentNick(c.reply_target)} )} {!hidden && !isEditing && canApprove && ( )} {!hidden && !isEditing && !!renderReplyBox && ( isReplying ? ( ) : ( ) )} {!hidden && !isEditing && canEdit && ( )} {bountyAward?.open && bountyAward.canAward && c.user_id !== bountyAward.postAuthorId && c.status === 'published' && !hidden && ( )} {!hidden && !isEditing && isAdmin && showEdited && ( )} {!hidden && !isEditing && canDelete && ( 确定删除该评论? 将同时移入回收站其下所有回复,可在后台恢复或永久删除。 取消 { setDeleting(true); try { await onDelete(c); } finally { setDeleting(false); } }} > 删除 )} {canReport && ( 举报 )}
{isAdmin && ( )} 举报评论 请选择原因,管理员将尽快处理。