"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { MessageSquare, Send } from "lucide-react"; import { apiCreateSnippetComment, apiUpdateSnippetComment, apiDeleteSnippetComment, apiSnippetCommentHistory, apiMe, type SnippetCommentItem, } from "@/lib/api"; import { toast } from "@/lib/toast"; import { formatRelative, formatDateTime } from "@/lib/format"; import Avatar from "@/components/Avatar"; import { useUrlStyle } from "@/components/SiteBrandProvider"; import { userPath } from "@/lib/urlStyle"; import Link from "next/link"; import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import EditHistoryModal, { type EditHistoryItem } from "@/components/EditHistoryModal"; import ConfirmDialog from "@/components/ConfirmDialog"; import { snippetCommentCountEvent } from "@/components/SnippetCommentCountChip"; const COMMENT_MAX = 2000; // 与后端 snippetCommentMaxLen 一致 type MoreItem = { key: string; label: string; danger?: boolean; disabled?: boolean; onSelect: () => void; }; /** 低频操作:编辑、历史、删除。Escape 关闭并回到触发按钮。 */ function CommentMoreMenu({ items }: { items: MoreItem[] }) { const [open, setOpen] = useState(false); const ref = useRef(null); const btnRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.preventDefault(); e.stopPropagation(); setOpen(false); btnRef.current?.focus(); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); if (items.length === 0) return null; const normal = items.filter((item) => !item.danger); const danger = items.filter((item) => item.danger); return (
{open && (
{normal.map((item) => ( ))} {normal.length > 0 && danger.length > 0 ?
: null} {danger.map((item) => ( ))}
)}
); } function CommentNode({ item, depth, snippetId, onUpdate, onDelete, onOpenHistory, loggedIn, }: { item: SnippetCommentItem; depth: number; snippetId: number; onUpdate: (id: number, content: string) => Promise; onDelete: (id: number) => Promise; onOpenHistory: (id: number, content: string) => void; loggedIn: boolean; }) { const urlStyle = useUrlStyle(); const [editing, setEditing] = useState(false); const [editContent, setEditContent] = useState(item.content); const [editUploading, setEditUploading] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const [replyContent, setReplyContent] = useState(""); const [replyUploading, setReplyUploading] = useState(false); const [submitting, setSubmitting] = useState(false); const [currentUserId, setCurrentUserId] = useState(null); const [isStaff, setIsStaff] = useState(false); const [deleteTarget, setDeleteTarget] = useState(false); useEffect(() => { apiMe() .then((res) => { if (res.user) { setCurrentUserId(res.user.id); setIsStaff(["admin", "moderator"].includes(res.user.role)); } }) .catch(() => {}); }, []); const canEdit = currentUserId === item.user_id; const canDelete = canEdit || isStaff; const canViewHistory = loggedIn; const userName = item.user ? item.user.nickname || item.user.username : "已注销"; const isFloor = depth === 0; const handleEdit = async () => { const next = editContent.trim(); if (!next || submitting) return; setSubmitting(true); try { await onUpdate(item.id, next); setEditing(false); toast("已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSubmitting(false); } }; const handleDelete = async () => { setDeleteTarget(false); if (submitting) return; setSubmitting(true); try { await onDelete(item.id); toast("已删除", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "删除失败"); } finally { setSubmitting(false); } }; const handleReply = async () => { const next = replyContent.trim(); if (!next || submitting) return; setSubmitting(true); try { await apiCreateSnippetComment(snippetId, next, item.id); setReplyContent(""); setReplyOpen(false); toast("已回复", "ok"); window.dispatchEvent(new CustomEvent("j13-snippet-comment-refresh")); } catch (e) { toast(e instanceof Error ? e.message : "回复失败"); } finally { setSubmitting(false); } }; const moreItems: MoreItem[] = []; if (canEdit && !editing) { moreItems.push({ key: "edit", label: "编辑", disabled: submitting, onSelect: () => setEditing(true), }); } if (canViewHistory && item.edited && !editing) { moreItems.push({ key: "history", label: "编辑历史", onSelect: () => onOpenHistory(item.id, item.content), }); } if (canDelete && !editing) { moreItems.push({ key: "delete", label: "删除", danger: true, disabled: submitting, onSelect: () => setDeleteTarget(true), }); } const editedMark = item.edited ? ( canViewHistory ? ( ) : ( 已编辑 ) ) : null; if (item.deleted) { return (
{userName} {item.floor && item.floor > 0 && ( #{item.floor} )} {formatRelative(item.created_at)}

该评论已删除

{item.replies && item.replies.length > 0 && (
{item.replies.map((r) => ( ))}
)}
setDeleteTarget(false)} />
); } return (
{userName} {item.floor && item.floor > 0 && ( #{item.floor} )} {editedMark}
{editing ? (
setEditContent(v.slice(0, COMMENT_MAX))} maxChars={COMMENT_MAX} disabled={submitting} autoFocus allowHide={false} ariaLabel="编辑评论" placeholder="编辑评论…" shortcutName="保存" onUploadingChange={setEditUploading} onSubmitShortcut={() => { void handleEdit(); }} footer={ <> } />
) : ( )}
{!editing && loggedIn && ( )}
{replyOpen && !editing && (
{ e.preventDefault(); void handleReply(); }} className="cmt-reply-form" >
回复 @{userName}
setReplyContent(v.slice(0, COMMENT_MAX))} maxChars={COMMENT_MAX} disabled={submitting} autoFocus allowHide={false} ariaLabel={`回复 ${userName}`} placeholder="写下你的回复…" shortcutName="回复" onUploadingChange={setReplyUploading} onSubmitShortcut={() => { void handleReply(); }} footer={ } />
)} {item.replies && item.replies.length > 0 && (
{item.replies.map((r) => ( ))}
)}
setDeleteTarget(false)} />
); } export default function SnippetCommentSection({ snippetId, initialItems, initialTotal, urlStyle: _urlStyle, }: { snippetId: number; initialItems: SnippetCommentItem[]; initialTotal: number; urlStyle: string; }) { const [items, setItems] = useState(initialItems); const [total, setTotal] = useState(initialTotal); const [content, setContent] = useState(""); const [submitting, setSubmitting] = useState(false); const [uploading, setUploading] = useState(false); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [loggedIn, setLoggedIn] = useState(false); const [historyCommentId, setHistoryCommentId] = useState(null); const [historyCommentContent, setHistoryCommentContent] = useState(""); useEffect(() => { window.dispatchEvent(new CustomEvent(snippetCommentCountEvent(snippetId), { detail: total })); }, [total, snippetId]); useEffect(() => { apiMe() .then((res) => setLoggedIn(!!res.user)) .catch(() => {}); }, []); const loadComments = useCallback( async (p: number) => { setLoading(true); try { const { fetchSnippetComments } = await import("@/lib/api"); const res = await fetchSnippetComments(snippetId, p, 20); setItems(res.items); setTotal(res.total); setPage(p); } catch { toast("加载评论失败"); } finally { setLoading(false); } }, [snippetId] ); useEffect(() => { const onRefresh = () => loadComments(page); window.addEventListener("j13-snippet-comment-refresh", onRefresh); return () => window.removeEventListener("j13-snippet-comment-refresh", onRefresh); }, [loadComments, page]); const handleSubmit = async () => { const next = content.trim(); if (!next || submitting) return; setSubmitting(true); try { await apiCreateSnippetComment(snippetId, next); setContent(""); toast("评论成功", "ok"); loadComments(1); } catch (e) { toast(e instanceof Error ? e.message : "评论失败"); } finally { setSubmitting(false); } }; const handleUpdate = async (cid: number, newContent: string) => { await apiUpdateSnippetComment(snippetId, cid, newContent); loadComments(page); }; const handleDelete = async (cid: number) => { await apiDeleteSnippetComment(snippetId, cid); loadComments(page); }; const openHistory = (cid: number, currentContent: string) => { setHistoryCommentContent(currentContent); setHistoryCommentId(cid); }; const fetchHistoryPage = async (p: number, size: number) => { if (!historyCommentId) return { items: [], total: 0, page: p, size }; const res = await apiSnippetCommentHistory(snippetId, historyCommentId, p, size); const items: EditHistoryItem[] = res.items.map((it) => ({ id: it.id, editor: it.editor ? { id: it.editor.id, username: it.editor.username, nickname: it.editor.nickname, avatar: it.editor.avatar, } : { id: 0, username: "已注销", nickname: "已注销", avatar: "" }, old_content: it.old_content, created_at: it.created_at, })); return { items, total: res.total, page: res.page, size: res.size }; }; const totalPages = Math.max(1, Math.ceil(total / 20)); return (

评论({total})

{loggedIn ? (
setContent(v.slice(0, COMMENT_MAX))} maxChars={COMMENT_MAX} disabled={submitting} allowHide={false} ariaLabel="发表评论" placeholder="写下你的看法,支持 Markdown…" onUploadingChange={setUploading} onSubmitShortcut={() => { void handleSubmit(); }} footer={ } />
) : (

登录 {" "} 后参与评论

)} {loading ? (

加载中…

) : items.length === 0 ? (

还没有评论,来抢沙发吧

) : (
{items.map((item) => ( ))}
)} {totalPages > 1 && (
{page} / {totalPages}
)} setHistoryCommentId(null)} />
); }