feat(settings): 站点界面动画总开关与子开关
管理后台可关全站动画,并细调代码折叠、平滑滚动、装饰入场;统一 lib/motion,尊重系统减少动态效果。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<typeof key, [string, string]> = {
|
||||
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 (
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-5">
|
||||
@@ -286,6 +320,79 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="j13-module p-5 sm:p-6 mb-5">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||
<Sparkles size={15} /> 界面动画
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1 leading-relaxed">
|
||||
总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={uiAnimations}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
||||
label="界面动画总开关"
|
||||
/>
|
||||
</div>
|
||||
{uiAnimations ? (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--line)] space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
代码折叠动画
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
代码块展开/收起的高度过渡与跟滚。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={animCodeFold}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
|
||||
label="代码折叠动画"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
平滑滚动
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
目录跳转、回顶、新内容提示等 scroll 行为。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={animSmoothScroll}
|
||||
disabled={togglingMotion}
|
||||
onChange={() =>
|
||||
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
||||
}
|
||||
label="平滑滚动"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
装饰入场
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={animChrome}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
|
||||
label="装饰入场"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`}
|
||||
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||
>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
{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"
|
||||
>
|
||||
<CodeFoldProvider
|
||||
initialEnabled={settings.code_block_auto_fold}
|
||||
initialFoldLines={settings.code_block_fold_lines}
|
||||
>
|
||||
{children}
|
||||
</CodeFoldProvider>
|
||||
<MotionProvider initial={motionCfg}>
|
||||
<CodeFoldProvider
|
||||
initialEnabled={settings.code_block_auto_fold}
|
||||
initialFoldLines={settings.code_block_fold_lines}
|
||||
>
|
||||
{children}
|
||||
</CodeFoldProvider>
|
||||
</MotionProvider>
|
||||
</main>
|
||||
<Footer siteName={settings.site_name} />
|
||||
<Toaster />
|
||||
|
||||
Reference in New Issue
Block a user