diff --git a/backend/handler/setting.go b/backend/handler/setting.go index b2eb090..32786ed 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -27,6 +27,8 @@ type updateSettingsRequest struct { SiteDescription *string `json:"site_description"` AllowRegister *bool `json:"allow_register"` PostCooldownHours *int `json:"post_cooldown_hours"` + CodeBlockAutoFold *bool `json:"code_block_auto_fold"` + CodeBlockFoldLines *int `json:"code_block_fold_lines"` } // PUT /api/admin/settings @@ -37,7 +39,8 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil && - req.SiteDescription == nil && req.AllowRegister == nil && req.PostCooldownHours == nil { + req.SiteDescription == nil && req.AllowRegister == nil && + req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"}) return } @@ -94,6 +97,22 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.CodeBlockAutoFold != nil { + if err := h.Setting.SetCodeBlockAutoFold(*req.CodeBlockAutoFold); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.CodeBlockFoldLines != nil { + if err := h.Setting.SetCodeBlockFoldLines(*req.CodeBlockFoldLines); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "折叠行数须为 0–100"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } saved, err := h.Setting.Public() if err != nil { @@ -101,16 +120,19 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } + payload := gin.H{ + "accent": saved.Accent, + "trust_reviewed_publish": saved.TrustReviewedPublish, + "site_name": saved.SiteName, + "site_description": saved.SiteDescription, + "allow_register": saved.AllowRegister, + "post_cooldown_hours": saved.PostCooldownHours, + "code_block_auto_fold": saved.CodeBlockAutoFold, + "code_block_fold_lines": saved.CodeBlockFoldLines, + } h.Hub.BroadcastAll(realtime.Envelope{ Type: realtime.EventSettingsChanged, - Data: gin.H{ - "accent": saved.Accent, - "trust_reviewed_publish": saved.TrustReviewedPublish, - "site_name": saved.SiteName, - "site_description": saved.SiteDescription, - "allow_register": saved.AllowRegister, - "post_cooldown_hours": saved.PostCooldownHours, - }, + Data: payload, }) c.JSON(http.StatusOK, gin.H{ "ok": true, @@ -120,5 +142,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { "site_description": saved.SiteDescription, "allow_register": saved.AllowRegister, "post_cooldown_hours": saved.PostCooldownHours, + "code_block_auto_fold": saved.CodeBlockAutoFold, + "code_block_fold_lines": saved.CodeBlockFoldLines, }) } diff --git a/backend/service/setting.go b/backend/service/setting.go index 91ca6b6..3924f7a 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -20,16 +20,23 @@ const ( SettingKeySiteName = "site_name" SettingKeySiteDescription = "site_description" SettingKeyAllowRegister = "allow_register" - SettingKeyPostCooldownHours = "post_cooldown_hours" + SettingKeyPostCooldownHours = "post_cooldown_hours" + // SettingKeyCodeBlockAutoFold 代码块自动折叠;缺行视为开启 + SettingKeyCodeBlockAutoFold = "code_block_auto_fold" + // SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5 + SettingKeyCodeBlockFoldLines = "code_block_fold_lines" ) const ( DefaultSiteName = "姜十三论坛" DefaultSiteDescription = "姜十三论坛 - 技术分享与讨论社区" DefaultCooldownHours = 24 + DefaultCodeFoldLines = 5 MaxSiteNameRunes = 32 MaxSiteDescRunes = 160 MaxCooldownHours = 168 + MinCodeFoldLines = 0 + MaxCodeFoldLines = 100 ) // ErrInvalidAccent 主题色格式非法 @@ -48,6 +55,8 @@ type PublicSiteSettings struct { SiteDescription string `json:"site_description"` AllowRegister bool `json:"allow_register"` PostCooldownHours int `json:"post_cooldown_hours"` + CodeBlockAutoFold bool `json:"code_block_auto_fold"` + CodeBlockFoldLines int `json:"code_block_fold_lines"` } // SettingService 站点级键值设置 @@ -108,6 +117,8 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { TrustReviewedPublish: true, AllowRegister: true, PostCooldownHours: DefaultCooldownHours, + CodeBlockAutoFold: true, + CodeBlockFoldLines: DefaultCodeFoldLines, } accent, err := s.AccentColor() if err != nil { @@ -151,6 +162,18 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { return out, err } out.PostCooldownHours = hours + + fold, err := s.CodeBlockAutoFold() + if err != nil { + return out, err + } + out.CodeBlockAutoFold = fold + + foldLines, err := s.CodeBlockFoldLines() + if err != nil { + return out, err + } + out.CodeBlockFoldLines = foldLines return out, nil } @@ -223,6 +246,7 @@ func (s *SettingService) SetAllowRegister(on bool) error { return s.setBoolDefaultTrue(SettingKeyAllowRegister, on) } + // PostCooldownHours 新用户发帖冷静期(小时)。缺行=24;0 表示关闭。 func (s *SettingService) PostCooldownHours() (int, error) { v, found, err := s.getValue(SettingKeyPostCooldownHours) @@ -251,3 +275,46 @@ func (s *SettingService) SetPostCooldownHours(hours int) error { } return s.putValue(SettingKeyPostCooldownHours, strconv.Itoa(hours)) } + +// CodeBlockAutoFold 是否对超过阈值行数的 Markdown 代码块默认折叠。缺行视为开启。 +func (s *SettingService) CodeBlockAutoFold() (bool, error) { + v, found, err := s.getValue(SettingKeyCodeBlockAutoFold) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +// SetCodeBlockAutoFold 持久化代码折叠开关;开启时删键保持「缺行=默认开」。 +func (s *SettingService) SetCodeBlockAutoFold(on bool) error { + return s.setBoolDefaultTrue(SettingKeyCodeBlockAutoFold, on) +} + +// CodeBlockFoldLines 超过该行数则折叠(折叠预览约同高;0=无预览)。缺行=5。 +func (s *SettingService) CodeBlockFoldLines() (int, error) { + v, found, err := s.getValue(SettingKeyCodeBlockFoldLines) + if err != nil { + return DefaultCodeFoldLines, err + } + if !found { + return DefaultCodeFoldLines, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < MinCodeFoldLines { + return DefaultCodeFoldLines, nil + } + if n > MaxCodeFoldLines { + return MaxCodeFoldLines, nil + } + return n, nil +} + +func (s *SettingService) SetCodeBlockFoldLines(lines int) error { + if lines < MinCodeFoldLines || lines > MaxCodeFoldLines { + return ErrInvalidSiteSetting + } + if lines == DefaultCodeFoldLines { + return s.deleteKey(SettingKeyCodeBlockFoldLines) + } + return s.putValue(SettingKeyCodeBlockFoldLines, strconv.Itoa(lines)) +} diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx index dc0d1c2..1e0fd87 100644 --- a/frontend/app/admin/settings/SettingsAdmin.tsx +++ b/frontend/app/admin/settings/SettingsAdmin.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { + Code2, Globe, Loader2, Save, @@ -47,13 +48,25 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) const [siteName, setSiteName] = useState(initial.site_name); const [siteDescription, setSiteDescription] = useState(initial.site_description); const [allowRegister, setAllowRegister] = useState(initial.allow_register); + const [codeAutoFold, setCodeAutoFold] = useState( + initial.code_block_auto_fold !== false + ); + const [foldLines, setFoldLines] = useState( + Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5 + ); + const [savedFoldLines, setSavedFoldLines] = useState( + Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5 + ); 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 [togglingFold, setTogglingFold] = useState(false); + const [savingFoldLines, setSavingFoldLines] = useState(false); const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc; + const foldLinesDirty = foldLines !== savedFoldLines; const saveIdentity = async () => { if (saving) return; @@ -95,6 +108,49 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) } }; + + const toggleCodeFold = async () => { + if (togglingFold) return; + const next = !codeAutoFold; + setTogglingFold(true); + try { + const res = await apiUpdateSiteSettings({ code_block_auto_fold: next }); + setCodeAutoFold(res.code_block_auto_fold); + toast( + res.code_block_auto_fold + ? `已开启代码块自动折叠(超过 ${res.code_block_fold_lines} 行)` + : "已关闭代码块自动折叠", + "ok" + ); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setTogglingFold(false); + } + }; + + const saveFoldLines = async () => { + if (savingFoldLines) return; + const raw = Math.round(Number(foldLines)); + const n = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 5; + setSavingFoldLines(true); + try { + const res = await apiUpdateSiteSettings({ code_block_fold_lines: n }); + setFoldLines(res.code_block_fold_lines); + setSavedFoldLines(res.code_block_fold_lines); + toast( + res.code_block_fold_lines === 0 + ? "折叠预览已设为 0 行(无预览)" + : `折叠阈值已设为超过 ${res.code_block_fold_lines} 行`, + "ok" + ); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSavingFoldLines(false); + } + }; + return (
@@ -174,6 +230,62 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) label="开放注册" /> + + +
+
+
+

+ 代码块自动折叠 +

+

+ 开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。 +

+
+ void toggleCodeFold()} + label="代码块自动折叠" + /> +
+ {codeAutoFold ? ( +
+ +

+ 代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。 +

+
+ setFoldLines(Number(e.target.value))} + aria-label="折叠行数阈值" + /> + 行 + +
+
+ ) : null} +
); } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 79f8b3b..5a8f85c 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -9,6 +9,7 @@ import TipHost from "@/components/TipHost"; import AccountGuard from "@/components/AccountGuard"; import ThemeSync from "@/components/ThemeSync"; import RealtimeProvider from "@/components/RealtimeProvider"; +import CodeFoldProvider from "@/components/markdown/CodeFoldProvider"; import type { User } from "@/lib/api"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies"; @@ -115,7 +116,12 @@ export default async function RootLayout({ tabIndex={-1} className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none" > - {children} + + {children} +