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

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}
+45 -5
View File
@@ -18,6 +18,25 @@ function trimRecords(records: Record<string, BookReadRecord>) {
return Object.fromEntries(entries.sort(([, a], [, b]) => b.saveTime - a.saveTime).slice(0, MAX_BOOK_HISTORY));
}
function readBookHistoryCache(): Record<string, BookReadRecord> {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(BOOK_HISTORY_KEY);
return raw ? (JSON.parse(raw) as Record<string, BookReadRecord>) : {};
} catch {
return {};
}
}
function writeBookHistoryCache(records: Record<string, BookReadRecord>) {
if (typeof window === 'undefined') return;
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(trimRecords(records)));
}
export function getCachedBookReadRecordsSnapshot(): Record<string, BookReadRecord> {
return readBookHistoryCache();
}
export async function getAllBookShelf(): Promise<Record<string, BookShelfItem>> {
if (typeof window === 'undefined') return {};
if (isRemoteStorage()) {
@@ -56,15 +75,32 @@ export async function deleteBookShelf(sourceId: string, bookId: string): Promise
export async function getAllBookReadRecords(): Promise<Record<string, BookReadRecord>> {
if (typeof window === 'undefined') return {};
if (isRemoteStorage()) {
return (await (await fetchWithAuth('/api/books/history')).json()) as Record<string, BookReadRecord>;
const cachedData = readBookHistoryCache();
if (Object.keys(cachedData).length > 0) {
fetchWithAuth('/api/books/history')
.then((response) => response.json() as Promise<Record<string, BookReadRecord>>)
.then((freshData) => {
writeBookHistoryCache(freshData);
window.dispatchEvent(new CustomEvent('bookHistoryUpdated', { detail: freshData }));
})
.catch(() => undefined);
return cachedData;
}
const freshData = (await (await fetchWithAuth('/api/books/history')).json()) as Record<string, BookReadRecord>;
writeBookHistoryCache(freshData);
return freshData;
}
const raw = localStorage.getItem(BOOK_HISTORY_KEY);
return raw ? (JSON.parse(raw) as Record<string, BookReadRecord>) : {};
return readBookHistoryCache();
}
export async function saveBookReadRecord(sourceId: string, bookId: string, record: BookReadRecord): Promise<void> {
const key = generateStorageKey(sourceId, bookId);
if (isRemoteStorage()) {
const cached = readBookHistoryCache();
cached[key] = record;
writeBookHistoryCache(cached);
window.dispatchEvent(new CustomEvent('bookHistoryUpdated', { detail: trimRecords(cached) }));
await fetchWithAuth('/api/books/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -74,16 +110,20 @@ export async function saveBookReadRecord(sourceId: string, bookId: string, recor
}
const data = await getAllBookReadRecords();
data[key] = record;
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(trimRecords(data)));
writeBookHistoryCache(data);
}
export async function deleteBookReadRecord(sourceId: string, bookId: string): Promise<void> {
const key = generateStorageKey(sourceId, bookId);
if (isRemoteStorage()) {
const cached = readBookHistoryCache();
delete cached[key];
writeBookHistoryCache(cached);
window.dispatchEvent(new CustomEvent('bookHistoryUpdated', { detail: cached }));
await fetchWithAuth(`/api/books/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
return;
}
const data = await getAllBookReadRecords();
delete data[key];
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(data));
writeBookHistoryCache(data);
}
+38 -1
View File
@@ -824,6 +824,42 @@ export function getCachedPlayRecordsSnapshot(): Record<string, PlayRecord> {
}
}
export function getCachedMangaReadRecordsSnapshot(): Record<string, MangaReadRecord> {
if (typeof window === 'undefined') {
return {};
}
if (STORAGE_TYPE !== 'localstorage') {
const cachedRecords = cacheManager.getCachedMangaReadRecords();
if (cachedRecords) {
return cachedRecords;
}
try {
const username = getAuthInfoFromBrowserCookie()?.username;
if (!username) return {};
const raw = localStorage.getItem(`${CACHE_PREFIX}${username}`);
if (!raw) return {};
const userCache = JSON.parse(raw) as UserCacheStore;
return userCache.mangaReadRecords?.data || {};
} catch (err) {
console.error('读取用户漫画历史快照失败:', err);
return {};
}
}
try {
const raw = localStorage.getItem(MANGA_HISTORY_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, MangaReadRecord>;
} catch (err) {
console.error('读取本地漫画历史快照失败:', err);
return {};
}
}
/**
* 保存播放记录。
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
@@ -1965,7 +2001,8 @@ export type CacheUpdateEvent =
| 'searchHistoryUpdated'
| 'skipConfigsUpdated'
| 'mangaShelfUpdated'
| 'mangaHistoryUpdated';
| 'mangaHistoryUpdated'
| 'bookHistoryUpdated';
/**
* 用于 React 组件监听数据更新的事件监听器