Files
jiang13-forum/frontend/src/hooks/useSiteBranding.ts
freefire 3c6d8cb891 feat(ssr): 帖子页全量 SSR——作者卡/目录首帧服务端渲染,消除冗余首屏请求
- 帖子快照携带作者主页资料与统计(authorProfile/authorStats),
  SSR 端与客户端预热均取 /api/users/:id;PostAuthorCard 首帧直接渲染,
  不再挂载后请求,统计数字不再显示 — 占位
- MainLayout 首帧从已 seed 的帖子快照派生右栏 outline(作者卡+文章目录),
  修复 outline 仅靠 effect 上报导致 SSR 不渲染右栏的问题
- 壳层首屏请求门控:useAuth 有种子跳过 /api/me、branding 缓存命中不重拉、
  侧栏三个区块首屏用 seed 缓存、未读数首屏跳过即时请求(轮询/事件刷新保留)
- serverDom(linkedom) 修复:DOMParser 片段包裹、属性顺序、SVG 自闭合展开
- 验证:首页与帖子页 hydration 后仅剩 pageview 上报,0 hydration 错误
2026-09-10 05:04:31 +08:00

186 lines
5.6 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.
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { SiteBranding } from '../api/types';
import { getScope } from '../ssr/scope';
export const DEFAULT_BRANDING: SiteBranding = {
name: '姜十三论坛',
slogan: '拾三一隅,自在交流',
description: '',
keywords: '',
logo_mark: '姜',
logo: '',
favicon: '',
og_image: '',
icp_beian: '',
icp_beian_url: 'https://beian.miit.gov.cn/',
friend_links: [],
};
declare global {
interface Window {
/** 服务端注入的首屏品牌配置(见 embed_static SPA HTML) */
__J13_BRANDING__?: Partial<SiteBranding>;
}
}
/** SEO / 首页展示用简介:优先 description,其次 slogan */
export function siteMetaDescription(brand: SiteBranding): string {
const d = brand.description?.trim() ?? '';
if (d) return d;
return brand.slogan?.trim() ?? '';
}
function readBootBranding(): SiteBranding | null {
if (typeof window === 'undefined') return null;
try {
const boot = window.__J13_BRANDING__;
if (!boot || typeof boot !== 'object') return null;
const name = typeof boot.name === 'string' ? boot.name.trim() : '';
if (!name) return null;
return { ...DEFAULT_BRANDING, ...boot, name };
} catch {
return null;
}
}
let cached: SiteBranding | null = readBootBranding();
let inflight: Promise<SiteBranding> | null = null;
let cacheEpoch = 0;
/** force 预热刚写入后,hook 因 epoch 重跑时复用缓存,避免连打两次 API */
let preferCacheOnce = false;
const listeners = new Set<() => void>();
/** 当前生效的品牌配置:SSR 读请求作用域,客户端读模块缓存 */
function currentBranding(): SiteBranding | null {
return getScope()?.branding ?? cached;
}
function fetchBranding(): Promise<SiteBranding> {
if (preferCacheOnce && cached) {
preferCacheOnce = false;
return Promise.resolve(cached);
}
// SSR +data / 冷启动预热已 seed 品牌配置时首屏直接复用,不重复请求;
// 最新配置同步由显式刷新(refetchSiteBranding / ensureSiteBrandingLoaded force)触发
if (cached) return Promise.resolve(cached);
if (inflight) return inflight;
inflight = api.siteBranding()
.then(b => {
cached = { ...DEFAULT_BRANDING, ...b };
return cached;
})
.catch(() => cached ?? DEFAULT_BRANDING)
.finally(() => { inflight = null; });
return inflight;
}
/** 浏览器标签标题:站点名 - 副标题(标语) */
export function formatDocumentTitle(brand: SiteBranding): string {
const name = brand.name.trim();
const subtitle = brand.slogan.trim();
return subtitle ? `${name} - ${subtitle}` : name;
}
function applyDocumentBrand(brand: SiteBranding) {
// 页面级 SEO hook 已接管标题时,勿覆盖
if (!document.documentElement.hasAttribute('data-j13-seo')) {
const title = formatDocumentTitle(brand);
if (document.title !== title) document.title = title;
}
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (brand.favicon) {
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
if (link.href !== new URL(brand.favicon, window.location.origin).href) {
link.href = brand.favicon;
}
}
}
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
export function getCachedSiteBranding(): SiteBranding {
return currentBranding() ?? DEFAULT_BRANDING;
}
/** 获取站点品牌配置(名称、Logo 等) */
export function useSiteBranding() {
const [branding, setBranding] = useState<SiteBranding>(currentBranding() ?? DEFAULT_BRANDING);
const [loading, setLoading] = useState(!currentBranding());
const [epoch, setEpoch] = useState(cacheEpoch);
useEffect(() => {
const onInvalidate = () => setEpoch(cacheEpoch);
listeners.add(onInvalidate);
return () => { listeners.delete(onInvalidate); };
}, []);
useEffect(() => {
let cancelled = false;
if (!cached) setLoading(true);
fetchBranding()
.then(next => {
if (cancelled) return;
setBranding(next);
applyDocumentBrand(next);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [epoch]);
return { branding, loading };
}
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
export function refetchSiteBranding() {
cached = null;
inflight = null;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 冷启动 / 预热:确保品牌已写入模块缓存;force 时重拉并在成功后通知 hook */
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
if (!opts?.force && cached) return cached;
if (opts?.force) {
cached = null;
inflight = null;
preferCacheOnce = false;
}
const next = await fetchBranding();
applyDocumentBrand(next);
if (opts?.force) {
preferCacheOnce = true;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
return next;
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateSiteBrandingCache() {
cached = null;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 用管理端刚保存的值立即更新缓存与文档标题(SSR 时只写请求作用域) */
export function seedSiteBrandingCache(brand: SiteBranding) {
const merged: SiteBranding = { ...DEFAULT_BRANDING, ...brand };
const scope = getScope();
if (scope) {
scope.branding = merged;
return;
}
cached = merged;
applyDocumentBrand(cached);
cacheEpoch += 1;
listeners.forEach(fn => fn());
}