feat: 排序标签强制刷新、推荐帖仅 featured,软刷新同步 limits/品牌文案

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 07:32:21 +08:00
parent 4ca5c17e2e
commit 21c6233fc0
9 changed files with 79 additions and 33 deletions

View File

@@ -16,7 +16,7 @@ export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
reply: { hint: '最近有人评论', icon: MessageCircle },
latest: { hint: '按发帖时间', icon: Clock },
hot: { hint: '站内推荐', icon: BadgeCheck },
hot: { hint: '仅展示推荐', icon: BadgeCheck },
};
interface Props {

View File

@@ -7,7 +7,7 @@ import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
reply: { hint: '按最后评论时间', placeholder: '新评论' },
latest: { hint: '按发帖时间', placeholder: '新帖子' },
hot: { hint: '推荐优先,再按互动', placeholder: '推荐帖' },
hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' },
};
type Props = {

View File

@@ -80,9 +80,18 @@ export function useForumLimits() {
return { limits, loading };
}
/** 冷启动 / 预热:确保 limits 已写入模块缓存 */
export function ensureForumLimitsLoaded(): Promise<ForumLimitsPublic> {
return fetchLimits();
/** 冷启动 / 预热:确保 limits 已写入模块缓存force 时重拉并在成功后通知 hook */
export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promise<ForumLimitsPublic> {
if (opts?.force) {
cached = null;
inflight = null;
}
const limits = await fetchLimits();
if (opts?.force) {
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
return limits;
}
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */

View File

@@ -46,9 +46,15 @@ function readBootBranding(): SiteBranding | 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>();
function fetchBranding(): Promise<SiteBranding> {
if (preferCacheOnce && cached) {
preferCacheOnce = false;
return Promise.resolve(cached);
}
if (inflight) return inflight;
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
inflight = api.siteBranding()
@@ -130,6 +136,23 @@ export function refetchSiteBranding() {
listeners.forEach(fn => fn());
}
/** 冷启动 / 预热确保品牌已写入模块缓存force 时重拉并在成功后通知 hook */
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
if (!opts?.force && cached) return cached;
if (opts?.force) {
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;

View File

@@ -341,7 +341,6 @@ export default function MainLayout() {
setTagsLoading(false);
setAsideLoading(false);
asideEverLoaded.current = true;
refetchSiteBranding();
refreshUnreadMessages();
};
const onForce = () => {

View File

@@ -208,6 +208,8 @@ export default function HomePage() {
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop */
const hydratedKeyRef = useRef<string | null>(null);
/** 强制刷新已发起 loadFirst消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
const refreshFetchKeyRef = useRef<string | null>(null);
pageRef.current = page;
cacheKeyRef.current = cacheKey;
viewKeyRef.current = view.cacheKey;
@@ -338,7 +340,9 @@ export default function HomePage() {
}
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
const loadFirst = useCallback((opts?: { resetScroll?: boolean }) => (
fetchPage(1, { resetScroll: opts?.resetScroll })
), [fetchPage]);
const goToPage = useCallback((p: number) => {
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
@@ -362,24 +366,14 @@ export default function HomePage() {
if (forceRefresh && navType !== 'POP') {
fetchSeqRef.current += 1;
hydratedKeyRef.current = cacheKey;
// 预取已写入 store整页替换不卸成骨架
refreshFetchKeyRef.current = cacheKey;
// 排序等强制刷新:丢弃该键旧分页/滚动,置顶重拉第 1 页
getHomeStoreState().clearFeed(cacheKey);
resetFeedView();
const warm = getHomeStoreState().getFeed(cacheKey);
if (warm && warm.posts.length > 0) {
commitDisplayed({
posts: warm.posts,
postTotal: warm.postTotal,
page: warm.page,
scrollTop: 0,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
// 预取失败时保留旧列表并重拉
setListPending(postsRef.current.length > 0);
if (postsRef.current.length === 0) setLoading(true);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
loadFirst();
}
setListPending(postsRef.current.length > 0);
if (postsRef.current.length === 0) setLoading(true);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
void loadFirst({ resetScroll: true });
// 消费后清掉 state防止该 history 条目永远带着刷新标记
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
return;
@@ -390,6 +384,12 @@ export default function HomePage() {
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}
// 刚强制刷新并清缓存:跳过随后因 state 清空触发的空缓存首拉
if (refreshFetchKeyRef.current === cacheKey) {
refreshFetchKeyRef.current = null;
return;
}
const cached = getHomeStoreState().getFeed(cacheKey);
if (cached && cached.posts.length > 0) {
const needRestore = hydratedKeyRef.current !== cacheKey;
@@ -511,13 +511,16 @@ export default function HomePage() {
}, []);
const handleSortChange = (next: FeedSort) => {
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
// 排序标签:一律强制刷新到第 1 页顶部,不恢复浏览进度
if (next === sort) {
const tid = startTransition();
getHomeStoreState().clearFeed(cacheKeyRef.current);
beginFeedRefresh();
void Promise.resolve(loadFirst()).finally(() => doneTransition(tid));
void Promise.resolve(loadFirst({ resetScroll: true })).finally(() => doneTransition(tid));
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }));
navigateFeed(nav, url, { refresh: true });
};
const showSortBar = !view.keyword && !view.tag && !view.author;

View File

@@ -13,6 +13,7 @@ import type {
import { parseFeedSort } from '../components/FeedSortBar';
import { checkInCacheKey } from '../hooks/useCheckIn';
import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits';
import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { resolveAsideWidgets } from './asideWidgets';
@@ -343,7 +344,8 @@ export async function prefetchRoute(to: To, opts?: { force?: boolean }): Promise
/** 壳层数据boards/stats/站点页/右栏(与冷启动、软刷新共用) */
export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<void> {
await ensureForumLimitsLoaded();
const force = !!opts?.force;
await ensureForumLimitsLoaded({ force });
const limits = getCachedForumLimits();
const widgets = resolveAsideWidgets(limits);
const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled);
@@ -359,9 +361,14 @@ export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<v
api.stats().then((next) => {
if (next) setCachedStats(next);
}).catch(() => undefined),
ensureSitePagesLoaded({ force: !!opts?.force }),
ensureSitePagesLoaded({ force }),
];
// 软刷新:与壳层同拍重拉站名 / 友链等品牌文案
if (force) {
tasks.push(ensureSiteBrandingLoaded({ force: true }).catch(() => undefined));
}
if (!hideAside) {
if (showRecentComments) {
tasks.push(