优化漫画展馆和电子书室的历史加载速度
This commit is contained in:
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 组件监听数据更新的事件监听器
|
||||
|
||||
Reference in New Issue
Block a user