import { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Inbox, SearchX } from 'lucide-react'; import { Button } from '@/components/ui/button'; import PostListItem from './PostListItem'; import { feedListRowEstimate } from './PostListSkeleton'; import FeedPagination from './FeedPagination'; import { InFlowSiteFooter } from './SiteFooter'; import { useAuth } from '../hooks/useAuth'; import { useForumLimits } from '../hooks/useForumLimits'; import { useMediaQuery } from '../hooks/useTheme'; import { loginPath } from '../utils/authRedirect'; import { dispatchOpenPostSearch } from '../hooks/usePostSearch'; import type { PostItem } from '../api/types'; import type { FeedSort } from './FeedSortBar'; import { getDefaultFeedSortFromCache } from './FeedSortBar'; interface Props { posts: PostItem[]; sort?: FeedSort; loading: boolean; /** 当前页之后是否还有更多 */ hasMore: boolean; /** 是否显示底部分页控件 */ showPagination: boolean; page: number; totalPages: number; postTotal: number; onPageChange: (page: number) => void; onSelect: (id: number) => void; restoreScrollTop?: number | null; resetScrollKey?: number; onScrollTopChange?: (top: number) => void; onScrollRestored?: () => void; /** 搜索关键词(用于空态文案) */ keyword?: string; /** 是否为帖子搜索(区别于标签筛选) */ isSearchMode?: boolean; searchKeyword?: string; searchAuthor?: string; searchTitleOnly?: boolean; searchScopeBoardId?: number; onClearSearch?: () => void; /** 当前板块 id,0 表示全部 */ boardId?: number; /** 当前板块名 */ boardName?: string; /** 站点是否尚无任何板块(全新安装) */ noBoards?: boolean; } /** 手机 Feed 整栏滚动时的容器 */ function getMobileFeedScrollEl(): HTMLElement | null { return document.querySelector('.main-content--feed-mobile-scroll'); } export default function VirtualPostList({ posts, sort = getDefaultFeedSortFromCache(), loading, hasMore, showPagination, page, totalPages, postTotal, onPageChange, onSelect, restoreScrollTop, resetScrollKey = 0, onScrollTopChange, onScrollRestored, keyword = '', isSearchMode = false, searchKeyword = '', searchAuthor = '', searchTitleOnly = false, searchScopeBoardId = 0, onClearSearch, boardId = 0, boardName = '', noBoards = false, }: Props) { const nav = useNavigate(); const { user } = useAuth(); const { limits } = useForumLimits(); const isMobile = useMediaQuery('(max-width: 768px)'); const feedStyle = limits.feed_list_style ?? 'title'; const rowEstimate = feedListRowEstimate(feedStyle); const parentRef = useRef(null); const restoredRef = useRef(false); const onScrollTopChangeRef = useRef(onScrollTopChange); const onScrollRestoredRef = useRef(onScrollRestored); onScrollTopChangeRef.current = onScrollTopChange; onScrollRestoredRef.current = onScrollRestored; const [scrollMargin, setScrollMargin] = useState(0); const getScrollElement = useCallback(() => { if (isMobile) { return getMobileFeedScrollEl() ?? parentRef.current; } return parentRef.current; }, [isMobile]); /** 列表相对整栏滚动容器顶部的偏移,供虚拟列表对齐 */ useLayoutEffect(() => { if (!isMobile) { setScrollMargin(0); return; } const main = getMobileFeedScrollEl(); const list = parentRef.current; if (!main || !list) return; const update = () => { // 相对 main 内容顶(含当前 scrollTop),避免 offsetParent 链不准 const next = Math.max( 0, Math.round(list.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop), ); setScrollMargin(prev => (prev === next ? prev : next)); }; update(); const ro = new ResizeObserver(update); ro.observe(main); const boardBar = main.querySelector('.mobile-board-bar'); const feedTop = main.querySelector('.feed-top'); if (boardBar) ro.observe(boardBar); if (feedTop) ro.observe(feedTop); return () => ro.disconnect(); }, [isMobile, posts.length, loading, keyword, boardId]); const virtualizer = useVirtualizer({ count: posts.length, getScrollElement, estimateSize: () => rowEstimate, overscan: 8, scrollMargin: isMobile ? scrollMargin : 0, measureElement: typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox') ? (el) => el.getBoundingClientRect().height : undefined, }); const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading; const isInitialLoad = loading && posts.length === 0; const isEmpty = !loading && posts.length === 0; const isSearchEmpty = isEmpty && (isSearchMode || !!keyword.trim()); const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose'; const isAdmin = user?.role === 'admin'; useLayoutEffect(() => { if (resetScrollKey <= 0) return; const el = getScrollElement(); if (el) { el.scrollTop = 0; virtualizer.scrollToOffset(0); } restoredRef.current = true; 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(); if (el) { el.scrollTop = restoreScrollTop; } else { virtualizer.scrollToOffset(restoreScrollTop); } restoredRef.current = true; onScrollRestoredRef.current?.(); }, [restoreScrollTop, posts.length, virtualizer, getScrollElement]); useEffect(() => { const el = getScrollElement(); if (!el) return; const onScroll = () => { onScrollTopChangeRef.current?.(el.scrollTop); }; el.addEventListener('scroll', onScroll, { passive: true }); return () => el.removeEventListener('scroll', onScroll); }, [getScrollElement, isMobile]); const searchSummaryParts: string[] = []; if (searchKeyword.trim()) searchSummaryParts.push(`关键词「${searchKeyword.trim()}」`); if (searchAuthor.trim()) searchSummaryParts.push(`作者 ${searchAuthor.trim()}`); if (searchTitleOnly && searchKeyword.trim()) searchSummaryParts.push('仅标题'); if (searchScopeBoardId > 0 && boardName) searchSummaryParts.push(`板块 ${boardName}`); const emptyActions = (
{noBoards ? ( isAdmin ? ( ) : ( ) ) : isSearchEmpty ? ( <> ) : ( <> {boardId > 0 && ( )} {user ? ( ) : ( )} )}
); return (
{isInitialLoad ? null : isEmpty ? (
{isSearchEmpty ? : }

{noBoards ? '暂无板块' : isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}

{noBoards ? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论') : isSearchEmpty ? (searchSummaryParts.length > 0 ? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。` : '试试更短的关键词,或浏览标签云 / 板块') : boardName ? `「${boardName}」还没有内容,来发第一篇吧` : '换个板块看看,或发第一篇内容'}

{emptyActions}
) : ( <>
{virtualizer.getVirtualItems().map(vi => { const post = posts[vi.index]; if (!post) return null; // scrollMargin 模式下 start 含偏移,需减回才能在列表内绝对定位 const offsetY = isMobile ? vi.start - scrollMargin : vi.start; return (
); })}
{showPagination && (
)} {showEnd && (
— 已加载全部 —
)} )}
); }