import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } 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'; import type { LayoutCtx } from '../layouts/MainLayout'; import VirtualPostList from '../components/VirtualPostList'; import StaticFeedList from '../components/StaticFeedList'; import FeedHeader from '../components/FeedHeader'; import FeedSearchFilters from '../components/search/FeedSearchFilters'; import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar'; import { useForumLimits } from '../hooks/useForumLimits'; import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch'; import { navigateFeed, FEED_RESET_EVENT, FEED_PULL_REFRESH_EVENT, type FeedNavState, } from '../utils/feedCache'; import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; import { feedCacheKey, getHomeStoreState } from '../store/homeStore'; import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs'; import { openForumPost } from '../utils/openPost'; import { startTransition, doneTransition } from '../utils/spaTransition'; import { isHomeHydrating } from '../utils/homeHydrate'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding'; import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink'; import NotFoundPage from './NotFoundPage'; /** 仅从 URL 解析板块 ID(不以 layout 状态回退,避免回首页误用上一板块) */ function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearchParams): number { if (routeId) { const id = parsePermalinkID(routeId); return Number.isFinite(id) && id > 0 ? id : 0; } const q = Number(searchParams.get('board')) || 0; 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(); const { branding } = useSiteBranding(); const { limits, loading: limitsLoading } = useForumLimits(); const postSearch = usePostSearch(limits); const pageSize = Math.max(1, limits.page_size_default); const boardId = boardIdFromLocation(boardRouteId, params); const queryBoardId = Number(params.get('board')) || 0; const isBoardRoute = !!boardRouteId; const isInvalidBoardRoute = isBoardRoute && boardId === 0; const boardsLoading = ctx?.boardsLoading ?? true; const isMissingBoard = isBoardRoute && boardId > 0 && !boardsLoading && !(ctx?.boards ?? []).some(b => b.id === boardId); const keyword = params.get('keyword') || ''; const tag = params.get('tag') || ''; const author = params.get('author') || ''; const titleOnly = params.get('title_only') === '1'; const rawSort = params.get('sort'); const sort = parseFeedSort(rawSort, limits.feed_sort_tabs); const board = (ctx?.boards ?? []).find(b => b.id === boardId); const isSiteHome = !boardId && !keyword && !tag && !author; const siteIntro = siteMetaDescription(branding); const feedTitle = tag ? `标签:${tag}` : keyword || author ? '搜索结果' : (boardId && board ? board.name : ''); usePageSEO({ title: feedTitle || undefined, description: board?.description?.trim() || siteIntro, keywords: joinSEOKeywords(board?.name, tag, branding.keywords), canonicalPath: tag ? `/?tag=${encodeURIComponent(tag)}` : boardId ? boardPath(boardId, limits) : '/', ogType: 'website', }); // 旧版 /?board=id 重定向到规范板块路径 useEffect(() => { if (queryBoardId && !boardRouteId) { const p = new URLSearchParams(params); p.delete('board'); const qs = p.toString(); const target = boardPath(queryBoardId, limits) + (qs ? `?${qs}` : ''); nav(target, { replace: true }); return; } if (boardRouteId && boardId > 0) { const redirect = canonicalRedirectPath('board', boardId, location.pathname, limits); if (redirect) { const qs = location.search; nav(redirect + qs, { replace: true }); } } }, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]); // URL 上的 sort 已被后台关闭时,纠正为默认排序 useEffect(() => { if (limitsLoading) return; if (rawSort !== 'latest' && rawSort !== 'hot' && rawSort !== 'reply') return; const stillOn = enabledFeedSortTabs(limits.feed_sort_tabs).some(t => t.id === rawSort); if (stillOn) return; const def = getDefaultFeedSort(limits.feed_sort_tabs); nav(buildHomeUrl(boardId, def, { keyword, tag, author, titleOnly, permalink: limits }), { replace: true }); }, [ limitsLoading, limits.feed_sort_tabs, limits, rawSort, boardId, keyword, tag, author, titleOnly, nav, ]); 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(initial.posts); const [postTotal, setPostTotal] = useState(initial.postTotal); const [page, setPage] = useState(initial.page); const [loading, setLoading] = useState(initial.loading); /** 画面上已提交的筛选(URL 已变但数据未到时仍画上一份) */ const [view, setView] = useState({ cacheKey, sort, boardId, keyword, tag, author, titleOnly, }); const [listPending, setListPending] = useState(false); const [restoreScrollTop, setRestoreScrollTop] = useState( initial.posts.length > 0 ? initial.scrollTop : null, ); const [listResetKey, setListResetKey] = useState(0); /** hydrate 首帧用静态列表;完成后升级 VirtualPostList */ const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating()); useEffect(() => { if (useVirtualList) return; reactStartTransition(() => setUseVirtualList(true)); }, [useVirtualList]); const scrollTopRef = useRef(initial.scrollTop); const fetchSeqRef = useRef(0); const pageRef = useRef(initial.page); const cacheKeyRef = useRef(cacheKey); const viewKeyRef = useRef(view.cacheKey); const postsRef = useRef(posts); const scrollRafRef = useRef(0); /** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */ const hydratedKeyRef = useRef(null); /** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */ const refreshFetchKeyRef = useRef(null); pageRef.current = page; cacheKeyRef.current = cacheKey; viewKeyRef.current = view.cacheKey; postsRef.current = posts; const commitDisplayed = useCallback(( next: FeedHydrate, meta: { cacheKey: string; sort: FeedSort; boardId: number; keyword: string; tag: string; author: string; titleOnly: boolean }, ) => { 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); setListPending(false); setView(meta); }, []); // 筛选键切换:有快照则立刻换页;否则保留当前画面等请求结束 useEffect(() => { if ((location.state as FeedNavState | null)?.refreshFeed && navType !== 'POP') { hydratedKeyRef.current = null; return; } const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly); if (next.posts.length > 0) { hydratedKeyRef.current = cacheKey; commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly }); return; } hydratedKeyRef.current = null; if (postsRef.current.length > 0) { setListPending(true); setLoading(false); return; } commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly }); }, [cacheKey, boardId, keyword, sort, tag, author, titleOnly, commitDisplayed, location.state, navType]); const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize)); const showPagination = totalPages > 1 && posts.length > 0; const hasMore = page < totalPages; const resetFeedView = useCallback(() => { setRestoreScrollTop(null); scrollTopRef.current = 0; setListResetKey(k => k + 1); }, []); /** 同筛选强制刷新:只复位滚动,保留旧列表直到 loadFirst 覆盖 */ const beginFeedRefresh = useCallback(() => { resetFeedView(); }, [resetFeedView]); /** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */ const persistFeed = useCallback(( nextPosts: PostItem[], nextTotal: number, nextPage: number, opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string }, ) => { const key = opts?.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; resetScroll?: boolean }) => { const seq = ++fetchSeqRef.current; const requestKey = cacheKeyRef.current; const silent = !!opts?.silent; const keepView = postsRef.current.length > 0; // 有旧列表时不卸页,只标 pending if (!silent && !keepView) setLoading(true); if (!silent && keepView) setListPending(true); try { const data = await api.posts({ page: p, size: pageSize, board_id: boardId || '', keyword: tag ? '' : keyword, tag: tag || '', author: tag ? '' : author, title_only: !tag && titleOnly ? '1' : '', sort, }); if (seq !== fetchSeqRef.current) return; const batch = Array.isArray(data.posts) ? data.posts : []; const total = data.total ?? 0; const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current); const scrollTop = jumpTop ? 0 : scrollTopRef.current; if (jumpTop) { setRestoreScrollTop(null); scrollTopRef.current = 0; setListResetKey(k => k + 1); } setPosts(batch); setPostTotal(total); setPage(p); pageRef.current = p; setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly }); persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey }); } catch (e: unknown) { if (seq !== fetchSeqRef.current) return; if (!silent) { notify.error(e instanceof Error ? e.message : '加载失败'); if (!keepView) { setPosts([]); setPostTotal(0); setPage(1); pageRef.current = 1; } } } finally { if (seq === fetchSeqRef.current) { setLoading(false); setListPending(false); } } }, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]); 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)); if (p < 1 || p > maxPage) return; if (p === pageRef.current) return; void fetchPage(p, { resetScroll: true }); }, [fetchPage, postTotal, pageSize]); const handleSelectPost = useCallback((id: number) => { // 离开前再写一次滚动,确保详情页返回可还原 getHomeStoreState().patchScroll(viewKeyRef.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; // 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存 if (forceRefresh && navType !== 'POP') { fetchSeqRef.current += 1; hydratedKeyRef.current = cacheKey; refreshFetchKeyRef.current = cacheKey; // 排序等强制刷新:丢弃该键旧分页/滚动,置顶重拉第 1 页 getHomeStoreState().clearFeed(cacheKey); resetFeedView(); 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; } // POP 带回 refreshFeed 时也清掉,避免下次同条目再误触发 if (forceRefresh && navType === 'POP') { 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; hydratedKeyRef.current = cacheKey; if (needRestore) { commitDisplayed({ posts: cached.posts, postTotal: cached.postTotal, page: cached.page, scrollTop: cached.scrollTop, loading: false, }, { cacheKey, sort, boardId, keyword, tag, author, titleOnly }); } else { setPosts(cached.posts); setPostTotal(cached.postTotal); setPage(cached.page); pageRef.current = cached.page; setLoading(false); setListPending(false); setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly }); } return; } hydratedKeyRef.current = cacheKey; if (postsRef.current.length === 0) { setRestoreScrollTop(null); scrollTopRef.current = 0; } loadFirst(); }, [ limitsLoading, pageSize, cacheKey, location.key, location.state, location.pathname, location.search, location.hash, navType, nav, loadFirst, commitDisplayed, resetFeedView, isInvalidBoardRoute, isMissingBoard, sort, boardId, keyword, tag, author, titleOnly, ]); useEffect(() => { // Logo 等同 URL 强制刷新:仅复位滚动,不卸列表(数据由 transitionTo 预热) const onFeedReset = () => { fetchSeqRef.current += 1; resetFeedView(); }; window.addEventListener(FEED_RESET_EVENT, onFeedReset); return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset); }, [resetFeedView]); useEffect(() => { // 下拉 / posts-refresh / 软刷新 commit:有预热则一次覆盖;禁止先卸列表 const applyWarmOrReload = () => { fetchSeqRef.current += 1; const key = cacheKeyRef.current; const warm = getHomeStoreState().getFeed(key); if (warm && warm.posts.length > 0) { // 先写入新数据,再复位滚动(避免先 reset 造成空白闪一下) commitDisplayed({ posts: warm.posts, postTotal: warm.postTotal, page: warm.page, scrollTop: 0, loading: false, }, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly }); setListResetKey((k) => k + 1); return; } // 未命中:保留旧 posts,静默重拉 if (postsRef.current.length > 0) { setListPending(true); setLoading(false); void loadFirst(); return; } setLoading(true); void loadFirst(); }; window.addEventListener('posts-refresh', applyWarmOrReload); window.addEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload); window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload); return () => { window.removeEventListener('posts-refresh', applyWarmOrReload); window.removeEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload); window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload); }; }, [ commitDisplayed, loadFirst, sort, boardId, keyword, tag, author, titleOnly, ]); // 卸载时取消未执行的 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(viewKeyRef.current, scrollTopRef.current); }); }, []); 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({ resetScroll: true })).finally(() => doneTransition(tid)); return; } navigateFeed(nav, url, { refresh: true }); }; const showSortBar = !view.keyword && !view.tag && !view.author; const searchFilters = parseSearchFromUrl(location.pathname, params); const isSearchActive = !!(view.keyword || view.author); if (isInvalidBoardRoute || isMissingBoard) { return ( ); } // 冷启动由门闩 / ensureColdBootReady 挡住;有旧列表时软刷新绝不卸空 if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) { return null; } return (
{!useVirtualList ? ( (view.keyword || view.tag || view.author) ? (

{view.tag ? `#${view.tag}` : view.keyword ? `搜索:${view.keyword}` : `作者:${view.author}`}

) : null ) : ( )} {showSortBar && ( )}
{isSearchActive && ( )}
{!useVirtualList ? ( ) : ( setRestoreScrollTop(null)} keyword={view.keyword || view.tag || view.author} isSearchMode={!!(view.keyword || view.author)} searchKeyword={view.keyword} searchAuthor={view.author} searchTitleOnly={view.titleOnly} searchScopeBoardId={searchFilters.scopeBoardId} onClearSearch={postSearch.clearSearch} boardId={view.boardId} boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''} noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0} /> )}
); }