fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求
点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
@@ -51,7 +51,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
@@ -4480,6 +4481,34 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, ArrowDown } from 'lucide-react';
|
||||
import { FEED_PULL_REFRESH_EVENT } from '../utils/feedCache';
|
||||
|
||||
/** 触发刷新的下拉距离(px) */
|
||||
const REFRESH_THRESHOLD = 68;
|
||||
@@ -167,6 +168,18 @@ export default function PullToRefresh() {
|
||||
setSettling(true);
|
||||
setPull(REFRESH_THRESHOLD * 0.7);
|
||||
window.setTimeout(() => {
|
||||
// Feed 页:应用内强制重拉(保留 SPA 其它状态);其它页仍整页 reload
|
||||
const isFeed = !!(
|
||||
document.querySelector('.main-content--feed-mobile-scroll')
|
||||
|| document.querySelector('.page-wrap--feed .post-list-scroll')
|
||||
);
|
||||
if (isFeed) {
|
||||
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
|
||||
setRefreshing(false);
|
||||
setSettling(true);
|
||||
setPull(0);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}, 180);
|
||||
return;
|
||||
|
||||
@@ -161,6 +161,12 @@ export default function VirtualPostList({
|
||||
onScrollTopChangeRef.current?.(0);
|
||||
}, [resetScrollKey, virtualizer, getScrollElement]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// 收到新的恢复目标时允许再次 restore;null 表示已消费,勿动标记
|
||||
if (restoreScrollTop == null) return;
|
||||
restoredRef.current = false;
|
||||
}, [restoreScrollTop]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||
const el = getScrollElement();
|
||||
@@ -173,10 +179,6 @@ export default function VirtualPostList({
|
||||
onScrollRestoredRef.current?.();
|
||||
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
|
||||
|
||||
useEffect(() => {
|
||||
restoredRef.current = false;
|
||||
}, [restoreScrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = getScrollElement();
|
||||
if (!el) return;
|
||||
|
||||
@@ -386,6 +386,7 @@ export default function MainLayout() {
|
||||
<Menu size={18} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{/* 点 Logo:回首页并强制刷新列表(已在首页时也会重拉) */}
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation, useParams } from 'react-router-dom';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
useSearchParams,
|
||||
useLocation,
|
||||
useParams,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
@@ -12,13 +19,13 @@ import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../comp
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
||||
import {
|
||||
getFeedCache,
|
||||
setFeedCache,
|
||||
clearAllFeedCache,
|
||||
navigateFeed,
|
||||
FEED_RESET_EVENT,
|
||||
FEED_PULL_REFRESH_EVENT,
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
@@ -35,9 +42,41 @@ function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearc
|
||||
return q > 0 ? q : 0;
|
||||
}
|
||||
|
||||
type FeedHydrate = {
|
||||
posts: PostItem[];
|
||||
postTotal: number;
|
||||
page: number;
|
||||
scrollTop: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
/** 首屏同步读取 Zustand,避免先骨架屏再恢复 */
|
||||
function readHydrate(
|
||||
boardId: number,
|
||||
keyword: string,
|
||||
sort: FeedSort,
|
||||
tag: string,
|
||||
author: string,
|
||||
titleOnly: boolean,
|
||||
): FeedHydrate {
|
||||
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
||||
const cached = getHomeStoreState().getFeed(key);
|
||||
if (cached && cached.posts.length > 0) {
|
||||
return {
|
||||
posts: cached.posts,
|
||||
postTotal: cached.postTotal,
|
||||
page: cached.page,
|
||||
scrollTop: cached.scrollTop,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const navType = useNavigationType();
|
||||
const { id: boardRouteId } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
@@ -96,20 +135,49 @@ export default function HomePage() {
|
||||
}
|
||||
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
|
||||
const cacheKey = useMemo(
|
||||
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
|
||||
[boardId, keyword, sort, tag, author, titleOnly],
|
||||
);
|
||||
|
||||
// 筛选键变化时同步水合(含首次挂载),保证第一帧就有列表数据
|
||||
const initial = useMemo(
|
||||
() => readHydrate(boardId, keyword, sort, tag, author, titleOnly),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随筛选键变化重置
|
||||
[cacheKey],
|
||||
);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>(initial.posts);
|
||||
const [postTotal, setPostTotal] = useState(initial.postTotal);
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [loading, setLoading] = useState(initial.loading);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
|
||||
initial.posts.length > 0 ? initial.scrollTop : null,
|
||||
);
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
|
||||
const scrollTopRef = useRef(0);
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
const scrollTopRef = useRef(initial.scrollTop);
|
||||
const loadingRef = useRef(false);
|
||||
const pageRef = useRef(1);
|
||||
const pageRef = useRef(initial.page);
|
||||
const cacheKeyRef = useRef(cacheKey);
|
||||
const scrollRafRef = useRef(0);
|
||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||
const hydratedKeyRef = useRef<string | null>(null);
|
||||
pageRef.current = page;
|
||||
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
|
||||
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
|
||||
cacheKeyRef.current = cacheKey;
|
||||
|
||||
// 筛选键切换:用新键的缓存重置本地 state(useMemo initial 不会自动 setState)
|
||||
useEffect(() => {
|
||||
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
|
||||
hydratedKeyRef.current = null;
|
||||
setPosts(next.posts);
|
||||
setPostTotal(next.postTotal);
|
||||
setPage(next.page);
|
||||
pageRef.current = next.page;
|
||||
scrollTopRef.current = next.scrollTop;
|
||||
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
||||
setLoading(next.loading);
|
||||
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
@@ -121,16 +189,38 @@ export default function HomePage() {
|
||||
setListResetKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
|
||||
const beginFeedRefresh = useCallback(() => {
|
||||
skipCacheSaveRef.current = true;
|
||||
clearAllFeedCache();
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
const fetchPage = useCallback(async (p: number) => {
|
||||
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
|
||||
const persistFeed = useCallback((
|
||||
nextPosts: PostItem[],
|
||||
nextTotal: number,
|
||||
nextPage: number,
|
||||
opts?: { scrollTop?: number; touchFetchTime?: boolean },
|
||||
) => {
|
||||
const key = cacheKeyRef.current;
|
||||
const prev = getHomeStoreState().getFeed(key);
|
||||
getHomeStoreState().setFeed(key, {
|
||||
posts: nextPosts,
|
||||
postTotal: nextTotal,
|
||||
page: nextPage,
|
||||
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
|
||||
lastFetchTime: opts?.touchFetchTime === false
|
||||
? (prev?.lastFetchTime ?? Date.now())
|
||||
: Date.now(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean }) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
const silent = !!opts?.silent;
|
||||
// 静默刷新:保留现有列表,不展示加载骨架
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
@@ -148,17 +238,20 @@ export default function HomePage() {
|
||||
setPostTotal(total);
|
||||
setPage(p);
|
||||
pageRef.current = p;
|
||||
persistFeed(batch, total, p, { touchFetchTime: true });
|
||||
} catch (e: unknown) {
|
||||
if (!silent) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
setPosts([]);
|
||||
setPostTotal(0);
|
||||
setPage(1);
|
||||
pageRef.current = 1;
|
||||
}
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
|
||||
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
|
||||
|
||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||
|
||||
@@ -168,89 +261,84 @@ export default function HomePage() {
|
||||
if (p < 1 || p > maxPage) return;
|
||||
if (p === pageRef.current) return;
|
||||
resetFeedView();
|
||||
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
|
||||
fetchPage(p);
|
||||
}, [fetchPage, postTotal, pageSize, resetFeedView]);
|
||||
|
||||
const handleSelectPost = useCallback((id: number) => {
|
||||
// 离开前再写一次滚动,确保详情页返回可还原
|
||||
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
|
||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||
}, [nav, limits.open_posts_in_new_tab]);
|
||||
|
||||
// 等限制就绪后再拉列表;筛选变化时重载
|
||||
// 等限制就绪后再决定:强制刷新 / 用缓存 / 静默刷新 / 首拉
|
||||
useEffect(() => {
|
||||
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
|
||||
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
// 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存
|
||||
if (forceRefresh && navType !== 'POP') {
|
||||
hydratedKeyRef.current = cacheKey;
|
||||
beginFeedRefresh();
|
||||
setPosts([]);
|
||||
setPostTotal(0);
|
||||
setPage(1);
|
||||
pageRef.current = 1;
|
||||
setLoading(true);
|
||||
loadFirst();
|
||||
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
|
||||
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
|
||||
// POP 带回 refreshFeed 时也清掉,避免下次同条目再误触发
|
||||
if (forceRefresh && navType === 'POP') {
|
||||
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}
|
||||
|
||||
const cached = getHomeStoreState().getFeed(cacheKey);
|
||||
if (cached && cached.posts.length > 0) {
|
||||
const needRestore = hydratedKeyRef.current !== cacheKey;
|
||||
hydratedKeyRef.current = cacheKey;
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
setPage(cached.page);
|
||||
pageRef.current = cached.page;
|
||||
setLoading(false);
|
||||
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
|
||||
if (needRestore) {
|
||||
setRestoreScrollTop(cached.scrollTop);
|
||||
scrollTopRef.current = cached.scrollTop;
|
||||
setLoading(false);
|
||||
}
|
||||
// 超过 TTL:后台静默刷新,不重置滚动
|
||||
if (getHomeStoreState().isStale(cacheKey)) {
|
||||
void fetchPage(cached.page, { silent: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hydratedKeyRef.current = cacheKey;
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
loadFirst();
|
||||
}, [
|
||||
limitsLoading,
|
||||
pageSize,
|
||||
boardId,
|
||||
keyword,
|
||||
tag,
|
||||
author,
|
||||
titleOnly,
|
||||
sort,
|
||||
cacheKey,
|
||||
location.key,
|
||||
location.state,
|
||||
location.pathname,
|
||||
location.search,
|
||||
location.hash,
|
||||
navType,
|
||||
nav,
|
||||
loadFirst,
|
||||
fetchPage,
|
||||
beginFeedRefresh,
|
||||
isInvalidBoardRoute,
|
||||
isMissingBoard,
|
||||
]);
|
||||
|
||||
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
|
||||
if (
|
||||
feedSnapRef.current.boardId === boardId
|
||||
&& feedSnapRef.current.keyword === keyword
|
||||
&& feedSnapRef.current.tag === tag
|
||||
&& feedSnapRef.current.author === author
|
||||
&& feedSnapRef.current.titleOnly === titleOnly
|
||||
&& feedSnapRef.current.sort === sort
|
||||
) {
|
||||
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page };
|
||||
}
|
||||
|
||||
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
||||
useEffect(() => {
|
||||
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
||||
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts: [], postTotal: 0, page: 1 };
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current) return;
|
||||
const snap = feedSnapRef.current;
|
||||
if (snap.posts.length === 0) return;
|
||||
setFeedCache(snap.boardId, snap.keyword, snap.sort, {
|
||||
posts: snap.posts,
|
||||
postTotal: snap.postTotal,
|
||||
page: snap.page,
|
||||
scrollTop: scrollTopRef.current,
|
||||
}, snap.tag, snap.author, snap.titleOnly);
|
||||
};
|
||||
}, [boardId, keyword, tag, author, titleOnly, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
}, [loading, posts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFeedReset = () => beginFeedRefresh();
|
||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
@@ -258,14 +346,39 @@ export default function HomePage() {
|
||||
}, [beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
|
||||
const fn = () => {
|
||||
beginFeedRefresh();
|
||||
setPosts([]);
|
||||
setPostTotal(0);
|
||||
setPage(1);
|
||||
pageRef.current = 1;
|
||||
setLoading(true);
|
||||
loadFirst();
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
window.addEventListener(FEED_PULL_REFRESH_EVENT, fn);
|
||||
return () => {
|
||||
window.removeEventListener('posts-refresh', fn);
|
||||
window.removeEventListener(FEED_PULL_REFRESH_EVENT, fn);
|
||||
};
|
||||
}, [beginFeedRefresh, loadFirst]);
|
||||
|
||||
// 卸载时取消未执行的 scroll rAF
|
||||
useEffect(() => () => {
|
||||
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
|
||||
}, []);
|
||||
|
||||
const handleScrollTopChange = useCallback((top: number) => {
|
||||
scrollTopRef.current = top;
|
||||
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
|
||||
if (scrollRafRef.current) return;
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) {
|
||||
beginFeedRefresh();
|
||||
@@ -331,7 +444,7 @@ export default function HomePage() {
|
||||
onSelect={handleSelectPost}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollTopChange={handleScrollTopChange}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={keyword || tag || author}
|
||||
isSearchMode={!!(keyword || author)}
|
||||
|
||||
106
frontend/src/store/homeStore.ts
Normal file
106
frontend/src/store/homeStore.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
/** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */
|
||||
export const FEED_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 单条 Feed 筛选对应的缓存快照 */
|
||||
export type FeedCacheEntry = {
|
||||
posts: PostItem[];
|
||||
postTotal: number;
|
||||
page: number;
|
||||
scrollTop: number;
|
||||
/** 上次成功拉取 API 的时间戳(ms) */
|
||||
lastFetchTime: number;
|
||||
};
|
||||
|
||||
type FeedKeyParts = {
|
||||
boardId: number;
|
||||
keyword: string;
|
||||
sort: FeedSort;
|
||||
tag?: string;
|
||||
author?: string;
|
||||
titleOnly?: boolean;
|
||||
};
|
||||
|
||||
/** 与筛选条件一一对应的缓存键 */
|
||||
export function feedCacheKey(parts: FeedKeyParts): string {
|
||||
const {
|
||||
boardId,
|
||||
keyword,
|
||||
sort,
|
||||
tag = '',
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
} = parts;
|
||||
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
|
||||
}
|
||||
|
||||
type HomeStoreState = {
|
||||
/** 按筛选键存放多份列表,首页 / 板块 / 搜索互不覆盖 */
|
||||
feeds: Record<string, FeedCacheEntry>;
|
||||
getFeed: (key: string) => FeedCacheEntry | null;
|
||||
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
|
||||
/** 滚动时只更新 scrollTop,不改动列表数据 */
|
||||
patchScroll: (key: string, scrollTop: number) => void;
|
||||
isStale: (key: string, now?: number) => boolean;
|
||||
clearFeed: (key: string) => void;
|
||||
clearAll: () => void;
|
||||
};
|
||||
|
||||
export const useHomeStore = create<HomeStoreState>((set, get) => ({
|
||||
feeds: {},
|
||||
|
||||
getFeed: (key) => get().feeds[key] ?? null,
|
||||
|
||||
setFeed: (key, data) => {
|
||||
set((state) => ({
|
||||
feeds: {
|
||||
...state.feeds,
|
||||
[key]: {
|
||||
posts: data.posts,
|
||||
postTotal: data.postTotal,
|
||||
page: data.page,
|
||||
scrollTop: data.scrollTop,
|
||||
lastFetchTime: data.lastFetchTime ?? Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
patchScroll: (key, scrollTop) => {
|
||||
const prev = get().feeds[key];
|
||||
if (!prev) return;
|
||||
// 数值未变则跳过,减少无意义的 store 更新
|
||||
if (prev.scrollTop === scrollTop) return;
|
||||
set((state) => ({
|
||||
feeds: {
|
||||
...state.feeds,
|
||||
[key]: { ...prev, scrollTop },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
isStale: (key, now = Date.now()) => {
|
||||
const entry = get().feeds[key];
|
||||
if (!entry) return true;
|
||||
return now - entry.lastFetchTime > FEED_STALE_MS;
|
||||
},
|
||||
|
||||
clearFeed: (key) => {
|
||||
set((state) => {
|
||||
if (!(key in state.feeds)) return state;
|
||||
const next = { ...state.feeds };
|
||||
delete next[key];
|
||||
return { feeds: next };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => set({ feeds: {} }),
|
||||
}));
|
||||
|
||||
/** 非 React 路径(如 feedCache 封装)直接读写 */
|
||||
export function getHomeStoreState() {
|
||||
return useHomeStore.getState();
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
import {
|
||||
feedCacheKey,
|
||||
getHomeStoreState,
|
||||
type FeedCacheEntry,
|
||||
} from '../store/homeStore';
|
||||
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
/** 对外暴露的缓存形状(兼容旧调用方;不含 lastFetchTime) */
|
||||
export type FeedCache = {
|
||||
posts: PostItem[];
|
||||
postTotal: number;
|
||||
@@ -12,18 +18,13 @@ export type FeedCache = {
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||
const store = new Map<string, FeedCache>();
|
||||
|
||||
function cacheKey(
|
||||
boardId: number,
|
||||
keyword: string,
|
||||
sort: FeedSort,
|
||||
tag = '',
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
) {
|
||||
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
|
||||
function toPublic(entry: FeedCacheEntry): FeedCache {
|
||||
return {
|
||||
posts: entry.posts,
|
||||
postTotal: entry.postTotal,
|
||||
page: entry.page,
|
||||
scrollTop: entry.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||
@@ -35,10 +36,12 @@ export function getFeedCache(
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
): FeedCache | null {
|
||||
return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
|
||||
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
||||
const entry = getHomeStoreState().getFeed(key);
|
||||
return entry ? toPublic(entry) : null;
|
||||
}
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
/** 保存帖子列表缓存(写入 Zustand,并记录拉取时间) */
|
||||
export function setFeedCache(
|
||||
boardId: number,
|
||||
keyword: string,
|
||||
@@ -48,20 +51,44 @@ export function setFeedCache(
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
) {
|
||||
store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
|
||||
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
||||
getHomeStoreState().setFeed(key, data);
|
||||
}
|
||||
|
||||
/** 清除所有帖子列表缓存 */
|
||||
export function clearAllFeedCache() {
|
||||
store.clear();
|
||||
getHomeStoreState().clearAll();
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
export const FEED_RESET_EVENT = 'feed-reset';
|
||||
|
||||
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
|
||||
/** 手机下拉刷新(Feed 页):强制重拉列表并重置滚动,不整页 reload */
|
||||
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
|
||||
|
||||
/** 当前浏览器地址是否已是目标 Feed URL(忽略 hash) */
|
||||
function isSameFeedUrl(url: string): boolean {
|
||||
try {
|
||||
const target = new URL(url, window.location.origin);
|
||||
return (
|
||||
window.location.pathname === target.pathname
|
||||
&& window.location.search === target.search
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存并导航到帖子列表。
|
||||
* 已在目标 URL(如首页再点 Logo)时额外派发强制重拉,不依赖 RR 是否换 key。
|
||||
*/
|
||||
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));
|
||||
}
|
||||
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user