From 4b937394689d521ff08e39afbf5c0841e5a5e9bb Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 17 Sep 2026 04:16:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E5=8F=B0=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E6=B6=88=E6=81=AF=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭后前台隐藏私聊/群聊入口并由后端拦截;同步去掉公告页重复计数胶囊。 Co-authored-by: Cursor --- backend/handler/auth.go | 7 ++- backend/handler/chat.go | 28 ++++++++-- backend/handler/setting.go | 9 ++++ backend/router/router.go | 41 +++++++------- backend/service/setting.go | 54 +++++++++++++------ .../admin/announcements/AnnouncementAdmin.tsx | 23 +------- frontend/app/admin/settings/SettingsAdmin.tsx | 37 ++++++++++++- frontend/app/chat/[id]/page.tsx | 9 +++- frontend/app/chat/page.tsx | 6 ++- frontend/app/layout.tsx | 37 +++++++------ frontend/app/login/page.tsx | 4 +- frontend/app/u/[id]/StartDMButton.tsx | 4 ++ frontend/app/u/[id]/page.tsx | 6 ++- frontend/components/Header.tsx | 14 ++--- .../components/MessagesPolicyProvider.tsx | 52 ++++++++++++++++++ frontend/components/MessagesUnsupported.tsx | 29 ++++++++++ frontend/lib/api.ts | 5 ++ frontend/lib/realtime.ts | 1 + 18 files changed, 275 insertions(+), 91 deletions(-) create mode 100644 frontend/components/MessagesPolicyProvider.tsx create mode 100644 frontend/components/MessagesUnsupported.tsx diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 8aa4b78..1719b9a 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -279,7 +279,12 @@ func (h *Handlers) Me(c *gin.Context) { if n, err := h.Notification.UnreadCount(claims.ID); err == nil { unread = n } - chatUnread := h.Chat.TotalUnread(claims.ID) + chatUnread := int64(0) + if h.Setting != nil && h.Chat != nil { + if allow, err := h.Setting.AllowMessages(); err == nil && allow { + chatUnread = h.Chat.TotalUnread(claims.ID) + } + } // 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染 var boardIDs []uint if user.Role == model.RoleBoardAdmin { diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 637ae55..b086e52 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -47,15 +47,33 @@ func (h *Handlers) chatOversee(userID uint) bool { return service.CanOverseeChat(actor.Role) } +// RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。 +func (h *Handlers) RequireMessagesOpen(c *gin.Context) { + if h.Setting == nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "消息暂时不可用"}) + return + } + allow, err := h.Setting.AllowMessages() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "消息暂时不可用"}) + return + } + if !allow { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "本站不支持消息"}) + return + } + c.Next() +} + // CreateChatRoom 建群(可公开/私密,私密可带初始成员) func (h *Handlers) CreateChatRoom(c *gin.Context) { claims := middleware.CurrentUser(c) var req struct { - Name string `json:"name"` - Description string `json:"description"` - IsPrivate bool `json:"is_private"` - MemberIDs []uint `json:"member_ids"` - MemberUsernames []string `json:"member_usernames"` + Name string `json:"name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + MemberIDs []uint `json:"member_ids"` + MemberUsernames []string `json:"member_usernames"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 1829ed8..d339294 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -27,6 +27,7 @@ type updateSettingsRequest struct { SiteDescription *string `json:"site_description"` AllowRegister *bool `json:"allow_register"` AllowComments *bool `json:"allow_comments"` + AllowMessages *bool `json:"allow_messages"` PostCooldownHours *int `json:"post_cooldown_hours"` CodeBlockAutoFold *bool `json:"code_block_auto_fold"` CodeBlockFoldLines *int `json:"code_block_fold_lines"` @@ -45,6 +46,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "site_description": saved.SiteDescription, "allow_register": saved.AllowRegister, "allow_comments": saved.AllowComments, + "allow_messages": saved.AllowMessages, "post_cooldown_hours": saved.PostCooldownHours, "code_block_auto_fold": saved.CodeBlockAutoFold, "code_block_fold_lines": saved.CodeBlockFoldLines, @@ -65,6 +67,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { } if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil && req.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil && + req.AllowMessages == nil && req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil && req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil && req.AnimChrome == nil && req.PostLinkNewTab == nil { @@ -120,6 +123,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.AllowMessages != nil { + if err := h.Setting.SetAllowMessages(*req.AllowMessages); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } if req.PostCooldownHours != nil { if err := h.Setting.SetPostCooldownHours(*req.PostCooldownHours); err != nil { if errors.Is(err, service.ErrInvalidSiteSetting) { diff --git a/backend/router/router.go b/backend/router/router.go index 607286e..4d3e4fa 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -179,25 +179,28 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.GET("/my/media", h.MyMedia) api.DELETE("/my/attachments/:id", h.DeleteAttachment) - // 群聊(二期):建群/成员/消息/未读 - api.GET("/chat/rooms", h.ListChatRooms) - api.POST("/chat/rooms", h.CreateChatRoom) - api.POST("/chat/direct", h.OpenDirectChat) - api.GET("/chat/users", h.SearchChatUsers) - api.GET("/chat/rooms/:id", h.GetChatRoom) - api.PUT("/chat/rooms/:id", h.UpdateChatRoom) - api.PUT("/chat/rooms/:id/pin", h.PinChatRoom) - api.DELETE("/chat/rooms/:id", h.DissolveChatRoom) - api.POST("/chat/rooms/:id/join", h.JoinChatRoom) - api.POST("/chat/rooms/:id/leave", h.LeaveChatRoom) - api.GET("/chat/rooms/:id/members", h.ListChatMembers) - api.POST("/chat/rooms/:id/members", h.InviteChatMembers) - api.DELETE("/chat/rooms/:id/members/:uid", h.KickChatMember) - api.GET("/chat/rooms/:id/messages", h.ListChatMessages) - api.POST("/chat/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage) - api.DELETE("/chat/rooms/:id/messages/:mid", h.RecallChatMessage) - api.PUT("/chat/rooms/:id/read", h.MarkChatRead) - api.GET("/chat/unread-summary", h.ChatUnreadSummary) + // 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403 + chatAPI := api.Group("/chat", h.RequireMessagesOpen) + { + chatAPI.GET("/rooms", h.ListChatRooms) + chatAPI.POST("/rooms", h.CreateChatRoom) + chatAPI.POST("/direct", h.OpenDirectChat) + chatAPI.GET("/users", h.SearchChatUsers) + chatAPI.GET("/rooms/:id", h.GetChatRoom) + chatAPI.PUT("/rooms/:id", h.UpdateChatRoom) + chatAPI.PUT("/rooms/:id/pin", h.PinChatRoom) + chatAPI.DELETE("/rooms/:id", h.DissolveChatRoom) + chatAPI.POST("/rooms/:id/join", h.JoinChatRoom) + chatAPI.POST("/rooms/:id/leave", h.LeaveChatRoom) + chatAPI.GET("/rooms/:id/members", h.ListChatMembers) + chatAPI.POST("/rooms/:id/members", h.InviteChatMembers) + chatAPI.DELETE("/rooms/:id/members/:uid", h.KickChatMember) + chatAPI.GET("/rooms/:id/messages", h.ListChatMessages) + chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage) + chatAPI.DELETE("/rooms/:id/messages/:mid", h.RecallChatMessage) + chatAPI.PUT("/rooms/:id/read", h.MarkChatRead) + chatAPI.GET("/unread-summary", h.ChatUnreadSummary) + } } // 管理后台 API:RequireStaff 基础鉴权(板块管理员及以上,Actor 实时从 DB 现取), diff --git a/backend/service/setting.go b/backend/service/setting.go index ac8228b..f1b36ca 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -21,7 +21,9 @@ const ( SettingKeySiteDescription = "site_description" SettingKeyAllowRegister = "allow_register" // SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开) - SettingKeyAllowComments = "allow_comments" + SettingKeyAllowComments = "allow_comments" + // SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开) + SettingKeyAllowMessages = "allow_messages" SettingKeyPostCooldownHours = "post_cooldown_hours" // SettingKeyCodeBlockAutoFold 代码块自动折叠;缺行视为开启 SettingKeyCodeBlockAutoFold = "code_block_auto_fold" @@ -61,20 +63,21 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示) type PublicSiteSettings struct { - Accent string `json:"accent"` - TrustReviewedPublish bool `json:"trust_reviewed_publish"` - SiteName string `json:"site_name"` - SiteDescription string `json:"site_description"` - AllowRegister bool `json:"allow_register"` - AllowComments bool `json:"allow_comments"` - PostCooldownHours int `json:"post_cooldown_hours"` - CodeBlockAutoFold bool `json:"code_block_auto_fold"` - CodeBlockFoldLines int `json:"code_block_fold_lines"` - UIAnimations bool `json:"ui_animations"` - AnimCodeFold bool `json:"anim_code_fold"` - AnimSmoothScroll bool `json:"anim_smooth_scroll"` - AnimChrome bool `json:"anim_chrome"` - PostLinkNewTab bool `json:"post_link_new_tab"` + Accent string `json:"accent"` + TrustReviewedPublish bool `json:"trust_reviewed_publish"` + SiteName string `json:"site_name"` + SiteDescription string `json:"site_description"` + AllowRegister bool `json:"allow_register"` + AllowComments bool `json:"allow_comments"` + AllowMessages bool `json:"allow_messages"` + PostCooldownHours int `json:"post_cooldown_hours"` + CodeBlockAutoFold bool `json:"code_block_auto_fold"` + CodeBlockFoldLines int `json:"code_block_fold_lines"` + UIAnimations bool `json:"ui_animations"` + AnimCodeFold bool `json:"anim_code_fold"` + AnimSmoothScroll bool `json:"anim_smooth_scroll"` + AnimChrome bool `json:"anim_chrome"` + PostLinkNewTab bool `json:"post_link_new_tab"` } // SettingService 站点级键值设置 @@ -135,6 +138,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { TrustReviewedPublish: true, AllowRegister: true, AllowComments: true, + AllowMessages: true, PostCooldownHours: DefaultCooldownHours, CodeBlockAutoFold: true, CodeBlockFoldLines: DefaultCodeFoldLines, @@ -187,6 +191,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { } out.AllowComments = comments + messages, err := s.AllowMessages() + if err != nil { + return out, err + } + out.AllowMessages = messages + hours, err := s.PostCooldownHours() if err != nil { return out, err @@ -320,6 +330,20 @@ func (s *SettingService) SetAllowComments(on bool) error { return s.setBoolDefaultTrue(SettingKeyAllowComments, on) } +// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。 +func (s *SettingService) AllowMessages() (bool, error) { + v, found, err := s.getValue(SettingKeyAllowMessages) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +// SetAllowMessages 持久化消息开关;开启时删键保持「缺行=默认开」。 +func (s *SettingService) SetAllowMessages(on bool) error { + return s.setBoolDefaultTrue(SettingKeyAllowMessages, on) +} + // PostCooldownHours 新用户发帖冷静期(小时)。缺行=24;0 表示关闭。 func (s *SettingService) PostCooldownHours() (int, error) { v, found, err := s.getValue(SettingKeyPostCooldownHours) diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index 94f70da..ae17848 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -334,28 +334,7 @@ export default function AnnouncementAdmin({ } /> - {/* 计数条 */} -
-
- - 草稿{" "} - - {draftCount} - - - - | - - - 已发布{" "} - - {publishedCount} - - -
+
ariaLabel="公告状态筛选" size="sm" diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx index 4ae7a31..ad693a4 100644 --- a/frontend/app/admin/settings/SettingsAdmin.tsx +++ b/frontend/app/admin/settings/SettingsAdmin.tsx @@ -6,6 +6,7 @@ import { MessageSquare, Save, UserPlus, + MessagesSquare, ExternalLink, Shield, Mail, @@ -107,12 +108,14 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) const [siteDescription, setSiteDescription] = useState(initial.site_description); const [allowRegister, setAllowRegister] = useState(initial.allow_register); const [allowComments, setAllowComments] = useState(initial.allow_comments !== false); + const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false); const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false); const [savedName, setSavedName] = useState(initial.site_name); const [savedDesc, setSavedDesc] = useState(initial.site_description); const [saving, setSaving] = useState(false); const [togglingReg, setTogglingReg] = useState(false); const [togglingComments, setTogglingComments] = useState(false); + const [togglingMessages, setTogglingMessages] = useState(false); const [togglingLink, setTogglingLink] = useState(false); const [active, setActive] = useState("basic"); @@ -199,6 +202,21 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) } }; + const toggleMessages = async () => { + if (togglingMessages) return; + const next = !allowMessages; + setTogglingMessages(true); + try { + const res = await apiUpdateSiteSettings({ allow_messages: next }); + setAllowMessages(res.allow_messages); + toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setTogglingMessages(false); + } + }; + const togglePostLinkNewTab = async () => { if (togglingLink) return; const next = !postLinkNewTab; @@ -293,7 +311,7 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
- + } /> + + 开放消息 + + } + description="关闭后前台不展示私聊与群聊入口;已有会话由后台消息管理继续监管。" + control={ + void toggleMessages()} + label="开放消息" + /> + } + /> diff --git a/frontend/app/chat/[id]/page.tsx b/frontend/app/chat/[id]/page.tsx index d1ebebe..05e0b09 100644 --- a/frontend/app/chat/[id]/page.tsx +++ b/frontend/app/chat/[id]/page.tsx @@ -2,10 +2,11 @@ import type { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import { cookies } from "next/headers"; import { authCookieHeader } from "@/lib/cookies"; -import { getMeCached } from "@/lib/serverData"; +import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { fetchChatRoom, fetchChatMessages, fetchChatRooms } from "@/lib/api"; import ChatShell from "../ChatShell"; import ChatRoomClient from "./ChatRoomClient"; +import MessagesUnsupported from "@/components/MessagesUnsupported"; interface PageProps { params: Promise<{ id: string }>; @@ -13,6 +14,9 @@ interface PageProps { export async function generateMetadata({ params }: PageProps): Promise { const { id } = await params; + if ((await getPublicSettingsCached()).allow_messages === false) { + return { title: "本站不支持消息" }; + } try { const { room } = await fetchChatRoom(id, authCookieHeader(await cookies())); const title = @@ -27,6 +31,9 @@ export async function generateMetadata({ params }: PageProps): Promise export default async function ChatRoomPage({ params }: PageProps) { const { id } = await params; + if ((await getPublicSettingsCached()).allow_messages === false) { + return ; + } const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); if (!me.user) { diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index 7cae236..cc2d302 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -2,9 +2,10 @@ import { redirect } from "next/navigation"; import { cookies } from "next/headers"; import type { Metadata } from "next"; import { authCookieHeader } from "@/lib/cookies"; -import { getMeCached } from "@/lib/serverData"; +import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { fetchChatRooms } from "@/lib/api"; import ChatShell from "./ChatShell"; +import MessagesUnsupported from "@/components/MessagesUnsupported"; export const metadata: Metadata = { title: "消息", @@ -12,6 +13,9 @@ export const metadata: Metadata = { }; export default async function ChatListPage() { + if ((await getPublicSettingsCached()).allow_messages === false) { + return ; + } const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); if (!me.user) { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a42b81a..eccb130 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -13,6 +13,7 @@ import PostLinkProvider from "@/components/PostLinkProvider"; import CodeFoldProvider from "@/components/markdown/CodeFoldProvider"; import MotionProvider from "@/components/MotionProvider"; import CommentsPolicyProvider from "@/components/CommentsPolicyProvider"; +import MessagesPolicyProvider from "@/components/MessagesPolicyProvider"; import type { User } from "@/lib/api"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies"; @@ -121,24 +122,26 @@ export default async function RootLayout({ - - - + + - {children} - - - + + {children} + + + + diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 365ca6c..244e18b 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -7,6 +7,7 @@ import { MessagesSquare } from "lucide-react"; import { apiLogin, apiMe, apiGetSettings } from "@/lib/api"; import { hasAuthCookieHint } from "@/lib/cookies"; import { useAllowComments } from "@/components/CommentsPolicyProvider"; +import { useAllowMessages } from "@/components/MessagesPolicyProvider"; // 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向 function safeRedirect(to: string | null): string { @@ -42,6 +43,7 @@ export default function LoginPage() { const router = useRouter(); const searchParams = useSearchParams(); const allowComments = useAllowComments(); + const allowMessages = useAllowMessages(); const redirect = safeRedirect(searchParams.get("redirect")); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -93,7 +95,7 @@ export default function LoginPage() { 欢迎回来

- 登录后发帖{allowComments ? "、评论" : ""}与点赞 + 登录后发帖{allowComments ? "、评论" : ""}{allowMessages ? "、发消息" : ""}与点赞

diff --git a/frontend/app/u/[id]/StartDMButton.tsx b/frontend/app/u/[id]/StartDMButton.tsx index 52e7801..52f918a 100644 --- a/frontend/app/u/[id]/StartDMButton.tsx +++ b/frontend/app/u/[id]/StartDMButton.tsx @@ -5,12 +5,16 @@ import { useRouter } from "next/navigation"; import { MessageSquare } from "lucide-react"; import { apiOpenDirectChat } from "@/lib/api"; import { toast } from "@/lib/toast"; +import { useAllowMessages } from "@/components/MessagesPolicyProvider"; /** 用户主页:发起私聊 */ export default function StartDMButton({ userId }: { userId: number }) { const router = useRouter(); + const allowMessages = useAllowMessages(); const [loading, setLoading] = useState(false); + if (!allowMessages) return null; + const start = async () => { setLoading(true); try { diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx index 10ba0a7..bff5c9b 100644 --- a/frontend/app/u/[id]/page.tsx +++ b/frontend/app/u/[id]/page.tsx @@ -56,7 +56,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps) const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); - const allowComments = (await getPublicSettingsCached()).allow_comments !== false; + const settings = await getPublicSettingsCached(); + const allowComments = settings.allow_comments !== false; + const allowMessages = settings.allow_messages !== false; let data: UserProfile; try { @@ -172,7 +174,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps) banned: u.banned, }} /> - + {allowMessages ? : null}
) : null} diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index 6b7a0c2..d493349 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -29,6 +29,7 @@ import { onDocumentVisible } from "@/lib/visibilityReconcile"; import NotificationBell from "./NotificationBell"; import ThemeToggle from "./ThemeToggle"; import Avatar from "./Avatar"; +import { useAllowMessages } from "./MessagesPolicyProvider"; export default function Header({ initialUser, @@ -47,6 +48,7 @@ export default function Header({ }) { const router = useRouter(); const pathname = usePathname(); + const allowMessages = useAllowMessages(); const [menuOpen, setMenuOpen] = useState(false); // 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正 const [user, setUser] = useState(initialUser); @@ -105,7 +107,7 @@ export default function Header({ // 实时未读:chat:unread 推送给所有成员(含未打开会话页);在当前会话页则忽略 useEffect(() => { - if (!user) return; + if (!user || !allowMessages) return; const off = realtime.on(RT_CHAT_UNREAD, (data) => { if (!data || typeof data.room_id !== "number") return; if (pathname === `/chat/${data.room_id}`) return; @@ -113,11 +115,11 @@ export default function Header({ setChatUnread((c) => Math.max(0, c + delta)); }); return off; - }, [user, pathname]); + }, [user, pathname, allowMessages]); // 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary) useEffect(() => { - if (!user) return; + if (!user || !allowMessages) return; let debounceTimer: ReturnType | null = null; const pullSummary = () => { apiChatUnreadSummary() @@ -135,7 +137,7 @@ export default function Header({ window.removeEventListener("j13-chat-unread-sync", onSync); offVisible(); }; - }, [user]); + }, [user, allowMessages]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -271,7 +273,7 @@ export default function Header({ className={`${iconBtn} hidden lg:inline-flex`} style={iconBtnStyle} /> - {user && ( + {user && allowMessages && ( 首页 {user && 发帖} - {user && ( + {user && allowMessages && ( 消息 diff --git a/frontend/components/MessagesPolicyProvider.tsx b/frontend/components/MessagesPolicyProvider.tsx new file mode 100644 index 0000000..90913cd --- /dev/null +++ b/frontend/components/MessagesPolicyProvider.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { useRouter } from "next/navigation"; +import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; + +const AllowMessagesContext = createContext(true); + +/** 前台是否开放私聊/群聊(SSR 初值 + 后台开关热更新) */ +export function useAllowMessages(): boolean { + return useContext(AllowMessagesContext); +} + +/** + * 根布局注入:关闭消息后前台立即隐藏入口;开启则刷新以拉回会话。 + */ +export default function MessagesPolicyProvider({ + allowMessages, + children, +}: { + allowMessages: boolean; + children: ReactNode; +}) { + const router = useRouter(); + const [open, setOpen] = useState(allowMessages); + const openRef = useRef(open); + openRef.current = open; + + useEffect(() => { + setOpen(allowMessages); + }, [allowMessages]); + + useEffect(() => { + return realtime.on(RT_SETTINGS_CHANGED, (data) => { + if (typeof data?.allow_messages !== "boolean") return; + if (openRef.current === data.allow_messages) return; + setOpen(data.allow_messages); + router.refresh(); + }); + }, [router]); + + return ( + {children} + ); +} diff --git a/frontend/components/MessagesUnsupported.tsx b/frontend/components/MessagesUnsupported.tsx new file mode 100644 index 0000000..bb8d9a0 --- /dev/null +++ b/frontend/components/MessagesUnsupported.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { MessageSquareOff } from "lucide-react"; + +/** 全站关闭消息时的前台说明:表达「不支持」而非暂时关闭。 */ +export default function MessagesUnsupported() { + return ( +
+ + + +

+ 本站不支持消息 +

+

+ 站点未提供私聊与群聊功能。 +

+ + 返回首页 + +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index ddb245c..dc55e66 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -596,6 +596,8 @@ export interface PublicSettings { allow_register: boolean; /** 全站是否开放评论;关闭后前台不展示评论能力 */ allow_comments: boolean; + /** 全站是否开放私聊/群聊;关闭后前台不展示消息入口 */ + allow_messages: boolean; post_cooldown_hours: number; /** 超过 N 行的代码块是否默认折叠;缺省 true */ code_block_auto_fold: boolean; @@ -620,6 +622,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { site_description: "姜十三论坛 - 技术分享与讨论社区", allow_register: true, allow_comments: true, + allow_messages: true, post_cooldown_hours: 24, code_block_auto_fold: true, code_block_fold_lines: 5, @@ -641,6 +644,7 @@ function normalizePublicSettings(data: Partial | null | undefine : DEFAULT_PUBLIC_SETTINGS.site_description, allow_register: data?.allow_register !== false, allow_comments: data?.allow_comments !== false, + allow_messages: data?.allow_messages !== false, post_cooldown_hours: typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0 ? data.post_cooldown_hours @@ -1744,6 +1748,7 @@ export type UpdateSiteSettingsBody = { site_description?: string; allow_register?: boolean; allow_comments?: boolean; + allow_messages?: boolean; post_cooldown_hours?: number; code_block_auto_fold?: boolean; code_block_fold_lines?: number; diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index b15b4a5..e80f938 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -45,6 +45,7 @@ export interface RtSettingsData { site_description?: string; allow_register?: boolean; allow_comments?: boolean; + allow_messages?: boolean; post_cooldown_hours?: number; code_block_auto_fold?: boolean; code_block_fold_lines?: number;