From 05c39f3a5ac164d9fed7d9bb2558e44d6011e68e Mon Sep 17 00:00:00 2001 From: thazjswe42700 <131556390+thazjswe42700@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:49:59 +0800 Subject: [PATCH 1/2] feat: infinite scroll and virtual list for the video listing The /list page paged through videos twenty at a time; browsing a large catalog meant repeated round trips through the pagination bar, and rendering a whole page of cards at once put every node in the DOM. Replace the pagination with cursor-style accumulation plus windowed rendering: - useInfiniteListing keeps one accumulating session per query. The cursor advances by requested count rather than rendered count, so server-side dedup and hidden rows cannot shift the offsets, and batches are merged by id because page boundaries move when videos are inserted or removed mid-scroll. - VirtualVideoGrid folds the flat list into rows and hands them to @tanstack/react-virtual's window virtualizer, so only the rows near the viewport stay mounted while the canvas keeps the scrollbar as tall as the full catalog. Row heights and column counts are measured from the real layout, so the existing responsive grid CSS still drives the design. - Loading more is derived from the rendered window instead of a separate sentinel node, which a virtualized list can recycle out from under itself. Loading, end-of-list and tail-error states live at the bottom of the list; a failed tail retries only that batch. - Back/forward restores both the loaded batches and the position. The progress is stored per history entry, and the position is captured while the list is still on screen: reading window.scrollY during unmount returns a value the browser already clamped against the next page's shorter document. Scrolling to the top is now instant on this page and in the back-to-top button: the virtualizer's measurement-driven scroll adjustments interrupt smooth animations and leave the page stranded partway. backend/cmd/seed-videos generates listable copies of an existing video for local load testing, alongside the other one-off cmd tools. Verified against a 600-video local catalog: the list loads to the end, keeps at most ~32 cards in the DOM, reports a document height matching the full catalog, and returns to the exact scroll position after visiting a video and going back. --- backend/cmd/seed-videos/main.go | 93 +++++++++ package-lock.json | 28 +++ package.json | 1 + src/components/BackToTop.tsx | 10 +- src/components/VirtualVideoGrid.tsx | 183 ++++++++++++++++++ src/lib/infiniteListing.ts | 207 ++++++++++++++++++++ src/lib/listingScrollRestore.ts | 151 +++++++++++++++ src/lib/useInfiniteListing.ts | 289 ++++++++++++++++++++++++++++ src/lib/useListingScrollRestore.ts | 181 +++++++++++++++++ src/lib/virtualGrid.ts | 69 +++++++ src/pages/ListingPage.tsx | 167 ++++++++++------ src/styles/layout.css | 16 ++ src/styles/video-card.css | 24 +++ tests/infiniteListing.test.ts | 249 ++++++++++++++++++++++++ tests/listSortOptions.test.ts | 29 ++- tests/listingInfiniteScroll.test.ts | 186 ++++++++++++++++++ tests/listingScrollRestore.test.ts | 211 ++++++++++++++++++++ tests/virtualGrid.test.ts | 87 +++++++++ 18 files changed, 2106 insertions(+), 75 deletions(-) create mode 100644 backend/cmd/seed-videos/main.go create mode 100644 src/components/VirtualVideoGrid.tsx create mode 100644 src/lib/infiniteListing.ts create mode 100644 src/lib/listingScrollRestore.ts create mode 100644 src/lib/useInfiniteListing.ts create mode 100644 src/lib/useListingScrollRestore.ts create mode 100644 src/lib/virtualGrid.ts create mode 100644 tests/infiniteListing.test.ts create mode 100644 tests/listingInfiniteScroll.test.ts create mode 100644 tests/listingScrollRestore.test.ts create mode 100644 tests/virtualGrid.test.ts diff --git a/backend/cmd/seed-videos/main.go b/backend/cmd/seed-videos/main.go new file mode 100644 index 00000000..a0139296 --- /dev/null +++ b/backend/cmd/seed-videos/main.go @@ -0,0 +1,93 @@ +// 本地开发用的一次性数据放大工具:以现有的某条视频为模板生成大量可列出的 +// 行,用来压测列表页的无限滚动 / 虚拟列表。只做 INSERT,不改动既有数据。 +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "time" + + _ "modernc.org/sqlite" +) + +func main() { + dbPath := flag.String("db", "./data/video-site.db", "sqlite path") + count := flag.Int("count", 0, "how many synthetic rows to insert") + prefix := flag.String("prefix", "seed", "id prefix for generated rows") + purge := flag.Bool("purge", false, "delete previously generated rows with this prefix") + flag.Parse() + + db, err := sql.Open("sqlite", *dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + like := *prefix + "-%" + + if *purge { + res, err := db.Exec(`DELETE FROM videos WHERE id LIKE ?`, like) + if err != nil { + log.Fatal(err) + } + removed, _ := res.RowsAffected() + fmt.Printf("purged %d generated rows\n", removed) + } + + if *count > 0 { + now := time.Now().UnixMilli() + // content_hash / sampled_sha256 置空 + file_name 唯一,避免被去重触发器 + // 判成同一条视频的副本而丢掉 is_canonical。 + res, err := db.Exec(` +WITH RECURSIVE seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < ? +), +template AS ( + SELECT * FROM videos WHERE id NOT LIKE ? ORDER BY created_at LIMIT 1 +) +INSERT INTO videos ( + id, drive_id, file_id, file_name, content_hash, sampled_sha256, + fingerprint_status, parent_id, dir_name, title, author, tags, + duration_seconds, size_bytes, ext, quality, thumbnail_url, + thumbnail_updated_at, thumbnail_status, preview_local, preview_updated_at, + preview_status, views, favorites, comments, likes, dislikes, hidden, + is_canonical, badges, description, published_at, created_at, updated_at +) +SELECT + ? || '-' || printf('%05d', seq.n), + template.drive_id, + -- 复用模板行的真实 file_id,生成的视频才真的能播放和出封面。 + template.file_id, + ? || '-' || printf('%05d', seq.n) || '.mp4', + '', '', 'pending', + template.parent_id, template.dir_name, + '压测视频 ' || printf('%05d', seq.n), + template.author, template.tags, + template.duration_seconds, + COALESCE(template.size_bytes, 0) + seq.n, + template.ext, template.quality, template.thumbnail_url, + template.thumbnail_updated_at, template.thumbnail_status, + template.preview_local, template.preview_updated_at, template.preview_status, + seq.n % 97, 0, 0, seq.n % 53, 0, 0, 1, + template.badges, template.description, + ? - seq.n * 60000, ?, ? +FROM seq, template`, + *count, like, *prefix, *prefix, now, now, now) + if err != nil { + log.Fatal(err) + } + inserted, _ := res.RowsAffected() + fmt.Printf("inserted %d rows\n", inserted) + } + + var total, listable int + if err := db.QueryRow(`SELECT COUNT(*) FROM videos`).Scan(&total); err != nil { + log.Fatal(err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM videos WHERE COALESCE(hidden,0)=0 AND is_canonical=1`).Scan(&listable); err != nil { + log.Fatal(err) + } + fmt.Printf("total rows: %d, listable rows: %d\n", total, listable) +} diff --git a/package-lock.json b/package-lock.json index c6ac30eb..6be12cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.7.1", "@noble/hashes": "^2.3.0", + "@tanstack/react-virtual": "^3.14.9", "@uiw/react-codemirror": "^4.25.11", "artplayer": "^5.4.0", "hls.js": "^1.6.16", @@ -914,6 +915,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", diff --git a/package.json b/package.json index 7a511899..f51c16ca 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.7.1", "@noble/hashes": "^2.3.0", + "@tanstack/react-virtual": "^3.14.9", "@uiw/react-codemirror": "^4.25.11", "artplayer": "^5.4.0", "hls.js": "^1.6.16", diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx index 65887809..d9452a73 100644 --- a/src/components/BackToTop.tsx +++ b/src/components/BackToTop.tsx @@ -5,6 +5,14 @@ type Props = { onVisibilityChange?: (visible: boolean) => void; }; +/** + * 虚拟列表会在滚动过程中按实测行高做位置补偿,平滑滚动动画会被这些补偿 + * 打断、停在半路,所以返回顶部直接落到 0。 + */ +function scrollToTop() { + window.scrollTo({ top: 0, behavior: "auto" }); +} + export function BackToTop({ onVisibilityChange }: Props) { const [visible, setVisible] = useState(false); @@ -26,7 +34,7 @@ export function BackToTop({ onVisibilityChange }: Props) { return ( + ); + })} + + ); +} + +export { HOME_FEED_TABS }; diff --git a/src/lib/infiniteFeedSource.ts b/src/lib/infiniteFeedSource.ts new file mode 100644 index 00000000..dd54dac9 --- /dev/null +++ b/src/lib/infiniteFeedSource.ts @@ -0,0 +1,114 @@ +import { fetchHomeVideos, fetchListing } from "@/data/videos"; +import { infiniteListingKey } from "@/lib/infiniteListing"; +import type { SortKey, VideoItem } from "@/types"; + +/** + * 无限滚动的数据来源。累积、去重、游标推进由 useInfiniteListing 统一负责, + * 各个 feed 只描述"第 N 批怎么取"。 + */ + +export type InfiniteFeedBatch = { items: VideoItem[]; total: number }; + +export type InfiniteFeedSource = { + /** 同一个 key 代表同一条累积会话,变了就重新开始。 */ + key: string; + /** 每批请求多少条。 */ + batchSize: number; + /** + * 服务端轮换 feed 既没有 total、也永远返回满批,只能靠"整批都是已看过的 + * 内容"判断转完了一圈。 + */ + stopOnDuplicateBatch?: boolean; + /** + * 是否可以用一次大请求把之前的进度补回来。轮换 feed 的游标在服务端且不 + * 幂等,补回来的不是原来那些视频,因此不支持。 + */ + supportsRestore?: boolean; + fetchBatch: ( + request: { offset: number; size: number }, + options: { signal: AbortSignal } + ) => Promise; +}; + +/** + * page/size 接口只能表达页边界上的区间,所以偏移量必须是批大小的整数倍; + * 恢复现场时的大请求也因此被约束成批大小的整数倍。 + */ +export function listingPageFromOffset( + offset: number, + size: number +): number | null { + if (!Number.isInteger(offset) || offset < 0) return null; + if (!Number.isInteger(size) || size <= 0) return null; + if (offset % size !== 0) return null; + return offset / size + 1; +} + +export type ListingFeedQuery = { + q: string; + tag: string; + sort: SortKey; + pageSize: number; +}; + +/** /api/list:真正的分页,有 total,可以恢复现场。 */ +export function listingFeedSource(query: ListingFeedQuery): InfiniteFeedSource { + return { + key: `listing:${infiniteListingKey(query)}`, + batchSize: query.pageSize, + supportsRestore: true, + fetchBatch: (request, options) => { + const page = listingPageFromOffset(request.offset, request.size); + if (page === null) { + return Promise.reject( + new Error( + `Listing offset ${request.offset} is not aligned to size ${request.size}` + ) + ); + } + return fetchListing( + page, + request.size, + { q: query.q, tag: query.tag, sort: query.sort }, + { signal: options.signal } + ); + }, + }; +} + +/** /api/home 单次上限,后端超过就直接 400。 */ +export const HOME_RECOMMENDATION_BATCH_SIZE = 12; + +/** + * /api/home:整库随机轮换,游标由服务端按会话维护,请求里带不了偏移量。 + * 每次拿一批新的随机视频接到列表尾部,转完一圈后开始重复,此时收尾。 + */ +export function homeRecommendationFeedSource(): InfiniteFeedSource { + return { + key: "home:recommend", + batchSize: HOME_RECOMMENDATION_BATCH_SIZE, + stopOnDuplicateBatch: true, + supportsRestore: false, + fetchBatch: async (request) => { + const items = await fetchHomeVideos( + Math.min(request.size, HOME_RECOMMENDATION_BATCH_SIZE) + ); + // 轮换 feed 不知道总量,交给"整批重复"和"返回不足一批"两个收尾条件。 + return { items, total: 0 }; + }, + }; +} + +/** + * 首页"最新视频"走 /api/list?sort=latest 而不是 /api/home/latest:后者单次 + * 上限 12 条且只在最新 96 条里绕圈,撑不起无限滚动。 + */ +export function homeLatestFeedSource(pageSize: number): InfiniteFeedSource { + const source = listingFeedSource({ + q: "", + tag: "", + sort: "latest", + pageSize, + }); + return { ...source, key: `home:latest:${pageSize}` }; +} diff --git a/src/lib/infiniteListing.ts b/src/lib/infiniteListing.ts index 6f542925..0ff17be1 100644 --- a/src/lib/infiniteListing.ts +++ b/src/lib/infiniteListing.ts @@ -59,6 +59,8 @@ export type InfiniteListingAction = items: VideoItem[]; total: number; receivedAt: number; + /** 服务端轮换 feed 没有终点,整批都是重复内容时就收尾。 */ + stopOnDuplicateBatch?: boolean; } | { type: "load-failure"; requestID: number; error: Error }; @@ -157,9 +159,11 @@ export function infiniteListingReducer( requestedCount, exhausted: isListingExhausted({ received: action.items.length, + added: items.length - state.items.length, batchSize: action.batchSize, requestedCount, total: action.total, + stopOnDuplicateBatch: action.stopOnDuplicateBatch, }), status: "ready", error: null, @@ -178,28 +182,29 @@ export function infiniteListingReducer( */ export function isListingExhausted(input: { received: number; + /** 去重后真正新增的条数。 */ + added: number; batchSize: number; requestedCount: number; total: number; + /** 轮换 feed 没有 total,也永远返回满批,只能靠"整批都重复"收尾。 */ + stopOnDuplicateBatch?: boolean; }): boolean { if (input.received < input.batchSize) return true; + if (input.stopOnDuplicateBatch && input.added === 0) return true; return input.total > 0 && input.requestedCount >= input.total; } -export type InfiniteListingRequest = { page: number; size: number }; +export type InfiniteListingRequest = { offset: number; size: number }; -/** - * 下一批的分页参数。偏移量必须是页大小的整数倍,否则 page/size 接口无法 - * 表达这个区间——恢复现场时的大请求也因此被约束成页大小的整数倍。 - */ +/** 下一批要取的区间;具体怎么翻页由各个 feed source 自己决定。 */ export function nextListingRequest( state: InfiniteListingState ): InfiniteListingRequest | null { if (state.exhausted) return null; - const pageSize = state.pageSize; - if (!Number.isInteger(pageSize) || pageSize <= 0) return null; - if (state.requestedCount % pageSize !== 0) return null; - return { page: state.requestedCount / pageSize + 1, size: pageSize }; + const size = state.pageSize; + if (!Number.isInteger(size) || size <= 0) return null; + return { offset: state.requestedCount, size }; } export function infiniteListingHasMore(state: InfiniteListingState): boolean { diff --git a/src/lib/listingSearchParams.ts b/src/lib/listingSearchParams.ts index 3dc29d01..c38a5438 100644 --- a/src/lib/listingSearchParams.ts +++ b/src/lib/listingSearchParams.ts @@ -72,6 +72,26 @@ export function withListingView( return next; } +export type HomeFeedKey = "recommend" | "latest"; + +/** 首页推荐/最新两个 tab 记在 URL 里,前进后退才能回到原来那个 tab。 */ +export function readHomeFeed(params: URLSearchParams): HomeFeedKey { + return params.get("feed") === "latest" ? "latest" : "recommend"; +} + +export function withHomeFeed( + params: URLSearchParams, + feed: HomeFeedKey +): URLSearchParams { + const next = new URLSearchParams(params); + if (feed === "latest") { + next.set("feed", "latest"); + } else { + next.delete("feed"); + } + return next; +} + export function withListingNavigation( params: URLSearchParams, patch: ListingNavigationPatch diff --git a/src/lib/useInfiniteListing.ts b/src/lib/useInfiniteListing.ts index 7f1603fb..667f09de 100644 --- a/src/lib/useInfiniteListing.ts +++ b/src/lib/useInfiniteListing.ts @@ -1,19 +1,18 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import { fetchListing } from "@/data/videos"; +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { emptyInfiniteListingState, infiniteListingHasMore, - infiniteListingKey, infiniteListingReducer, nextListingRequest, - type InfiniteListingQuery, type InfiniteListingState, } from "@/lib/infiniteListing"; +import type { InfiniteFeedSource } from "@/lib/infiniteFeedSource"; import type { VideoItem } from "@/types"; /** - * 无限滚动列表的数据层:只负责"按游标往后追加"和会话内的缓存, - * 渲染窗口交给 VirtualVideoGrid,滚动现场交给 useListingScrollRestore。 + * 无限滚动的数据层:按 feed source 描述的方式一批批往后取,负责累积、去重、 + * 中断过期请求和会话内缓存。渲染窗口交给 VirtualVideoGrid,滚动现场交给 + * useListingScrollRestore。 */ const INFINITE_LISTING_CACHE_TTL_MS = 60_000; @@ -50,128 +49,105 @@ function writeInfiniteListingCache(entry: CachedInfiniteListing) { } } -export function clearInfiniteListingCache() { - infiniteListingCache.clear(); +export function clearInfiniteListingCache(key?: string) { + if (key === undefined) { + infiniteListingCache.clear(); + return; + } + infiniteListingCache.delete(key); } function cacheIsFresh(entry: CachedInfiniteListing, now: number): boolean { return now - entry.receivedAt < INFINITE_LISTING_CACHE_TTL_MS; } -function normalizeQuery(query: InfiniteListingQuery): InfiniteListingQuery { - return { - q: query.q.trim(), - tag: query.tag.trim(), - sort: query.sort, - pageSize: - Number.isInteger(query.pageSize) && query.pageSize > 0 - ? query.pageSize - : 1, - }; -} - /** - * 恢复现场的首个请求要对齐页边界,否则后续 page/size 分页无法接着这个偏移量走。 + * 恢复现场的首个请求要对齐批边界,否则后续游标接不上(page/size 接口尤其 + * 如此)。不支持恢复的 feed 一律按普通首屏来。 */ -function initialBatchSize(restoreCount: number, pageSize: number): number { - if (!Number.isInteger(restoreCount) || restoreCount <= pageSize) { - return pageSize; +function initialBatchSize(restoreCount: number, batchSize: number): number { + if (!Number.isInteger(restoreCount) || restoreCount <= batchSize) { + return batchSize; } - return Math.ceil(restoreCount / pageSize) * pageSize; + return Math.ceil(restoreCount / batchSize) * batchSize; } function errorValue(error: unknown): Error { return error instanceof Error ? error : new Error("视频列表加载失败"); } -function hydratedState( - key: string, - pageSize: number, - cached: CachedInfiniteListing -): InfiniteListingState { - return { - key, - requestID: 0, - pageSize, - items: cached.items, - total: cached.total, - requestedCount: cached.requestedCount, - exhausted: cached.exhausted, - status: "ready", - error: null, - receivedAt: cached.receivedAt, - }; -} - function initialState( - key: string, - query: InfiniteListingQuery, + source: InfiniteFeedSource, enabled: boolean ): InfiniteListingState { - const base = emptyInfiniteListingState(key, query.pageSize); + const base = emptyInfiniteListingState(source.key, source.batchSize); if (!enabled) return base; - const cached = infiniteListingCache.get(key) ?? null; + const cached = infiniteListingCache.get(source.key) ?? null; if (cached && cacheIsFresh(cached, Date.now())) { - return hydratedState(key, query.pageSize, cached); + return { + ...base, + items: cached.items, + total: cached.total, + requestedCount: cached.requestedCount, + exhausted: cached.exhausted, + status: "ready", + receivedAt: cached.receivedAt, + }; } return { ...base, status: "initial-loading" }; } -export type UseInfiniteListingInput = InfiniteListingQuery & { +export type UseInfiniteListingOptions = { enabled?: boolean; /** 后退回列表时要一次补回的条目数,0 表示普通首屏。 */ restoreCount?: number; }; -export function useInfiniteListing(input: UseInfiniteListingInput) { - const enabled = input.enabled ?? true; - const query = useMemo( - () => normalizeQuery(input), - [input.q, input.tag, input.sort, input.pageSize] - ); - const key = infiniteListingKey(query); +export function useInfiniteListing( + source: InfiniteFeedSource, + options: UseInfiniteListingOptions = {} +) { + const enabled = options.enabled ?? true; + const key = source.key; + const batchSize = source.batchSize; const [state, dispatch] = useReducer(infiniteListingReducer, undefined, () => - initialState(key, query, enabled) + initialState(source, enabled) ); - const [retryVersion, setRetryVersion] = useState(0); + const [reloadVersion, setReloadVersion] = useState(0); const nextRequestIDRef = useRef(0); const controllerRef = useRef(null); const stateRef = useRef(state); - const queryRef = useRef(query); + const sourceRef = useRef(source); const enabledRef = useRef(enabled); - const restoreCountRef = useRef(input.restoreCount ?? 0); + const restoreCountRef = useRef(options.restoreCount ?? 0); stateRef.current = state; - queryRef.current = query; + sourceRef.current = source; enabledRef.current = enabled; - restoreCountRef.current = input.restoreCount ?? 0; + restoreCountRef.current = options.restoreCount ?? 0; const sendRequest = useCallback( ( requestID: number, - requestQuery: InfiniteListingQuery, - request: { page: number; size: number }, - offset: number + feed: InfiniteFeedSource, + request: { offset: number; size: number } ) => { const controller = new AbortController(); controllerRef.current = controller; dispatch({ type: "load-start", requestID }); - fetchListing( - request.page, - request.size, - { q: requestQuery.q, tag: requestQuery.tag, sort: requestQuery.sort }, - { signal: controller.signal } - ) + feed + .fetchBatch(request, { signal: controller.signal }) .then((result) => { if (controller.signal.aborted) return; dispatch({ type: "load-success", requestID, - offset, + offset: request.offset, batchSize: request.size, items: result.items ?? [], total: result.total ?? 0, receivedAt: Date.now(), + stopOnDuplicateBatch: feed.stopOnDuplicateBatch, }); }) .catch((error) => { @@ -198,7 +174,7 @@ export function useInfiniteListing(input: UseInfiniteListingInput) { type: "hydrate", requestID, key, - pageSize: query.pageSize, + pageSize: batchSize, items: cached.items, total: cached.total, requestedCount: cached.requestedCount, @@ -208,19 +184,20 @@ export function useInfiniteListing(input: UseInfiniteListingInput) { return; } - dispatch({ type: "reset", requestID, key, pageSize: query.pageSize }); - sendRequest( - requestID, - query, - { page: 1, size: initialBatchSize(restoreCountRef.current, query.pageSize) }, - 0 - ); + dispatch({ type: "reset", requestID, key, pageSize: batchSize }); + const restoreCount = sourceRef.current.supportsRestore + ? restoreCountRef.current + : 0; + sendRequest(requestID, sourceRef.current, { + offset: 0, + size: initialBatchSize(restoreCount, batchSize), + }); return () => { controllerRef.current?.abort(); controllerRef.current = null; }; - }, [enabled, key, query, retryVersion, sendRequest]); + }, [batchSize, enabled, key, reloadVersion, sendRequest]); // 会话内缓存以真实响应时间为准:hydrate 回来的状态不会自我续命。 useEffect(() => { @@ -237,40 +214,41 @@ export function useInfiniteListing(input: UseInfiniteListingInput) { }, [enabled, key, state]); const requestBatch = useCallback( - (options: { force?: boolean } = {}) => { + (batchOptions: { force?: boolean } = {}) => { if (!enabledRef.current) return; const current = stateRef.current; if (current.status === "initial-loading" || current.status === "loading-more") { return; } - if (!options.force && current.status === "error") return; + if (!batchOptions.force && current.status === "error") return; const request = nextListingRequest(current); if (!request) return; - sendRequest( - ++nextRequestIDRef.current, - queryRef.current, - request, - current.requestedCount - ); + sendRequest(++nextRequestIDRef.current, sourceRef.current, request); }, [sendRequest] ); const loadMore = useCallback(() => requestBatch(), [requestBatch]); + const reload = useCallback(() => { + clearInfiniteListingCache(sourceRef.current.key); + setReloadVersion((version) => version + 1); + }, []); + // 首屏失败要整段重来,尾部失败只重试失败的那一批,已加载内容保持不动。 const retry = useCallback(() => { if (stateRef.current.items.length === 0) { - setRetryVersion((version) => version + 1); + reload(); return; } requestBatch({ force: true }); - }, [requestBatch]); + }, [reload, requestBatch]); const matchesQuery = state.key === key; const items = matchesQuery ? state.items : []; const initialLoading = - enabled && (!matchesQuery || (state.status === "initial-loading" && items.length === 0)); + enabled && + (!matchesQuery || (state.status === "initial-loading" && items.length === 0)); return { items, @@ -284,6 +262,7 @@ export function useInfiniteListing(input: UseInfiniteListingInput) { hasMore: matchesQuery && infiniteListingHasMore(state), requestedCount: matchesQuery ? state.requestedCount : 0, loadMore, + reload, retry, }; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 8821da0f..5c97f969 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,40 +1,58 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { RefreshCw } from "lucide-react"; -import { useSearchParams } from "react-router"; +import { useLocation, useSearchParams } from "react-router"; import { AdminEmptyVisual } from "@/admin/AdminEmptyVisual"; import { AppShell } from "@/components/AppShell"; +import { HomeFeedTabs } from "@/components/HomeFeedTabs"; import { ListingLoadError } from "@/components/ListingLoadError"; import { Pagination } from "@/components/Pagination"; import { PromoStrip } from "@/components/PromoStrip"; import { SearchPanel } from "@/components/SearchPanel"; -import { SectionHeader } from "@/components/SectionHeader"; import { SortToolbar } from "@/components/SortToolbar"; import { TagCloud } from "@/components/TagCloud"; import { VideoGrid } from "@/components/VideoGrid"; -import { fetchHomeVideos, fetchLatestHomeVideos } from "@/data/videos"; import { + VirtualVideoGrid, + type VirtualGridRange, +} from "@/components/VirtualVideoGrid"; +import { + homeLatestFeedSource, + homeRecommendationFeedSource, +} from "@/lib/infiniteFeedSource"; +import { + readHomeFeed, readListingPage, readListingSort, readListingView, + withHomeFeed, withListingNavigation, withListingPage, withListingView, } from "@/lib/listingSearchParams"; import { MOBILE_VIDEO_PAGE_SIZE, useIsMobile } from "@/lib/responsive"; +import { useInfiniteListing } from "@/lib/useInfiniteListing"; import { useListingQuery } from "@/lib/useListingQuery"; -import type { VideoItem } from "@/types"; +import { + useListingRestoreTarget, + useListingScrollRestore, +} from "@/lib/useListingScrollRestore"; +import { shouldLoadMore } from "@/lib/virtualGrid"; -const DESKTOP_COUNT = 12; -const MOBILE_COUNT = 8; const HOME_SEARCH_DESKTOP_PAGE_SIZE = 20; +const HOME_FEED_DESKTOP_BATCH_SIZE = 20; -// 首页推荐接口每次请求都会推进会话轮换游标。模块级快照因此在 SPA 会话内 -// 保持稳定,只有用户主动刷新、浏览器整页刷新或响应式布局需要补足卡片时才更新。 -let cachedRanking: VideoItem[] | null = null; -let cachedLatest: VideoItem[] | null = null; +// 距列表尾部还有两行时就续下一批,滚动到底之前数据已经在路上。 +const PREFETCH_ROWS = 2; + +const EMPTY_RANGE: VirtualGridRange = { + startIndex: 0, + endIndex: 0, + columns: 1, +}; export default function HomePage() { const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); const activeSearchQuery = searchParams.get("q")?.trim() ?? ""; const activeTag = searchParams.get("tag")?.trim() ?? ""; const hasActiveSearch = activeSearchQuery.length > 0; @@ -43,8 +61,11 @@ export default function HomePage() { const searchPage = readListingPage(searchParams); const searchSort = readListingSort(searchParams); const searchView = readListingView(searchParams); + const feed = readHomeFeed(searchParams); const isMobile = useIsMobile(); - const displayCount = isMobile ? MOBILE_COUNT : DESKTOP_COUNT; + const eagerCount = isMobile ? 2 : 4; + + // 搜索和标签结果仍然分页:这类结果常常需要定位到具体某一页。 const searchPageSize = isMobile ? MOBILE_VIDEO_PAGE_SIZE : HOME_SEARCH_DESKTOP_PAGE_SIZE; @@ -68,82 +89,41 @@ export default function HomePage() { searchResult.phase === "error" && searchHasContent; const searchShowEmptyError = searchResult.phase === "error" && !searchHasContent; - const eagerCount = isMobile ? 2 : 4; - - const [rankingVideos, setRankingVideos] = useState(cachedRanking ?? []); - const [latestVideos, setLatestVideos] = useState(cachedLatest ?? []); - const [rankingLoading, setRankingLoading] = useState(cachedRanking === null); - const [rankingError, setRankingError] = useState(false); - const [rankingRevalidating, setRankingRevalidating] = useState(false); - const [latestLoading, setLatestLoading] = useState(cachedLatest === null); - const [latestError, setLatestError] = useState(false); - const [latestRevalidating, setLatestRevalidating] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const homeRequestVersion = useRef(0); - const rankingRequestVersion = useRef(0); - const latestRequestVersion = useRef(0); - const displayCountRef = useRef(displayCount); - const previousDisplayCountRef = useRef(displayCount); const previousSearchPageSizeRef = useRef(searchPageSize); const searchScrollOnCommitRef = useRef(false); - displayCountRef.current = displayCount; - const loadRanking = useCallback(async (background: boolean) => { - const requestVersion = ++rankingRequestVersion.current; - const hasCachedContent = cachedRanking !== null; - setRankingLoading(!hasCachedContent); - setRankingRevalidating(background && hasCachedContent); - setRankingError(false); - try { - const rankingItems = await fetchHomeVideos(DESKTOP_COUNT); - if (requestVersion !== rankingRequestVersion.current) return; - cachedRanking = rankingItems; - setRankingVideos(rankingItems); - setRankingError(false); - } catch { - if (requestVersion !== rankingRequestVersion.current) return; - setRankingError(true); - } finally { - if (requestVersion === rankingRequestVersion.current) { - setRankingLoading(false); - setRankingRevalidating(false); - } - } - }, []); - - const loadLatest = useCallback(async (background: boolean) => { - const requestVersion = ++latestRequestVersion.current; - const hasCachedContent = cachedLatest !== null; - setLatestLoading(!hasCachedContent); - setLatestRevalidating(background && hasCachedContent); - setLatestError(false); - try { - const latestItems = await fetchLatestHomeVideos(displayCountRef.current); - if (requestVersion !== latestRequestVersion.current) return; - cachedLatest = latestItems; - setLatestVideos(latestItems); - setLatestError(false); - } catch { - if (requestVersion !== latestRequestVersion.current) return; - setLatestError(true); - } finally { - if (requestVersion === latestRequestVersion.current) { - setLatestLoading(false); - setLatestRevalidating(false); - } - } - }, []); - - const refreshRanking = useCallback(() => loadRanking(true), [loadRanking]); - const refreshLatest = useCallback(() => loadLatest(true), [loadLatest]); + // 两个推荐 tab 都是无限滚动。随机推荐走服务端轮换 feed,最新视频走真正 + // 分页的列表接口。 + const feedBatchSize = isMobile + ? MOBILE_VIDEO_PAGE_SIZE + : HOME_FEED_DESKTOP_BATCH_SIZE; + const feedSource = useMemo( + () => + feed === "latest" + ? homeLatestFeedSource(feedBatchSize) + : homeRecommendationFeedSource(), + [feed, feedBatchSize] + ); + const restoreTarget = useListingRestoreTarget({ + historyKey: location.key, + queryKey: feedSource.key, + pageSize: feedSource.batchSize, + }); + const homeFeed = useInfiniteListing(feedSource, { + enabled: !hasActiveFilter, + restoreCount: restoreTarget.count, + }); + useListingScrollRestore({ + target: restoreTarget, + queryKey: feedSource.key, + requestedCount: hasActiveFilter ? 0 : homeFeed.requestedCount, + itemCount: homeFeed.items.length, + }); - const refreshHome = useCallback(async () => { - const requestVersion = ++homeRequestVersion.current; - setRefreshing(true); - await Promise.allSettled([loadRanking(false), loadLatest(false)]); - if (requestVersion !== homeRequestVersion.current) return; - setRefreshing(false); - }, [loadLatest, loadRanking]); + const feedItems = homeFeed.items; + const feedHasContent = feedItems.length > 0; + const [range, setRange] = useState(EMPTY_RANGE); + const previousFeedKeyRef = useRef(feedSource.key); useEffect(() => { document.title = activeSearchQuery @@ -153,52 +133,6 @@ export default function HomePage() { : "首页"; }, [activeSearchQuery, activeTag]); - useEffect(() => { - if (cachedRanking === null) { - void loadRanking(false); - } - - if (cachedLatest === null) { - void loadLatest(false); - } else if (cachedLatest.length < displayCountRef.current) { - void loadLatest(true); - } else { - setLatestVideos(cachedLatest); - setLatestLoading(false); - } - - return () => { - homeRequestVersion.current += 1; - rankingRequestVersion.current += 1; - latestRequestVersion.current += 1; - }; - }, [loadLatest, loadRanking]); - - useEffect(() => { - if (hasActiveFilter) return; - const previousCount = previousDisplayCountRef.current; - if (displayCount <= previousCount) { - previousDisplayCountRef.current = displayCount; - return; - } - if (latestVideos.length >= displayCount) { - previousDisplayCountRef.current = displayCount; - return; - } - if (refreshing || latestLoading || latestRevalidating) return; - - previousDisplayCountRef.current = displayCount; - void refreshLatest(); - }, [ - displayCount, - hasActiveFilter, - latestLoading, - latestRevalidating, - latestVideos.length, - refreshLatest, - refreshing, - ]); - useEffect(() => { if (previousSearchPageSizeRef.current === searchPageSize) return; previousSearchPageSizeRef.current = searchPageSize; @@ -217,17 +151,61 @@ export default function HomePage() { window.scrollTo({ top: 0, behavior: "smooth" }); }, [searchResult.key, searchSnapshot?.key]); - const ranking = rankingVideos.slice(0, displayCount); - const latest = latestVideos.slice(0, displayCount); - const homeLoading = rankingLoading || latestLoading; - const hasAnyVideos = ranking.length > 0 || latest.length > 0; - const hasHomeError = rankingError || latestError; - const showEmptyHome = !homeLoading && !hasHomeError && !hasAnyVideos; + // 换 tab 是一次全新的列表,回到顶部再开始累积。平滑滚动会被虚拟列表的 + // 行高补偿打断而停在半路,所以直接落到顶部。 + useEffect(() => { + if (previousFeedKeyRef.current === feedSource.key) return; + previousFeedKeyRef.current = feedSource.key; + window.scrollTo({ top: 0, behavior: "auto" }); + }, [feedSource.key]); + + const handleRangeChange = useCallback((next: VirtualGridRange) => { + setRange((current) => + current.startIndex === next.startIndex && + current.endIndex === next.endIndex && + current.columns === next.columns + ? current + : next + ); + }, []); + + const { loadMore, loadingMore, hasMore } = homeFeed; + useEffect(() => { + if (hasActiveFilter) return; + if ( + shouldLoadMore({ + endIndex: range.endIndex, + itemCount: feedItems.length, + columns: range.columns, + hasMore, + loading: loadingMore, + prefetchRows: PREFETCH_ROWS, + }) + ) { + loadMore(); + } + }, [ + feedItems.length, + hasActiveFilter, + hasMore, + loadMore, + loadingMore, + range, + ]); + + const reloadFeed = homeFeed.reload; + const refreshHome = useCallback(() => { + window.scrollTo({ top: 0, behavior: "auto" }); + reloadFeed(); + }, [reloadFeed]); + const displayedSearchSort = searchResult.phase === "error" && searchSnapshot ? searchSnapshot.query.sort : searchSort; const displayedSearchPage = searchSnapshot?.query.page ?? searchPage; + const showRefresh = !hasActiveFilter && feed === "recommend"; + const refreshing = showRefresh && homeFeed.initialLoading; return ( @@ -239,7 +217,7 @@ export default function HomePage() { placeholder="" className="search-panel--public search-panel--transparent" /> - {hasAnyVideos || hasActiveFilter ? ( + {feedHasContent || hasActiveFilter ? ( ) : ( - ) : showEmptyHome ? ( + ) : (
- { + setSearchParams(withHomeFeed(searchParams, nextFeed), { + replace: true, + }); + }} /> -
- ) : ( - <> -
- - {rankingError && ranking.length > 0 && ( - void refreshRanking()} - /> - )} - -
-
- - {latestError && latest.length > 0 && ( - void refreshLatest()} - /> - )} - + ) : homeFeed.failed && !feedHasContent ? ( + + ) : !feedHasContent ? ( + -
- + ) : ( + <> + + + {homeFeed.failed ? ( + + ) : homeFeed.loadingMore ? ( +
+
+ ) : homeFeed.exhausted ? ( +
+ 没有更多了 +
+ ) : null} + + )} + )} - {!hasActiveFilter && ( + {showRefresh && (