fix: 软刷新齐套前保留旧画面,避免一点击就卸光

Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
This commit is contained in:
2026-09-01 04:56:10 +08:00
parent ba60a9b1c4
commit 9e134916a4
47 changed files with 2446 additions and 636 deletions

View File

@@ -0,0 +1,421 @@
import type { To } from 'react-router-dom';
import { api } from '../api/client';
import type {
CheckInStatus,
Comment,
CommunityShowcaseItem,
FriendLinkApply,
PollView,
PostItem,
PostLotteryView,
SitePage,
} from '../api/types';
import { parseFeedSort } from '../components/FeedSortBar';
import { checkInCacheKey } from '../hooks/useCheckIn';
import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { resolveAsideWidgets } from './asideWidgets';
import { isTimeDiffSignificant } from './content';
import { loadMyCommentIds } from './guest';
import {
setCachedBoards,
setCachedStats,
setCachedRecentComments,
setCachedRecentUsers,
setCachedTags,
} from './layoutCache';
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
/** 与 PostDetailPage 会话快照同形,供预取写入 */
type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
function resolveUrl(to: To): URL {
if (typeof to === 'string') return new URL(to, window.location.origin);
const path = to.pathname ?? '/';
const search = to.search ?? '';
const hash = to.hash ?? '';
return new URL(path + search + hash, window.location.origin);
}
/** 预加载对应路由的 lazy chunk(与 App.tsx lazyWithRetry 对齐) */
function preloadChunk(pathname: string): Promise<unknown> {
if (pathname === '/' || /^\/board\//.test(pathname)) {
return import('../pages/HomePage');
}
if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') {
return import('../pages/ComposePage');
}
if (/^\/post\//.test(pathname)) {
return import('../pages/PostDetailPage');
}
if (pathname === '/profile') return import('../pages/ProfilePage');
if (/^\/user\//.test(pathname)) return import('../pages/UserProfilePage');
if (pathname === '/favorites') return import('../pages/FavoritesPage');
if (pathname === '/projects') return import('../pages/ProjectsPage');
if (pathname === '/links') return import('../pages/LinksPage');
if (pathname === '/showcase') return import('../pages/ShowcasePage');
if (pathname === '/messages') return import('../pages/MessagesPage');
if (/^\/page\//.test(pathname)) return import('../pages/SitePageView');
if (pathname === '/login') return import('../pages/LoginPage');
if (pathname === '/register') return import('../pages/RegisterPage');
if (pathname === '/forgot-password') return import('../pages/ForgotPasswordPage');
return Promise.resolve();
}
async function prefetchFeed(url: URL, force: boolean): Promise<void> {
const limits = getCachedForumLimits();
const pageSize = Math.max(1, limits.page_size_default);
const boardMatch = url.pathname.match(/^\/board\/([^/]+)/);
const boardId = boardMatch ? (parsePermalinkID(boardMatch[1]) || 0) : 0;
const keyword = url.searchParams.get('keyword') || '';
const tag = url.searchParams.get('tag') || '';
const author = url.searchParams.get('author') || '';
const titleOnly = url.searchParams.get('title_only') === '1';
const sort = parseFeedSort(url.searchParams.get('sort'), limits.feed_sort_tabs);
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
if (!force) {
const hit = getHomeStoreState().getFeed(key);
if (hit && hit.posts.length > 0) return;
}
// force:不提前清空 store,等新数据写入时覆盖,避免软刷新中间态读到空列表
const data = await api.posts({
page: 1,
size: pageSize,
board_id: boardId || '',
keyword: tag ? '' : keyword,
tag: tag || '',
author: tag ? '' : author,
title_only: !tag && titleOnly ? '1' : '',
sort,
});
getHomeStoreState().setFeed(key, {
posts: Array.isArray(data.posts) ? data.posts : [],
postTotal: data.total ?? 0,
page: 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});
}
async function prefetchPost(id: number, force: boolean): Promise<void> {
const key = `post:${id}`;
if (!force && getSessionSnapshot(key) !== undefined) return;
// force:保留旧快照直到新数据写回,避免软刷新中间态空白
const myIds = loadMyCommentIds();
const [detail, comm] = await Promise.all([
api.post(id),
api.comments(id, myIds),
]);
const snap: PostDetailSnapshot = {
post: detail.post,
comments: Array.isArray(comm.comments) ? comm.comments : [],
poll: detail.poll ?? null,
lottery: detail.lottery ?? null,
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited
?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
setSessionSnapshot(key, snap);
}
async function prefetchUser(id: number, force: boolean): Promise<void> {
const profileKey = `user:${id}`;
if (!force && getSessionSnapshot(profileKey) !== undefined) return;
const limits = getCachedForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [profileRes, postsRes] = await Promise.all([
api.userProfile(id),
api.posts({ user_id: id, page: 1, size: pageSize, sort: 'latest' }),
]);
setSessionSnapshot(profileKey, {
profile: profileRes.user,
stats: profileRes.stats ?? null,
});
setSessionSnapshot(`${profileKey}:posts:1:${pageSize}`, {
posts: Array.isArray(postsRes.posts) ? postsRes.posts : [],
total: postsRes.total ?? 0,
});
}
async function prefetchKeyed<T>(
key: string,
force: boolean,
fetcher: () => Promise<T>,
): Promise<void> {
if (!force && getSessionSnapshot(key) !== undefined) return;
const data = await fetcher();
setSessionSnapshot(key, data);
}
/** 静默预热签到(401 / 失败不阻断跳转) */
async function prefetchCheckIn(force: boolean): Promise<void> {
try {
const me = await api.me();
const id = me.user?.id;
if (!id) return;
const key = checkInCacheKey(id);
if (!force && getSessionSnapshot<CheckInStatus>(key) !== undefined) return;
const d = await api.checkInStatus();
setSessionSnapshot(key, d.check_in);
} catch {
// 未登录或接口失败:忽略
}
}
/** 右栏展柜与全页共用 showcase 快照 */
async function prefetchShowcaseIfEnabled(force: boolean): Promise<void> {
const limits = getCachedForumLimits();
const enabled = resolveAsideWidgets(limits).some((w) => w.id === 'showcase' && w.enabled);
if (!enabled) return;
await prefetchKeyed<CommunityShowcaseItem[]>('showcase', force, () =>
api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
).catch(() => undefined);
}
/** MainLayout 壳层:签到 +(可选)展柜,与主内容并行 */
function prefetchShell(force: boolean): Promise<void> {
return Promise.all([
prefetchCheckIn(force),
prefetchShowcaseIfEnabled(force),
]).then(() => undefined);
}
/** 是否会挂载前台 MainLayout(非纯 auth / 后台页) */
export function isMainLayoutPath(pathname: string): boolean {
if (pathname.startsWith('/admin')) return false;
if (pathname === '/login' || pathname === '/register' || pathname === '/forgot-password') return false;
return true;
}
/** 按目标 URL 预热会话快照 / Feed 缓存(不改 fetch 缓存策略) */
export async function prefetchData(to: To, opts?: { force?: boolean }): Promise<void> {
const force = !!opts?.force;
const url = resolveUrl(to);
const { pathname } = url;
const shell = isMainLayoutPath(pathname) ? prefetchShell(force) : Promise.resolve();
if (pathname === '/' || /^\/board\//.test(pathname)) {
await Promise.all([prefetchFeed(url, force), shell]);
return;
}
const postEdit = pathname.match(/^\/post\/([^/]+)\/edit$/);
if (postEdit) {
await shell;
return;
}
const postMatch = pathname.match(/^\/post\/([^/]+)/);
if (postMatch) {
const id = parsePermalinkID(postMatch[1]);
await Promise.all([
id && !Number.isNaN(id) ? prefetchPost(id, force) : Promise.resolve(),
shell,
]);
return;
}
const userMatch = pathname.match(/^\/user\/([^/]+)/);
if (userMatch) {
const id = parsePermalinkID(userMatch[1]);
await Promise.all([
id && !Number.isNaN(id) ? prefetchUser(id, force) : Promise.resolve(),
shell,
]);
return;
}
if (pathname === '/favorites') {
await Promise.all([
prefetchKeyed('favorites', force, () =>
api.favorites().then((d) => (Array.isArray(d.favorites) ? d.favorites : [])),
),
shell,
]);
return;
}
if (pathname === '/projects') {
await Promise.all([
prefetchKeyed(`projects:1:`, force, () =>
api.projects({ page: 1, limit: 30 }).then((d) => ({
list: Array.isArray(d.projects) ? d.projects : [],
total: d.total ?? 0,
totalPages: d.total_pages ?? 0,
})),
),
shell,
]);
return;
}
if (pathname === '/links') {
await Promise.all([
prefetchKeyed<FriendLinkApply[]>('links:applies', force, () =>
api.myFriendLinkApplies().then((r) => r.applies ?? []).catch(() => []),
),
shell,
]);
return;
}
if (pathname === '/showcase') {
await Promise.all([
prefetchKeyed('showcase', force, () =>
api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
),
shell,
]);
return;
}
if (pathname === '/messages') {
await Promise.all([
prefetchKeyed('messages:conv:1', force, () =>
api.messageConversations({ page: 1, size: 30 }).then((r) => ({
conversations: r.conversations || [],
total: r.total || 0,
page: r.page || 1,
})),
),
shell,
]);
return;
}
if (pathname === '/profile' || pathname === '/compose') {
await shell;
return;
}
const pageMatch = pathname.match(/^\/page\/([^/]+)/);
if (pageMatch) {
const slug = parsePermalinkSlug(pageMatch[1]);
await Promise.all([
slug
? prefetchKeyed<SitePage | null>(`sitepage:${slug}`, force, () =>
api.page(slug).then((d) => d.page),
)
: Promise.resolve(),
shell,
]);
}
}
/** chunk + 数据并行预热 */
export async function prefetchRoute(to: To, opts?: { force?: boolean }): Promise<void> {
const url = resolveUrl(to);
await Promise.all([
preloadChunk(url.pathname),
prefetchData(to, opts),
]);
}
/** 壳层数据:boards/stats/站点页/右栏(与冷启动、软刷新共用) */
export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<void> {
await ensureForumLimitsLoaded();
const limits = getCachedForumLimits();
const widgets = resolveAsideWidgets(limits);
const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled);
const showRecentUsers = widgets.some((w) => w.id === 'recent_users' && w.enabled);
const showTagCloud = widgets.some((w) => w.id === 'tag_cloud' && w.enabled);
const hideAside = typeof window !== 'undefined'
&& window.matchMedia('(max-width: 1100px)').matches;
const tasks: Promise<unknown>[] = [
api.boards().then((d) => {
setCachedBoards(d.boards ?? []);
}).catch(() => undefined),
api.stats().then((next) => {
if (next) setCachedStats(next);
}).catch(() => undefined),
ensureSitePagesLoaded({ force: !!opts?.force }),
];
if (!hideAside) {
if (showRecentComments) {
tasks.push(
api.recentComments().then((d) => {
setCachedRecentComments(Array.isArray(d.comments) ? d.comments : []);
}).catch(() => undefined),
);
}
if (showRecentUsers) {
tasks.push(
api.recentUsers().then((d) => {
setCachedRecentUsers(Array.isArray(d.users) ? d.users : []);
}).catch(() => undefined),
);
}
if (showTagCloud) {
tasks.push(
api.tags(40).then((d) => {
setCachedTags(Array.isArray(d.tags) ? d.tags : []);
}).catch(() => undefined),
);
}
}
await Promise.all(tasks);
}
let coldBootEnsured = false;
/** main.tsx 是否已完成前台冷启动预热(MainLayout 可同步放行) */
export function wasColdBootEnsured(): boolean {
return coldBootEnsured;
}
/**
* 冷启动:在 createRoot 之前静默预热当前前台路由与壳层。
* 不触发顶栏进度条;失败也放行,避免永久空白。
*/
export async function ensureColdBootReady(to?: string): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
const url = resolveUrl(path);
if (!isMainLayoutPath(url.pathname)) {
coldBootEnsured = true;
return;
}
try {
await Promise.all([
prefetchRoute(path, { force: false }),
prefetchLayoutShell(),
]);
} catch {
// 忽略:仍挂载 App
} finally {
coldBootEnsured = true;
}
}