From e59878227976d02541310d994e696b34a20cd773 Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 20 Sep 2026 10:36:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=8C=BA=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=85=AC=E5=85=B1=20Markdown=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=EF=BC=88=E4=B8=8D=E5=90=AB=E9=9A=90=E8=97=8F=E5=86=85=E5=AE=B9?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=B9=B6=E5=BF=BD=E7=95=A5=20GORM=20?= =?UTF-8?q?=E6=9C=AA=E5=91=BD=E4=B8=AD=E6=97=A5=E5=BF=97=E5=99=AA=E9=9F=B3?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/model/db.go | 12 ++- backend/service/comment.go | 6 ++ backend/service/content_hide.go | 2 + frontend/app/globals.css | 36 ++++++- frontend/components/CommentSection.tsx | 133 +++++++++++++------------ frontend/components/MarkdownEditor.tsx | 4 + 6 files changed, 126 insertions(+), 67 deletions(-) diff --git a/backend/model/db.go b/backend/model/db.go index 4e1c3e8..07f4227 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "log" + "os" "strings" + "time" "github.com/freefire/jiang13-bbs/markdown" "gorm.io/driver/postgres" @@ -20,7 +22,15 @@ var DB *gorm.DB // InitDB 连接 PostgreSQL 并自动迁移 func InitDB(dsn string) error { db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Warn), + Logger: logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: 200 * time.Millisecond, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, // First 未命中是业务常态,勿当 Warn 刷屏 + Colorful: true, + }, + ), }) if err != nil { return fmt.Errorf("连接 PostgreSQL 失败: %w", err) diff --git a/backend/service/comment.go b/backend/service/comment.go index bb4b80f..2933b19 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -302,6 +302,9 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u if content == "" { return nil, nil, errors.New("评论内容不能为空") } + if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil { + return nil, nil, err + } if status != model.ContentStatusPending && status != model.ContentStatusPublished { status = model.ContentStatusPending } @@ -584,6 +587,9 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st if content == "" { return nil, errors.New("评论内容不能为空") } + if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil { + return nil, err + } var comment model.Comment if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { return nil, ErrCommentNotFound diff --git a/backend/service/content_hide.go b/backend/service/content_hide.go index e79e0c5..4898245 100644 --- a/backend/service/content_hide.go +++ b/backend/service/content_hide.go @@ -11,6 +11,8 @@ var ( ErrAnnouncementReplyHideNotAllowed = errors.New("公告不可评论,不能使用回复可见") // ErrSitePageHideNotAllowed 站点单页正文禁止 [hide](本轮单页仍无评论/解锁) ErrSitePageHideNotAllowed = errors.New("页面不支持隐藏内容") + // ErrCommentHideNotAllowed 评论无隐藏解锁流程,禁止 [hide] + ErrCommentHideNotAllowed = errors.New("评论不支持隐藏内容") ) // rejectHideBlocks 若正文含 [hide] 开标记(忽略代码围栏内伪语法)则返回指定错误。 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6e387ef..fa084b9 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -7189,8 +7189,40 @@ html.dark .prose-content pre[data-theme] span { background: var(--panel-2); margin-top: 8px; } -.cmt-reply-form .field { - font-size: 13.5px; + +/* 评论区公共编辑器:矮于发帖画布,带边框贴近原 field */ +.cmt-md-editor { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.35rem 0.85rem 0.55rem; + background: var(--panel); +} +.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; + padding-top: 8px; } /* 软删占位:匿名马赛克(不含真实头像/昵称) */ diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 3e69527..94c1def 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { @@ -35,6 +35,7 @@ import CommentsUnsupported from "@/components/CommentsUnsupported"; import Avatar from "./Avatar"; import Pagination from "./Pagination"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; +import MarkdownEditor from "@/components/MarkdownEditor"; import ConfirmDialog from "@/components/ConfirmDialog"; import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal"; import CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; @@ -127,7 +128,6 @@ export default function CommentSection({ const [historyCommentId, setHistoryCommentId] = useState(null); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); - const textareaRef = useRef(null); useEffect(() => { if (!commentsOpen) { @@ -142,7 +142,7 @@ export default function CommentSection({ const focusComposer = () => { const hash = window.location.hash; if (hash !== "#comment-input" && hash !== "#comments") return; - const el = textareaRef.current ?? document.getElementById("comment-input"); + const el = document.getElementById("comment-input"); if (!(el instanceof HTMLTextAreaElement)) return; el.scrollIntoView({ behavior: "smooth", block: "center" }); window.setTimeout(() => el.focus({ preventScroll: true }), 280); @@ -152,9 +152,8 @@ export default function CommentSection({ return () => window.removeEventListener("hashchange", focusComposer); }, [user, commentsOpen]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!commentsOpen || !content.trim()) return; + const submitFloor = async () => { + if (!commentsOpen || !content.trim() || submitting) return; setSubmitting(true); try { const res = await apiCreateComment(postId, content); @@ -179,9 +178,13 @@ export default function CommentSection({ } }; - const handleSubmitReply = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!commentsOpen || !replyTarget || !replyContent.trim()) return; + void submitFloor(); + }; + + const submitReply = async () => { + if (!commentsOpen || !replyTarget || !replyContent.trim() || submitting) return; setSubmitting(true); try { const res = await apiCreateComment(postId, replyContent, replyTarget.id); @@ -207,6 +210,11 @@ export default function CommentSection({ } }; + const handleSubmitReply = (e: React.FormEvent) => { + e.preventDefault(); + void submitReply(); + }; + const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null; const deleteMode: "self" | "staff" = deleteTarget && user && user.id === (deleteTarget.user?.id ?? deleteTarget.user_id) @@ -373,7 +381,7 @@ export default function CommentSection({ }; const handleSaveEdit = async () => { - if (editingId == null || !editContent.trim()) return; + if (editingId == null || !editContent.trim() || savingEdit) return; setSavingEdit(true); try { const res = await apiUpdateComment(postId, editingId, editContent); @@ -394,14 +402,18 @@ export default function CommentSection({ const renderContentOrEditor = (c: CommentNode) => { if (editingId === c.id) { return ( -
-