From 3a6e38573361160ac599a85eb53afd482a9a44a0 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 2 May 2026 10:26:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=BC=AB=E7=94=BB=E5=B1=95?= =?UTF-8?q?=E9=A6=86=E5=92=8C=E7=94=B5=E5=AD=90=E4=B9=A6=E5=AE=A4=E7=9A=84?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=8A=A0=E8=BD=BD=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/books/history/page.tsx | 104 ++++++++++++++++++++++++--------- src/app/manga/history/page.tsx | 34 ++++++++--- src/lib/book.db.client.ts | 50 ++++++++++++++-- src/lib/db.client.ts | 39 ++++++++++++- 4 files changed, 188 insertions(+), 39 deletions(-) diff --git a/src/app/books/history/page.tsx b/src/app/books/history/page.tsx index 74ffe67..99913f0 100644 --- a/src/app/books/history/page.tsx +++ b/src/app/books/history/page.tsx @@ -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 ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + export default function BookHistoryPage() { const [records, setRecords] = useState>({}); const [shelf, setShelf] = useState>({}); + const [loading, setLoading] = useState(true); const [cacheModalOpen, setCacheModalOpen] = useState(false); const [cacheItems, setCacheItems] = useState([]); 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) => { + 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>('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() {
- {items.map((item) => ( -
-
-
{item.cover ? {item.title} : null}
-
-
{item.title}
-
{item.author || item.sourceName}
-
已读 {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}
-
- {item.sourceId ? ( - { 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' - > - 继续阅读 - - ) : ( - 历史记录缺少书源信息 - )} - + {loading ? ( + + ) : ( + visibleItems.map((item) => ( +
+
+
{item.cover ? {item.title} : null}
+
+
{item.title}
+
{item.author || item.sourceName}
+
已读 {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}
+
+ {item.sourceId ? ( + { 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' + > + 继续阅读 + + ) : ( + 历史记录缺少书源信息 + )} + +
-
- ))} - {items.length === 0 ?
暂无阅读历史
: null} + )) + )} + {!loading && items.length === 0 ?
暂无阅读历史
: null} {cacheModalOpen && mounted && createPortal(
setCacheModalOpen(false)}> diff --git a/src/app/manga/history/page.tsx b/src/app/manga/history/page.tsx index ddd9800..ba9e9aa 100644 --- a/src/app/manga/history/page.tsx +++ b/src/app/manga/history/page.tsx @@ -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>({}); const [loading, setLoading] = useState(true); const [shelf, setShelf] = useState>({}); + const [displayAll, setDisplayAll] = useState(false); + + const updateHistory = (nextHistory: Record) => { + 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>('mangaHistoryUpdated', setHistory); + const unsubscribeHistory = subscribeToDataUpdates>('mangaHistoryUpdated', updateHistory); const unsubscribeShelf = subscribeToDataUpdates>('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() {
) : (
- {historyList.map(([key, item]) => ( + {visibleHistoryList.map(([key, item]) => ( ) { return Object.fromEntries(entries.sort(([, a], [, b]) => b.saveTime - a.saveTime).slice(0, MAX_BOOK_HISTORY)); } +function readBookHistoryCache(): Record { + if (typeof window === 'undefined') return {}; + try { + const raw = localStorage.getItem(BOOK_HISTORY_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function writeBookHistoryCache(records: Record) { + if (typeof window === 'undefined') return; + localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(trimRecords(records))); +} + +export function getCachedBookReadRecordsSnapshot(): Record { + return readBookHistoryCache(); +} + export async function getAllBookShelf(): Promise> { 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> { if (typeof window === 'undefined') return {}; if (isRemoteStorage()) { - return (await (await fetchWithAuth('/api/books/history')).json()) as Record; + const cachedData = readBookHistoryCache(); + if (Object.keys(cachedData).length > 0) { + fetchWithAuth('/api/books/history') + .then((response) => response.json() as Promise>) + .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; + writeBookHistoryCache(freshData); + return freshData; } - const raw = localStorage.getItem(BOOK_HISTORY_KEY); - return raw ? (JSON.parse(raw) as Record) : {}; + return readBookHistoryCache(); } export async function saveBookReadRecord(sourceId: string, bookId: string, record: BookReadRecord): Promise { 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 { 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); } diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 119e361..0e0ccca 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -824,6 +824,42 @@ export function getCachedPlayRecordsSnapshot(): Record { } } +export function getCachedMangaReadRecordsSnapshot(): Record { + 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; + } catch (err) { + console.error('读取本地漫画历史快照失败:', err); + return {}; + } +} + /** * 保存播放记录。 * 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。 @@ -1965,7 +2001,8 @@ export type CacheUpdateEvent = | 'searchHistoryUpdated' | 'skipConfigsUpdated' | 'mangaShelfUpdated' - | 'mangaHistoryUpdated'; + | 'mangaHistoryUpdated' + | 'bookHistoryUpdated'; /** * 用于 React 组件监听数据更新的事件监听器