From fabc3e6bff649c48d21cb7194c523c5854779574 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 29 Apr 2026 12:55:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=B5=E5=AD=90=E4=B9=A6=E5=AE=A4=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/books/history/page.tsx | 18 +- src/app/books/read/page.tsx | 256 +++++++++++++++++---------- src/components/books/BooksLayout.tsx | 5 +- 3 files changed, 187 insertions(+), 92 deletions(-) diff --git a/src/app/books/history/page.tsx b/src/app/books/history/page.tsx index d865b9b..146a7f3 100644 --- a/src/app/books/history/page.tsx +++ b/src/app/books/history/page.tsx @@ -7,6 +7,22 @@ import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/li import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client'; import { BookReadRecord, BookShelfItem } from '@/lib/book.types'; + +function looksLikeInternalHref(value?: string) { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return /\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) || /^nav/.test(normalized); +} + +function getReadableChapterLabel(item: BookReadRecord) { + const candidates = [item.chapterTitle, item.locator.chapterTitle]; + for (const candidate of candidates) { + const text = (candidate || '').trim(); + if (text && !looksLikeInternalHref(text)) return text; + } + return '定位已保存'; +} + export default function BookHistoryPage() { const [records, setRecords] = useState>({}); const [shelf, setShelf] = useState>({}); @@ -44,7 +60,7 @@ export default function BookHistoryPage() {
{item.title}
{item.author || item.sourceName}
-
已读 {Math.round(item.progressPercent || 0)}% · {item.chapterTitle || item.locator.chapterTitle || '定位已保存'}
+
已读 {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}
{item.sourceId ? ( = { light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' }, @@ -141,6 +143,19 @@ function flattenToc(items: TocItem[]): TocItem[] { return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); } +function tocItemIsActive(item: TocItem, currentHref: string): boolean { + return isSameTocTarget(currentHref, item.href) || (item.subitems || []).some((subitem) => tocItemIsActive(subitem, currentHref)); +} + +function findTocLabelByHref(items: TocItem[], currentHref: string): string { + for (const item of items) { + if (isSameTocTarget(currentHref, item.href)) return item.label; + const nested = findTocLabelByHref(item.subitems || [], currentHref); + if (nested) return nested; + } + return ''; +} + async function downloadBookWithProgress( manifest: Pick, onProgress: (received: number, total: number | null) => void @@ -235,11 +250,14 @@ export default function BookReadPage() { const tocItemRefs = useRef>({}); const bookRef = useRef(null); const renditionRef = useRef(null); - const saveTimerRef = useRef(null); + const pendingRecordRef = useRef(null); + const pendingRecordDirtyRef = useRef(false); + const saveInFlightRef = useRef(false); const lastLocationRef = useRef(null); const lastProgressRef = useRef(0); const lastChapterRef = useRef(''); const locationsReadyRef = useRef(false); + const tocItemsRef = useRef([]); useEffect(() => { setSettings(loadReaderSettings()); @@ -303,40 +321,65 @@ export default function BookReadPage() { .catch((err) => setError(err.message || '获取阅读信息失败')); }, [sourceId, bookId, cached]); - const saveProgress = useMemo(() => { - return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { - if (!manifest) return; - const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; - if (!locatorValue) return; - await saveBookReadRecord(manifest.book.sourceId, manifest.book.id, { - sourceId: manifest.book.sourceId, - sourceName: manifest.book.sourceName, - bookId: manifest.book.id, - title: manifest.book.title, - author: manifest.book.author, - cover: manifest.book.cover, - detailHref: manifest.book.detailHref, - acquisitionHref: manifest.acquisitionHref, - format: manifest.format, - locator: { - type: 'epub-cfi', - value: locatorValue, - href: location?.start?.href, - chapterTitle, - }, + const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => { + if (!manifest) return null; + const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; + if (!locatorValue) return null; + return { + sourceId: manifest.book.sourceId, + sourceName: manifest.book.sourceName, + bookId: manifest.book.id, + title: manifest.book.title, + author: manifest.book.author, + cover: manifest.book.cover, + detailHref: manifest.book.detailHref, + acquisitionHref: manifest.acquisitionHref, + format: manifest.format, + locator: { + type: 'epub-cfi', + value: locatorValue, + href: location?.start?.href, chapterTitle, - chapterHref: location?.start?.href, - progressPercent: nextProgress, - saveTime: Date.now(), - }); + }, + chapterTitle, + chapterHref: location?.start?.href, + progressPercent: nextProgress, + saveTime: Date.now(), }; }, [manifest]); + const queueReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { + const record = buildReadRecord(location, nextProgress, chapterTitle); + if (!record) return; + pendingRecordRef.current = record; + pendingRecordDirtyRef.current = true; + }, [buildReadRecord]); + + const flushPendingReadRecord = useCallback(async () => { + const record = pendingRecordRef.current; + if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return; + + saveInFlightRef.current = true; + try { + await saveBookReadRecord(record.sourceId, record.bookId, { + ...record, + saveTime: Date.now(), + }); + pendingRecordRef.current = { ...record, saveTime: Date.now() }; + pendingRecordDirtyRef.current = false; + } catch { + // ignore + } finally { + saveInFlightRef.current = false; + } + }, []); + const persistCurrentProgress = useCallback(() => { if (lastLocationRef.current) { - void saveProgress(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); + queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); } - }, [saveProgress]); + void flushPendingReadRecord(); + }, [queueReadRecord, flushPendingReadRecord]); const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => { const rendition = renditionRef.current; @@ -383,6 +426,9 @@ export default function BookReadPage() { + + + useEffect(() => { if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return; let destroyed = false; @@ -477,7 +523,8 @@ export default function BookReadPage() { window.setTimeout(() => setRestoredMessage(''), 3000); } lastLocationRef.current = location; - const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; + const hrefLabel = location?.start?.href ? findTocLabelByHref(tocItemsRef.current, location.start.href) : ''; + const chapterTitle = hrefLabel || 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)) @@ -488,10 +535,7 @@ export default function BookReadPage() { 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); + queueReadRecord(location, normalizedProgress, chapterTitle); }); void navigateToTarget(restoreTarget).catch(() => { @@ -533,25 +577,37 @@ export default function BookReadPage() { return () => { destroyed = true; - if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); persistCurrentProgress(); renditionRef.current?.destroy?.(); bookRef.current?.destroy?.(); }; - }, [manifest, settings, applyReaderTheme, persistCurrentProgress, saveProgress, navigateToTarget]); + }, [manifest, settings, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]); useEffect(() => { - const handleVisibility = () => { - if (document.visibilityState === 'hidden') persistCurrentProgress(); + const flushPendingReadRecordOnLeave = () => { + if (!pendingRecordDirtyRef.current || saveInFlightRef.current) return; + persistCurrentProgress(); }; - const handleUnload = () => persistCurrentProgress(); - document.addEventListener('visibilitychange', handleVisibility); - window.addEventListener('beforeunload', handleUnload); + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + flushPendingReadRecordOnLeave(); + } + }; + + const intervalId = window.setInterval(() => { + void flushPendingReadRecord(); + }, SAVE_INTERVAL_MS); + window.addEventListener('pagehide', flushPendingReadRecordOnLeave); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { - document.removeEventListener('visibilitychange', handleVisibility); - window.removeEventListener('beforeunload', handleUnload); + window.clearInterval(intervalId); + window.removeEventListener('pagehide', flushPendingReadRecordOnLeave); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [persistCurrentProgress]); + }, [flushPendingReadRecord, persistCurrentProgress]); + useEffect(() => { @@ -588,15 +644,8 @@ export default function BookReadPage() { 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]); + tocItemsRef.current = tocItems; + }, [tocItems]); const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]); const activeTocHref = useMemo( @@ -604,6 +653,19 @@ export default function BookReadPage() { [flatToc, currentHref] ); + const currentTocLabel = useMemo(() => findTocLabelByHref(tocItems, currentHref), [tocItems, currentHref]); + + useEffect(() => { + if (!manifest) return; + window.dispatchEvent(new CustomEvent('books-read-update-header', { + detail: { + title: manifest.book.title, + subtitle: currentTocLabel || currentChapter || manifest.book.author || '分页阅读', + backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, + }, + })); + }, [manifest, currentChapter, currentTocLabel]); + useEffect(() => { if (!tocOpen || !activeTocHref) return; const activeNode = tocItemRefs.current[activeTocHref]; @@ -611,6 +673,34 @@ export default function BookReadPage() { activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, [tocOpen, activeTocHref]); + const renderTocItems = useCallback((items: TocItem[], depth = 0) => items.map((item) => { + const active = tocItemIsActive(item, currentHref); + const clickable = !!item.href; + return ( +
+ + {item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null} +
+ ); + }), [currentHref, navigateToTarget]); + const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); if (error) return
{error}
; @@ -664,47 +754,27 @@ export default function BookReadPage() {
) : null} - {tocOpen && ( + {tocOpen && typeof document !== 'undefined' ? createPortal(
setTocOpen(false)}>
event.stopPropagation()} >
-
-
目录
- -
-
- {flatToc.length === 0 ? ( +
+ {tocItems.length === 0 ? (
当前 EPUB 未提供目录
) : ( - flatToc.map((item) => { - const active = activeTocHref === item.href; - return ( - - ); - }) + renderTocItems(tocItems) )}
-
- )} +
, + document.body + ) : null} - {settingsOpen && ( + {settingsOpen && typeof document !== 'undefined' ? createPortal(
setSettingsOpen(false)}>
-
- )} + , + document.body + ) : null} {ready && !tocOpen && !settingsOpen ? ( -
-
+ <> +