feat(frontend): add theme real-time sync feature
1. 新增ThemeSync组件实现站点主题色无刷新同步 2. 给获取站点设置的接口添加no-store缓存策略确保拿到最新配置 3. 调整主题应用代码的属性设置顺序避免逻辑问题
This commit is contained in:
@@ -6,6 +6,7 @@ import Header from "@/components/Header";
|
|||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import Toaster from "@/components/Toaster";
|
import Toaster from "@/components/Toaster";
|
||||||
import AccountGuard from "@/components/AccountGuard";
|
import AccountGuard from "@/components/AccountGuard";
|
||||||
|
import ThemeSync from "@/components/ThemeSync";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
@@ -104,6 +105,7 @@ export default async function RootLayout({
|
|||||||
<Footer />
|
<Footer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<AccountGuard />
|
<AccountGuard />
|
||||||
|
<ThemeSync initialAccent={accent || ""} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
70
frontend/components/ThemeSync.tsx
Normal file
70
frontend/components/ThemeSync.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { apiGetSettings } from "@/lib/api";
|
||||||
|
import { applyAccentTheme } from "@/lib/theme";
|
||||||
|
|
||||||
|
// 站点主题色同步器:
|
||||||
|
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
|
||||||
|
// SSR,本组件通过轮询 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
|
||||||
|
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
|
||||||
|
const MIN_FETCH_GAP_MS = 10_000;
|
||||||
|
|
||||||
|
export default function ThemeSync({ initialAccent }: { initialAccent: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
// 当前已应用的已保存色(空串=内置默认);与 SSR 注入的 style 节点保持一致
|
||||||
|
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
|
||||||
|
const lastFetchAtRef = useRef(0);
|
||||||
|
const inFlightRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 外观设置页存在"未保存的本地实时预览",该页面内暂停同步,
|
||||||
|
// 避免轮询结果覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
|
||||||
|
if (pathname?.startsWith("/admin/appearance")) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// no-store:必须拿到管理员刚保存的最新值,不能走浏览器缓存
|
||||||
|
const { accent } = await apiGetSettings();
|
||||||
|
if (cancelled) return;
|
||||||
|
const next = (accent || "").trim().toLowerCase();
|
||||||
|
if (next !== currentRef.current) {
|
||||||
|
currentRef.current = next;
|
||||||
|
applyAccentTheme(next || null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 主题同步失败静默处理,下个周期/下次回前台再试,不打扰用户
|
||||||
|
} finally {
|
||||||
|
inFlightRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onActive = () => {
|
||||||
|
if (document.visibilityState === "visible") void sync();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onActive);
|
||||||
|
window.addEventListener("focus", onActive);
|
||||||
|
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
|
||||||
|
void sync();
|
||||||
|
const timer = setInterval(() => void sync(), POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
document.removeEventListener("visibilitychange", onActive);
|
||||||
|
window.removeEventListener("focus", onActive);
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -949,7 +949,8 @@ export async function apiAdminSetUserBan(
|
|||||||
// ===== 站点外观设置 =====
|
// ===== 站点外观设置 =====
|
||||||
|
|
||||||
export async function apiGetSettings(): Promise<PublicSettings> {
|
export async function apiGetSettings(): Promise<PublicSettings> {
|
||||||
const res = await fetchWithRefresh("/api/settings", { method: "GET" });
|
// no-store:主题同步轮询必须读到管理员刚保存的最新配色,不走浏览器缓存
|
||||||
|
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
||||||
if (!res.ok) throw new Error("获取站点设置失败");
|
if (!res.ok) throw new Error("获取站点设置失败");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ export function applyAccentTheme(input: string | null): void {
|
|||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement("style");
|
el = document.createElement("style");
|
||||||
el.id = STYLE_ELEMENT_ID;
|
el.id = STYLE_ELEMENT_ID;
|
||||||
el.setAttribute("data-accent-source", "runtime");
|
|
||||||
document.head.appendChild(el);
|
document.head.appendChild(el);
|
||||||
}
|
}
|
||||||
|
el.setAttribute("data-accent-source", "runtime");
|
||||||
el.textContent = css;
|
el.textContent = css;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user