feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View 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>
);
}