feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -4,12 +4,12 @@ import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { apiGetSettings } from "@/lib/api";
import { applyAccentTheme } from "@/lib/theme";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
// 站点主题色同步器:
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过轮询 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
// SSR,本组件通过 WS 推送 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
const POLL_INTERVAL_MS = 60_000;
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
const MIN_FETCH_GAP_MS = 10_000;
@@ -27,6 +27,12 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
let cancelled = false;
const apply = (next: string) => {
if (cancelled || next === currentRef.current) return;
currentRef.current = next;
applyAccentTheme(next || null);
};
const sync = async () => {
if (inFlightRef.current) return;
const now = Date.now();
@@ -37,18 +43,19 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
// no-store:必须拿到管理员刚保存的最新值,不能走浏览器缓存
const { accent } = await apiGetSettings();
if (cancelled) return;
const next = (accent || "").trim().toLowerCase();
if (next !== currentRef.current) {
currentRef.current = next;
applyAccentTheme(next || null);
}
apply((accent || "").trim().toLowerCase());
} catch {
// 主题同步失败静默处理,下个周期/下次回前台再试,不打扰用户
// 主题同步失败静默处理,下次回前台再试,不打扰用户
} finally {
inFlightRef.current = false;
}
};
// WebSocket 推送优先:管理员保存即换肤;回前台 HTTP 校准兜底
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (data) apply((data.accent || "").trim().toLowerCase());
});
const onActive = () => {
if (document.visibilityState === "visible") void sync();
};
@@ -56,13 +63,12 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
window.addEventListener("focus", onActive);
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
void sync();
const timer = setInterval(() => void sync(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
offPush();
document.removeEventListener("visibilitychange", onActive);
window.removeEventListener("focus", onActive);
clearInterval(timer);
};
}, [pathname]);