From bebda83ac86b2922029256c4d9710e4bb3a778ec Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Mon, 22 Jun 2026 13:06:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=8C=E9=98=B2=E6=AD=A2=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/pages/Reader.tsx | 64 +++++++++- apps/desktop/src/utils/bookCache.ts | 177 ++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/utils/bookCache.ts diff --git a/apps/desktop/src/pages/Reader.tsx b/apps/desktop/src/pages/Reader.tsx index bf9cc107..576350f6 100644 --- a/apps/desktop/src/pages/Reader.tsx +++ b/apps/desktop/src/pages/Reader.tsx @@ -8,6 +8,7 @@ import type { ReaderMode } from '@bookdock/ebook-reader'; import { ArrowLeft, Settings, BookOpen, Bookmark, ChevronLeft, ChevronRight, Volume2, Timer, X, Sun, Moon, ScrollText, Plus, Highlighter, MessageSquare, MessageSquarePlus } from 'lucide-react'; import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import { getCachedChapters, setCachedChapters, getCachedChapterContent, setCachedChapterContent, getCachedFile, setCachedFile } from '../utils/bookCache'; // 设置 PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; @@ -805,15 +806,28 @@ export default function Reader() { const apiClient = getApiClient(); const token = localStorage.getItem('bookdock_auth_token') || ''; const baseUrl = `${apiClient.baseURL}/books/${id}/download`; + + // Try cache first + const cachedBlob = await getCachedFile(id); + if (cachedBlob) { + const pdfBlob = new Blob([cachedBlob], { type: 'application/pdf' }); + const blobUrl = URL.createObjectURL(pdfBlob); + setPdfUrl(blobUrl); + return; + } + + // Fetch from server and cache fetch(`${baseUrl}?token=${encodeURIComponent(token)}`) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob(); }) - .then((blob) => { + .then(async (blob) => { const pdfBlob = new Blob([blob], { type: 'application/pdf' }); const blobUrl = URL.createObjectURL(pdfBlob); setPdfUrl(blobUrl); + // Cache the blob for next time + await setCachedFile(id, blob, 'application/pdf'); }) .catch((err) => { console.error('Failed to load PDF:', err); @@ -824,6 +838,30 @@ export default function Reader() { try { const apiClient = getApiClient(); + + // Try cache first + const cachedChapters = await getCachedChapters(id); + if (cachedChapters && cachedChapters.length > 0) { + const progressRes = await apiClient.getReadingProgress(id); + const progressData = progressRes?.success ? progressRes.data : null; + const savedChapter = progressData?.currentChapter ?? 0; + const savedScroll = progressData?.scrollOffset ?? 0; + if (savedChapter >= 0 && savedChapter < cachedChapters.length) { + setChapters(cachedChapters); + setCurrentChapter(savedChapter); + setPendingScrollTop(savedScroll); + } else { + setChapters(cachedChapters); + } + // Still fetch fresh chapters in background to update cache + apiClient.getChapters(id).then((res) => { + if (res.success && res.data && res.data.length > 0) { + setCachedChapters(id, res.data); + } + }).catch(() => { /* ignore background refresh error */ }); + return; + } + const [chaptersRes, progressRes] = await Promise.allSettled([ apiClient.getChapters(id), apiClient.getReadingProgress(id), @@ -839,6 +877,9 @@ export default function Reader() { : null; if (chaptersData && chaptersData.length > 0) { + // Cache chapters + await setCachedChapters(id, chaptersData); + const savedChapter = progressData?.currentChapter ?? 0; const savedScroll = progressData?.scrollOffset ?? 0; if (savedChapter >= 0 && savedChapter < chaptersData.length) { @@ -865,11 +906,32 @@ export default function Reader() { setIsChapterLoading(true); setReaderError(null); try { + // Try cache first + const cachedContent = await getCachedChapterContent(id, currentChapter); + if (cachedContent) { + if (!cancelled) { + setChapterContent(cachedContent); + setIsChapterLoading(false); + } + // Still fetch fresh content in background to update cache + const apiClient = getApiClient(); + apiClient.getChapterContent(id, currentChapter) + .then((response) => { + if (response.success && response.data && response.data.content) { + setCachedChapterContent(id, currentChapter, response.data.content); + } + }) + .catch(() => { /* ignore background refresh error */ }); + return; + } + const apiClient = getApiClient(); const response = await apiClient.getChapterContent(id, currentChapter); if (cancelled) return; if (response.success && response.data) { setChapterContent(response.data.content); + // Cache the content + await setCachedChapterContent(id, currentChapter, response.data.content); } else { setReaderError(response.error || '加载章节失败'); } diff --git a/apps/desktop/src/utils/bookCache.ts b/apps/desktop/src/utils/bookCache.ts new file mode 100644 index 00000000..4c26df31 --- /dev/null +++ b/apps/desktop/src/utils/bookCache.ts @@ -0,0 +1,177 @@ +/** + * Desktop book cache utility + * Caches book files, chapters, and chapter content to IndexedDB for offline reading. + */ + +const DB_NAME = 'BookDockCache'; +const DB_VERSION = 1; +const STORE_FILES = 'files'; // { bookId -> Blob } +const STORE_CHAPTERS = 'chapters'; // { bookId -> Array<{title, index}> } +const STORE_CONTENT = 'content'; // { `${bookId}:${chapterIndex}` -> string } + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_FILES)) { + db.createObjectStore(STORE_FILES, { keyPath: 'bookId' }); + } + if (!db.objectStoreNames.contains(STORE_CHAPTERS)) { + db.createObjectStore(STORE_CHAPTERS, { keyPath: 'bookId' }); + } + if (!db.objectStoreNames.contains(STORE_CONTENT)) { + db.createObjectStore(STORE_CONTENT, { keyPath: 'key' }); + } + }; + }); +} + +async function getStore(storeName: string, mode: IDBTransactionMode = 'readonly') { + const db = await openDB(); + const tx = db.transaction(storeName, mode); + return tx.objectStore(storeName); +} + +// ── File cache (PDF blob etc.) ─────────────────────────────────────── + +export async function getCachedFile(bookId: string): Promise { + try { + const store = await getStore(STORE_FILES); + const req = store.get(bookId); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const result = req.result; + resolve(result?.blob ? new Blob([result.blob], { type: result.type || 'application/octet-stream' }) : null); + }; + req.onerror = () => reject(req.error); + }); + } catch { + return null; + } +} + +export async function setCachedFile(bookId: string, blob: Blob, type?: string): Promise { + try { + const store = await getStore(STORE_FILES, 'readwrite'); + const arrayBuffer = await blob.arrayBuffer(); + const req = store.put({ bookId, blob: arrayBuffer, type: type || blob.type, cachedAt: Date.now() }); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch (e) { + console.warn('Failed to cache file:', e); + } +} + +export async function deleteCachedFile(bookId: string): Promise { + try { + const store = await getStore(STORE_FILES, 'readwrite'); + const req = store.delete(bookId); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch (e) { + console.warn('Failed to delete cached file:', e); + } +} + +// ── Chapters cache ───────────────────────────────────────────────────── + +export async function getCachedChapters(bookId: string): Promise | null> { + try { + const store = await getStore(STORE_CHAPTERS); + const req = store.get(bookId); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const result = req.result; + resolve(result?.chapters || null); + }; + req.onerror = () => reject(req.error); + }); + } catch { + return null; + } +} + +export async function setCachedChapters(bookId: string, chapters: Array<{ title: string; index: number }>): Promise { + try { + const store = await getStore(STORE_CHAPTERS, 'readwrite'); + const req = store.put({ bookId, chapters, cachedAt: Date.now() }); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch (e) { + console.warn('Failed to cache chapters:', e); + } +} + +// ── Chapter content cache ────────────────────────────────────────────── + +export async function getCachedChapterContent(bookId: string, chapterIndex: number): Promise { + try { + const store = await getStore(STORE_CONTENT); + const req = store.get(`${bookId}:${chapterIndex}`); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const result = req.result; + resolve(result?.content || null); + }; + req.onerror = () => reject(req.error); + }); + } catch { + return null; + } +} + +export async function setCachedChapterContent(bookId: string, chapterIndex: number, content: string): Promise { + try { + const store = await getStore(STORE_CONTENT, 'readwrite'); + const req = store.put({ key: `${bookId}:${chapterIndex}`, content, cachedAt: Date.now() }); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch (e) { + console.warn('Failed to cache chapter content:', e); + } +} + +// ── Clear all cache for a book ───────────────────────────────────────── + +export async function clearBookCache(bookId: string): Promise { + await Promise.all([ + deleteCachedFile(bookId), + (async () => { + try { + const store = await getStore(STORE_CHAPTERS, 'readwrite'); + store.delete(bookId); + } catch { /* ignore */ } + })(), + (async () => { + try { + const store = await getStore(STORE_CONTENT, 'readwrite'); + // Delete all content keys for this book + const range = IDBKeyRange.bound(`${bookId}:`, `${bookId}:\xFF`); + const req = store.openCursor(range); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolve(); + } + }; + req.onerror = () => reject(req.error); + }); + } catch { /* ignore */ } + })(), + ]); +}