电子书室优化

This commit is contained in:
mtvpls
2026-04-29 12:55:04 +08:00
parent 3acb4e82cd
commit fabc3e6bff
3 changed files with 187 additions and 92 deletions
+17 -1
View File
@@ -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<Record<string, BookReadRecord>>({});
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
@@ -44,7 +60,7 @@ export default function BookHistoryPage() {
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{item.title}</div>
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
<div className='mt-1 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}% · {item.chapterTitle || item.locator.chapterTitle || '定位已保存'}</div>
<div className='mt-1 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}</div>
<div className='mt-3 flex flex-wrap gap-2'>
{item.sourceId ? (
<Link
+167 -89
View File
@@ -3,6 +3,7 @@
import { BookOpen, List, Moon, Settings2, Sun } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
buildBookCacheKey,
@@ -13,7 +14,7 @@ import {
} 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';
import { BookReadManifest, BookReadRecord } from '@/lib/book.types';
declare global {
interface Window {
@@ -82,6 +83,7 @@ const DEFAULT_SETTINGS: ReaderSettings = {
lineHeight: 1.7,
theme: 'light',
};
const SAVE_INTERVAL_MS = 10000;
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
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<BookReadManifest, 'book' | 'format' | 'acquisitionHref'>,
onProgress: (received: number, total: number | null) => void
@@ -235,11 +250,14 @@ export default function BookReadPage() {
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const bookRef = useRef<EpubBookInstance | null>(null);
const renditionRef = useRef<EpubRendition | null>(null);
const saveTimerRef = useRef<number | null>(null);
const pendingRecordRef = useRef<BookReadRecord | null>(null);
const pendingRecordDirtyRef = useRef(false);
const saveInFlightRef = useRef(false);
const lastLocationRef = useRef<EpubLocation | null>(null);
const lastProgressRef = useRef(0);
const lastChapterRef = useRef('');
const locationsReadyRef = useRef(false);
const tocItemsRef = useRef<TocItem[]>([]);
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 (
<div key={`${item.href || item.label}-${depth}`} className='space-y-2'>
<button
ref={(node) => {
if (item.href) tocItemRefs.current[item.href] = node;
}}
onClick={() => {
if (!clickable) return;
void navigateToTarget(item.href);
setTocOpen(false);
}}
disabled={!clickable}
className={`group relative block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'} ${!clickable ? 'cursor-default opacity-80' : ''}`}
style={{ paddingLeft: `${16 + depth * 14}px` }}
>
<span className='block truncate'>{item.label}</span>
<div className='pointer-events-none absolute bottom-full left-1/2 z-[100] mb-2 -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out group-hover:visible group-hover:opacity-100 dark:bg-gray-900 whitespace-nowrap'>
<div className='text-sm'>{item.label}</div>
</div>
</button>
{item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null}
</div>
);
}), [currentHref, navigateToTarget]);
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
@@ -664,47 +754,27 @@ export default function BookReadPage() {
</div>
) : null}
{tocOpen && (
{tocOpen && typeof document !== 'undefined' ? createPortal(
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
<div
className='absolute right-0 top-0 h-[calc(100vh-3.5rem)] w-full max-w-sm overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='p-4'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><BookOpen className='h-4 w-4' /></div>
<button onClick={() => setTocOpen(false)} className='text-xs text-gray-500'></button>
</div>
<div ref={tocScrollRef} className='space-y-2'>
{flatToc.length === 0 ? (
<div className='space-y-2' ref={tocScrollRef}>
{tocItems.length === 0 ? (
<div className='p-3 text-sm text-gray-500'> EPUB </div>
) : (
flatToc.map((item) => {
const active = activeTocHref === item.href;
return (
<button
key={`${item.href}-${item.label}`}
ref={(node) => {
tocItemRefs.current[item.href] = node;
}}
onClick={() => {
void navigateToTarget(item.href);
setTocOpen(false);
}}
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white shadow-sm' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
>
{item.label}
</button>
);
})
renderTocItems(tocItems)
)}
</div>
</div>
</div>
</div>
)}
</div>,
document.body
) : null}
{settingsOpen && (
{settingsOpen && typeof document !== 'undefined' ? createPortal(
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
<div
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
@@ -757,16 +827,24 @@ export default function BookReadPage() {
</div>
</div>
</div>
</div>
)}
</div>,
document.body
) : null}
{ready && !tocOpen && !settingsOpen ? (
<div className='absolute inset-0 z-10 grid grid-cols-3'>
<button aria-label='上一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('left')} />
<button aria-label='切换工具栏' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('center')} />
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
</div>
<>
<button
aria-label='上一页'
className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent'
onClick={() => handleReaderTap('left')}
/>
<button
aria-label='下一页'
className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent'
onClick={() => handleReaderTap('right')}
/>
</>
) : null}
<div ref={viewerRef} className='h-full w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
+3 -2
View File
@@ -82,8 +82,9 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
<div className='min-w-0 flex-1'>
<div className='group relative'>
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900 sm:max-w-none sm:whitespace-nowrap'>
<div className='break-words whitespace-normal sm:whitespace-nowrap'>{meta.title}</div>
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900'>
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>{meta.title}</div>
{meta.subtitle ? <div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>{meta.subtitle}</div> : null}
</div>
</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>