703 lines
22 KiB
TypeScript
703 lines
22 KiB
TypeScript
"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<HTMLDivElement>(null);
|
||
const btnRef = useRef<HTMLButtonElement>(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 (
|
||
<div className="cmt-more" ref={ref}>
|
||
<button
|
||
ref={btnRef}
|
||
type="button"
|
||
className="cmt-more-btn"
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
更多
|
||
</button>
|
||
{open && (
|
||
<div role="menu" className="cmt-more-menu" aria-label="评论操作">
|
||
{normal.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
role="menuitem"
|
||
disabled={item.disabled}
|
||
onClick={() => {
|
||
setOpen(false);
|
||
item.onSelect();
|
||
}}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
{normal.length > 0 && danger.length > 0 ? <div className="cmt-more-sep" role="separator" /> : null}
|
||
{danger.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
role="menuitem"
|
||
className="is-danger"
|
||
disabled={item.disabled}
|
||
onClick={() => {
|
||
setOpen(false);
|
||
item.onSelect();
|
||
}}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CommentNode({
|
||
item,
|
||
depth,
|
||
snippetId,
|
||
onUpdate,
|
||
onDelete,
|
||
onOpenHistory,
|
||
loggedIn,
|
||
}: {
|
||
item: SnippetCommentItem;
|
||
depth: number;
|
||
snippetId: number;
|
||
onUpdate: (id: number, content: string) => Promise<void>;
|
||
onDelete: (id: number) => Promise<void>;
|
||
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<number | null>(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 ? (
|
||
<button
|
||
type="button"
|
||
className="cmt-edited"
|
||
onClick={() => onOpenHistory(item.id, item.content)}
|
||
>
|
||
已编辑
|
||
</button>
|
||
) : (
|
||
<span className="cmt-edited" data-tip="此评论曾被编辑">
|
||
已编辑
|
||
</span>
|
||
)
|
||
) : null;
|
||
|
||
if (item.deleted) {
|
||
return (
|
||
<div
|
||
id={item.floor ? `comment-${item.floor}` : undefined}
|
||
data-comment-id={item.id}
|
||
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
|
||
>
|
||
<div className="flex gap-3">
|
||
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="cmt-meta">
|
||
<div className="cmt-meta-lead">
|
||
<span className="cmt-name" style={{ color: "var(--ink)" }}>
|
||
{userName}
|
||
</span>
|
||
{item.floor && item.floor > 0 && (
|
||
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
|
||
#{item.floor}
|
||
</a>
|
||
)}
|
||
<span className="cmt-time">{formatRelative(item.created_at)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="cmt-tombstone">
|
||
<p className="cmt-tombstone-title">该评论已删除</p>
|
||
</div>
|
||
<div className="cmt-actions">
|
||
<CommentMoreMenu items={moreItems} />
|
||
</div>
|
||
{item.replies && item.replies.length > 0 && (
|
||
<div className="cmt-children">
|
||
{item.replies.map((r) => (
|
||
<CommentNode
|
||
key={r.id}
|
||
item={r}
|
||
depth={depth + 1}
|
||
snippetId={snippetId}
|
||
onUpdate={onUpdate}
|
||
onDelete={onDelete}
|
||
onOpenHistory={onOpenHistory}
|
||
loggedIn={loggedIn}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<ConfirmDialog
|
||
open={deleteTarget}
|
||
title="删除评论"
|
||
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
|
||
confirmLabel="删除"
|
||
danger
|
||
onConfirm={handleDelete}
|
||
onCancel={() => setDeleteTarget(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
id={item.floor ? `comment-${item.floor}` : undefined}
|
||
data-comment-id={item.id}
|
||
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
|
||
>
|
||
<div className="flex gap-3">
|
||
<Link
|
||
href={userPath(urlStyle, item.user_id)}
|
||
className="shrink-0"
|
||
aria-label={userName}
|
||
>
|
||
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
|
||
</Link>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="cmt-meta">
|
||
<div className="cmt-meta-lead">
|
||
<Link href={userPath(urlStyle, item.user_id)} className="cmt-name-link group">
|
||
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
|
||
{userName}
|
||
</span>
|
||
</Link>
|
||
{item.floor && item.floor > 0 && (
|
||
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
|
||
#{item.floor}
|
||
</a>
|
||
)}
|
||
<time className="cmt-time" dateTime={item.created_at} data-tip={formatDateTime(item.created_at)}>
|
||
{formatRelative(item.created_at)}
|
||
</time>
|
||
{editedMark}
|
||
</div>
|
||
</div>
|
||
|
||
{editing ? (
|
||
<div className="cmt-md-editor mt-1">
|
||
<MarkdownEditor
|
||
density="comment"
|
||
value={editContent}
|
||
onChange={(v) => setEditContent(v.slice(0, COMMENT_MAX))}
|
||
maxChars={COMMENT_MAX}
|
||
disabled={submitting}
|
||
autoFocus
|
||
allowHide={false}
|
||
ariaLabel="编辑评论"
|
||
placeholder="编辑评论…"
|
||
shortcutName="保存"
|
||
onUploadingChange={setEditUploading}
|
||
onSubmitShortcut={() => {
|
||
void handleEdit();
|
||
}}
|
||
footer={
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={submitting}
|
||
onClick={() => {
|
||
setEditing(false);
|
||
setEditContent(item.content);
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleEdit}
|
||
disabled={submitting || editUploading || !editContent.trim()}
|
||
className="btn btn-primary cmt-submit"
|
||
>
|
||
{submitting ? "保存中…" : "保存修改"}
|
||
</button>
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<MarkdownBodyClient content={item.content} className="prose-content prose-comment" />
|
||
)}
|
||
|
||
<div className="cmt-actions">
|
||
{!editing && loggedIn && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setReplyOpen(!replyOpen)}
|
||
className={`cmt-reply-btn${replyOpen ? " !text-[var(--accent)]" : ""}`}
|
||
aria-label={`回复 ${userName}`}
|
||
>
|
||
回复
|
||
</button>
|
||
)}
|
||
<CommentMoreMenu items={moreItems} />
|
||
</div>
|
||
|
||
{replyOpen && !editing && (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void handleReply();
|
||
}}
|
||
className="cmt-reply-form"
|
||
>
|
||
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||
<span>
|
||
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{userName}</span>
|
||
</span>
|
||
<button type="button" className="cmt-reply-btn" onClick={() => setReplyOpen(false)}>
|
||
取消
|
||
</button>
|
||
</div>
|
||
<div className="cmt-md-editor">
|
||
<MarkdownEditor
|
||
density="comment"
|
||
value={replyContent}
|
||
onChange={(v) => setReplyContent(v.slice(0, COMMENT_MAX))}
|
||
maxChars={COMMENT_MAX}
|
||
disabled={submitting}
|
||
autoFocus
|
||
allowHide={false}
|
||
ariaLabel={`回复 ${userName}`}
|
||
placeholder="写下你的回复…"
|
||
shortcutName="回复"
|
||
onUploadingChange={setReplyUploading}
|
||
onSubmitShortcut={() => {
|
||
void handleReply();
|
||
}}
|
||
footer={
|
||
<button
|
||
type="submit"
|
||
disabled={submitting || replyUploading || !replyContent.trim()}
|
||
aria-busy={submitting}
|
||
className="btn btn-primary cmt-submit"
|
||
>
|
||
{submitting ? "回复中…" : "回复"}
|
||
</button>
|
||
}
|
||
/>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{item.replies && item.replies.length > 0 && (
|
||
<div className="cmt-children">
|
||
{item.replies.map((r) => (
|
||
<CommentNode
|
||
key={r.id}
|
||
item={r}
|
||
depth={depth + 1}
|
||
snippetId={snippetId}
|
||
onUpdate={onUpdate}
|
||
onDelete={onDelete}
|
||
onOpenHistory={onOpenHistory}
|
||
loggedIn={loggedIn}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
open={deleteTarget}
|
||
title="删除评论"
|
||
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
|
||
confirmLabel="删除"
|
||
danger
|
||
onConfirm={handleDelete}
|
||
onCancel={() => setDeleteTarget(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<number | null>(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 (
|
||
<div>
|
||
<h2 className="text-[15px] font-bold mb-4 inline-flex items-center gap-2" style={{ color: "var(--ink)" }}>
|
||
<MessageSquare size={15} style={{ color: "var(--accent)" }} />
|
||
评论({total})
|
||
</h2>
|
||
|
||
{loggedIn ? (
|
||
<div className="mb-6 cmt-md-editor">
|
||
<MarkdownEditor
|
||
id={`snippet-comment-input-${snippetId}`}
|
||
density="comment"
|
||
value={content}
|
||
onChange={(v) => setContent(v.slice(0, COMMENT_MAX))}
|
||
maxChars={COMMENT_MAX}
|
||
disabled={submitting}
|
||
allowHide={false}
|
||
ariaLabel="发表评论"
|
||
placeholder="写下你的看法,支持 Markdown…"
|
||
onUploadingChange={setUploading}
|
||
onSubmitShortcut={() => {
|
||
void handleSubmit();
|
||
}}
|
||
footer={
|
||
<button
|
||
type="button"
|
||
onClick={handleSubmit}
|
||
disabled={submitting || uploading || !content.trim()}
|
||
className="btn btn-primary cmt-submit inline-flex items-center gap-1.5"
|
||
>
|
||
<Send size={13} />
|
||
{submitting ? "发送中…" : "发表评论"}
|
||
</button>
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="mb-6 rounded-xl px-4 py-6 text-center"
|
||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||
>
|
||
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||
<Link href="/login" className="hover:underline" style={{ color: "var(--accent)" }}>
|
||
登录
|
||
</Link>{" "}
|
||
后参与评论
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<p className="meta text-[13px] text-center py-8">加载中…</p>
|
||
) : items.length === 0 ? (
|
||
<div
|
||
className="rounded-xl py-10 text-center"
|
||
style={{ background: "var(--panel-2)", border: "1px dashed var(--line-2)" }}
|
||
>
|
||
<MessageSquare size={20} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
|
||
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||
还没有评论,来抢沙发吧
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||
{items.map((item) => (
|
||
<CommentNode
|
||
key={item.id}
|
||
item={item}
|
||
depth={0}
|
||
snippetId={snippetId}
|
||
onUpdate={handleUpdate}
|
||
onDelete={handleDelete}
|
||
onOpenHistory={openHistory}
|
||
loggedIn={loggedIn}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{totalPages > 1 && (
|
||
<div className="mt-4 flex items-center justify-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={page <= 1 || loading}
|
||
onClick={() => loadComments(page - 1)}
|
||
className="btn btn-line btn-sm"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span className="meta text-[12.5px] tabular-nums">
|
||
{page} / {totalPages}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={page >= totalPages || loading}
|
||
onClick={() => loadComments(page + 1)}
|
||
className="btn btn-line btn-sm"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<EditHistoryModal
|
||
open={historyCommentId !== null}
|
||
title="评论编辑历史"
|
||
currentContent={historyCommentContent}
|
||
fetchPage={fetchHistoryPage}
|
||
onClose={() => setHistoryCommentId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|