diff --git a/src/app/api/books/detail/route.ts b/src/app/api/books/detail/route.ts index 8de0caa..7314d8b 100644 --- a/src/app/api/books/detail/route.ts +++ b/src/app/api/books/detail/route.ts @@ -1,45 +1,89 @@ import { NextRequest, NextResponse } from 'next/server'; import { BookAcquisitionLink } from '@/lib/book.types'; +import { db } from '@/lib/db'; import { opdsClient } from '@/lib/opds.client'; import { getAuthorizedBooksUsername } from '../_utils'; export const runtime = 'nodejs'; +type DetailPayload = { + sourceId?: string; + bookId?: string; + href?: string; + title?: string; + author?: string; + cover?: string; + summary?: string; + acquisitionLinks?: BookAcquisitionLink[]; +}; + +async function resolveDetail(username: string, payload: DetailPayload) { + const sourceId = payload.sourceId?.trim(); + if (!sourceId) { + return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 }); + } + + const bookId = payload.bookId?.trim(); + if (!bookId) { + return NextResponse.json({ error: '缺少 bookId' }, { status: 400 }); + } + + const shelfItem = await db.getBookShelf(username, sourceId, bookId); + const readRecord = await db.getBookReadRecord(username, sourceId, bookId); + const href = payload.href?.trim() || shelfItem?.detailHref || readRecord?.detailHref || ''; + const acquisitionLinks = (payload.acquisitionLinks && payload.acquisitionLinks.length > 0) + ? payload.acquisitionLinks + : shelfItem?.acquisitionHref + ? [{ + rel: 'http://opds-spec.org/acquisition', + type: shelfItem.format === 'pdf' ? 'application/pdf' : 'application/epub+zip', + href: shelfItem.acquisitionHref, + }] + : readRecord?.acquisitionHref + ? [{ + rel: 'http://opds-spec.org/acquisition', + type: readRecord.format === 'pdf' ? 'application/pdf' : 'application/epub+zip', + href: readRecord.acquisitionHref, + }] + : undefined; + + const detail = await opdsClient.getBookDetail(sourceId, href, { + id: bookId, + title: payload.title || shelfItem?.title || readRecord?.title || undefined, + author: payload.author || shelfItem?.author || readRecord?.author || undefined, + cover: payload.cover || shelfItem?.cover || readRecord?.cover || undefined, + summary: payload.summary || undefined, + detailHref: href || undefined, + acquisitionLinks, + }); + + return NextResponse.json(detail); +} + export async function GET(request: NextRequest) { const username = await getAuthorizedBooksUsername(request); if (username instanceof NextResponse) return username; try { const { searchParams } = new URL(request.url); - const sourceId = searchParams.get('sourceId')?.trim(); - const href = searchParams.get('href')?.trim() || ''; - - if (!sourceId) { - return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 }); - } - - const acquisitionLinksRaw = searchParams.get('acquisitionLinks'); - let acquisitionLinks: BookAcquisitionLink[] | undefined; - if (acquisitionLinksRaw) { - try { - acquisitionLinks = JSON.parse(acquisitionLinksRaw) as BookAcquisitionLink[]; - } catch { - acquisitionLinks = undefined; - } - } - - const detail = await opdsClient.getBookDetail(sourceId, href, { - id: searchParams.get('bookId') || undefined, - title: searchParams.get('title') || undefined, - author: searchParams.get('author') || undefined, - cover: searchParams.get('cover') || undefined, - summary: searchParams.get('summary') || undefined, - detailHref: href || undefined, - acquisitionLinks, + return await resolveDetail(username, { + sourceId: searchParams.get('sourceId') || undefined, + bookId: searchParams.get('bookId') || undefined, }); - return NextResponse.json(detail); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const payload = await request.json() as DetailPayload; + return await resolveDetail(username, payload); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); } diff --git a/src/app/api/books/file/route.ts b/src/app/api/books/file/route.ts index 4bf36f2..23850a1 100644 --- a/src/app/api/books/file/route.ts +++ b/src/app/api/books/file/route.ts @@ -1,61 +1,118 @@ import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; import { opdsClient } from '@/lib/opds.client'; import { getAuthorizedBooksUsername } from '../_utils'; export const runtime = 'nodejs'; +type FilePayload = { + sourceId?: string; + bookId?: string; + href?: string; + format?: 'epub' | 'pdf' | null; +}; + +async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> { + const sourceId = payload.sourceId?.trim(); + if (!sourceId) throw new Error('缺少 sourceId'); + + if (payload.href?.trim()) { + return { sourceId, href: payload.href.trim() }; + } + + const bookId = payload.bookId?.trim(); + if (!bookId) throw new Error('缺少 bookId 或 href'); + + const shelfItem = await db.getBookShelf(username, sourceId, bookId); + const readRecord = await db.getBookReadRecord(username, sourceId, bookId); + const directHref = shelfItem?.acquisitionHref || readRecord?.acquisitionHref; + if (directHref) { + return { sourceId, href: directHref }; + } + + const detailHref = shelfItem?.detailHref || readRecord?.detailHref; + if (!detailHref) throw new Error('找不到可下载文件'); + + const preferred = await opdsClient.getPreferredAcquisition(sourceId, detailHref); + if (payload.format && preferred.format !== payload.format) { + const detail = await opdsClient.getBookDetail(sourceId, detailHref); + const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub'))); + if (!matched?.href) throw new Error('找不到对应格式文件'); + return { sourceId, href: matched.href }; + } + + return { sourceId, href: preferred.href }; +} + +async function proxyFile(request: NextRequest, sourceId: string, href: string) { + const source = await opdsClient.getSourceById(sourceId); + const headers = new Headers(); + if (source.authMode === 'basic' && source.username) { + headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`); + } else if (source.authMode === 'header' && source.headerName && source.headerValue) { + headers.set(source.headerName, source.headerValue); + } + const range = request.headers.get('range'); + if (range) headers.set('Range', range); + + const response = await fetch(href, { + headers, + redirect: 'follow', + cache: 'no-store', + }); + + if (!response.ok) { + return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status }); + } + + const outHeaders = new Headers(); + const contentType = response.headers.get('content-type'); + const contentLength = response.headers.get('content-length'); + const acceptRanges = response.headers.get('accept-ranges'); + const contentRange = response.headers.get('content-range'); + const disposition = response.headers.get('content-disposition'); + if (contentType) outHeaders.set('Content-Type', contentType); + if (contentLength) outHeaders.set('Content-Length', contentLength); + if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges); + if (contentRange) outHeaders.set('Content-Range', contentRange); + if (disposition) outHeaders.set('Content-Disposition', disposition); + outHeaders.set('Cache-Control', 'private, max-age=300'); + + return new NextResponse(response.body, { + status: response.status, + headers: outHeaders, + }); +} + export async function GET(request: NextRequest) { const username = await getAuthorizedBooksUsername(request); if (username instanceof NextResponse) return username; try { const { searchParams } = new URL(request.url); - const sourceId = searchParams.get('sourceId')?.trim(); - const href = searchParams.get('href')?.trim(); - if (!sourceId || !href) { - return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 }); - } - - const source = await opdsClient.getSourceById(sourceId); - const headers = new Headers(); - if (source.authMode === 'basic' && source.username) { - headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`); - } else if (source.authMode === 'header' && source.headerName && source.headerValue) { - headers.set(source.headerName, source.headerValue); - } - const range = request.headers.get('range'); - if (range) headers.set('Range', range); - - const response = await fetch(href, { - headers, - redirect: 'follow', - cache: 'no-store', - }); - - if (!response.ok) { - return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status }); - } - - const outHeaders = new Headers(); - const contentType = response.headers.get('content-type'); - const contentLength = response.headers.get('content-length'); - const acceptRanges = response.headers.get('accept-ranges'); - const contentRange = response.headers.get('content-range'); - const disposition = response.headers.get('content-disposition'); - if (contentType) outHeaders.set('Content-Type', contentType); - if (contentLength) outHeaders.set('Content-Length', contentLength); - if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges); - if (contentRange) outHeaders.set('Content-Range', contentRange); - if (disposition) outHeaders.set('Content-Disposition', disposition); - outHeaders.set('Cache-Control', 'private, max-age=300'); - - return new NextResponse(response.body, { - status: response.status, - headers: outHeaders, + const resolved = await resolveFileHref(username, { + sourceId: searchParams.get('sourceId') || undefined, + bookId: searchParams.get('bookId') || undefined, + href: searchParams.get('href') || undefined, + format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null, }); + return await proxyFile(request, resolved.sourceId, resolved.href); } catch (error) { - return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} + +export async function POST(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const payload = await request.json() as FilePayload; + const resolved = await resolveFileHref(username, payload); + return await proxyFile(request, resolved.sourceId, resolved.href); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); } } diff --git a/src/app/api/books/read/manifest/route.ts b/src/app/api/books/read/manifest/route.ts index beffc80..d1c89f7 100644 --- a/src/app/api/books/read/manifest/route.ts +++ b/src/app/api/books/read/manifest/route.ts @@ -1,24 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { BookAcquisitionLink } from '@/lib/book.types'; -import { opdsClient } from '@/lib/opds.client'; import { db } from '@/lib/db'; +import { opdsClient } from '@/lib/opds.client'; import { getAuthorizedBooksUsername } from '../../_utils'; export const runtime = 'nodejs'; -export async function GET(request: NextRequest) { - const username = await getAuthorizedBooksUsername(request); - if (username instanceof NextResponse) return username; +type ManifestPayload = { + sourceId?: string; + bookId?: string; + href?: string; + acquisitionHref?: string; + format?: 'epub' | 'pdf' | null; + title?: string; + author?: string; + cover?: string; + summary?: string; +}; +async function resolveManifest(username: string, payload: ManifestPayload) { try { - const { searchParams } = new URL(request.url); - const sourceId = searchParams.get('sourceId')?.trim(); - const href = searchParams.get('href')?.trim(); - const acquisitionHref = searchParams.get('acquisitionHref')?.trim(); - const format = searchParams.get('format')?.trim() as 'epub' | 'pdf' | null; - const bookId = searchParams.get('bookId')?.trim(); + const sourceId = payload.sourceId?.trim(); + const href = payload.href?.trim(); + const acquisitionHref = payload.acquisitionHref?.trim(); + const format = payload.format; + const bookId = payload.bookId?.trim(); if (!sourceId) { return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 }); @@ -44,10 +52,10 @@ export async function GET(request: NextRequest) { const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', { id: bookId || resolvedAcquisitionHref || undefined, - title: searchParams.get('title') || existingRecord?.title || shelfItem?.title || undefined, - author: searchParams.get('author') || existingRecord?.author || shelfItem?.author || undefined, - cover: searchParams.get('cover') || existingRecord?.cover || shelfItem?.cover || undefined, - summary: searchParams.get('summary') || undefined, + title: payload.title || existingRecord?.title || shelfItem?.title || undefined, + author: payload.author || existingRecord?.author || shelfItem?.author || undefined, + cover: payload.cover || existingRecord?.cover || shelfItem?.cover || undefined, + summary: payload.summary || undefined, detailHref: resolvedHref || undefined, acquisitionLinks: fallbackAcquisitionLinks, }); @@ -62,9 +70,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ book: detail, format: preferred.format, - fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(preferred.href)}`, + fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`, acquisitionHref: preferred.href, - cacheKey: `${sourceId}::${detail.id}::${preferred.href}`, + cacheKey: `${sourceId}::${detail.id}::${preferred.format}`, coverUrl: detail.cover, lastRecord, }); @@ -72,3 +80,26 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); } } + +export async function GET(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + const { searchParams } = new URL(request.url); + return resolveManifest(username, { + sourceId: searchParams.get('sourceId') || undefined, + bookId: searchParams.get('bookId') || undefined, + }); +} + +export async function POST(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const payload = await request.json() as ManifestPayload; + return await resolveManifest(username, payload); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx index 3a5d6c5..d8093f4 100644 --- a/src/app/books/catalog/page.tsx +++ b/src/app/books/catalog/page.tsx @@ -2,23 +2,14 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import BookCard from '@/components/books/BookCard'; +import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client'; import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types'; function makeHref(sourceId: string, item: BookListItem) { - const params = new URLSearchParams({ - sourceId, - href: item.detailHref || '', - bookId: item.id, - title: item.title, - author: item.author || '', - cover: item.cover || '', - summary: item.summary || '', - acquisitionLinks: JSON.stringify(item.acquisitionLinks || []), - }); - return `/books/detail?${params.toString()}`; + return buildBookDetailPath(sourceId, item.id); } function CatalogSkeleton() { @@ -51,30 +42,175 @@ function CatalogSkeleton() { ); } +function LoadingMoreSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+ ); +} + +function isMeaningfulNavTitle(title?: string) { + const text = (title || '').trim(); + return !!text && text !== '目录'; +} + export default function BooksCatalogPage() { const searchParams = useSearchParams(); const sourceId = searchParams.get('sourceId') || ''; const href = searchParams.get('href') || ''; const [sources, setSources] = useState([]); const [data, setData] = useState(null); + const [entries, setEntries] = useState([]); + const [nextHref, setNextHref] = useState(undefined); const [error, setError] = useState(''); + const [loadingMore, setLoadingMore] = useState(false); + const loaderRef = useRef(null); + const navScrollerRef = useRef(null); + const loadedPageHrefsRef = useRef>(new Set()); + const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean } | null>(null); useEffect(() => { fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])); }, []); + 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]; + for (const item of next) { + const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`; + if (!seen.has(key)) { + seen.add(key); + merged.push(item); + } + } + return merged; + }, []); + + const loadCatalog = useCallback(async (targetHref?: string, append = false) => { + if (!sourceId) return; + const normalizedHref = targetHref || ''; + if (append) { + if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref)) return; + setLoadingMore(true); + } else { + setError(''); + setData(null); + setEntries([]); + setNextHref(undefined); + loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']); + } + + try { + const params = new URLSearchParams({ sourceId }); + if (normalizedHref) params.set('href', normalizedHref); + const res = await fetch(`/api/books/catalog?${params.toString()}`); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取目录失败'); + const nextData = json as BookCatalogResult; + if (append) { + loadedPageHrefsRef.current.add(normalizedHref); + setEntries((prev) => mergeEntries(prev, nextData.entries || [])); + } else { + setData(nextData); + setEntries(nextData.entries || []); + } + setNextHref(nextData.nextHref || undefined); + if (!append) setData(nextData); + } catch (err) { + setError(err instanceof Error ? err.message : '获取目录失败'); + } finally { + setLoadingMore(false); + } + }, [mergeEntries, sourceId]); + useEffect(() => { if (!sourceId) return; - const params = new URLSearchParams({ sourceId }); - if (href) params.set('href', href); - fetch(`/api/books/catalog?${params.toString()}`) - .then(async (res) => { - const json = await res.json(); - if (!res.ok) throw new Error(json.error || '获取目录失败'); - setData(json); - }) - .catch((err) => setError(err.message || '获取目录失败')); - }, [sourceId, href]); + void loadCatalog(href, false); + }, [sourceId, href, loadCatalog]); + + useEffect(() => { + const node = loaderRef.current; + if (!node || !nextHref || loadingMore || !data) return; + + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + if (entry?.isIntersecting && nextHref && !loadingMore) { + void loadCatalog(nextHref, true); + } + }, { rootMargin: '800px 0px' }); + + observer.observe(node); + return () => observer.disconnect(); + }, [data, nextHref, loadingMore, loadCatalog]); + + + const handleNavPointerDown = useCallback((event: ReactPointerEvent) => { + const node = navScrollerRef.current; + if (!node) return; + navDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: node.scrollLeft, + moved: false, + }; + node.setPointerCapture?.(event.pointerId); + }, []); + + const handleNavPointerMove = useCallback((event: ReactPointerEvent) => { + const node = navScrollerRef.current; + const dragState = navDragStateRef.current; + if (!node || !dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + if (Math.abs(deltaX) > 4) dragState.moved = true; + node.scrollLeft = dragState.startScrollLeft - deltaX; + }, []); + + const handleNavPointerUp = useCallback((event: ReactPointerEvent) => { + const node = navScrollerRef.current; + const dragState = navDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + if (dragState.moved) { + event.preventDefault(); + window.setTimeout(() => { + navDragStateRef.current = null; + }, 0); + } else { + navDragStateRef.current = null; + } + node?.releasePointerCapture?.(event.pointerId); + }, []); + + const handleNavWheel = useCallback((event: ReactWheelEvent) => { + const node = navScrollerRef.current; + if (!node) return; + const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (!delta) return; + node.scrollLeft += delta; + event.preventDefault(); + }, []); + + const navigationItems = useMemo(() => { + const items = (data?.navigation || []).filter((item) => { + const rel = (item.rel || '').toLowerCase(); + if (rel === 'next' || rel === 'previous') return false; + return isMeaningfulNavTitle(item.title); + }); + + const seen = new Set(); + return items.filter((item) => { + const key = `${item.href}::${(item.title || '').trim()}`; + if (!item.href || seen.has(key)) return false; + seen.add(key); + return true; + }); + }, [data]); return (
@@ -91,22 +227,30 @@ export default function BooksCatalogPage() {

{data.title}

{data.subtitle ?

{data.subtitle}

: null} -
- {data.previousHref ? 上一页 : null} - {data.nextHref ? 下一页 : null} -
- {data.navigation.length > 0 ? ( + {navigationItems.length > 0 ? (
目录
-
- {data.navigation.map((item, index) => ( +
+ {navigationItems.map((item, index) => ( event.preventDefault()} + onClick={(event) => { if (navDragStateRef.current?.moved) event.preventDefault(); }} 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}
+
{item.title.trim()}
点击进入子目录
))} @@ -114,8 +258,10 @@ export default function BooksCatalogPage() {
) : null}
- {data.entries.map((item) => )} + {entries.map((item) => cacheBookListItem(item)} />)}
+ {loadingMore ? : null} + {!loadingMore && nextHref ?
: null} ) : !error ? : null}
diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx index fc173d7..4481f6f 100644 --- a/src/app/books/detail/page.tsx +++ b/src/app/books/detail/page.tsx @@ -2,10 +2,11 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import { BookDetail, BookShelfItem } from '@/lib/book.types'; +import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client'; +import { BookDetail, BookShelfItem } from '@/lib/book.types'; function DetailSkeleton() { return ( @@ -30,29 +31,79 @@ function DetailSkeleton() { ); } +async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string) { + const response = await fetch('/api/books/file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceId, bookId, format: format || null, href: href || undefined }), + }); + if (!response.ok) { + let message = '打开文件失败'; + try { + const json = await response.json(); + message = json.error || message; + } catch {} + throw new Error(message); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + if (download) { + const link = document.createElement('a'); + link.href = url; + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + export default function BookDetailPage() { const searchParams = useSearchParams(); const sourceId = searchParams.get('sourceId') || ''; - const href = searchParams.get('href') || ''; + const bookId = searchParams.get('bookId') || ''; const [detail, setDetail] = useState(null); const [shelf, setShelf] = useState>({}); const [error, setError] = useState(''); + const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>(''); + const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]); useEffect(() => { - getAllBookShelf().then(setShelf).catch(() => undefined); + getAllBookShelf().then((items) => { + setShelf(items); + }).catch(() => undefined); }, []); useEffect(() => { - const params = new URLSearchParams(searchParams.toString()); - fetch(`/api/books/detail?${params.toString()}`) + if (!sourceId || !bookId) return; + fetch('/api/books/detail', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId, + bookId, + href: cached?.detailHref, + title: cached?.title, + author: cached?.author, + cover: cached?.cover, + summary: cached?.summary, + acquisitionLinks: cached?.acquisitionLinks || [], + }), + }) .then(async (res) => { const json = await res.json(); if (!res.ok) throw new Error(json.error || '获取详情失败'); setDetail(json); + cacheBookDetail(json); }) .catch((err) => setError(err.message || '获取详情失败')); - }, [searchParams]); + }, [sourceId, bookId, cached]); + + const readable = detail?.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf')); + const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub'; const toggleShelf = async () => { if (!detail) return; @@ -73,20 +124,19 @@ export default function BookDetailPage() { title: detail.title, author: detail.author, cover: detail.cover, + format: readableFormat, detailHref: detail.detailHref, acquisitionHref: readable?.href, saveTime: Date.now(), }; await saveBookShelf(detail.sourceId, detail.id, item); setShelf((prev) => ({ ...prev, [bookKey]: item })); + cacheBookDetail(detail); }; if (error) return
{error}
; if (!detail) return ; - const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf')); - const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub'; - return (
@@ -103,24 +153,27 @@ export default function BookDetailPage() { {(detail.categories || detail.tags || []).map((tag) => {tag})}
- {readable ? 在线阅读 : null} + {readable ? cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读 : null} - {detail.acquisitionLinks[0] ? 下载文件 : null} + {readable ? : null}

可用格式

- {detail.acquisitionLinks.map((item) => ( -
-
-
{item.title || item.type}
-
{item.rel}
+ {detail.acquisitionLinks.map((item) => { + const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined; + return ( +
+
+
{item.title || item.type}
+
{item.rel}
+
+
- 打开 -
- ))} + ); + })}
diff --git a/src/app/books/history/page.tsx b/src/app/books/history/page.tsx index ce45132..d865b9b 100644 --- a/src/app/books/history/page.tsx +++ b/src/app/books/history/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; +import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/lib/book-route-cache.client'; import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client'; import { BookReadRecord, BookShelfItem } from '@/lib/book.types'; @@ -47,19 +48,8 @@ export default function BookHistoryPage() {
{item.sourceId ? ( { cacheBookReadRecord(item); if (item.sourceId && item.bookId) { cacheBookShelfItem({ sourceId: item.sourceId, sourceName: item.sourceName, bookId: item.bookId, title: item.title, author: item.author, cover: item.cover, format: item.format, detailHref: item.detailHref, acquisitionHref: item.acquisitionHref, saveTime: item.saveTime }); } }} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white' > 继续阅读 diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index 10fd0e6..13dbf9a 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -11,6 +11,7 @@ import { putCachedBookFile, touchCachedBookFile, } from '@/lib/book-cache.client'; +import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; import { saveBookReadRecord } from '@/lib/book.db.client'; import { BookReadManifest } from '@/lib/book.types'; @@ -141,10 +142,20 @@ function flattenToc(items: TocItem[]): TocItem[] { } async function downloadBookWithProgress( - url: string, + manifest: Pick, onProgress: (received: number, total: number | null) => void ): Promise { - const response = await fetch(url, { cache: 'force-cache' }); + const response = await fetch('/api/books/file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + format: manifest.format, + href: manifest.acquisitionHref || undefined, + }), + cache: 'force-cache', + }); if (!response.ok) throw new Error(`下载电子书失败: ${response.status}`); const total = Number(response.headers.get('content-length') || '') || null; if (!response.body) { @@ -173,6 +184,24 @@ async function downloadBookWithProgress( return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' }); } + +function normalizeHrefForMatch(href?: string) { + if (!href) return ''; + try { + const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim(); + return normalized.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, ''); + } catch { + return href.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '').trim(); + } +} + +function isSameTocTarget(currentHref?: string, tocHref?: string) { + const current = normalizeHrefForMatch(currentHref); + const target = normalizeHrefForMatch(tocHref); + if (!current || !target) return false; + return current === target || current.endsWith(target) || target.endsWith(current); +} + function formatBytes(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; @@ -182,7 +211,8 @@ function formatBytes(size: number): string { export default function BookReadPage() { const searchParams = useSearchParams(); const sourceId = searchParams.get('sourceId') || ''; - const href = searchParams.get('href') || ''; + const bookId = searchParams.get('bookId') || ''; + const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]); const [manifest, setManifest] = useState(null); const [error, setError] = useState(''); const [ready, setReady] = useState(false); @@ -199,7 +229,10 @@ export default function BookReadPage() { const [progressPercent, setProgressPercent] = useState(0); const [restoredMessage, setRestoredMessage] = useState(''); const [controlsVisible, setControlsVisible] = useState(true); + const [pdfBlobUrl, setPdfBlobUrl] = useState(''); const viewerRef = useRef(null); + const tocScrollRef = useRef(null); + const tocItemRefs = useRef>({}); const bookRef = useRef(null); const renditionRef = useRef(null); const saveTimerRef = useRef(null); @@ -218,25 +251,57 @@ export default function BookReadPage() { } }, [settings]); + useEffect(() => { - const params = new URLSearchParams({ - sourceId, - href, - acquisitionHref: searchParams.get('acquisitionHref') || '', - format: searchParams.get('format') || '', - bookId: searchParams.get('bookId') || '', - title: searchParams.get('title') || '', - author: searchParams.get('author') || '', - cover: searchParams.get('cover') || '', - }); - fetch(`/api/books/read/manifest?${params.toString()}`) + const handleToggleSettings = () => { + setSettingsOpen((prev) => !prev); + setTocOpen(false); + }; + + window.addEventListener('books-read-toggle-settings', handleToggleSettings); + return () => { + window.removeEventListener('books-read-toggle-settings', handleToggleSettings); + }; + }, []); + + useEffect(() => { + const handleToggleChapters = () => { + setTocOpen((prev) => !prev); + setSettingsOpen(false); + }; + + window.addEventListener('books-read-toggle-chapters', handleToggleChapters); + return () => { + window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); + }; + }, []); + + + useEffect(() => { + if (!sourceId || !bookId) return; + fetch('/api/books/read/manifest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId, + bookId, + href: cached?.detailHref, + acquisitionHref: cached?.acquisitionHref, + format: cached?.format || null, + title: cached?.title, + author: cached?.author, + cover: cached?.cover, + summary: cached?.summary, + }), + }) .then(async (res) => { const json = await res.json(); if (!res.ok) throw new Error(json.error || '获取阅读信息失败'); setManifest(json); + cacheBookDetail(json.book); }) .catch((err) => setError(err.message || '获取阅读信息失败')); - }, [sourceId, href, searchParams]); + }, [sourceId, bookId, cached]); const saveProgress = useMemo(() => { return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { @@ -312,11 +377,12 @@ export default function BookReadPage() { renditionRef.current?.next?.(); return; } - setControlsVisible((prev) => !prev); setTocOpen(false); setSettingsOpen(false); }, [ready]); + + useEffect(() => { if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return; let destroyed = false; @@ -337,7 +403,7 @@ export default function BookReadPage() { const cacheKey = manifest.cacheKey || buildBookCacheKey( manifest.book.sourceId, manifest.book.id, - manifest.acquisitionHref || manifest.fileUrl + manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}` ); let fileBuffer: ArrayBuffer; @@ -351,7 +417,7 @@ export default function BookReadPage() { fileBuffer = await cached.blob.arrayBuffer(); } else { setFileLoadState('downloading'); - const blob = await downloadBookWithProgress(manifest.fileUrl, (received, total) => { + const blob = await downloadBookWithProgress(manifest, (received, total) => { if (!destroyed) { setDownloadedBytes(received); setTotalBytes(total); @@ -364,7 +430,7 @@ export default function BookReadPage() { bookId: manifest.book.id, title: manifest.book.title, format: manifest.format, - acquisitionHref: manifest.acquisitionHref || manifest.fileUrl, + acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`, blob, size: blob.size, mimeType: blob.type || 'application/epub+zip', @@ -397,16 +463,43 @@ export default function BookReadPage() { applyReaderTheme(settings); const restoreTarget = manifest.lastRecord?.locator?.value || undefined; - await navigateToTarget(restoreTarget); - window.clearTimeout(readyFallbackTimer); - if (destroyed) return; - setReady(true); - setFileLoadState('ready'); + let restoreMessageShown = false; - if (restoreTarget) { - setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`); - window.setTimeout(() => setRestoredMessage(''), 3000); - } + rendition.on('relocated', (location: EpubLocation) => { + if (!destroyed) { + window.clearTimeout(readyFallbackTimer); + setReady(true); + setFileLoadState('ready'); + } + if (restoreTarget && !restoreMessageShown) { + restoreMessageShown = true; + setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`); + window.setTimeout(() => setRestoredMessage(''), 3000); + } + lastLocationRef.current = location; + const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; + const cfi = location?.start?.cfi || ''; + const computedProgress = locationsReadyRef.current && cfi + ? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100)) + : null; + const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0; + setProgressPercent(normalizedProgress); + setCurrentChapter(chapterTitle); + setCurrentHref(location?.start?.href || ''); + lastProgressRef.current = normalizedProgress; + lastChapterRef.current = chapterTitle; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(() => { + void saveProgress(location, normalizedProgress, chapterTitle); + }, locationsReadyRef.current ? 1500 : 3500); + }); + + void navigateToTarget(restoreTarget).catch(() => { + if (!destroyed) { + setReady(true); + setFileLoadState('ready'); + } + }); void (async () => { try { @@ -432,25 +525,6 @@ export default function BookReadPage() { // ignore } })(); - - rendition.on('relocated', (location: EpubLocation) => { - lastLocationRef.current = location; - const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; - const cfi = location?.start?.cfi || ''; - const computedProgress = locationsReadyRef.current && cfi - ? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100)) - : null; - const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0; - setProgressPercent(normalizedProgress); - setCurrentChapter(chapterTitle); - setCurrentHref(location?.start?.href || ''); - lastProgressRef.current = normalizedProgress; - lastChapterRef.current = chapterTitle; - if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = window.setTimeout(() => { - void saveProgress(location, normalizedProgress, chapterTitle); - }, locationsReadyRef.current ? 1500 : 3500); - }); }) .catch((err) => { setReady(false); @@ -479,47 +553,84 @@ export default function BookReadPage() { }; }, [persistCurrentProgress]); + + useEffect(() => { + if (!manifest || manifest.format !== 'pdf') return; + let revokedUrl = ''; + let cancelled = false; + setFileLoadState('downloading'); + setDownloadedBytes(0); + setTotalBytes(null); + setPdfBlobUrl(''); + + downloadBookWithProgress(manifest, (received, total) => { + if (!cancelled) { + setDownloadedBytes(received); + setTotalBytes(total); + } + }) + .then(async (blob) => { + if (cancelled) return; + const objectUrl = URL.createObjectURL(blob); + revokedUrl = objectUrl; + setPdfBlobUrl(objectUrl); + setFileLoadState('ready'); + }) + .catch((err) => { + if (!cancelled) setError(err.message || 'PDF 加载失败'); + }); + + return () => { + cancelled = true; + if (revokedUrl) URL.revokeObjectURL(revokedUrl); + }; + }, [manifest]); + + + useEffect(() => { + if (!manifest) return; + window.dispatchEvent(new CustomEvent('books-read-update-header', { + detail: { + title: manifest.book.title, + subtitle: currentChapter || manifest.book.author || '分页阅读', + backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, + }, + })); + }, [manifest, currentChapter]); + const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]); const activeTocHref = useMemo( - () => flatToc.find((item) => currentHref.includes(item.href) || item.href.includes(currentHref))?.href || '', + () => flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || '', [flatToc, currentHref] ); + + useEffect(() => { + if (!tocOpen || !activeTocHref) return; + const activeNode = tocItemRefs.current[activeTocHref]; + if (!activeNode) return; + activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }, [tocOpen, activeTocHref]); + const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); if (error) return
{error}
; if (!manifest) return
准备阅读器中...
; if (manifest.format === 'pdf') { - return