后台站点设计UIUX优化

This commit is contained in:
2026-09-19 04:14:13 +08:00
parent a8efb9ecef
commit 2b3d122e56
24 changed files with 2952 additions and 306 deletions

View File

@@ -0,0 +1,183 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark } from "@/lib/brand";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
export type SiteBrand = {
site_name: string;
site_wordmark: string;
site_slogan: string;
logo_light_url: string;
logo_dark_url: string;
favicon_url: string;
brand_mark: PublicSettings["brand_mark"];
brand_logo_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"];
};
const SiteBrandContext = createContext<SiteBrand | null>(null);
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
return {
site_name: s.site_name,
site_wordmark: s.site_wordmark,
site_slogan: s.site_slogan,
logo_light_url: s.logo_light_url,
logo_dark_url: s.logo_dark_url,
favicon_url: s.favicon_url,
brand_mark: s.brand_mark,
brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links,
};
}
function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
return (
a.site_name === b.site_name &&
a.site_wordmark === b.site_wordmark &&
a.site_slogan === b.site_slogan &&
a.logo_light_url === b.logo_light_url &&
a.logo_dark_url === b.logo_dark_url &&
a.favicon_url === b.favicon_url &&
a.brand_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
);
}
function applyFavicon(url: string) {
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
if (!url) {
document.getElementById("j13-favicon")?.remove();
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
if (!hasDefault) {
const link = document.createElement("link");
link.rel = "icon";
link.href = "/favicon.ico";
document.head.appendChild(link);
}
return;
}
existing.forEach((el) => {
if (el.id !== "j13-favicon") el.remove();
});
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = "j13-favicon";
link.rel = "icon";
document.head.appendChild(link);
}
if (link.getAttribute("href") !== url) link.href = url;
}
function retitle(prev: SiteBrand, next: SiteBrand) {
if (prev.site_name === next.site_name && prev.site_slogan === next.site_slogan) return;
const oldDefault = siteDocumentTitle(prev.site_name, prev.site_slogan);
const nextDefault = siteDocumentTitle(next.site_name, next.site_slogan);
const cur = document.title;
if (cur === oldDefault) {
document.title = nextDefault;
return;
}
const oldName = prev.site_name.trim() || "姜十三论坛";
const newName = next.site_name.trim() || "姜十三论坛";
const suffix = ` · ${oldName}`;
if (oldName !== newName && cur.endsWith(suffix)) {
document.title = cur.slice(0, -suffix.length) + ` · ${newName}`;
}
}
export function useSiteBrand(): SiteBrand {
const ctx = useContext(SiteBrandContext);
if (!ctx) {
return {
site_name: "姜十三论坛",
site_wordmark: "",
site_slogan: "",
logo_light_url: "",
logo_dark_url: "",
favicon_url: "",
brand_mark: "image_text",
brand_logo_size: "sq",
brand_logo_fit: "contain",
footer_links: [],
};
}
return ctx;
}
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
export default function SiteBrandProvider({
initial,
children,
}: {
initial: PublicSettings;
children: ReactNode;
}) {
const [brand, setBrand] = useState<SiteBrand>(() => fromSettings(initial));
const brandRef = useRef(brand);
brandRef.current = brand;
const value = useMemo(() => brand, [brand]);
useEffect(() => {
const next = fromSettings(initial);
setBrand((prev) => {
if (sameBrand(prev, next)) return prev;
retitle(prev, next);
applyFavicon(next.favicon_url);
return next;
});
}, [initial]);
useEffect(() => {
const apply = (raw: Partial<PublicSettings> | RtSettingsData) => {
const prev = brandRef.current;
const next: SiteBrand = {
site_name: typeof raw.site_name === "string" && raw.site_name.trim() ? raw.site_name : prev.site_name,
site_wordmark: typeof raw.site_wordmark === "string" ? raw.site_wordmark : prev.site_wordmark,
site_slogan: typeof raw.site_slogan === "string" ? raw.site_slogan : prev.site_slogan,
logo_light_url: typeof raw.logo_light_url === "string" ? raw.logo_light_url : prev.logo_light_url,
logo_dark_url: typeof raw.logo_dark_url === "string" ? raw.logo_dark_url : prev.logo_dark_url,
favicon_url: typeof raw.favicon_url === "string" ? raw.favicon_url : prev.favicon_url,
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
footer_links: Array.isArray(raw.footer_links) ? raw.footer_links : prev.footer_links,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);
applyFavicon(next.favicon_url);
brandRef.current = next;
setBrand(next);
};
const off = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (data) apply(data);
});
let last = 0;
const offVisible = onDocumentVisible(() => {
const now = Date.now();
if (now - last < VISIBLE_RECONCILE_GAP_MS) return;
last = now;
void apiGetSettings()
.then(apply)
.catch(() => {});
}, VISIBLE_RECONCILE_GAP_MS);
return () => {
off();
offVisible();
};
}, []);
return <SiteBrandContext.Provider value={value}>{children}</SiteBrandContext.Provider>;
}