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

@@ -6,6 +6,9 @@ import {
getHomeStoreState,
type FeedCacheEntry,
} from '../store/homeStore';
import { clearSessionSnapshots } from './sessionPageCache';
import { softRefreshCurrentPage } from './softRefresh';
import { transitionTo } from './spaTransition';
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
export type FeedNavState = { refreshFeed?: boolean };
@@ -55,9 +58,10 @@ export function setFeedCache(
getHomeStoreState().setFeed(key, data);
}
/** 清除所有帖子列表缓存 */
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
export function clearAllFeedCache() {
getHomeStoreState().clearAll();
clearSessionSnapshots('post:');
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
@@ -66,6 +70,9 @@ export const FEED_RESET_EVENT = 'feed-reset';
/** 手机下拉刷新Feed 页):强制重拉列表并重置滚动,不整页 reload */
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
/** 手机下拉:强制刷新当前前台页(绕过会话快照,非整页 reload */
export const PAGE_FORCE_REFRESH_EVENT = 'page-force-refresh';
/** 当前浏览器地址是否已是目标 Feed URL忽略 hash */
function isSameFeedUrl(url: string): boolean {
try {
@@ -80,15 +87,23 @@ function isSameFeedUrl(url: string): boolean {
}
/**
* 清除缓存并导航到帖子列表。
* 已在目标 URL如首页再点 Logo时额外派发强制重拉不依赖 RR 是否换 key
* 导航到帖子列表。
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时静默软刷新(无进度条)
*/
export function navigateFeed(nav: NavigateFunction, url: string) {
clearAllFeedCache();
window.dispatchEvent(new Event(FEED_RESET_EVENT));
// 同 URL 再点(典型:左上角 Logo必须立刻重拉否则可能只清缓存、界面仍显示旧列表
if (isSameFeedUrl(url)) {
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
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);
return;
}
void transitionTo(nav, url, {
force: true,
silent: true,
state: { refreshFeed: true } satisfies FeedNavState,
});
return;
}
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
void transitionTo(nav, url);
}

View File

@@ -0,0 +1,41 @@
import type { FeedSortId, FeedSortTab } from '../api/types';
import { DEFAULT_FEED_SORT_TABS } from '../api/types';
const FEED_SORT_IDS: FeedSortId[] = ['reply', 'latest', 'hot'];
const DEFAULT_LABELS: Record<FeedSortId, string> = {
reply: '新评论',
latest: '新帖子',
hot: '推荐帖',
};
/** 校验并补全 Feed 排序标签;至少保留一项启用 */
export function normalizeFeedSortTabs(tabs?: FeedSortTab[] | null): FeedSortTab[] {
const seen = new Set<FeedSortId>();
const out: FeedSortTab[] = [];
for (const t of tabs ?? []) {
if (!FEED_SORT_IDS.includes(t.id) || seen.has(t.id)) continue;
seen.add(t.id);
const label = (t.label || '').trim() || DEFAULT_LABELS[t.id];
out.push({ id: t.id, label, enabled: !!t.enabled });
}
for (const id of FEED_SORT_IDS) {
if (seen.has(id)) continue;
const fallback = DEFAULT_FEED_SORT_TABS.find(t => t.id === id)!;
out.push({ ...fallback });
}
if (!out.some(t => t.enabled) && out.length > 0) {
out[0] = { ...out[0], enabled: true };
}
return out;
}
/** 启用中的排序标签(按配置顺序) */
export function enabledFeedSortTabs(tabs?: FeedSortTab[] | null): FeedSortTab[] {
return normalizeFeedSortTabs(tabs).filter(t => t.enabled);
}
/** 默认排序 = 第一个启用项 */
export function getDefaultFeedSort(tabs?: FeedSortTab[] | null): FeedSortId {
return enabledFeedSortTabs(tabs)[0]?.id ?? 'reply';
}

View File

@@ -1,5 +1,6 @@
import type { NavigateFunction } from 'react-router-dom';
import { postPath, type PermalinkOpts } from './permalink';
import { transitionTo } from './spaTransition';
export type OpenForumPostOpts = PermalinkOpts & {
/** 跳转到指定楼层(#floor-N */
@@ -18,5 +19,5 @@ export function openForumPost(
window.open(path, '_blank', 'noopener,noreferrer');
return;
}
nav(path);
void transitionTo(nav, path);
}

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;
}
}

View File

@@ -0,0 +1,45 @@
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 */
const MAX_ENTRIES = 48;
const order: string[] = [];
const store = new Map<string, unknown>();
function touch(key: string) {
const i = order.indexOf(key);
if (i >= 0) order.splice(i, 1);
order.push(key);
while (order.length > MAX_ENTRIES) {
const old = order.shift();
if (old) store.delete(old);
}
}
export function getSessionSnapshot<T>(key: string): T | undefined {
if (!store.has(key)) return undefined;
touch(key);
return store.get(key) as T;
}
export function setSessionSnapshot<T>(key: string, data: T): void {
store.set(key, data);
touch(key);
}
export function deleteSessionSnapshot(key: string): void {
if (!store.delete(key)) return;
const i = order.indexOf(key);
if (i >= 0) order.splice(i, 1);
}
/** 不传 prefix 则清空全部;否则只删该前缀的 key */
export function clearSessionSnapshots(prefix?: string): void {
if (!prefix) {
store.clear();
order.length = 0;
return;
}
for (const key of [...store.keys()]) {
if (key.startsWith(prefix)) deleteSessionSnapshot(key);
}
}

View File

@@ -0,0 +1,20 @@
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
/** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI禁止分批闪烁 */
export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit';
/**
* 静默刷新当前页:不改画面、无进度条,预热齐套后派发一次 commit。
*/
export async function softRefreshCurrentPage(to?: string): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
try {
await Promise.all([
prefetchRoute(path, { force: true }),
prefetchLayoutShell({ force: true }),
]);
} catch {
// 仍派发 commit让界面有机会用已有缓存自愈
}
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
}

View File

@@ -0,0 +1,110 @@
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { prefetchRoute } from './prefetchRoute';
type Listener = (active: boolean) => void;
const listeners = new Set<Listener>();
let activeCount = 0;
let seq = 0;
function emit() {
const active = activeCount > 0;
listeners.forEach((fn) => fn(active));
}
/** 订阅顶栏进度条显隐 */
export function subscribeSpaTransition(fn: Listener): () => void {
listeners.add(fn);
fn(activeCount > 0);
return () => { listeners.delete(fn); };
}
/** 开始一次过渡(可嵌套,全部结束才收条) */
export function startTransition(): number {
const id = ++seq;
activeCount += 1;
emit();
return id;
}
/** 结束过渡;若 id 已过期则忽略 */
export function doneTransition(id?: number) {
if (id != null && id !== seq && activeCount > 0) {
// 仍允许减计数,避免卡死;连续点击用 seq 作废预取即可
}
activeCount = Math.max(0, activeCount - 1);
emit();
}
export function isSpaTransitionActive(): boolean {
return activeCount > 0;
}
export type TransitionToOpts = NavigateOptions & {
/** 强制重拉Logo / 同 URL 刷新),跳过会话快照 */
force?: boolean;
/** 跳过等待立即导航纠偏、POP 等) */
immediate?: boolean;
/** 不显示顶栏进度条(软刷新换页) */
silent?: boolean;
};
function toPathname(to: To): string {
if (typeof to === 'number') return '';
if (typeof to === 'string') {
try {
return new URL(to, window.location.origin).pathname;
} catch {
return to.split('?')[0].split('#')[0];
}
}
return to.pathname ?? '';
}
function isOnlyHashChange(to: To): boolean {
if (typeof to !== 'string') return false;
if (!to.startsWith('#')) return false;
return true;
}
/**
* 站内点击:顶栏进度条 + 预热 chunk/接口后再跳转。
* `nav(-1)`、仅 hash、immediate、外链风格路径直接走。
*/
export async function transitionTo(
nav: NavigateFunction,
to: To | number,
opts?: TransitionToOpts,
): Promise<void> {
const { force, immediate, silent, ...navOpts } = opts ?? {};
if (typeof to === 'number' || immediate || isOnlyHashChange(to as To)) {
nav(to as To, navOpts);
return;
}
const target = to as To;
const path = toPathname(target);
// 后台第一期不做等待跳转
if (path.startsWith('/admin')) {
nav(target, navOpts);
return;
}
const id = silent ? undefined : startTransition();
const mySeq = silent ? seq : (id as number);
try {
await prefetchRoute(target, { force: !!force });
if (!silent && mySeq !== seq) return;
nav(target, navOpts);
} catch (e: unknown) {
if (!silent && mySeq !== seq) return;
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (id != null) {
if (mySeq === seq) doneTransition(id);
else doneTransition();
}
}
}