Files
jiang13-bbs/frontend/components/ThemeSync.tsx
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

89 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { apiGetSettings } from "@/lib/api";
import { applyAccentTheme } from "@/lib/theme";
import { applyCustomCSS } from "@/lib/customCode";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import {
VISIBLE_RECONCILE_GAP_MS,
onDocumentVisible,
} from "@/lib/visibilityReconcile";
// 站点外观同步器:
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
export default function ThemeSync({
initialAccent,
initialCustomCSS,
}: {
initialAccent: string;
initialCustomCSS: string;
}) {
const pathname = usePathname();
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
const cssRef = useRef((initialCustomCSS || "").trim());
const lastFetchAtRef = useRef(0);
const inFlightRef = useRef(false);
useEffect(() => {
// 外观设置页存在未保存的本地实时预览,暂停同步避免覆盖草稿
if (pathname?.startsWith("/admin/appearance")) return;
let cancelled = false;
const applyAccent = (next: string) => {
if (cancelled || next === currentRef.current) return;
currentRef.current = next;
applyAccentTheme(next || null);
};
const applyCSS = (next: string) => {
if (cancelled) return;
const text = (next || "").trim();
if (text === cssRef.current) return;
cssRef.current = text;
applyCustomCSS(text);
};
const sync = async () => {
if (inFlightRef.current) return;
const now = Date.now();
if (now - lastFetchAtRef.current < MIN_FETCH_GAP_MS) return;
lastFetchAtRef.current = now;
inFlightRef.current = true;
try {
const settings = await apiGetSettings();
if (cancelled) return;
applyAccent((settings.accent || "").trim().toLowerCase());
applyCSS(settings.custom_css || "");
} catch {
// 同步失败静默,下次回前台再试
} finally {
inFlightRef.current = false;
}
};
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (!data) return;
applyAccent((data.accent || "").trim().toLowerCase());
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
});
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
void sync();
return () => {
cancelled = true;
offPush();
offVisible();
};
}, [pathname]);
return null;
}