diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 32786ed..12a3951 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -29,6 +29,27 @@ type updateSettingsRequest struct { 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"` +} + +func settingsPayload(saved service.PublicSiteSettings) gin.H { + return 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, + "ui_animations": saved.UIAnimations, + "anim_code_fold": saved.AnimCodeFold, + "anim_smooth_scroll": saved.AnimSmoothScroll, + "anim_chrome": saved.AnimChrome, + } } // PUT /api/admin/settings @@ -40,7 +61,9 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { } if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil && req.SiteDescription == nil && req.AllowRegister == nil && - req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil { + req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil && + req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil && + req.AnimChrome == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"}) return } @@ -113,6 +136,30 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.UIAnimations != nil { + if err := h.Setting.SetUIAnimations(*req.UIAnimations); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.AnimCodeFold != nil { + if err := h.Setting.SetAnimCodeFold(*req.AnimCodeFold); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.AnimSmoothScroll != nil { + if err := h.Setting.SetAnimSmoothScroll(*req.AnimSmoothScroll); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.AnimChrome != nil { + if err := h.Setting.SetAnimChrome(*req.AnimChrome); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } saved, err := h.Setting.Public() if err != nil { @@ -120,29 +167,12 @@ 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, - } + payload := settingsPayload(saved) h.Hub.BroadcastAll(realtime.Envelope{ Type: realtime.EventSettingsChanged, Data: payload, }) - c.JSON(http.StatusOK, gin.H{ - "ok": true, - "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, - }) + out := settingsPayload(saved) + out["ok"] = true + c.JSON(http.StatusOK, out) } diff --git a/backend/service/setting.go b/backend/service/setting.go index 3924f7a..39fb375 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -25,6 +25,14 @@ const ( SettingKeyCodeBlockAutoFold = "code_block_auto_fold" // SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5 SettingKeyCodeBlockFoldLines = "code_block_fold_lines" + // SettingKeyUIAnimations 界面动画总开关;缺行视为开启 + SettingKeyUIAnimations = "ui_animations" + // SettingKeyAnimCodeFold 代码折叠高度/跟滚动画;缺行视为开启 + SettingKeyAnimCodeFold = "anim_code_fold" + // SettingKeyAnimSmoothScroll 平滑滚动;缺行视为开启 + SettingKeyAnimSmoothScroll = "anim_smooth_scroll" + // SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启 + SettingKeyAnimChrome = "anim_chrome" ) const ( @@ -57,6 +65,10 @@ type PublicSiteSettings struct { 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"` } // SettingService 站点级键值设置 @@ -119,6 +131,10 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { PostCooldownHours: DefaultCooldownHours, CodeBlockAutoFold: true, CodeBlockFoldLines: DefaultCodeFoldLines, + UIAnimations: true, + AnimCodeFold: true, + AnimSmoothScroll: true, + AnimChrome: true, } accent, err := s.AccentColor() if err != nil { @@ -174,6 +190,30 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { return out, err } out.CodeBlockFoldLines = foldLines + + uiAnim, err := s.UIAnimations() + if err != nil { + return out, err + } + out.UIAnimations = uiAnim + + animFold, err := s.AnimCodeFold() + if err != nil { + return out, err + } + out.AnimCodeFold = animFold + + animScroll, err := s.AnimSmoothScroll() + if err != nil { + return out, err + } + out.AnimSmoothScroll = animScroll + + animChrome, err := s.AnimChrome() + if err != nil { + return out, err + } + out.AnimChrome = animChrome return out, nil } @@ -318,3 +358,55 @@ func (s *SettingService) SetCodeBlockFoldLines(lines int) error { } return s.putValue(SettingKeyCodeBlockFoldLines, strconv.Itoa(lines)) } + +// UIAnimations 界面动画总开关。缺行视为开启;关闭后全站减少动态效果。 +func (s *SettingService) UIAnimations() (bool, error) { + v, found, err := s.getValue(SettingKeyUIAnimations) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +func (s *SettingService) SetUIAnimations(on bool) error { + return s.setBoolDefaultTrue(SettingKeyUIAnimations, on) +} + +// AnimCodeFold 代码折叠动画子开关(总开关开启时生效)。 +func (s *SettingService) AnimCodeFold() (bool, error) { + v, found, err := s.getValue(SettingKeyAnimCodeFold) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +func (s *SettingService) SetAnimCodeFold(on bool) error { + return s.setBoolDefaultTrue(SettingKeyAnimCodeFold, on) +} + +// AnimSmoothScroll 平滑滚动子开关(总开关开启时生效)。 +func (s *SettingService) AnimSmoothScroll() (bool, error) { + v, found, err := s.getValue(SettingKeyAnimSmoothScroll) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +func (s *SettingService) SetAnimSmoothScroll(on bool) error { + return s.setBoolDefaultTrue(SettingKeyAnimSmoothScroll, on) +} + +// AnimChrome 装饰入场子开关(列表/Toast/点赞等;总开关开启时生效)。 +func (s *SettingService) AnimChrome() (bool, error) { + v, found, err := s.getValue(SettingKeyAnimChrome) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +func (s *SettingService) SetAnimChrome(on bool) error { + return s.setBoolDefaultTrue(SettingKeyAnimChrome, on) +} diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx index 1e0fd87..37fe1ab 100644 --- a/frontend/app/admin/settings/SettingsAdmin.tsx +++ b/frontend/app/admin/settings/SettingsAdmin.tsx @@ -6,6 +6,7 @@ import { Globe, Loader2, Save, + Sparkles, UserPlus, } from "lucide-react"; import { @@ -63,6 +64,13 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) const [togglingReg, setTogglingReg] = useState(false); const [togglingFold, setTogglingFold] = useState(false); const [savingFoldLines, setSavingFoldLines] = useState(false); + const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false); + const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false); + const [animSmoothScroll, setAnimSmoothScroll] = useState( + initial.anim_smooth_scroll !== false + ); + const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false); + const [togglingMotion, setTogglingMotion] = useState(false); const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc; @@ -151,6 +159,32 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) } }; + const toggleMotionField = async ( + key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome", + next: boolean + ) => { + if (togglingMotion) return; + setTogglingMotion(true); + try { + const res = await apiUpdateSiteSettings({ [key]: next }); + setUiAnimations(res.ui_animations); + setAnimCodeFold(res.anim_code_fold); + setAnimSmoothScroll(res.anim_smooth_scroll); + setAnimChrome(res.anim_chrome); + const labels: Record = { + ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"], + anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"], + anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"], + anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"], + }; + toast(next ? labels[key][0] : labels[key][1], "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setTogglingMotion(false); + } + }; + return (
@@ -286,6 +320,79 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
) : null} + +
+
+
+

+ 界面动画 +

+

+ 总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。 +

+
+ void toggleMotionField("ui_animations", !uiAnimations)} + label="界面动画总开关" + /> +
+ {uiAnimations ? ( +
+
+
+

+ 代码折叠动画 +

+

+ 代码块展开/收起的高度过渡与跟滚。 +

+
+ void toggleMotionField("anim_code_fold", !animCodeFold)} + label="代码折叠动画" + /> +
+
+
+

+ 平滑滚动 +

+

+ 目录跳转、回顶、新内容提示等 scroll 行为。 +

+
+ + void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) + } + label="平滑滚动" + /> +
+
+
+

+ 装饰入场 +

+

+ 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。 +

+
+ void toggleMotionField("anim_chrome", !animChrome)} + label="装饰入场" + /> +
+
+ ) : null} +
); } diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index 9589edb..3f2b24d 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -46,6 +46,7 @@ import type { } from "@/lib/realtime"; import { canOverseeChat } from "@/lib/roles"; import { toast } from "@/lib/toast"; +import { scrollBehavior } from "@/lib/motion"; import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; @@ -870,7 +871,7 @@ export default function ChatRoomClient({ const scrollToMsg = (id: number) => { const el = msgRefs.current.get(id); - el?.scrollIntoView({ behavior: "smooth", block: "center" }); + el?.scrollIntoView({ behavior: scrollBehavior(), block: "center" }); }; const msgMenuItems = (m: ChatMessage): CtxMenuItem[] => { diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 19896a4..32f08a5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1888,6 +1888,9 @@ a.j13-mention-token:hover { transition: none !important; } } +html.j13-reduce-motion .prose-content .md-code-body { + transition: none !important; +} .prose-content .md-code-fold { display: flex; width: 100%; @@ -2164,6 +2167,23 @@ html.dark .prose-content pre[data-theme] span { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } +/* 站点关闭界面动画总开关(与系统 reduce 等效) */ +html.j13-reduce-motion * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; +} + +/* 仅关闭装饰入场子开关 */ +html.j13-reduce-chrome .j13-fade-up, +html.j13-reduce-chrome .j13-toast-in, +html.j13-reduce-chrome .j13-heart-pop, +html.j13-reduce-chrome .j13-pulse-dot, +html.j13-reduce-chrome .j13-tip-bubble, +html.j13-reduce-chrome .j13-tip-in { + animation: none !important; + transition: none !important; +} + /* 头像配色(浅/暗各自保证可读) */ .av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; } .av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 5a8f85c..9cfc635 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -10,10 +10,12 @@ import AccountGuard from "@/components/AccountGuard"; import ThemeSync from "@/components/ThemeSync"; import RealtimeProvider from "@/components/RealtimeProvider"; import CodeFoldProvider from "@/components/markdown/CodeFoldProvider"; +import MotionProvider from "@/components/MotionProvider"; import type { User } from "@/lib/api"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies"; import { accentStyleCSS } from "@/lib/theme"; +import { motionConfigFromSettings } from "@/lib/motion"; // 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正, // 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动 @@ -73,6 +75,13 @@ export default async function RootLayout({ const settings = await getPublicSettingsCached(); const accent = settings.accent; const accentCSS = accentStyleCSS(accent || ""); + const motionCfg = motionConfigFromSettings(settings); + const motionHtmlClass = [ + !motionCfg.ui_animations ? "j13-reduce-motion" : "", + motionCfg.ui_animations && !motionCfg.anim_chrome ? "j13-reduce-chrome" : "", + ] + .filter(Boolean) + .join(" "); let user: User | null = null; let unread = 0; @@ -89,7 +98,7 @@ export default async function RootLayout({ return ( {accentCSS ? ( @@ -116,12 +125,14 @@ 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} + +