优化漫画展馆和电子书室的历史加载速度

This commit is contained in:
mtvpls
2026-05-02 10:26:12 +08:00
parent cc7fa6fb98
commit 3a6e385733
4 changed files with 188 additions and 39 deletions
+78 -26
View File
@@ -7,8 +7,9 @@ import { createPortal } from 'react-dom';
import { deleteCachedBookFile, listCachedBookFiles, type CachedBookFile } from '@/lib/book-cache.client';
import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/lib/book-route-cache.client';
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client';
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf, getCachedBookReadRecordsSnapshot } from '@/lib/book.db.client';
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
import { subscribeToDataUpdates } from '@/lib/db.client';
function looksLikeInternalHref(value?: string) {
if (!value) return false;
@@ -31,19 +32,62 @@ function formatBytes(size: number) {
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function BookHistorySkeleton() {
return (
<div className='space-y-4'>
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='flex gap-4'>
<div className='h-28 w-20 animate-pulse overflow-hidden rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='min-w-0 flex-1 space-y-3'>
<div className='h-5 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-1/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='flex gap-2 pt-1'>
<div className='h-9 w-20 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-9 w-16 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
</div>
</div>
</div>
</div>
))}
</div>
);
}
export default function BookHistoryPage() {
const [records, setRecords] = useState<Record<string, BookReadRecord>>({});
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
const [loading, setLoading] = useState(true);
const [cacheModalOpen, setCacheModalOpen] = useState(false);
const [cacheItems, setCacheItems] = useState<CachedBookFile[]>([]);
const [cacheLoading, setCacheLoading] = useState(false);
const [mounted, setMounted] = useState(false);
const [confirmAction, setConfirmAction] = useState<{ type: 'delete-one' | 'clear-all'; key?: string; title?: string } | null>(null);
const [displayAll, setDisplayAll] = useState(false);
const updateRecords = (nextRecords: Record<string, BookReadRecord>) => {
const count = Object.keys(nextRecords).length;
setRecords(nextRecords);
setDisplayAll(count <= 10);
if (count > 10) {
setTimeout(() => setDisplayAll(true), 0);
}
};
useEffect(() => {
setMounted(true);
getAllBookReadRecords().then(setRecords).catch(() => undefined);
const cachedRecords = getCachedBookReadRecordsSnapshot();
if (Object.keys(cachedRecords).length > 0) {
updateRecords(cachedRecords);
setLoading(false);
}
getAllBookReadRecords().then(updateRecords).catch(() => undefined).finally(() => setLoading(false));
getAllBookShelf().then(setShelf).catch(() => undefined);
const unsubscribeHistory = subscribeToDataUpdates<Record<string, BookReadRecord>>('bookHistoryUpdated', updateRecords);
return unsubscribeHistory;
}, []);
const loadCacheItems = async () => {
@@ -79,6 +123,10 @@ export default function BookHistoryPage() {
};
})
.sort((a, b) => b.saveTime - a.saveTime), [records, shelf]);
const visibleItems = useMemo(
() => (displayAll ? items : items.slice(0, 10)),
[displayAll, items]
);
const cacheTotalSize = useMemo(() => cacheItems.reduce((sum, item) => sum + item.size, 0), [cacheItems]);
@@ -97,33 +145,37 @@ export default function BookHistoryPage() {
</button>
</div>
{items.map((item) => (
<div key={item.storageKey} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='flex gap-4'>
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
<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)}% · {getReadableChapterLabel(item)}</div>
<div className='mt-3 flex flex-wrap gap-2'>
{item.sourceId ? (
<Link
href={buildBookReadPath(item.sourceId, item.bookId)}
onClick={() => { 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'
>
</Link>
) : (
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'></span>
)}
<button onClick={async () => { const [deleteSourceId = item.sourceId, deleteBookId = item.bookId] = item.storageKey.split('+'); await deleteBookReadRecord(deleteSourceId, deleteBookId); setRecords((prev) => { const next = { ...prev }; delete next[item.storageKey]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button>
{loading ? (
<BookHistorySkeleton />
) : (
visibleItems.map((item) => (
<div key={item.storageKey} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='flex gap-4'>
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
<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)}% · {getReadableChapterLabel(item)}</div>
<div className='mt-3 flex flex-wrap gap-2'>
{item.sourceId ? (
<Link
href={buildBookReadPath(item.sourceId, item.bookId)}
onClick={() => { 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'
>
</Link>
) : (
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'></span>
)}
<button onClick={async () => { const [deleteSourceId = item.sourceId, deleteBookId = item.bookId] = item.storageKey.split('+'); await deleteBookReadRecord(deleteSourceId, deleteBookId); updateRecords((() => { const next = { ...records }; delete next[item.storageKey]; return next; })()); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button>
</div>
</div>
</div>
</div>
</div>
))}
{items.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
))
)}
{!loading && items.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
{cacheModalOpen && mounted && createPortal(
<div className='fixed inset-0 z-50 bg-black/40' onClick={() => setCacheModalOpen(false)}>
+27 -7
View File
@@ -3,7 +3,7 @@
import { History } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { deleteMangaReadRecord, deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf, subscribeToDataUpdates } from '@/lib/db.client';
import { deleteMangaReadRecord, deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, getCachedMangaReadRecordsSnapshot, saveMangaShelf, subscribeToDataUpdates } from '@/lib/db.client';
import { MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
import MangaHistoryCard from '@/components/manga/MangaHistoryCard';
@@ -28,17 +28,33 @@ export default function MangaHistoryPage() {
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
const [loading, setLoading] = useState(true);
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
const [displayAll, setDisplayAll] = useState(false);
const updateHistory = (nextHistory: Record<string, MangaReadRecord>) => {
const sortedCount = Object.keys(nextHistory).length;
setHistory(nextHistory);
setDisplayAll(sortedCount <= 10);
if (sortedCount > 10) {
setTimeout(() => setDisplayAll(true), 0);
}
};
useEffect(() => {
const cachedHistory = getCachedMangaReadRecordsSnapshot();
if (Object.keys(cachedHistory).length > 0) {
updateHistory(cachedHistory);
setLoading(false);
}
Promise.all([getAllMangaReadRecords(), getAllMangaShelf()])
.then(([historyData, shelfData]) => {
setHistory(historyData);
updateHistory(historyData);
setShelf(shelfData);
})
.catch(() => undefined)
.finally(() => setLoading(false));
const unsubscribeHistory = subscribeToDataUpdates<Record<string, MangaReadRecord>>('mangaHistoryUpdated', setHistory);
const unsubscribeHistory = subscribeToDataUpdates<Record<string, MangaReadRecord>>('mangaHistoryUpdated', updateHistory);
const unsubscribeShelf = subscribeToDataUpdates<Record<string, MangaShelfItem>>('mangaShelfUpdated', setShelf);
return () => {
@@ -51,6 +67,10 @@ export default function MangaHistoryPage() {
() => Object.entries(history).sort(([, a], [, b]) => b.saveTime - a.saveTime),
[history]
);
const visibleHistoryList = useMemo(
() => (displayAll ? historyList : historyList.slice(0, 10)),
[displayAll, historyList]
);
const toggleShelf = async (item: MangaReadRecord) => {
@@ -83,11 +103,11 @@ export default function MangaHistoryPage() {
const deleteHistory = async (item: MangaReadRecord) => {
const key = `${item.sourceId}+${item.mangaId}`;
await deleteMangaReadRecord(item.sourceId, item.mangaId);
setHistory((prev) => {
const next = { ...prev };
updateHistory((() => {
const next = { ...history };
delete next[key];
return next;
});
})());
};
return (
@@ -103,7 +123,7 @@ export default function MangaHistoryPage() {
</div>
) : (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{historyList.map(([key, item]) => (
{visibleHistoryList.map(([key, item]) => (
<MangaHistoryCard
key={key}
item={item}