feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动
补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
43
frontend/src/utils/authBoot.ts
Normal file
43
frontend/src/utils/authBoot.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { User } from '../api/types';
|
||||
|
||||
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) */
|
||||
let seededUser: User | null | undefined;
|
||||
let seededUnread = 0;
|
||||
let hasAuthSeed = false;
|
||||
/** 未读数可被 MainLayout 同步读取 */
|
||||
let bootUnread = 0;
|
||||
|
||||
export function seedAuthFromHomeBoot(user: User | null | undefined, unread: number) {
|
||||
hasAuthSeed = true;
|
||||
seededUser = user ?? null;
|
||||
seededUnread = Math.max(0, unread | 0);
|
||||
bootUnread = seededUnread;
|
||||
}
|
||||
|
||||
type AuthSeed = { hasSeed: boolean; user: User | null; unread: number };
|
||||
|
||||
/** 只读种子,不清空(StrictMode 双挂 / useState initializer 可重复调用) */
|
||||
export function peekAuthSeed(): AuthSeed {
|
||||
if (!hasAuthSeed) {
|
||||
return { hasSeed: false, user: null, unread: bootUnread };
|
||||
}
|
||||
return { hasSeed: true, user: seededUser ?? null, unread: seededUnread };
|
||||
}
|
||||
|
||||
/** mount 后清除用户种子,避免后续误用 */
|
||||
export function clearAuthSeed() {
|
||||
hasAuthSeed = false;
|
||||
seededUser = undefined;
|
||||
seededUnread = 0;
|
||||
}
|
||||
|
||||
/** @deprecated 改用 peekAuthSeed + clearAuthSeed;保留兼容 */
|
||||
export function takeAuthSeed(): AuthSeed {
|
||||
const s = peekAuthSeed();
|
||||
if (s.hasSeed) clearAuthSeed();
|
||||
return s;
|
||||
}
|
||||
|
||||
export function getBootUnread(): number {
|
||||
return bootUnread;
|
||||
}
|
||||
@@ -88,19 +88,18 @@ function isSameFeedUrl(url: string): boolean {
|
||||
|
||||
/**
|
||||
* 导航到帖子列表。
|
||||
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时静默软刷新(无进度条)。
|
||||
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时软刷新(带顶栏进度条)。
|
||||
*/
|
||||
export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) {
|
||||
const same = isSameFeedUrl(url);
|
||||
const refresh = opts?.refresh ?? same;
|
||||
if (refresh) {
|
||||
if (same) {
|
||||
void softRefreshCurrentPage(url);
|
||||
void softRefreshCurrentPage(url, { progress: true });
|
||||
return;
|
||||
}
|
||||
void transitionTo(nav, url, {
|
||||
force: true,
|
||||
silent: true,
|
||||
state: { refreshFeed: true } satisfies FeedNavState,
|
||||
});
|
||||
return;
|
||||
|
||||
110
frontend/src/utils/homeBoot.ts
Normal file
110
frontend/src/utils/homeBoot.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Board,
|
||||
CheckInStatus,
|
||||
CommunityShowcaseItem,
|
||||
ForumLimitsPublic,
|
||||
ForumStats,
|
||||
PostItem,
|
||||
RecentComment,
|
||||
RecentUser,
|
||||
SiteBranding,
|
||||
SitePageSummary,
|
||||
TagCount,
|
||||
User,
|
||||
} from '../api/types';
|
||||
import { seedSiteBrandingCache } from '../hooks/useSiteBranding';
|
||||
import { seedForumLimitsCache } from '../hooks/useForumLimits';
|
||||
import { seedSitePagesCache } from '../hooks/useSitePages';
|
||||
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||
import {
|
||||
setCachedBoards,
|
||||
setCachedRecentComments,
|
||||
setCachedRecentUsers,
|
||||
setCachedStats,
|
||||
setCachedTags,
|
||||
} from './layoutCache';
|
||||
import { setSessionSnapshot } from './sessionPageCache';
|
||||
import { seedAuthFromHomeBoot } from './authBoot';
|
||||
import { checkInCacheKey } from '../hooks/useCheckIn';
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
|
||||
export type HomeBootPayload = {
|
||||
board_id: number;
|
||||
sort: string;
|
||||
keyword: string;
|
||||
tag: string;
|
||||
author: string;
|
||||
title_only: boolean;
|
||||
posts: PostItem[];
|
||||
post_total: number;
|
||||
page: number;
|
||||
boards: Board[];
|
||||
stats: ForumStats;
|
||||
recent_comments: RecentComment[];
|
||||
recent_users: RecentUser[];
|
||||
tags: TagCount[];
|
||||
showcase: CommunityShowcaseItem[];
|
||||
pages: SitePageSummary[];
|
||||
limits: ForumLimitsPublic;
|
||||
branding: SiteBranding;
|
||||
user?: User | null;
|
||||
unread_messages?: number;
|
||||
check_in?: CheckInStatus | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__J13_HOME_BOOT__?: HomeBootPayload;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */
|
||||
export function consumeHomeBoot(): HomeBootPayload | null {
|
||||
const boot = window.__J13_HOME_BOOT__;
|
||||
try {
|
||||
delete window.__J13_HOME_BOOT__;
|
||||
} catch {
|
||||
window.__J13_HOME_BOOT__ = undefined;
|
||||
}
|
||||
if (!boot || typeof boot !== 'object') return null;
|
||||
|
||||
if (boot.limits) seedForumLimitsCache(boot.limits);
|
||||
if (boot.branding) seedSiteBrandingCache(boot.branding);
|
||||
if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages);
|
||||
if (Array.isArray(boot.boards)) setCachedBoards(boot.boards);
|
||||
if (boot.stats) setCachedStats(boot.stats);
|
||||
if (Array.isArray(boot.recent_comments)) setCachedRecentComments(boot.recent_comments);
|
||||
if (Array.isArray(boot.recent_users)) setCachedRecentUsers(boot.recent_users);
|
||||
if (Array.isArray(boot.tags)) setCachedTags(boot.tags);
|
||||
if (Array.isArray(boot.showcase)) setSessionSnapshot('showcase', boot.showcase);
|
||||
|
||||
// 鉴权 / 签到:有 user 字段即种子(含 null = 已确认访客)
|
||||
if ('user' in boot) {
|
||||
seedAuthFromHomeBoot(boot.user ?? null, boot.unread_messages ?? 0);
|
||||
const uid = boot.user?.id;
|
||||
if (uid && boot.check_in) {
|
||||
setSessionSnapshot(checkInCacheKey(uid), boot.check_in);
|
||||
}
|
||||
}
|
||||
|
||||
const sort = (boot.sort || 'reply') as FeedSort;
|
||||
const key = feedCacheKey({
|
||||
boardId: boot.board_id || 0,
|
||||
keyword: boot.keyword || '',
|
||||
tag: boot.tag || '',
|
||||
author: boot.author || '',
|
||||
titleOnly: !!boot.title_only,
|
||||
sort,
|
||||
});
|
||||
const posts = Array.isArray(boot.posts) ? boot.posts : [];
|
||||
getHomeStoreState().setFeed(key, {
|
||||
posts,
|
||||
postTotal: boot.post_total ?? posts.length,
|
||||
page: boot.page || 1,
|
||||
scrollTop: 0,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
|
||||
return boot;
|
||||
}
|
||||
41
frontend/src/utils/homeHydrate.ts
Normal file
41
frontend/src/utils/homeHydrate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** 首页 SSR hydrate 首帧同构标志(仅 / 与板块首页) */
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__J13_HYDRATING_HOME__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
let hydrating = false;
|
||||
|
||||
export function beginHomeHydrate() {
|
||||
hydrating = true;
|
||||
try {
|
||||
window.__J13_HYDRATING_HOME__ = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function endHomeHydrate() {
|
||||
hydrating = false;
|
||||
try {
|
||||
delete window.__J13_HYDRATING_HOME__;
|
||||
} catch {
|
||||
try {
|
||||
window.__J13_HYDRATING_HOME__ = undefined;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 首帧是否必须与 Go SSR DOM 同构 */
|
||||
export function isHomeHydrating(): boolean {
|
||||
if (hydrating) return true;
|
||||
try {
|
||||
return !!window.__J13_HYDRATING_HOME__;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
|
||||
import { doneTransition, startTransition } from './spaTransition';
|
||||
|
||||
/** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI,禁止分批闪烁 */
|
||||
export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit';
|
||||
|
||||
export type SoftRefreshOpts = {
|
||||
/** 是否显示顶栏进度条(Logo 刷新开;下拉关) */
|
||||
progress?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 静默刷新当前页:不改画面、无进度条,预热齐套后派发一次 commit。
|
||||
* 软刷新当前页:预热齐套后派发一次 commit。
|
||||
* `progress: true` 时走顶栏进度条。
|
||||
*/
|
||||
export async function softRefreshCurrentPage(to?: string): Promise<void> {
|
||||
export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> {
|
||||
const path = to ?? `${window.location.pathname}${window.location.search}`;
|
||||
const id = opts?.progress ? startTransition() : undefined;
|
||||
try {
|
||||
await Promise.all([
|
||||
prefetchRoute(path, { force: true }),
|
||||
@@ -17,4 +25,5 @@ export async function softRefreshCurrentPage(to?: string): Promise<void> {
|
||||
// 仍派发 commit,让界面有机会用已有缓存自愈
|
||||
}
|
||||
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
|
||||
if (id != null) doneTransition(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user