feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
702
frontend/components/SnippetCommentSection.tsx
Normal file
702
frontend/components/SnippetCommentSection.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user