diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx
index 6f06ea5..046d07d 100644
--- a/src/app/books/catalog/page.tsx
+++ b/src/app/books/catalog/page.tsx
@@ -1,8 +1,8 @@
'use client';
import Link from 'next/link';
-import { useSearchParams } from 'next/navigation';
-import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import BookCard from '@/components/books/BookCard';
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
@@ -20,13 +20,9 @@ function CatalogSkeleton() {
))}
-
-
+
{Array.from({ length: 5 }).map((_, index) => (
-
+
))}
@@ -62,18 +58,25 @@ function isMeaningfulNavTitle(title?: string) {
}
export default function BooksCatalogPage() {
+ const router = useRouter();
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const [sources, setSources] = useState
([]);
+ const [selectedSourceId, setSelectedSourceId] = useState(sourceId);
+ const [selectedHref, setSelectedHref] = useState(href);
const [data, setData] = useState(null);
+ const [catalogNavigation, setCatalogNavigation] = useState([]);
const [entries, setEntries] = useState([]);
const [nextHref, setNextHref] = useState(undefined);
const [error, setError] = useState('');
+ const [loadingCatalog, setLoadingCatalog] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const loaderRef = useRef(null);
const sourceScrollerRef = useRef(null);
+ const activeSourceItemRef = useRef(null);
const navScrollerRef = useRef(null);
+ const activeNavItemRef = useRef(null);
const loadedPageHrefsRef = useRef>(new Set());
const failedPageHrefsRef = useRef>(new Set());
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 suppressNavClickRef = useRef(false);
+ const showImmediateContentLoading = useCallback(() => {
+ setError('');
+ setLoadingCatalog(true);
+ setEntries([]);
+ setNextHref(undefined);
+ }, []);
+
useEffect(() => {
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 seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`));
const merged = [...prev];
@@ -106,7 +159,8 @@ export default function BooksCatalogPage() {
setLoadingMore(true);
} else {
setError('');
- setData(null);
+ setLoadingCatalog(true);
+ if (!normalizedHref) setData(null);
setEntries([]);
setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
@@ -125,6 +179,7 @@ export default function BooksCatalogPage() {
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
} else {
setData(nextData);
+ setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []);
setEntries(nextData.entries || []);
}
setNextHref(nextData.nextHref || undefined);
@@ -136,6 +191,7 @@ export default function BooksCatalogPage() {
}
setError(err instanceof Error ? err.message : '获取目录失败');
} finally {
+ if (!append) setLoadingCatalog(false);
setLoadingMore(false);
}
}, [mergeEntries, sourceId]);
@@ -279,7 +335,7 @@ export default function BooksCatalogPage() {
}, []);
const navigationItems = useMemo(() => {
- const items = (data?.navigation || []).filter((item) => {
+ const items = (catalogNavigation || []).filter((item) => {
const rel = (item.rel || '').toLowerCase();
if (rel === 'next' || rel === 'previous') return false;
return isMeaningfulNavTitle(item.title);
@@ -292,7 +348,41 @@ export default function BooksCatalogPage() {
seen.add(key);
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 (
@@ -309,6 +399,7 @@ export default function BooksCatalogPage() {
{sources.map((source) => (
event.preventDefault()}
@@ -316,58 +407,62 @@ export default function BooksCatalogPage() {
if (suppressSourceClickRef.current) {
event.preventDefault();
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}
))}
{error ? {error}
: null}
- {data ? (
+ {data || navigationItems.length > 0 ? (
<>
-
- {data.title}
- {data.subtitle ? {data.subtitle}
: null}
-
{navigationItems.length > 0 ? (
-
- 目录
-
- {navigationItems.map((item, index) => (
-
event.preventDefault()}
- onClick={(event) => {
- if (suppressNavClickRef.current) {
- event.preventDefault();
- suppressNavClickRef.current = false;
- }
- }}
- 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'
- >
-
{item.title.trim()}
-
点击进入子目录
-
- ))}
-
-
+
+ {navigationItems.map((item, index) => (
+ event.preventDefault()}
+ onClick={(event) => {
+ if (suppressNavClickRef.current) {
+ event.preventDefault();
+ suppressNavClickRef.current = false;
+ return;
+ }
+ setSelectedHref(item.href);
+ showImmediateContentLoading();
+ }}
+ 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'}`}
+ >
+ {item.title.trim()}
+
+ ))}
+
) : null}
-
- {entries.map((item) => cacheBookListItem(item)} />)}
-
+ {loadingCatalog ? (
+
+ ) : (
+
+ {entries.map((item) => cacheBookListItem(item)} />)}
+
+ )}
{loadingMore ? : null}
{!loadingMore && nextHref ? : null}
>