评论区改用公共 Markdown 编辑器(不含隐藏内容),并忽略 GORM 未命中日志噪音。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 10:36:41 +08:00
parent 803b694659
commit e598782279
6 changed files with 126 additions and 67 deletions

View File

@@ -6,7 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"strings" "strings"
"time"
"github.com/freefire/jiang13-bbs/markdown" "github.com/freefire/jiang13-bbs/markdown"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
@@ -20,7 +22,15 @@ var DB *gorm.DB
// InitDB 连接 PostgreSQL 并自动迁移 // InitDB 连接 PostgreSQL 并自动迁移
func InitDB(dsn string) error { func InitDB(dsn string) error {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 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 { if err != nil {
return fmt.Errorf("连接 PostgreSQL 失败: %w", err) return fmt.Errorf("连接 PostgreSQL 失败: %w", err)

View File

@@ -302,6 +302,9 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
if content == "" { if content == "" {
return nil, nil, errors.New("评论内容不能为空") return nil, nil, errors.New("评论内容不能为空")
} }
if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil {
return nil, nil, err
}
if status != model.ContentStatusPending && status != model.ContentStatusPublished { if status != model.ContentStatusPending && status != model.ContentStatusPublished {
status = model.ContentStatusPending status = model.ContentStatusPending
} }
@@ -584,6 +587,9 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
if content == "" { if content == "" {
return nil, errors.New("评论内容不能为空") return nil, errors.New("评论内容不能为空")
} }
if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil {
return nil, err
}
var comment model.Comment var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return nil, ErrCommentNotFound return nil, ErrCommentNotFound

View File

@@ -11,6 +11,8 @@ var (
ErrAnnouncementReplyHideNotAllowed = errors.New("公告不可评论,不能使用回复可见") ErrAnnouncementReplyHideNotAllowed = errors.New("公告不可评论,不能使用回复可见")
// ErrSitePageHideNotAllowed 站点单页正文禁止 [hide](本轮单页仍无评论/解锁) // ErrSitePageHideNotAllowed 站点单页正文禁止 [hide](本轮单页仍无评论/解锁)
ErrSitePageHideNotAllowed = errors.New("页面不支持隐藏内容") ErrSitePageHideNotAllowed = errors.New("页面不支持隐藏内容")
// ErrCommentHideNotAllowed 评论无隐藏解锁流程,禁止 [hide]
ErrCommentHideNotAllowed = errors.New("评论不支持隐藏内容")
) )
// rejectHideBlocks 若正文含 [hide] 开标记(忽略代码围栏内伪语法)则返回指定错误。 // rejectHideBlocks 若正文含 [hide] 开标记(忽略代码围栏内伪语法)则返回指定错误。

View File

@@ -7189,8 +7189,40 @@ html.dark .prose-content pre[data-theme] span {
background: var(--panel-2); background: var(--panel-2);
margin-top: 8px; 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;
} }
/* 软删占位:匿名马赛克(不含真实头像/昵称) */ /* 软删占位:匿名马赛克(不含真实头像/昵称) */

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -35,6 +35,7 @@ import CommentsUnsupported from "@/components/CommentsUnsupported";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import Pagination from "./Pagination"; import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import MarkdownEditor from "@/components/MarkdownEditor";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal"; import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
@@ -127,7 +128,6 @@ export default function CommentSection({
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null); const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set()); const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
if (!commentsOpen) { if (!commentsOpen) {
@@ -142,7 +142,7 @@ export default function CommentSection({
const focusComposer = () => { const focusComposer = () => {
const hash = window.location.hash; const hash = window.location.hash;
if (hash !== "#comment-input" && hash !== "#comments") return; 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; if (!(el instanceof HTMLTextAreaElement)) return;
el.scrollIntoView({ behavior: "smooth", block: "center" }); el.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => el.focus({ preventScroll: true }), 280); window.setTimeout(() => el.focus({ preventScroll: true }), 280);
@@ -152,9 +152,8 @@ export default function CommentSection({
return () => window.removeEventListener("hashchange", focusComposer); return () => window.removeEventListener("hashchange", focusComposer);
}, [user, commentsOpen]); }, [user, commentsOpen]);
const handleSubmit = async (e: React.FormEvent) => { const submitFloor = async () => {
e.preventDefault(); if (!commentsOpen || !content.trim() || submitting) return;
if (!commentsOpen || !content.trim()) return;
setSubmitting(true); setSubmitting(true);
try { try {
const res = await apiCreateComment(postId, content); 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(); e.preventDefault();
if (!commentsOpen || !replyTarget || !replyContent.trim()) return; void submitFloor();
};
const submitReply = async () => {
if (!commentsOpen || !replyTarget || !replyContent.trim() || submitting) return;
setSubmitting(true); setSubmitting(true);
try { try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id); 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 deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null;
const deleteMode: "self" | "staff" = const deleteMode: "self" | "staff" =
deleteTarget && user && user.id === (deleteTarget.user?.id ?? deleteTarget.user_id) deleteTarget && user && user.id === (deleteTarget.user?.id ?? deleteTarget.user_id)
@@ -373,7 +381,7 @@ export default function CommentSection({
}; };
const handleSaveEdit = async () => { const handleSaveEdit = async () => {
if (editingId == null || !editContent.trim()) return; if (editingId == null || !editContent.trim() || savingEdit) return;
setSavingEdit(true); setSavingEdit(true);
try { try {
const res = await apiUpdateComment(postId, editingId, editContent); const res = await apiUpdateComment(postId, editingId, editContent);
@@ -394,14 +402,18 @@ export default function CommentSection({
const renderContentOrEditor = (c: CommentNode) => { const renderContentOrEditor = (c: CommentNode) => {
if (editingId === c.id) { if (editingId === c.id) {
return ( return (
<div className="mt-1"> <div className="cmt-md-editor mt-1">
<textarea <MarkdownEditor
value={editContent} value={editContent}
onChange={(e) => setEditContent(e.target.value)} onChange={setEditContent}
rows={4} rows={5}
className="input w-full text-sm"
disabled={savingEdit} disabled={savingEdit}
autoFocus autoFocus
allowHide={false}
placeholder="编辑评论…"
onSubmitShortcut={() => {
void handleSaveEdit();
}}
/> />
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<button <button
@@ -452,24 +464,21 @@ export default function CommentSection({
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}> <div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span> 回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
</div> </div>
<textarea <div className="cmt-md-editor">
<MarkdownEditor
value={replyContent} value={replyContent}
onChange={(e) => setReplyContent(e.target.value)} onChange={setReplyContent}
onKeyDown={(e) => { rows={4}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { disabled={submitting}
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={2}
autoFocus autoFocus
className="field" allowHide={false}
aria-label={`回复 ${replyTarget.nickname}`} placeholder={`友善回复 @${replyTarget.nickname}…`}
placeholder="友善回复..." onSubmitShortcut={() => {
void submitReply();
}}
/> />
<div className="flex items-center justify-between gap-3 mt-2"> </div>
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span> <div className="flex items-center justify-end gap-2 mt-2">
<div className="flex gap-2">
<button <button
type="button" type="button"
className="btn btn-line btn-sm" className="btn btn-line btn-sm"
@@ -484,7 +493,6 @@ export default function CommentSection({
{submitting ? "发送中..." : "回复"} {submitting ? "发送中..." : "回复"}
</button> </button>
</div> </div>
</div>
</form> </form>
); );
@@ -1066,24 +1074,21 @@ export default function CommentSection({
{user ? ( {user ? (
<form onSubmit={handleSubmit} className="mb-8"> <form onSubmit={handleSubmit} className="mb-8">
<textarea <div className="cmt-md-editor cmt-md-editor-floor scroll-mt-28">
ref={textareaRef} <MarkdownEditor
id="comment-input" id="comment-input"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={setContent}
onKeyDown={(e) => { rows={6}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { disabled={submitting}
e.preventDefault(); allowHide={false}
e.currentTarget.form?.requestSubmit(); placeholder="友善发言,发表一个新楼层…"
} onSubmitShortcut={() => {
void submitFloor();
}} }}
placeholder="友善发言,发表一个新楼层..."
rows={3}
className="field scroll-mt-28"
aria-label="写评论"
/> />
<div className="flex items-center justify-between gap-3 mt-2.5"> </div>
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span> <div className="flex items-center justify-end gap-3 mt-2.5">
<button type="submit" disabled={submitting || !content.trim()} className="btn btn-primary"> <button type="submit" disabled={submitting || !content.trim()} className="btn btn-primary">
{submitting ? "发送中..." : "发表评论"} {submitting ? "发送中..." : "发表评论"}
</button> </button>

View File

@@ -49,6 +49,8 @@ type MarkdownEditorProps = {
disabled?: boolean; disabled?: boolean;
onSubmitShortcut?: () => void; onSubmitShortcut?: () => void;
id?: string; id?: string;
/** 挂载后聚焦 textarea(回复/编辑评论等) */
autoFocus?: boolean;
/** 正文插图体积上限(MB),缺省 5 */ /** 正文插图体积上限(MB),缺省 5 */
imageMaxMB?: number; imageMaxMB?: number;
/** 是否提供隐藏可见([hide]) */ /** 是否提供隐藏可见([hide]) */
@@ -118,6 +120,7 @@ export default function MarkdownEditor({
disabled, disabled,
onSubmitShortcut, onSubmitShortcut,
id, id,
autoFocus = false,
imageMaxMB = 5, imageMaxMB = 5,
allowHide = false, allowHide = false,
allowReplyHide = true, allowReplyHide = true,
@@ -1037,6 +1040,7 @@ export default function MarkdownEditor({
rows={rows} rows={rows}
placeholder={placeholder} placeholder={placeholder}
className="j13-md-textarea" className="j13-md-textarea"
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={onTextareaKeyDown} onKeyDown={onTextareaKeyDown}
onPaste={(e) => void onPaste(e)} onPaste={(e) => void onPaste(e)}