评论区改用公共 Markdown 编辑器(不含隐藏内容),并忽略 GORM 未命中日志噪音。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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] 开标记(忽略代码围栏内伪语法)则返回指定错误。
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 软删占位:匿名马赛克(不含真实头像/昵称) */
|
/* 软删占位:匿名马赛克(不含真实头像/昵称) */
|
||||||
|
|||||||
@@ -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,38 +464,34 @@ 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">
|
||||||
value={replyContent}
|
<MarkdownEditor
|
||||||
onChange={(e) => setReplyContent(e.target.value)}
|
value={replyContent}
|
||||||
onKeyDown={(e) => {
|
onChange={setReplyContent}
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
rows={4}
|
||||||
e.preventDefault();
|
disabled={submitting}
|
||||||
e.currentTarget.form?.requestSubmit();
|
autoFocus
|
||||||
}
|
allowHide={false}
|
||||||
}}
|
placeholder={`友善回复 @${replyTarget.nickname}…`}
|
||||||
rows={2}
|
onSubmitShortcut={() => {
|
||||||
autoFocus
|
void submitReply();
|
||||||
className="field"
|
}}
|
||||||
aria-label={`回复 ${replyTarget.nickname}`}
|
/>
|
||||||
placeholder="友善回复..."
|
</div>
|
||||||
/>
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
<div className="flex items-center justify-between gap-3 mt-2">
|
<button
|
||||||
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
type="button"
|
||||||
<div className="flex gap-2">
|
className="btn btn-line btn-sm"
|
||||||
<button
|
onClick={() => {
|
||||||
type="button"
|
setReplyTarget(null);
|
||||||
className="btn btn-line btn-sm"
|
setReplyContent("");
|
||||||
onClick={() => {
|
}}
|
||||||
setReplyTarget(null);
|
>
|
||||||
setReplyContent("");
|
取消
|
||||||
}}
|
</button>
|
||||||
>
|
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
|
||||||
取消
|
{submitting ? "发送中..." : "回复"}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
|
|
||||||
{submitting ? "发送中..." : "回复"}
|
|
||||||
</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"
|
</div>
|
||||||
aria-label="写评论"
|
<div className="flex items-center justify-end gap-3 mt-2.5">
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between gap-3 mt-2.5">
|
|
||||||
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user