- 帖子快照携带作者主页资料与统计(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 错误
186 lines
5.6 KiB
TypeScript
186 lines
5.6 KiB
TypeScript
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());
|
||
}
|