From f3145b24ffabf186aff71938e3b3ee3abe1515d0 Mon Sep 17 00:00:00 2001
From: freefire
Date: Wed, 16 Sep 2026 05:37:57 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=B1=E5=9C=B0=E9=80=9A=E8=BF=87?=
=?UTF-8?q?=E8=AF=84=E5=AE=A1=EF=BC=8C=E5=B9=B6=E4=B8=BA=E5=B7=B2=E8=BF=87?=
=?UTF-8?q?=E5=AE=A1=E7=94=A8=E6=88=B7=E6=8F=90=E4=BE=9B=E9=BB=98=E8=AE=A4?=
=?UTF-8?q?=E5=8F=AF=E5=85=B3=E7=9A=84=E5=85=8D=E5=AE=A1=E5=8F=91=E5=B8=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
帖/评管理入口可直接通过;站点默认信任已有 published 内容的用户后续直发。
Co-authored-by: Cursor
---
backend/handler/comment.go | 7 +-
backend/handler/handlers.go | 17 +++++
backend/handler/post.go | 5 +-
backend/handler/setting.go | 76 +++++++++++++++----
backend/service/moderation.go | 20 +++++
backend/service/setting.go | 32 ++++++++
.../app/admin/appearance/AppearanceAdmin.tsx | 2 +-
.../app/admin/moderation/ModerationAdmin.tsx | 58 ++++++++++++++
frontend/app/admin/moderation/page.tsx | 9 ++-
frontend/app/post/[id]/page.tsx | 9 ++-
frontend/components/CommentSection.tsx | 53 ++++++++++++-
frontend/components/PostActions.tsx | 45 ++++++++++-
frontend/lib/api.ts | 36 ++++++---
frontend/lib/realtime.ts | 1 +
14 files changed, 330 insertions(+), 40 deletions(-)
diff --git a/backend/handler/comment.go b/backend/handler/comment.go
index a6ffe63..cf2c72a 100644
--- a/backend/handler/comment.go
+++ b/backend/handler/comment.go
@@ -71,11 +71,8 @@ func (h *Handlers) CreateComment(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- // 管理团队成员评论免审;普通用户评论进入待审核队列,审核通过时才发业务通知
- status := model.ContentStatusPending
- if model.IsStaff(model.Role(claims.Role)) {
- status = model.ContentStatusPublished
- }
+ // staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知)
+ status := h.resolvePublishStatus(claims.ID, claims.Role)
comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go
index 9205ffa..4550821 100644
--- a/backend/handler/handlers.go
+++ b/backend/handler/handlers.go
@@ -30,6 +30,23 @@ type Handlers struct {
Chat *service.ChatService
}
+// resolvePublishStatus 决定新帖/新评的初始状态:
+// staff 直发;若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。
+func (h *Handlers) resolvePublishStatus(userID uint, role string) string {
+ if model.IsStaff(model.Role(role)) {
+ return model.ContentStatusPublished
+ }
+ trust, err := h.Setting.TrustReviewedPublish()
+ if err != nil || !trust {
+ return model.ContentStatusPending
+ }
+ ok, err := h.Moderation.UserHasApprovedContent(userID)
+ if err != nil || !ok {
+ return model.ContentStatusPending
+ }
+ return model.ContentStatusPublished
+}
+
// broadcastFeedChanged 帖子流有公开发布内容时全员广播(三期)
func (h *Handlers) broadcastFeedChanged(kind string, postID, boardID, actorID uint) {
if h.Hub == nil {
diff --git a/backend/handler/post.go b/backend/handler/post.go
index fe49381..71c3373 100644
--- a/backend/handler/post.go
+++ b/backend/handler/post.go
@@ -110,10 +110,7 @@ func (h *Handlers) CreatePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- status := model.ContentStatusPending
- if model.IsStaff(model.Role(claims.Role)) {
- status = model.ContentStatusPublished
- }
+ status := h.resolvePublishStatus(claims.ID, claims.Role)
post, err := h.Post.Create(service.CreatePostInput{
UserID: claims.ID,
BoardID: req.BoardID,
diff --git a/backend/handler/setting.go b/backend/handler/setting.go
index 907fc0b..cc892f4 100644
--- a/backend/handler/setting.go
+++ b/backend/handler/setting.go
@@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
)
-// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量
+// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量等
// GET /api/settings
func (h *Handlers) PublicSettings(c *gin.Context) {
accent, err := h.Setting.AccentColor()
@@ -18,12 +18,23 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
- c.JSON(http.StatusOK, gin.H{"accent": accent})
+ trust, err := h.Setting.TrustReviewedPublish()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "accent": accent,
+ "trust_reviewed_publish": trust,
+ })
}
+// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖审核策略
type updateSettingsRequest struct {
// Accent 主题色 #RRGGBB;空串恢复默认
- Accent string `json:"accent"`
+ Accent *string `json:"accent"`
+ // TrustReviewedPublish 已过审用户后续内容免审
+ TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
}
// UpdateSettings 更新站点设置(管理员)
@@ -34,19 +45,58 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
- if err := h.Setting.SetAccent(req.Accent); err != nil {
- if errors.Is(err, service.ErrInvalidAccent) {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ if req.Accent == nil && req.TrustReviewedPublish == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
return
}
- // 归一化后的值实时广播:所有在线客户端立即热换肤,无需 F5 或等轮询
- saved := strings.ToLower(strings.TrimSpace(req.Accent))
+
+ savedAccent := ""
+ if req.Accent != nil {
+ if err := h.Setting.SetAccent(*req.Accent); err != nil {
+ if errors.Is(err, service.ErrInvalidAccent) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ savedAccent = strings.ToLower(strings.TrimSpace(*req.Accent))
+ } else {
+ var err error
+ savedAccent, err = h.Setting.AccentColor()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ return
+ }
+ }
+
+ trustOn := true
+ if req.TrustReviewedPublish != nil {
+ if err := h.Setting.SetTrustReviewedPublish(*req.TrustReviewedPublish); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ trustOn = *req.TrustReviewedPublish
+ } else {
+ var err error
+ trustOn, err = h.Setting.TrustReviewedPublish()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ return
+ }
+ }
+
+ // 归一化后广播:主题色客户端热换肤;免审开关由下次发帖/发评读库生效
h.Hub.BroadcastAll(realtime.Envelope{
Type: realtime.EventSettingsChanged,
- Data: map[string]string{"accent": saved},
+ Data: gin.H{
+ "accent": savedAccent,
+ "trust_reviewed_publish": trustOn,
+ },
+ })
+ c.JSON(http.StatusOK, gin.H{
+ "ok": true,
+ "accent": savedAccent,
+ "trust_reviewed_publish": trustOn,
})
- c.JSON(http.StatusOK, gin.H{"ok": true, "accent": saved})
}
diff --git a/backend/service/moderation.go b/backend/service/moderation.go
index 549de01..8101ecd 100644
--- a/backend/service/moderation.go
+++ b/backend/service/moderation.go
@@ -280,6 +280,26 @@ func (s *ModerationService) RejectComment(actor *Actor, id uint) error {
})
}
+// UserHasApprovedContent 用户是否已有至少一篇已发布帖子或一条已发布评论(即曾通过审核或免审直发)
+func (s *ModerationService) UserHasApprovedContent(userID uint) (bool, error) {
+ var n int64
+ if err := s.db.Model(&model.Post{}).
+ Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
+ Limit(1).Count(&n).Error; err != nil {
+ return false, err
+ }
+ if n > 0 {
+ return true, nil
+ }
+ n = 0
+ if err := s.db.Model(&model.Comment{}).
+ Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
+ Limit(1).Count(&n).Error; err != nil {
+ return false, err
+ }
+ return n > 0, nil
+}
+
func keys(m map[uint]bool) []uint {
out := make([]uint, 0, len(m))
for k := range m {
diff --git a/backend/service/setting.go b/backend/service/setting.go
index 54ec166..6793b0b 100644
--- a/backend/service/setting.go
+++ b/backend/service/setting.go
@@ -13,6 +13,8 @@ import (
const (
// SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色
SettingKeyAccent = "theme_accent"
+ // SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
+ SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
)
// ErrInvalidAccent 主题色格式非法
@@ -55,3 +57,33 @@ func (s *SettingService) SetAccent(hex string) error {
st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
return s.db.Save(&st).Error
}
+
+// TrustReviewedPublish 是否对「已有过审内容」的用户免审。
+// KV 缺行 / 空值视为开启(默认开);仅显式写入 false/0/off/no 为关闭。
+// 不用 GORM bool default:true,避免零值省略陷阱。
+func (s *SettingService) TrustReviewedPublish() (bool, error) {
+ var st model.SiteSetting
+ err := s.db.Where("key = ?", SettingKeyTrustReviewedPublish).First(&st).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return true, nil
+ }
+ if err != nil {
+ return true, err
+ }
+ v := strings.TrimSpace(strings.ToLower(st.Value))
+ switch v {
+ case "false", "0", "off", "no":
+ return false, nil
+ default:
+ return true, nil
+ }
+}
+
+// SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义
+func (s *SettingService) SetTrustReviewedPublish(on bool) error {
+ if on {
+ return s.db.Where("key = ?", SettingKeyTrustReviewedPublish).Delete(&model.SiteSetting{}).Error
+ }
+ st := model.SiteSetting{Key: SettingKeyTrustReviewedPublish, Value: "false"}
+ return s.db.Save(&st).Error
+}
diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx
index 978b987..0f46ff4 100644
--- a/frontend/app/admin/appearance/AppearanceAdmin.tsx
+++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx
@@ -58,7 +58,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
if (!normalized || saving) return;
setSaving(true);
try {
- const res = await apiUpdateSiteSettings(submitHex);
+ const res = await apiUpdateSiteSettings({ accent: submitHex });
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
applyAccentTheme(next || null);
diff --git a/frontend/app/admin/moderation/ModerationAdmin.tsx b/frontend/app/admin/moderation/ModerationAdmin.tsx
index 3990645..87518d1 100644
--- a/frontend/app/admin/moderation/ModerationAdmin.tsx
+++ b/frontend/app/admin/moderation/ModerationAdmin.tsx
@@ -20,6 +20,7 @@ import {
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
+ apiUpdateSiteSettings,
type PendingCommentItem,
type PendingCounts,
type Post,
@@ -47,10 +48,15 @@ export default function ModerationAdmin({
initialPosts,
initialPostsTotal,
initError,
+ canConfigureSettings,
+ initialTrustReviewedPublish,
}: {
initialPosts: Post[];
initialPostsTotal: number;
initError: string;
+ /** 超管/站长可改站点免审策略(后端 PermSettings) */
+ canConfigureSettings: boolean;
+ initialTrustReviewedPublish: boolean;
}) {
const [tab, setTab] = useState("posts");
const [counts, setCounts] = useState({ posts: initialPostsTotal, comments: 0 });
@@ -73,12 +79,30 @@ export default function ModerationAdmin({
const [confirmKey, setConfirmKey] = useState(null);
const confirmTimer = useRef | null>(null);
+ const [trustReviewedPublish, setTrustReviewedPublish] = useState(initialTrustReviewedPublish);
+ const [savingTrust, setSavingTrust] = useState(false);
+
const refreshCounts = useCallback(() => {
apiAdminPendingCounts()
.then(setCounts)
.catch(() => {});
}, []);
+ const toggleTrustReviewedPublish = async () => {
+ if (!canConfigureSettings || savingTrust) return;
+ const next = !trustReviewedPublish;
+ setSavingTrust(true);
+ try {
+ const res = await apiUpdateSiteSettings({ trust_reviewed_publish: next });
+ setTrustReviewedPublish(res.trust_reviewed_publish);
+ toast(res.trust_reviewed_publish ? "已开启:过审用户后续免审" : "已关闭:所有普通用户均需审核", "ok");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败");
+ } finally {
+ setSavingTrust(false);
+ }
+ };
+
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
@@ -215,6 +239,40 @@ export default function ModerationAdmin({
+ {canConfigureSettings && (
+
+
+
+ 已过审用户后续免审
+
+
+ 开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
+
+
+
+
+ )}
+
{/* 队列切换 */}
{(
diff --git a/frontend/app/admin/moderation/page.tsx b/frontend/app/admin/moderation/page.tsx
index d0be945..3031cfb 100644
--- a/frontend/app/admin/moderation/page.tsx
+++ b/frontend/app/admin/moderation/page.tsx
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
-import { getMeCached } from "@/lib/serverData";
-import { canModerateAny } from "@/lib/roles";
+import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
+import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ModerationAdmin from "./ModerationAdmin";
@@ -33,11 +33,16 @@ export default async function ModerationPage() {
initError = e instanceof Error ? e.message : "获取待审核内容失败";
}
+ const canConfigureSettings = isSuperOrOwner(me.user.role);
+ const settings = await getPublicSettingsCached();
+
return (
);
}
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 9e37117..cd8e334 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -494,7 +494,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
-
+
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);
diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx
index c7eef72..d81bfcc 100644
--- a/frontend/components/CommentSection.tsx
+++ b/frontend/components/CommentSection.tsx
@@ -3,9 +3,17 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
+import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react";
import ReactMarkdown from "react-markdown";
-import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, type CommentNode, type User } from "@/lib/api";
+import {
+ apiCreateComment,
+ apiDeleteComment,
+ apiAcceptBounty,
+ apiAcceptQuestion,
+ apiAdminApproveComment,
+ type CommentNode,
+ type User,
+} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "./Avatar";
@@ -59,6 +67,7 @@ export default function CommentSection({
const [deletingId, setDeletingId] = useState(null);
const [confirmingId, setConfirmingId] = useState(null);
const [acceptingId, setAcceptingId] = useState(null);
+ const [approvingId, setApprovingId] = useState(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState>(new Set());
const textareaRef = useRef(null);
@@ -163,11 +172,27 @@ export default function CommentSection({
}
};
+ const handleApprove = async (commentId: number) => {
+ setApprovingId(commentId);
+ try {
+ await apiAdminApproveComment(commentId);
+ toast("已通过评审", "ok");
+ router.refresh();
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "通过评审失败");
+ } finally {
+ setApprovingId(null);
+ }
+ };
+
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
const canDelete = (comment: CommentNode) =>
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
+ const canApprove = (comment: CommentNode) =>
+ !!user && comment.status === "pending" && canModerateBoard(user, boardId);
+
const canAccept = (comment: CommentNode) =>
!!acceptMode &&
!!user &&
@@ -302,6 +327,18 @@ export default function CommentSection({
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
)}
+ {canApprove(c) && (
+
+ )}
{canDelete(c) && (