Skip to content

Commit bebda83

Browse files
committed
feat: 新增文件缓存,防止重复请求
1 parent 3cc21bc commit bebda83

2 files changed

Lines changed: 240 additions & 1 deletion

File tree

apps/desktop/src/pages/Reader.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ReaderMode } from '@bookdock/ebook-reader';
88
import { ArrowLeft, Settings, BookOpen, Bookmark, ChevronLeft, ChevronRight, Volume2, Timer, X, Sun, Moon, ScrollText, Plus, Highlighter, MessageSquare, MessageSquarePlus } from 'lucide-react';
99
import * as pdfjsLib from 'pdfjs-dist';
1010
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
11+
import { getCachedChapters, setCachedChapters, getCachedChapterContent, setCachedChapterContent, getCachedFile, setCachedFile } from '../utils/bookCache';
1112

1213
// 设置 PDF.js worker
1314
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
@@ -805,15 +806,28 @@ export default function Reader() {
805806
const apiClient = getApiClient();
806807
const token = localStorage.getItem('bookdock_auth_token') || '';
807808
const baseUrl = `${apiClient.baseURL}/books/${id}/download`;
809+
810+
// Try cache first
811+
const cachedBlob = await getCachedFile(id);
812+
if (cachedBlob) {
813+
const pdfBlob = new Blob([cachedBlob], { type: 'application/pdf' });
814+
const blobUrl = URL.createObjectURL(pdfBlob);
815+
setPdfUrl(blobUrl);
816+
return;
817+
}
818+
819+
// Fetch from server and cache
808820
fetch(`${baseUrl}?token=${encodeURIComponent(token)}`)
809821
.then((res) => {
810822
if (!res.ok) throw new Error(`HTTP ${res.status}`);
811823
return res.blob();
812824
})
813-
.then((blob) => {
825+
.then(async (blob) => {
814826
const pdfBlob = new Blob([blob], { type: 'application/pdf' });
815827
const blobUrl = URL.createObjectURL(pdfBlob);
816828
setPdfUrl(blobUrl);
829+
// Cache the blob for next time
830+
await setCachedFile(id, blob, 'application/pdf');
817831
})
818832
.catch((err) => {
819833
console.error('Failed to load PDF:', err);
@@ -824,6 +838,30 @@ export default function Reader() {
824838

825839
try {
826840
const apiClient = getApiClient();
841+
842+
// Try cache first
843+
const cachedChapters = await getCachedChapters(id);
844+
if (cachedChapters && cachedChapters.length > 0) {
845+
const progressRes = await apiClient.getReadingProgress(id);
846+
const progressData = progressRes?.success ? progressRes.data : null;
847+
const savedChapter = progressData?.currentChapter ?? 0;
848+
const savedScroll = progressData?.scrollOffset ?? 0;
849+
if (savedChapter >= 0 && savedChapter < cachedChapters.length) {
850+
setChapters(cachedChapters);
851+
setCurrentChapter(savedChapter);
852+
setPendingScrollTop(savedScroll);
853+
} else {
854+
setChapters(cachedChapters);
855+
}
856+
// Still fetch fresh chapters in background to update cache
857+
apiClient.getChapters(id).then((res) => {
858+
if (res.success && res.data && res.data.length > 0) {
859+
setCachedChapters(id, res.data);
860+
}
861+
}).catch(() => { /* ignore background refresh error */ });
862+
return;
863+
}
864+
827865
const [chaptersRes, progressRes] = await Promise.allSettled([
828866
apiClient.getChapters(id),
829867
apiClient.getReadingProgress(id),
@@ -839,6 +877,9 @@ export default function Reader() {
839877
: null;
840878

841879
if (chaptersData && chaptersData.length > 0) {
880+
// Cache chapters
881+
await setCachedChapters(id, chaptersData);
882+
842883
const savedChapter = progressData?.currentChapter ?? 0;
843884
const savedScroll = progressData?.scrollOffset ?? 0;
844885
if (savedChapter >= 0 && savedChapter < chaptersData.length) {
@@ -865,11 +906,32 @@ export default function Reader() {
865906
setIsChapterLoading(true);
866907
setReaderError(null);
867908
try {
909+
// Try cache first
910+
const cachedContent = await getCachedChapterContent(id, currentChapter);
911+
if (cachedContent) {
912+
if (!cancelled) {
913+
setChapterContent(cachedContent);
914+
setIsChapterLoading(false);
915+
}
916+
// Still fetch fresh content in background to update cache
917+
const apiClient = getApiClient();
918+
apiClient.getChapterContent(id, currentChapter)
919+
.then((response) => {
920+
if (response.success && response.data && response.data.content) {
921+
setCachedChapterContent(id, currentChapter, response.data.content);
922+
}
923+
})
924+
.catch(() => { /* ignore background refresh error */ });
925+
return;
926+
}
927+
868928
const apiClient = getApiClient();
869929
const response = await apiClient.getChapterContent(id, currentChapter);
870930
if (cancelled) return;
871931
if (response.success && response.data) {
872932
setChapterContent(response.data.content);
933+
// Cache the content
934+
await setCachedChapterContent(id, currentChapter, response.data.content);
873935
} else {
874936
setReaderError(response.error || '加载章节失败');
875937
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Desktop book cache utility
3+
* Caches book files, chapters, and chapter content to IndexedDB for offline reading.
4+
*/
5+
6+
const DB_NAME = 'BookDockCache';
7+
const DB_VERSION = 1;
8+
const STORE_FILES = 'files'; // { bookId -> Blob }
9+
const STORE_CHAPTERS = 'chapters'; // { bookId -> Array<{title, index}> }
10+
const STORE_CONTENT = 'content'; // { `${bookId}:${chapterIndex}` -> string }
11+
12+
function openDB(): Promise<IDBDatabase> {
13+
return new Promise((resolve, reject) => {
14+
const req = indexedDB.open(DB_NAME, DB_VERSION);
15+
req.onerror = () => reject(req.error);
16+
req.onsuccess = () => resolve(req.result);
17+
req.onupgradeneeded = (event) => {
18+
const db = (event.target as IDBOpenDBRequest).result;
19+
if (!db.objectStoreNames.contains(STORE_FILES)) {
20+
db.createObjectStore(STORE_FILES, { keyPath: 'bookId' });
21+
}
22+
if (!db.objectStoreNames.contains(STORE_CHAPTERS)) {
23+
db.createObjectStore(STORE_CHAPTERS, { keyPath: 'bookId' });
24+
}
25+
if (!db.objectStoreNames.contains(STORE_CONTENT)) {
26+
db.createObjectStore(STORE_CONTENT, { keyPath: 'key' });
27+
}
28+
};
29+
});
30+
}
31+
32+
async function getStore(storeName: string, mode: IDBTransactionMode = 'readonly') {
33+
const db = await openDB();
34+
const tx = db.transaction(storeName, mode);
35+
return tx.objectStore(storeName);
36+
}
37+
38+
// ── File cache (PDF blob etc.) ───────────────────────────────────────
39+
40+
export async function getCachedFile(bookId: string): Promise<Blob | null> {
41+
try {
42+
const store = await getStore(STORE_FILES);
43+
const req = store.get(bookId);
44+
return new Promise((resolve, reject) => {
45+
req.onsuccess = () => {
46+
const result = req.result;
47+
resolve(result?.blob ? new Blob([result.blob], { type: result.type || 'application/octet-stream' }) : null);
48+
};
49+
req.onerror = () => reject(req.error);
50+
});
51+
} catch {
52+
return null;
53+
}
54+
}
55+
56+
export async function setCachedFile(bookId: string, blob: Blob, type?: string): Promise<void> {
57+
try {
58+
const store = await getStore(STORE_FILES, 'readwrite');
59+
const arrayBuffer = await blob.arrayBuffer();
60+
const req = store.put({ bookId, blob: arrayBuffer, type: type || blob.type, cachedAt: Date.now() });
61+
return new Promise((resolve, reject) => {
62+
req.onsuccess = () => resolve();
63+
req.onerror = () => reject(req.error);
64+
});
65+
} catch (e) {
66+
console.warn('Failed to cache file:', e);
67+
}
68+
}
69+
70+
export async function deleteCachedFile(bookId: string): Promise<void> {
71+
try {
72+
const store = await getStore(STORE_FILES, 'readwrite');
73+
const req = store.delete(bookId);
74+
return new Promise((resolve, reject) => {
75+
req.onsuccess = () => resolve();
76+
req.onerror = () => reject(req.error);
77+
});
78+
} catch (e) {
79+
console.warn('Failed to delete cached file:', e);
80+
}
81+
}
82+
83+
// ── Chapters cache ─────────────────────────────────────────────────────
84+
85+
export async function getCachedChapters(bookId: string): Promise<Array<{ title: string; index: number }> | null> {
86+
try {
87+
const store = await getStore(STORE_CHAPTERS);
88+
const req = store.get(bookId);
89+
return new Promise((resolve, reject) => {
90+
req.onsuccess = () => {
91+
const result = req.result;
92+
resolve(result?.chapters || null);
93+
};
94+
req.onerror = () => reject(req.error);
95+
});
96+
} catch {
97+
return null;
98+
}
99+
}
100+
101+
export async function setCachedChapters(bookId: string, chapters: Array<{ title: string; index: number }>): Promise<void> {
102+
try {
103+
const store = await getStore(STORE_CHAPTERS, 'readwrite');
104+
const req = store.put({ bookId, chapters, cachedAt: Date.now() });
105+
return new Promise((resolve, reject) => {
106+
req.onsuccess = () => resolve();
107+
req.onerror = () => reject(req.error);
108+
});
109+
} catch (e) {
110+
console.warn('Failed to cache chapters:', e);
111+
}
112+
}
113+
114+
// ── Chapter content cache ──────────────────────────────────────────────
115+
116+
export async function getCachedChapterContent(bookId: string, chapterIndex: number): Promise<string | null> {
117+
try {
118+
const store = await getStore(STORE_CONTENT);
119+
const req = store.get(`${bookId}:${chapterIndex}`);
120+
return new Promise((resolve, reject) => {
121+
req.onsuccess = () => {
122+
const result = req.result;
123+
resolve(result?.content || null);
124+
};
125+
req.onerror = () => reject(req.error);
126+
});
127+
} catch {
128+
return null;
129+
}
130+
}
131+
132+
export async function setCachedChapterContent(bookId: string, chapterIndex: number, content: string): Promise<void> {
133+
try {
134+
const store = await getStore(STORE_CONTENT, 'readwrite');
135+
const req = store.put({ key: `${bookId}:${chapterIndex}`, content, cachedAt: Date.now() });
136+
return new Promise((resolve, reject) => {
137+
req.onsuccess = () => resolve();
138+
req.onerror = () => reject(req.error);
139+
});
140+
} catch (e) {
141+
console.warn('Failed to cache chapter content:', e);
142+
}
143+
}
144+
145+
// ── Clear all cache for a book ─────────────────────────────────────────
146+
147+
export async function clearBookCache(bookId: string): Promise<void> {
148+
await Promise.all([
149+
deleteCachedFile(bookId),
150+
(async () => {
151+
try {
152+
const store = await getStore(STORE_CHAPTERS, 'readwrite');
153+
store.delete(bookId);
154+
} catch { /* ignore */ }
155+
})(),
156+
(async () => {
157+
try {
158+
const store = await getStore(STORE_CONTENT, 'readwrite');
159+
// Delete all content keys for this book
160+
const range = IDBKeyRange.bound(`${bookId}:`, `${bookId}:\xFF`);
161+
const req = store.openCursor(range);
162+
return new Promise<void>((resolve, reject) => {
163+
req.onsuccess = () => {
164+
const cursor = req.result;
165+
if (cursor) {
166+
cursor.delete();
167+
cursor.continue();
168+
} else {
169+
resolve();
170+
}
171+
};
172+
req.onerror = () => reject(req.error);
173+
});
174+
} catch { /* ignore */ }
175+
})(),
176+
]);
177+
}

0 commit comments

Comments
 (0)