diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2112121..6c127a2 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -2174,10 +2174,76 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint { gap: 12px; padding: 10px 0 0; font-size: 12.5px; - color: var(--ink-3); + color: var(--ink-2); border-top: 1px solid var(--line); background: transparent; } +.j13-md-foot-hint { + min-width: 0; +} +.j13-md-foot-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-left: auto; + flex-shrink: 0; +} +.j13-md-count { + color: var(--ink-2); + font-size: 12.5px; +} +.j13-md-upload, +.j13-md-error { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.45; +} +.j13-md-upload { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: var(--ink-2); +} +.j13-md-upload button { + color: var(--accent); + font-weight: 600; + font-size: 13px; +} +.j13-md-upload button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.j13-md-error { + color: var(--danger); +} +.j13-md-editor.is-comment:not(.is-fullscreen) .j13-md-textarea { + min-height: 96px; + max-height: 320px; + height: 96px; + overflow-y: auto; + padding: 0.5rem 0 0.35rem !important; + line-height: 1.65 !important; +} +.j13-md-editor.is-comment:not(.is-fullscreen) .j13-md-preview { + min-height: 96px; + max-height: 320px; + height: auto; + overflow-y: auto; +} +.j13-md-editor.is-comment .j13-md-toolbar { + flex-wrap: nowrap; + gap: 2px; + padding: 2px 0 8px; +} +.j13-md-editor.is-comment .j13-md-tool-groups { + flex-wrap: nowrap; +} +.j13-md-editor.is-comment .j13-md-mode button { + height: 32px; + padding: 0 8px; +} /* 正文内嵌隐藏块 */ .j13-hide-block { @@ -6649,6 +6715,7 @@ a.j13-mention-token:hover { text-decoration: underline; text-underline-offset: 3px; font-weight: 500; + overflow-wrap: anywhere; } /* 正文内嵌按钮链接:未分层的 .prose-content a 会盖掉 @layer 的 .btn-primary 字色 */ .prose-content a.btn, @@ -7020,7 +7087,7 @@ html.dark .prose-content pre[data-theme] span { } /* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */ -.prose-comment { font-size: 14.5px; line-height: 1.7; } +.prose-comment { font-size: 15.5px; line-height: 1.7; overflow-wrap: anywhere; } .prose-comment p { margin: 0.35rem 0; } .prose-comment blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; } .prose-comment .md-code-block { margin: 0.6rem 0; } @@ -7090,14 +7157,182 @@ html.dark .prose-content pre[data-theme] span { } } -/* 评论楼层树:抖音风格 - 主评论全宽;子回复折叠在 panel-2 背景容器内,点击"展开 X 条回复"显示 - 子回复之间用淡分隔线;depth≥2 时昵称后显示 ▶ 被回复者 */ +/* 评论楼层树 + 主评论全宽;子回复默认折叠。视觉缩进最多两级,更深靠「回复谁」说明。 */ -/* 子回复行:紧凑布局,头像 28px */ -.cmt-reply-row { - padding: 8px 0; +.cmt-item { + padding: 16px 0; border-top: 1px solid var(--line); + min-width: 0; +} +.cmt-item:first-child { + border-top: none; + padding-top: 2px; +} +.cmt-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + margin-bottom: 6px; +} +.cmt-meta-lead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + flex: 1 1 auto; +} +.cmt-name-link { + min-width: 0; + max-width: 100%; +} +.cmt-name { + display: block; + max-width: 16em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14.5px; + font-weight: 600; + line-height: 1.35; + color: var(--ink); +} +.cmt-time { + font-size: 12.5px; + line-height: 1.4; + color: var(--ink-2); + background: none; + border: 0; + padding: 0; + cursor: default; +} +.cmt-time:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} +.cmt-edited { + font-size: 12.5px; + font-weight: 600; + color: var(--ink-2); + background: none; + border: 0; + padding: 0; +} +button.cmt-edited { + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} +.cmt-badge { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1; + color: var(--accent); + background: var(--accent-soft); + flex-shrink: 0; +} +.cmt-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 10px; + margin-top: 8px; + min-width: 0; +} +.cmt-more { + position: relative; +} +.cmt-more-btn, +.cmt-reply-btn { + min-height: 32px; + padding: 0 4px; +} +.cmt-more-btn { + font-size: 13px; + font-weight: 600; + color: var(--ink-2); + background: none; + border: 0; + cursor: pointer; + border-radius: 8px; +} +.cmt-more-btn:hover, +.cmt-more-btn[aria-expanded="true"] { + color: var(--accent); +} +.cmt-more-menu { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + min-width: 148px; + padding: 4px; + border-radius: 12px; + background: var(--panel); + border: 1px solid var(--line); + box-shadow: var(--shadow-lg); +} +.cmt-more-menu button { + display: flex; + width: 100%; + text-align: left; + padding: 8px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink); + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.cmt-more-menu button:hover, +.cmt-more-menu button:focus-visible { + background: var(--panel-2); + outline: none; +} +.cmt-more-menu button.is-danger { + color: var(--danger); +} +.cmt-more-sep { + height: 1px; + margin: 4px 6px; + background: var(--line); +} +.cmt-posted-note { + margin-top: 10px; + font-size: 13px; + color: var(--ink-2); +} +.cmt-posted-note a { + color: var(--accent); + font-weight: 600; +} +.cmt-submit { + height: 38px; + padding: 0 16px; + font-size: 14px; +} +.cmt-submit:disabled { + background: var(--tint); + color: var(--ink-2); + box-shadow: none; + opacity: 1; +} + +/* 子回复行:紧凑布局,头像 32px */ +.cmt-reply-row { + padding: 14px 0; + border-top: 1px solid var(--line); + min-width: 0; } .cmt-reply-row:first-child { border-top: none; @@ -7106,9 +7341,9 @@ html.dark .prose-content pre[data-theme] span { /* 子回复昵称 */ .cmt-reply-name { - font-size: 13px; - font-weight: 500; - color: var(--ink-2); + font-size: 14px; + font-weight: 600; + color: var(--ink); } /* 楼层号标签(主评论昵称旁) */ @@ -7128,13 +7363,15 @@ html.dark .prose-content pre[data-theme] span { /* 回复文字按钮:放在时间右侧,抖音风格 */ .cmt-reply-btn { - font-size: 12px; - font-weight: 500; - color: var(--ink-3); + font-size: 13px; + font-weight: 600; + color: var(--ink-2); background: none; border: none; - padding: 0; + padding: 0 4px; + min-height: 32px; cursor: pointer; + border-radius: 8px; } .cmt-reply-btn:hover { color: var(--accent); @@ -7172,6 +7409,10 @@ html.dark .prose-content pre[data-theme] span { padding-left: 10px; border-left: 1px solid var(--line); } +.cmt-children .cmt-children .cmt-children { + padding-left: 0; + border-left: 0; +} /* 展开/收起按钮 */ .cmt-fold-btn { @@ -7200,39 +7441,33 @@ html.dark .prose-content pre[data-theme] span { margin-top: 8px; } -/* 评论区公共编辑器:矮于发帖画布,带边框贴近原 field */ +/* 评论区编辑器:矮输入区,底栏含提交 */ .cmt-md-editor { border: 1px solid var(--line); border-radius: 12px; - padding: 0.35rem 0.85rem 0.55rem; + padding: 0.25rem 0.75rem 0.55rem; background: var(--panel); + container-type: inline-size; } .cmt-md-editor .j13-md-editor:not(.is-fullscreen) { min-height: 0; } -.cmt-md-editor .j13-md-textarea, -.cmt-md-editor .j13-md-preview { - min-height: 7.5rem; - overflow-y: auto; -} -.cmt-md-editor-floor .j13-md-textarea, -.cmt-md-editor-floor .j13-md-preview { - min-height: 10rem; -} -.cmt-reply-form .cmt-md-editor { - background: var(--panel); - padding: 0.25rem 0.65rem 0.4rem; -} -.cmt-reply-form .cmt-md-editor .j13-md-textarea, -.cmt-reply-form .cmt-md-editor .j13-md-preview { - min-height: 6rem; -} .cmt-md-editor .j13-md-toolbar { padding-bottom: 8px; } .cmt-md-editor .j13-md-foot { - font-size: 11.5px; + font-size: 12.5px; padding-top: 8px; + flex-wrap: wrap; +} +@container (max-width: 420px) { + .cmt-md-editor .j13-md-editor.is-comment .j13-md-tool { + width: 36px; + height: 36px; + } + .cmt-md-editor .j13-md-mode button { + padding: 0 6px; + } } /* 软删占位:匿名马赛克(不含真实头像/昵称) */ @@ -7415,8 +7650,18 @@ html.dark .prose-content pre[data-theme] span { .cmt-replies-wrap { padding: 4px 8px; } - .cmt-children { - padding-left: 8px; + .cmt-children, + .cmt-children .cmt-children { + padding-left: 0; + border-left: 0; + } + .cmt-reply-btn, + .cmt-more-btn, + .cmt-more-menu button { + min-height: 44px; + } + .cmt-name { + max-width: 9.5em; } } diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 59eb449..6fdb772 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -1,18 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { - Trash2, MessagesSquare, ChevronRight, ChevronDown, - BadgeCheck, - XCircle, - RotateCcw, - Pencil, - History, } from "lucide-react"; import { apiCreateComment, @@ -30,6 +24,7 @@ import { } from "@/lib/api"; import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles"; import { toast } from "@/lib/toast"; +import { formatCommentTime, formatDateTime } from "@/lib/format"; import { useAllowComments } from "@/components/CommentsPolicyProvider"; import CommentsUnsupported from "@/components/CommentsUnsupported"; import Avatar from "./Avatar"; @@ -90,6 +85,101 @@ function MosaicName() { return ; } +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 focusCommentControl(id: number, kind: "reply" | "more") { + window.requestAnimationFrame(() => { + document.querySelector(`#comment-${id} [data-cmt-focus="${kind}"]`)?.focus(); + }); +} + export default function CommentSection({ postId, boardId, @@ -108,10 +198,27 @@ export default function CommentSection({ const router = useRouter(); const commentsOpen = useAllowComments(); const [content, setContent] = useState(""); - // 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框) - const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null); + // 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离 + const [replyTarget, setReplyTarget] = useState<{ + id: number; + nickname: string; + floor?: number; + rootId: number; + } | null>(null); const [replyContent, setReplyContent] = useState(""); + const replyDrafts = useRef>({}); + const editDrafts = useRef>({}); + const submitLock = useRef(false); const [submitting, setSubmitting] = useState(false); + const [floorError, setFloorError] = useState(""); + const [replyError, setReplyError] = useState(""); + const [editError, setEditError] = useState(""); + const [floorUploading, setFloorUploading] = useState(false); + const [replyUploading, setReplyUploading] = useState(false); + const [editUploading, setEditUploading] = useState(false); + const [awayHref, setAwayHref] = useState(null); + const [discardReplyOpen, setDiscardReplyOpen] = useState(false); + const [discardEditOpen, setDiscardEditOpen] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); const [acceptingId, setAcceptingId] = useState(null); @@ -153,27 +260,40 @@ export default function CommentSection({ }, [user, commentsOpen]); const submitFloor = async () => { - if (!commentsOpen || !content.trim() || submitting) return; + if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return; + submitLock.current = true; setSubmitting(true); + setFloorError(""); try { const res = await apiCreateComment(postId, content); if (res.comment) { setContent(""); + const destPage = Math.max(1, Math.ceil((total + 1) / size)); if (res.comment.status === "pending") { toast("已提交,等待审核通过后公开"); - router.refresh(); - } else { - // 新楼层在最后一页:刷新后由服务端直出最新分页 - router.refresh(); } + if (destPage !== page) { + setAwayHref( + destPage > 1 + ? `/post/${postId}?page=${destPage}#comment-${res.comment.id}` + : `/post/${postId}#comment-${res.comment.id}` + ); + } else { + setAwayHref(null); + } + router.refresh(); } else if (res.error === "未登录") { router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`); } else { - toast(res.error || "评论失败"); + const msg = res.error || "评论失败"; + setFloorError(msg); + toast(msg); } } catch { + setFloorError("网络错误,评论失败"); toast("网络错误,评论失败"); } finally { + submitLock.current = false; setSubmitting(false); } }; @@ -184,28 +304,39 @@ export default function CommentSection({ }; const submitReply = async () => { - if (!commentsOpen || !replyTarget || !replyContent.trim() || submitting) return; + if (!commentsOpen || !replyTarget || !replyContent.trim() || submitLock.current || replyUploading) return; + submitLock.current = true; setSubmitting(true); + setReplyError(""); + const target = replyTarget; try { - const res = await apiCreateComment(postId, replyContent, replyTarget.id); + const res = await apiCreateComment(postId, replyContent, target.id); if (res.comment) { + delete replyDrafts.current[target.id]; setReplyTarget(null); setReplyContent(""); + setExpandedFloors((prev) => { + const next = new Set(prev); + next.add(target.rootId); + return next; + }); if (res.comment.status === "pending") { toast("已提交,等待审核通过后公开"); - router.refresh(); - } else { - // 刷新后由 SSR 直出新树(子评论全量挂在本楼) - router.refresh(); } + router.refresh(); + focusCommentControl(target.id, "reply"); } else if (res.error === "未登录") { router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`); } else { - toast(res.error || "回复失败"); + const msg = res.error || "回复失败"; + setReplyError(msg); + toast(msg); } } catch { + setReplyError("网络错误,回复失败"); toast("网络错误,回复失败"); } finally { + submitLock.current = false; setSubmitting(false); } }; @@ -369,38 +500,105 @@ export default function CommentSection({ comment.status === "published" && comment.user?.id !== authorId; - const openReply = (c: CommentNode) => { - setEditingId(null); - setReplyTarget({ id: c.id, nickname: c.user?.nickname || c.user?.username || "用户" }); + const rememberReplyDraft = () => { + if (!replyTarget) return; + replyDrafts.current[replyTarget.id] = replyContent; + }; + + const openReply = (c: CommentNode, floor?: number) => { + rememberReplyDraft(); + if (editingId != null) { + editDrafts.current[editingId] = editContent; + setEditingId(null); + } + const rootId = c.root_id ?? (c.parent_id ? c.parent_id : c.id); + setReplyTarget({ + id: c.id, + nickname: c.user?.nickname || c.user?.username || "用户", + floor, + rootId: c.depth ? rootId : c.id, + }); + setReplyContent(replyDrafts.current[c.id] ?? ""); + setReplyError(""); + }; + + const closeReply = (discard: boolean) => { + const id = replyTarget?.id; + if (id != null) { + if (discard) delete replyDrafts.current[id]; + else replyDrafts.current[id] = replyContent; + } + setReplyTarget(null); setReplyContent(""); + setReplyError(""); + setDiscardReplyOpen(false); + if (id != null) focusCommentControl(id, "reply"); + }; + + const requestCancelReply = () => { + if (replyContent.trim()) { + setDiscardReplyOpen(true); + return; + } + closeReply(true); }; const startEdit = (c: CommentNode) => { + rememberReplyDraft(); setReplyTarget(null); + if (editingId != null && editingId !== c.id) { + editDrafts.current[editingId] = editContent; + } setEditingId(c.id); - setEditContent(c.content); + setEditContent(editDrafts.current[c.id] ?? c.content); + setEditError(""); + }; + + const finishCancelEdit = () => { + const id = editingId; + if (id != null) delete editDrafts.current[id]; + setEditingId(null); + setEditContent(""); + setEditError(""); + setDiscardEditOpen(false); + if (id != null) focusCommentControl(id, "more"); }; const cancelEdit = () => { - setEditingId(null); - setEditContent(""); + if (editingId == null) return; + const original = findComment(comments, editingId)?.content ?? ""; + if (editContent !== original) { + setDiscardEditOpen(true); + return; + } + finishCancelEdit(); }; const handleSaveEdit = async () => { - if (editingId == null || !editContent.trim() || savingEdit) return; + if (editingId == null || !editContent.trim() || submitLock.current || editUploading) return; + submitLock.current = true; setSavingEdit(true); + setEditError(""); + const id = editingId; try { - const res = await apiUpdateComment(postId, editingId, editContent); + const res = await apiUpdateComment(postId, id, editContent); if (res.error) { + setEditError(res.error); toast(res.error); return; } + delete editDrafts.current[id]; toast("评论已更新", "ok"); - cancelEdit(); + setEditingId(null); + setEditContent(""); router.refresh(); + focusCommentControl(id, "more"); } catch (e) { - toast(e instanceof Error ? e.message : "更新失败"); + const msg = e instanceof Error ? e.message : "更新失败"; + setEditError(msg); + toast(msg); } finally { + submitLock.current = false; setSavingEdit(false); } }; @@ -410,47 +608,122 @@ export default function CommentSection({ return (
{ + setEditContent(v); + if (editError) setEditError(""); + }} disabled={savingEdit} autoFocus allowHide={false} + ariaLabel="编辑评论" placeholder="编辑评论…" + shortcutName="保存" + error={editError} + onUploadingChange={setEditUploading} onSubmitShortcut={() => { void handleSaveEdit(); }} + footer={ + <> + + + + } /> -
- - -
); } return ; }; - const timeMeta = (c: CommentNode) => ( - - - {c.edited && 已编辑} - - ); + const timeMeta = (c: CommentNode) => { + const full = formatDateTime(c.created_at); + const short = formatCommentTime(c.created_at); + return ( + + ); + }; + + const editedMark = (c: CommentNode) => { + if (!c.edited) return null; + if (canViewHistory) { + return ( + + ); + } + return ( + + 已编辑 + + ); + }; + + const moreItemsFor = (c: CommentNode): MoreItem[] => { + const items: MoreItem[] = []; + if (canEdit(c) && editingId !== c.id) { + items.push({ + key: "edit", + label: "编辑", + disabled: savingEdit, + onSelect: () => startEdit(c), + }); + } + if (canViewHistory) { + items.push({ + key: "history", + label: "编辑历史", + onSelect: () => setHistoryCommentId(c.id), + }); + } + if (canRestore(c)) { + items.push({ + key: "restore", + label: "恢复", + disabled: restoring || purging, + onSelect: () => setRestoreTargetId(c.id), + }); + } + if (canSoftDelete(c)) { + items.push({ + key: "delete", + label: "删除", + danger: true, + disabled: deleting, + onSelect: () => setDeleteTargetId(c.id), + }); + } + if (canPurge(c)) { + items.push({ + key: "purge", + label: "永久删除", + danger: true, + disabled: purging, + onSelect: () => setPurgeTargetId(c.id), + }); + } + return items; + }; // 切换某楼层子回复的展开/折叠 const toggleFloor = (floorId: number) => { @@ -466,39 +739,47 @@ export default function CommentSection({ // 内联回复表单(出现在目标评论正下方;全站关评时不渲染) const inlineReplyForm = commentsOpen && replyTarget && ( -
-
- 回复 @{replyTarget.nickname} + +
+ + 回复 @{replyTarget.nickname} + {replyTarget.floor ? · #{replyTarget.floor} : null} + +
{ + setReplyContent(v); + if (replyError) setReplyError(""); + }} disabled={submitting} autoFocus allowHide={false} - placeholder={`友善回复 @${replyTarget.nickname}…`} + ariaLabel={`回复 ${replyTarget.nickname}`} + placeholder="写下你的回复…" + shortcutName="回复" + error={replyError} + onUploadingChange={setReplyUploading} onSubmitShortcut={() => { void submitReply(); }} + footer={ + + } />
-
- - -
); @@ -531,17 +812,17 @@ export default function CommentSection({ : ""; const identity = showMosaic ? ( - + ) : author ? ( - + ) : ( - + ); const nameRow = showMosaic ? ( @@ -614,65 +895,27 @@ export default function CommentSection({ )}
); - const purgeBtn = canPurge(c) && ( - - ); - const restoreBtn = canRestore(c) && ( - - ); - const historyBtn = canViewHistory && ( - - ); - const tombstoneActions = - restoreBtn || purgeBtn || historyBtn ? ( -
- {historyBtn} - {restoreBtn} - {purgeBtn} -
- ) : null; + const tombstoneMenu = ; if (!isFloor) { return (
-
+
{identity}
-
{nameRow}
- {tombstoneBody} -
- {timeMeta(c)} - {tombstoneActions} +
+
+ {nameRow} + {timeMeta(c)} +
+ {tombstoneBody} +
{tombstoneMenu}
{node.replies && node.replies.length > 0 && (
- {node.replies.map((r) => renderComment(r, depth + 1, undefined, node))} + {node.replies.map((r) => renderComment(r, depth + 1, floor, node))}
)}
@@ -680,28 +923,23 @@ export default function CommentSection({ } return ( -
+
{identity}
-
- {nameRow} - {floor && ( +
+
+ {nameRow} + {timeMeta(c)} +
+ {floor ? ( #{floor} - )} + ) : null}
{tombstoneBody} -
- {timeMeta(c)} - {tombstoneActions} -
+
{tombstoneMenu}
{node.replies && node.replies.length > 0 && (
@@ -730,215 +968,51 @@ export default function CommentSection({ const authorName = author?.nickname || author?.username || "用户"; if (!isFloor) { - // 子回复:紧凑行式布局(抖音风格) const isReplyToReply = depth >= 2; + const isOp = (author?.id ?? c.user_id) === authorId; return (
-
+
{author ? ( - + ) : ( - + )}
-
- {author ? ( - - - {authorName} - - - ) : ( - {authorName} - )} - {c.status === "pending" && 待审} - {acceptedCommentId === c.id && ( - 已采纳 - )} - {isReplyToReply && parent && ( - <> -
-
- {renderContentOrEditor(c)} -
- {/* 底部行:时间 · 回复(左),删除(右) */} -
-
+
+
+ {author ? ( + + + {authorName} + + + ) : ( + {authorName} + )} + {isOp && 楼主} + {c.status === "pending" && 待审} + {acceptedCommentId === c.id && 已采纳} {timeMeta(c)} - {commentsOpen && user && editingId !== c.id && ( - - )} - {canAccept(c) && ( - - )} - {canApprove(c) && ( - <> - - - + {editedMark(c)} + {isReplyToReply && parent && ( + + 回复 {parentAuthor} + )}
- {(canEdit(c) || canSoftDelete(c) || (canPurge(c) && !c.deleted) || canViewHistory) && ( -
- {canEdit(c) && editingId !== c.id && ( - - )} - {canViewHistory && ( - - )} - {canSoftDelete(c) && ( - - )} - {canPurge(c) && ( - - )} -
- )}
- {replyTarget?.id === c.id && inlineReplyForm} -
-
- {node.replies && node.replies.length > 0 && ( -
- {node.replies.map((r) => renderComment(r, depth + 1, undefined, node))} -
- )} -
- ); - } - - // 主评论(楼层) - return ( -
-
- {author ? ( - - - - ) : ( - - )} -
-
- {author ? ( - - - {authorName} - - - ) : ( - - {authorName} - - )} - {c.status === "pending" && 待审} - {acceptedCommentId === c.id && ( - 已采纳 - )} - {winnerCommentIdSet.has(c.id) && ( - 中奖楼层 - )} - {floor && ( - - #{floor} - - )} -
- {renderContentOrEditor(c)} - {/* 底部行:时间 · 回复(左),删除(右) */} -
-
- {timeMeta(c)} + {renderContentOrEditor(c)} +
{commentsOpen && user && editingId !== c.id && ( @@ -958,81 +1032,124 @@ export default function CommentSection({ <> )} +
- {(canEdit(c) || canSoftDelete(c) || canPurge(c) || canViewHistory) && ( -
- {canEdit(c) && editingId !== c.id && ( - - )} - {canViewHistory && ( - - )} - {canSoftDelete(c) && ( - - )} - {canPurge(c) && ( - - )} -
+ {replyTarget?.id === c.id && inlineReplyForm} +
+
+ {node.replies && node.replies.length > 0 && ( +
+ {node.replies.map((r) => renderComment(r, depth + 1, floor, node))} +
+ )} +
+ ); + } + + // 主评论(楼层) + const isOp = (author?.id ?? c.user_id) === authorId; + return ( +
+
+ {author ? ( + + + + ) : ( + + )} +
+
+
+ {author ? ( + + + {authorName} + + + ) : ( + {authorName} + )} + {isOp && 楼主} + {c.status === "pending" && 待审} + {acceptedCommentId === c.id && 已采纳} + {winnerCommentIdSet.has(c.id) && 中奖楼层} + {timeMeta(c)} + {editedMark(c)} +
+ {floor ? ( + + #{floor} + + ) : null} +
+ {renderContentOrEditor(c)} +
+ {commentsOpen && user && editingId !== c.id && ( + )} + {canAccept(c) && ( + + )} + {canApprove(c) && ( + <> + + + + )} +
{replyTarget?.id === c.id && inlineReplyForm} - - {/* 折叠/展开子回复 */} {node.replies && node.replies.length > 0 && (
{isExpanded && (
- {node.replies.map((r) => renderComment(r, 1, undefined, node))} + {node.replies.map((r) => renderComment(r, 1, floor, node))}
)}
@@ -1075,30 +1190,50 @@ export default function CommentSection({ > {totalComments} - 共 {totalPages} 页楼层 + {totalPages > 1 ? ( + + 第 {page} / {totalPages} 页 + + ) : null}
{user ? ( -
+
{ + setContent(v); + if (floorError) setFloorError(""); + }} disabled={submitting} allowHide={false} - placeholder="友善发言,发表一个新楼层…" + ariaLabel="发表评论" + placeholder="写下你的看法,参与讨论…" + error={floorError} + onUploadingChange={setFloorUploading} onSubmitShortcut={() => { void submitFloor(); }} + footer={ + + } />
-
- -
+ {awayHref ? ( +

+ 评论已发表,查看我的评论 +

+ ) : null}
) : (
+ closeReply(true)} + onCancel={() => closeReply(false)} + /> + + setDiscardEditOpen(false)} + /> + void; }; type EditorMode = "write" | "preview"; @@ -71,7 +85,7 @@ type WrapOpts = { linePrefix?: string; }; -type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | null; +type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | "more" | null; const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const; @@ -124,6 +138,13 @@ export default function MarkdownEditor({ imageMaxMB = 5, allowHide = false, allowReplyHide = true, + density = "default", + ariaLabel, + hint, + shortcutName, + footer, + error, + onUploadingChange, }: MarkdownEditorProps) { const taRef = useRef(null); const fileRef = useRef(null); @@ -142,6 +163,11 @@ export default function MarkdownEditor({ const [hidePoints, setHidePoints] = useState(10); const [hidePassword, setHidePassword] = useState(""); const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu"); + const [toolNarrow, setToolNarrow] = useState(false); + const [uploadError, setUploadError] = useState(""); + const [modKey, setModKey] = useState("Ctrl"); + const retryFile = useRef(null); + const moreBtnRef = useRef(null); const [timelineOpen, setTimelineOpen] = useState(false); const [timelineBody, setTimelineBody] = useState(""); const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>( @@ -149,6 +175,48 @@ export default function MarkdownEditor({ ); const savedSel = useRef<{ start: number; end: number } | null>(null); const fullscreenMounted = useRef(false); + const modeMounted = useRef(false); + const isComment = density === "comment"; + + useEffect(() => { + const ua = navigator.userAgent || ""; + const platform = navigator.platform || ""; + if (/Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS|iPhone|iPad/i.test(ua)) { + setModKey("⌘"); + } + }, []); + + const onUploadingChangeRef = useRef(onUploadingChange); + onUploadingChangeRef.current = onUploadingChange; + useEffect(() => { + onUploadingChangeRef.current?.(uploading); + }, [uploading]); + + useEffect(() => { + if (!isComment) return; + const el = toolbarRef.current; + if (!el) return; + const apply = () => setToolNarrow(el.clientWidth < 520); + apply(); + const ro = new ResizeObserver(apply); + ro.observe(el); + return () => ro.disconnect(); + }, [isComment]); + + useEffect(() => { + if (!isComment) return; + const el = taRef.current; + if (!el) return; + if (fullscreen || mode !== "write") { + el.style.height = ""; + return; + } + const prev = el.scrollTop; + el.style.height = "auto"; + const next = Math.min(Math.max(el.scrollHeight, 96), 320); + el.style.height = `${next}px`; + el.scrollTop = prev; + }, [value, isComment, fullscreen, mode]); const rememberCaret = () => { const el = taRef.current; @@ -179,6 +247,23 @@ export default function MarkdownEditor({ return () => cancelAnimationFrame(frame); }, [fullscreen]); + useEffect(() => { + if (!modeMounted.current) { + modeMounted.current = true; + return; + } + if (mode !== "write") return; + const sel = savedSel.current; + const el = taRef.current; + if (!el || !sel) return; + const frame = requestAnimationFrame(() => { + el.focus(); + const max = el.value.length; + el.setSelectionRange(Math.min(sel.start, max), Math.min(sel.end, max)); + }); + return () => cancelAnimationFrame(frame); + }, [mode]); + useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { @@ -209,7 +294,11 @@ export default function MarkdownEditor({ } }; const onEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") setPopover(null); + if (e.key !== "Escape") return; + e.preventDefault(); + const kind = popover; + setPopover(null); + if (kind === "more") moreBtnRef.current?.focus(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onEsc); @@ -237,6 +326,9 @@ export default function MarkdownEditor({ (opts: WrapOpts) => { const el = taRef.current; if (!el || disabled) return; + if (document.activeElement !== el && savedSel.current) { + el.setSelectionRange(savedSel.current.start, savedSel.current.end); + } const { next, selStart, selEnd } = applyWrap(el, value, opts); onChange(next); requestAnimationFrame(() => { @@ -278,27 +370,37 @@ export default function MarkdownEditor({ const uploadImageFile = async (file: File | null) => { if (!file || disabled) return; + retryFile.current = file; + setUploadError(""); if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) { + setUploadError("仅支持 JPEG / PNG / WebP"); toast("仅支持 JPEG / PNG / WebP", "error"); return; } const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024; if (file.size > maxBytes) { - toast(`图片不能超过 ${imageMaxMB}MB`, "error"); + const msg = `图片不能超过 ${imageMaxMB}MB`; + setUploadError(msg); + toast(msg, "error"); return; } setUploading(true); try { const res = await apiUploadPostImage(file); if (!res.url) { - toast(res.error || "上传失败", "error"); + const msg = res.error || "上传失败"; + setUploadError(msg); + toast(msg, "error"); return; } const alt = file.name.replace(/\.[^.]+$/, "") || "图片"; insertAtCursor(`![${alt}](${res.url})`); setMode("write"); + setUploadError(""); + retryFile.current = null; toast("图片已插入", "ok"); } catch { + setUploadError("上传失败"); toast("上传失败", "error"); } finally { setUploading(false); @@ -570,6 +672,10 @@ export default function MarkdownEditor({ data-tip={label} aria-label={label} disabled={disabled || dis} + onMouseDown={(e) => { + e.preventDefault(); + rememberCaret(); + }} onClick={onClick} > {icon} @@ -579,7 +685,7 @@ export default function MarkdownEditor({ const Sep = () => ; return ( -
+
} onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })} /> - } - onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })} - /> - } - onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} - /> - -
+ {!isComment && ( - - - - } - active={popover === "heading"} - onClick={() => openPopover("heading")} + label="斜体 Ctrl+I" + icon={} + onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })} /> - {popover === "heading" && ( -
- - -
- )} -
- - } - onClick={() => - runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) - } - /> - } - onClick={() => - runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) - } - /> - } - onClick={insertOrderedList} - /> - } onClick={insertTable} /> - } - onClick={() => insertAtCursor("---")} - /> + )} + {!isComment && ( + } + onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} + /> + )} + {!isComment && } + {!isComment && ( +
+ + + + + } + active={popover === "heading"} + onClick={() => openPopover("heading")} + /> + {popover === "heading" && ( +
+ + +
+ )} +
+ )} + {(!isComment || !toolNarrow) && } + {(!isComment || !toolNarrow) && ( + } + onClick={() => + runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) + } + /> + )} + {(!isComment || !toolNarrow) && ( + } + onClick={() => + runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) + } + /> + )} + {!isComment && ( + } + onClick={insertOrderedList} + /> + )} + {!isComment && } onClick={insertTable} />} + {!isComment && ( + } + onClick={() => insertAtCursor("---")} + /> + )} } onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })} /> + {(!isComment || !toolNarrow) && (
)}
+ )}
)}
- + {isComment && ( +
+ + {popover === "more" && ( +
+ {toolNarrow && ( + <> + + + + + )} + + + + + + + + + {toolNarrow && ( + + )} +
+ )} + {toolNarrow && popover === "code" && ( +
+ setCodeQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && filteredLangs[0]) { + e.preventDefault(); + insertCodeBlock(filteredLangs[0]); + } + }} + /> +
+ {filteredLangs.map((lang) => ( + + ))} + {filteredLangs.length === 0 && ( +

无匹配语言

+ )} +
+
+ )} +
+ )} + {!isComment && } + {!isComment && ( } onClick={openTimelineEditor} /> + )} {allowHide ? (
{ + e.preventDefault(); + rememberCaret(); + }} onClick={() => setMode("write")} > 编写 @@ -982,21 +1182,30 @@ export default function MarkdownEditor({ role="tab" aria-selected={mode === "preview"} className={mode === "preview" ? "is-on" : ""} + onMouseDown={(e) => { + e.preventDefault(); + rememberCaret(); + }} onClick={() => setMode("preview")} > 预览
+ {(!isComment || !toolNarrow) && ( + )}
@@ -1037,14 +1246,16 @@ export default function MarkdownEditor({ id={id} value={value} disabled={disabled} - rows={rows} + rows={isComment ? 3 : rows} placeholder={placeholder} + aria-label={ariaLabel || placeholder || "正文"} className="j13-md-textarea" autoFocus={autoFocus} onChange={(e) => onChange(e.target.value)} onKeyDown={onTextareaKeyDown} onPaste={(e) => void onPaste(e)} onSelect={rememberSel} + onBlur={rememberCaret} />
- 支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交 - {value.length} 字 + + {hint ?? + (isComment + ? `支持 Markdown · ${modKey}+Enter ${shortcutName || "发表"}` + : "支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交")} + + + {value.length} 字 + {footer} +
+ {(uploading || uploadError) && ( +

+ {uploading ? "图片上传中…" : uploadError} + {uploadError && !uploading ? ( + <> + + + + ) : null} +

+ )} + {error ? ( +

+ {error} +

+ ) : null}