新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。 Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
7.0 KiB
TypeScript
217 lines
7.0 KiB
TypeScript
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';
|
|
import type { PostItem } from '../api/types';
|
|
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,
|
|
clearAllFeedCache,
|
|
navigateFeed,
|
|
FEED_RESET_EVENT,
|
|
type FeedNavState,
|
|
} from '../utils/feedCache';
|
|
|
|
export default function HomePage() {
|
|
const nav = useNavigate();
|
|
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'));
|
|
const initialCache = getFeedCache(boardId, keyword, sort);
|
|
|
|
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
|
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
|
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
|
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
|
const [loading, setLoading] = useState(() => !initialCache);
|
|
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
|
const [listResetKey, setListResetKey] = useState(0);
|
|
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
|
const pageWrapRef = useRef<HTMLDivElement>(null);
|
|
/** 主动刷新时不把旧列表/滚动位置写回 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;
|
|
setListResetKey(k => k + 1);
|
|
pageWrapRef.current?.scrollTo(0);
|
|
}, []);
|
|
|
|
const beginFeedRefresh = useCallback(() => {
|
|
skipCacheSaveRef.current = true;
|
|
clearAllFeedCache();
|
|
resetFeedView();
|
|
}, [resetFeedView]);
|
|
|
|
const load = useCallback(async (p: number, reset = false) => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.posts({
|
|
page: p,
|
|
size: pageSize,
|
|
board_id: boardId || '',
|
|
keyword,
|
|
sort: sort === 'latest' ? '' : sort,
|
|
});
|
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
|
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
|
setPostTotal(data.total ?? 0);
|
|
setHasMore(!!data.has_more);
|
|
setPage(p);
|
|
} catch (e: unknown) {
|
|
notify.error(e instanceof Error ? e.message : '加载失败');
|
|
if (reset) setPosts([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [boardId, keyword, sort, pageSize]);
|
|
|
|
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
|
|
const revalidate = useCallback(async () => {
|
|
try {
|
|
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, pageSize]);
|
|
|
|
const loadNextPage = useCallback(() => {
|
|
if (loading || !hasMore) return;
|
|
load(page + 1);
|
|
}, [loading, hasMore, page, load]);
|
|
|
|
useEffect(() => {
|
|
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
|
if (forceRefresh) {
|
|
beginFeedRefresh();
|
|
load(1, true);
|
|
return;
|
|
}
|
|
const cached = getFeedCache(boardId, keyword, sort);
|
|
if (cached) {
|
|
setPosts(cached.posts);
|
|
setPostTotal(cached.postTotal);
|
|
setPage(cached.page);
|
|
setHasMore(cached.hasMore);
|
|
setRestoreScrollTop(cached.scrollTop);
|
|
scrollTopRef.current = cached.scrollTop;
|
|
setLoading(false);
|
|
revalidate();
|
|
return;
|
|
}
|
|
setRestoreScrollTop(null);
|
|
scrollTopRef.current = 0;
|
|
load(1, true);
|
|
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (skipCacheSaveRef.current || posts.length === 0) return;
|
|
setFeedCache(boardId, keyword, sort, {
|
|
posts,
|
|
postTotal,
|
|
page,
|
|
hasMore,
|
|
scrollTop: scrollTopRef.current,
|
|
});
|
|
};
|
|
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
|
|
|
useEffect(() => {
|
|
if (!loading && posts.length > 0) {
|
|
skipCacheSaveRef.current = false;
|
|
}
|
|
}, [loading, posts.length]);
|
|
|
|
useEffect(() => {
|
|
const onFeedReset = () => {
|
|
beginFeedRefresh();
|
|
};
|
|
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
|
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
|
}, [beginFeedRefresh]);
|
|
|
|
useEffect(() => {
|
|
const fn = () => {
|
|
beginFeedRefresh();
|
|
load(1, true);
|
|
};
|
|
window.addEventListener('posts-refresh', fn);
|
|
return () => window.removeEventListener('posts-refresh', fn);
|
|
}, [beginFeedRefresh, load]);
|
|
|
|
const handleSortChange = (next: FeedSort) => {
|
|
if (next === sort) {
|
|
beginFeedRefresh();
|
|
load(1, true);
|
|
return;
|
|
}
|
|
navigateFeed(nav, buildHomeUrl(boardId, next));
|
|
};
|
|
|
|
const showSortBar = !keyword;
|
|
|
|
return (
|
|
<div className="page-wrap" ref={pageWrapRef}>
|
|
<div className="feed-panel">
|
|
<div className="feed-top">
|
|
<FeedHeader
|
|
boardId={boardId}
|
|
keyword={keyword}
|
|
boards={ctx?.boards ?? []}
|
|
stats={ctx?.stats ?? null}
|
|
postTotal={postTotal}
|
|
/>
|
|
{showSortBar && (
|
|
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
|
)}
|
|
</div>
|
|
<VirtualPostList
|
|
posts={posts}
|
|
sort={sort}
|
|
loading={loading}
|
|
hasMore={hasMore}
|
|
canAutoLoad={canAutoLoad}
|
|
postTotal={postTotal}
|
|
onLoadMore={loadNextPage}
|
|
onSelect={(id) => nav(`/post/${id}`)}
|
|
restoreScrollTop={restoreScrollTop}
|
|
resetScrollKey={listResetKey}
|
|
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
|
onScrollRestored={() => setRestoreScrollTop(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|