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) && ( + )} {canDelete(c) && ( +
+ + )} {isAdmin && ( <>