限制首页 Feed 自动加载上限,超出后支持手动加载更多历史帖子。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -105,6 +105,8 @@ export interface ForumLimits {
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
page_size_max: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
}
|
||||
@@ -116,6 +118,9 @@ export interface ForumLimitsPublic {
|
||||
comment_max: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
@@ -10,6 +11,9 @@ interface Props {
|
||||
sort?: FeedSort;
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
/** 是否允许滚动触底自动加载(达到上限后为 false) */
|
||||
canAutoLoad: boolean;
|
||||
postTotal: number;
|
||||
onLoadMore: () => void;
|
||||
onSelect: (id: number) => void;
|
||||
/** 返回列表时恢复的滚动位置 */
|
||||
@@ -25,6 +29,8 @@ export default function VirtualPostList({
|
||||
sort = 'latest',
|
||||
loading,
|
||||
hasMore,
|
||||
canAutoLoad,
|
||||
postTotal,
|
||||
onLoadMore,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
@@ -42,6 +48,9 @@ export default function VirtualPostList({
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (resetScrollKey <= 0) return;
|
||||
const el = parentRef.current;
|
||||
@@ -69,13 +78,13 @@ export default function VirtualPostList({
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
onScrollTopChange?.(el.scrollTop);
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
@@ -99,12 +108,22 @@ export default function VirtualPostList({
|
||||
})}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="feed-list-footer">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasMore && posts.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 6, fontSize: 12, color: 'var(--color-text-3)' }}>— 已加载全部 —</div>
|
||||
{showHistoryPrompt && (
|
||||
<div className="feed-list-footer feed-list-footer--history">
|
||||
<p className="feed-list-footer__hint">
|
||||
已显示 {posts.length} / {postTotal} 条
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
|
||||
加载更多历史
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,9 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
comment_max: 5000,
|
||||
search_keyword_min: 1,
|
||||
search_keyword_max: 50,
|
||||
page_size_default: 30,
|
||||
feed_max_pages: 10,
|
||||
feed_max_items: 300,
|
||||
password_min_len: 6,
|
||||
avatar_max_mb: 2,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
@@ -7,6 +7,7 @@ import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import {
|
||||
getFeedCache,
|
||||
setFeedCache,
|
||||
@@ -21,6 +22,11 @@ export default function HomePage() {
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default;
|
||||
const feedMaxPages = limits.feed_max_pages;
|
||||
const feedMaxItems = limits.feed_max_items;
|
||||
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
@@ -38,6 +44,11 @@ export default function HomePage() {
|
||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
|
||||
const canAutoLoad = useMemo(
|
||||
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
|
||||
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
|
||||
);
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
@@ -56,7 +67,7 @@ export default function HomePage() {
|
||||
try {
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: 30,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
@@ -72,36 +83,32 @@ export default function HomePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword, sort]);
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
|
||||
/** 有缓存时静默拉取已加载页,更新置顶等状态同时保留滚动位置 */
|
||||
const revalidate = useCallback(async (maxPage: number) => {
|
||||
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
|
||||
const revalidate = useCallback(async () => {
|
||||
try {
|
||||
const all: PostItem[] = [];
|
||||
let total = 0;
|
||||
let hasMore = true;
|
||||
for (let p = 1; p <= maxPage; p++) {
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: 30,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
all.push(...batch);
|
||||
total = data.total ?? 0;
|
||||
hasMore = !!data.has_more;
|
||||
if (!data.has_more) break;
|
||||
}
|
||||
setPosts(all);
|
||||
setPostTotal(total);
|
||||
setHasMore(hasMore);
|
||||
setPage(maxPage);
|
||||
const data = await api.posts({
|
||||
page: 1,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const fresh = Array.isArray(data.posts) ? data.posts : [];
|
||||
const freshIds = new Set(fresh.map(p => p.id));
|
||||
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
} catch {
|
||||
// 静默失败,保留缓存数据
|
||||
}
|
||||
}, [boardId, keyword, sort]);
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
load(page + 1);
|
||||
}, [loading, hasMore, page, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
@@ -119,7 +126,7 @@ export default function HomePage() {
|
||||
setRestoreScrollTop(cached.scrollTop);
|
||||
scrollTopRef.current = cached.scrollTop;
|
||||
setLoading(false);
|
||||
revalidate(cached.page);
|
||||
revalidate();
|
||||
return;
|
||||
}
|
||||
setRestoreScrollTop(null);
|
||||
@@ -193,7 +200,9 @@ export default function HomePage() {
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => !loading && hasMore && load(page + 1)}
|
||||
canAutoLoad={canAutoLoad}
|
||||
postTotal={postTotal}
|
||||
onLoadMore={loadNextPage}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
|
||||
@@ -68,6 +68,8 @@ const SETTING_SECTIONS: SettingSection[] = [
|
||||
{ key: 'search_keyword_max', label: '关键词最长', unit: '字', min: 1 },
|
||||
{ key: 'page_size_default', label: '默认每页', unit: '条', min: 1 },
|
||||
{ key: 'page_size_max', label: '最大每页', unit: '条', min: 1 },
|
||||
{ key: 'feed_max_pages', label: '首页自动加载页数', unit: '页', hint: '超出后需手动加载', min: 1 },
|
||||
{ key: 'feed_max_items', label: '首页自动加载条数', unit: '条', hint: '与页数取先到上限', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -553,6 +553,31 @@ a:hover { text-decoration: underline; }
|
||||
.post-list-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feed-list-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.feed-list-footer--end {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.feed-list-footer--history {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feed-list-footer__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.page-inner { padding: 20px 24px; max-width: 720px; }
|
||||
.page-inner-wide { padding: 20px 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||
|
||||
Reference in New Issue
Block a user