优化books/catalog页面

This commit is contained in:
mtvpls
2026-05-25 11:23:56 +08:00
parent 7f5cdcec06
commit 3ff40532d7
+147 -52
View File
@@ -1,8 +1,8 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import BookCard from '@/components/books/BookCard'; import BookCard from '@/components/books/BookCard';
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client'; import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
@@ -20,13 +20,9 @@ function CatalogSkeleton() {
<div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' /> <div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' />
))} ))}
</div> </div>
<div className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <div className='flex gap-2 overflow-x-auto pb-1'>
<div className='h-6 w-40 rounded bg-gray-200 dark:bg-gray-800' />
<div className='mt-3 h-4 w-72 rounded bg-gray-200 dark:bg-gray-800' />
</div>
<div className='flex gap-3 overflow-x-auto pb-2'>
{Array.from({ length: 5 }).map((_, index) => ( {Array.from({ length: 5 }).map((_, index) => (
<div key={index} className='h-20 min-w-[180px] rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div key={index} className='h-10 w-28 shrink-0 rounded-full bg-gray-200 dark:bg-gray-800' />
))} ))}
</div> </div>
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'> <div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
@@ -62,18 +58,25 @@ function isMeaningfulNavTitle(title?: string) {
} }
export default function BooksCatalogPage() { export default function BooksCatalogPage() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || ''; const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || ''; const href = searchParams.get('href') || '';
const [sources, setSources] = useState<BookSource[]>([]); const [sources, setSources] = useState<BookSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState(sourceId);
const [selectedHref, setSelectedHref] = useState(href);
const [data, setData] = useState<BookCatalogResult | null>(null); const [data, setData] = useState<BookCatalogResult | null>(null);
const [catalogNavigation, setCatalogNavigation] = useState<BookCatalogResult['navigation']>([]);
const [entries, setEntries] = useState<BookListItem[]>([]); const [entries, setEntries] = useState<BookListItem[]>([]);
const [nextHref, setNextHref] = useState<string | undefined>(undefined); const [nextHref, setNextHref] = useState<string | undefined>(undefined);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadingCatalog, setLoadingCatalog] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const loaderRef = useRef<HTMLDivElement | null>(null); const loaderRef = useRef<HTMLDivElement | null>(null);
const sourceScrollerRef = useRef<HTMLDivElement | null>(null); const sourceScrollerRef = useRef<HTMLDivElement | null>(null);
const activeSourceItemRef = useRef<HTMLAnchorElement | null>(null);
const navScrollerRef = useRef<HTMLDivElement | null>(null); const navScrollerRef = useRef<HTMLDivElement | null>(null);
const activeNavItemRef = useRef<HTMLAnchorElement | null>(null);
const loadedPageHrefsRef = useRef<Set<string>>(new Set()); const loadedPageHrefsRef = useRef<Set<string>>(new Set());
const failedPageHrefsRef = useRef<Set<string>>(new Set()); const failedPageHrefsRef = useRef<Set<string>>(new Set());
const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
@@ -81,10 +84,60 @@ export default function BooksCatalogPage() {
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
const suppressNavClickRef = useRef(false); const suppressNavClickRef = useRef(false);
const showImmediateContentLoading = useCallback(() => {
setError('');
setLoadingCatalog(true);
setEntries([]);
setNextHref(undefined);
}, []);
useEffect(() => { useEffect(() => {
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])); fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
}, []); }, []);
useEffect(() => {
setSelectedSourceId(sourceId);
setSelectedHref(href);
setCatalogNavigation([]);
}, [sourceId]);
useEffect(() => {
setSelectedHref(href);
}, [href]);
useEffect(() => {
if (!sourceId || !href) return;
let cancelled = false;
const loadRootNavigation = async () => {
try {
const params = new URLSearchParams({ sourceId });
const res = await fetch(`/api/books/catalog?${params.toString()}`);
const json = await res.json();
if (!res.ok) return;
if (!cancelled) setCatalogNavigation((json as BookCatalogResult).navigation || []);
} catch {
// 当前分类内容仍可正常展示,根目录分类加载失败时忽略。
}
};
void loadRootNavigation();
return () => {
cancelled = true;
};
}, [sourceId, href]);
useEffect(() => {
if (!sourceId || href || catalogNavigation.length === 0) return;
const firstNavigationItem = catalogNavigation.find((item) => {
const rel = (item.rel || '').toLowerCase();
return item.href && rel !== 'next' && rel !== 'previous' && isMeaningfulNavTitle(item.title);
});
if (!firstNavigationItem?.href) return;
setSelectedHref(firstNavigationItem.href);
router.replace(`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(firstNavigationItem.href)}`);
}, [catalogNavigation, href, router, sourceId]);
const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => { const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => {
const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`)); const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`));
const merged = [...prev]; const merged = [...prev];
@@ -106,7 +159,8 @@ export default function BooksCatalogPage() {
setLoadingMore(true); setLoadingMore(true);
} else { } else {
setError(''); setError('');
setData(null); setLoadingCatalog(true);
if (!normalizedHref) setData(null);
setEntries([]); setEntries([]);
setNextHref(undefined); setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']); loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
@@ -125,6 +179,7 @@ export default function BooksCatalogPage() {
setEntries((prev) => mergeEntries(prev, nextData.entries || [])); setEntries((prev) => mergeEntries(prev, nextData.entries || []));
} else { } else {
setData(nextData); setData(nextData);
setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []);
setEntries(nextData.entries || []); setEntries(nextData.entries || []);
} }
setNextHref(nextData.nextHref || undefined); setNextHref(nextData.nextHref || undefined);
@@ -136,6 +191,7 @@ export default function BooksCatalogPage() {
} }
setError(err instanceof Error ? err.message : '获取目录失败'); setError(err instanceof Error ? err.message : '获取目录失败');
} finally { } finally {
if (!append) setLoadingCatalog(false);
setLoadingMore(false); setLoadingMore(false);
} }
}, [mergeEntries, sourceId]); }, [mergeEntries, sourceId]);
@@ -279,7 +335,7 @@ export default function BooksCatalogPage() {
}, []); }, []);
const navigationItems = useMemo(() => { const navigationItems = useMemo(() => {
const items = (data?.navigation || []).filter((item) => { const items = (catalogNavigation || []).filter((item) => {
const rel = (item.rel || '').toLowerCase(); const rel = (item.rel || '').toLowerCase();
if (rel === 'next' || rel === 'previous') return false; if (rel === 'next' || rel === 'previous') return false;
return isMeaningfulNavTitle(item.title); return isMeaningfulNavTitle(item.title);
@@ -292,7 +348,41 @@ export default function BooksCatalogPage() {
seen.add(key); seen.add(key);
return true; return true;
}); });
}, [data]); }, [catalogNavigation]);
useLayoutEffect(() => {
if (!selectedHref || navigationItems.length === 0) return;
const frameId = window.requestAnimationFrame(() => {
const container = navScrollerRef.current;
const activeItem = activeNavItemRef.current;
if (!container || !activeItem) return;
const containerRect = container.getBoundingClientRect();
const activeRect = activeItem.getBoundingClientRect();
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2;
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
});
return () => window.cancelAnimationFrame(frameId);
}, [selectedHref, navigationItems]);
useLayoutEffect(() => {
if (!selectedSourceId || sources.length === 0) return;
const frameId = window.requestAnimationFrame(() => {
const container = sourceScrollerRef.current;
const activeItem = activeSourceItemRef.current;
if (!container || !activeItem) return;
const containerRect = container.getBoundingClientRect();
const activeRect = activeItem.getBoundingClientRect();
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2;
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
});
return () => window.cancelAnimationFrame(frameId);
}, [selectedSourceId, sources.length]);
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
@@ -309,6 +399,7 @@ export default function BooksCatalogPage() {
{sources.map((source) => ( {sources.map((source) => (
<Link <Link
key={source.id} key={source.id}
ref={source.id === selectedSourceId ? activeSourceItemRef : undefined}
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
draggable={false} draggable={false}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
@@ -316,58 +407,62 @@ export default function BooksCatalogPage() {
if (suppressSourceClickRef.current) { if (suppressSourceClickRef.current) {
event.preventDefault(); event.preventDefault();
suppressSourceClickRef.current = false; suppressSourceClickRef.current = false;
return;
} }
setSelectedSourceId(source.id);
setSelectedHref('');
showImmediateContentLoading();
}} }}
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === selectedSourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
> >
{source.name} {source.name}
</Link> </Link>
))} ))}
</div> </div>
{error ? <div className='text-sm text-red-500'>{error}</div> : null} {error ? <div className='text-sm text-red-500'>{error}</div> : null}
{data ? ( {data || navigationItems.length > 0 ? (
<> <>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h1 className='text-lg font-semibold'>{data.title}</h1>
{data.subtitle ? <p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{data.subtitle}</p> : null}
</section>
{navigationItems.length > 0 ? ( {navigationItems.length > 0 ? (
<section className='space-y-3'> <div
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'></div> ref={navScrollerRef}
<div className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing'
ref={navScrollerRef} onPointerDown={handleNavPointerDown}
className='flex gap-3 overflow-x-auto pb-2 cursor-grab select-none touch-pan-x active:cursor-grabbing' onPointerMove={handleNavPointerMove}
onPointerDown={handleNavPointerDown} onPointerUp={handleNavPointerUp}
onPointerMove={handleNavPointerMove} onPointerCancel={handleNavPointerUp}
onPointerUp={handleNavPointerUp} onPointerLeave={handleNavPointerLeave}
onPointerCancel={handleNavPointerUp} onWheel={handleNavWheel}
onPointerLeave={handleNavPointerLeave} >
onWheel={handleNavWheel} {navigationItems.map((item, index) => (
> <Link
{navigationItems.map((item, index) => ( key={`${item.href}-${index}`}
<Link ref={item.href === selectedHref ? activeNavItemRef : undefined}
key={`${item.href}-${index}`} href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`} draggable={false}
draggable={false} onDragStart={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()} onClick={(event) => {
onClick={(event) => { if (suppressNavClickRef.current) {
if (suppressNavClickRef.current) { event.preventDefault();
event.preventDefault(); suppressNavClickRef.current = false;
suppressNavClickRef.current = false; return;
} }
}} setSelectedHref(item.href);
className='min-w-[180px] rounded-2xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950' showImmediateContentLoading();
> }}
<div className='line-clamp-2 font-medium'>{item.title.trim()}</div> className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${item.href === selectedHref ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'></div> >
</Link> {item.title.trim()}
))} </Link>
</div> ))}
</section> </div>
) : null} ) : null}
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'> {loadingCatalog ? (
{entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}-${item.detailHref || item.acquisitionLinks[0]?.href || ''}`} item={item} href={makeHref(sourceId, item)} onNavigate={() => cacheBookListItem(item)} />)} <LoadingMoreSkeleton />
</section> ) : (
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}-${item.detailHref || item.acquisitionLinks[0]?.href || ''}`} item={item} href={makeHref(sourceId, item)} onNavigate={() => cacheBookListItem(item)} />)}
</section>
)}
{loadingMore ? <LoadingMoreSkeleton /> : null} {loadingMore ? <LoadingMoreSkeleton /> : null}
{!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null} {!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null}
</> </>