Add support to load ch in manage db

This commit is contained in:
2026-02-18 16:14:55 +08:00
parent dc097031ed
commit f56a9e3b2a
13 changed files with 180 additions and 14 deletions

View File

@@ -40,4 +40,7 @@ export default defineConfig([
"@typescript-eslint/no-empty-object-type": "off",
}
},
{
ignores: ['build.js'],
}
]);

View File

@@ -12,6 +12,7 @@
"esbuild-plugin-eslint": "^0.3.12",
"eslint": "9",
"eslint-plugin-react": "^7.37.5",
"lodash.isequal": "^4.5.0",
"pocketbase": "^0.26.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View File

@@ -61,7 +61,37 @@ function get_datas<T>(db: IDBDatabase, storeName: string, key?: IDBValidKey | ID
});
}
function get_data_with_convert<T, U>(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise<void> | void , key?: IDBValidKey | IDBKeyRange, index?: string): Promise<U[]> {
function get_data_with_convert<T, U>(db: IDBDatabase, storeName: string, key: IDBValidKey | IDBKeyRange, convert: (key: IDBValidKey, data: T) => Promise<U> | U, index?: string, direction?: IDBCursorDirection): Promise<U | undefined> {
return new Promise<U | undefined>((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = index ? store.index(index).openCursor(key, direction) : store.openCursor(key, direction);
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
try {
const res = convert(cursor.primaryKey, cursor.value);
if (res instanceof Promise) {
res.then(resolve).catch(err => {
reject(err);
});
return;
}
resolve(res);
} catch (err) {
reject(err);
}
} else {
resolve(undefined);
}
};
req.onerror = () => {
reject(req.error);
};
});
}
function get_datas_with_convert<T, U>(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise<void> | void, key?: IDBValidKey | IDBKeyRange, index?: string): Promise<U[]> {
return new Promise<U[]>((resolve, reject) => {
const list: U[] = [];
const tx = db.transaction(storeName, 'readonly');
@@ -258,10 +288,10 @@ export class IndexedDb implements Db {
async getQdBook(id: number): Promise<QdBookInfo | undefined> {
return await get_data(this.qddb, 'books', id);
}
async getChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]> {
async getQdChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]> {
// chapterId-> [index, time]
const currents: Map<number, [number, number]> = new Map();
return await get_data_with_convert<QdChapterInfo | CompressedQdChapterInfo, QdChapterSimpleInfo>(this.qddb, 'chapters', async (key, data, list) => {
return await get_datas_with_convert<QdChapterInfo | CompressedQdChapterInfo, QdChapterSimpleInfo>(this.qddb, 'chapters', async (key, data, list) => {
if ('compressed' in data) {
const decompressed = await decompress(data.compressed);
const decoded = new TextDecoder().decode(decompressed);
@@ -277,6 +307,7 @@ export class IndexedDb implements Db {
id: data.id,
name: data.chapterInfo.chapterName,
bookId: data.bookId,
time: data.time,
};
} else if (!oldValue) {
currents.set(data.id, value);
@@ -285,10 +316,34 @@ export class IndexedDb implements Db {
id: data.id,
name: data.chapterInfo.chapterName,
bookId: data.bookId,
time: data.time,
});
}
}, bookId, 'bookId');
}
async getQdChapter(key: unknown): Promise<QdChapterInfo | undefined> {
const k = key as QdChapterKey;
const data = await get_data<CompressedQdChapterInfo | QdChapterInfo>(this.qddb, 'chapters', k);
if (!data) {
return undefined;
}
if ('compressed' in data) {
const decompressed = await decompress(data.compressed);
const decoded = new TextDecoder().decode(decompressed);
return JSON.parse(decoded) as QdChapterInfo;
}
return data;
}
async getLatestQdChapter(id: number): Promise<QdChapterInfo | undefined> {
return await get_data_with_convert<CompressedQdChapterInfo | QdChapterInfo, QdChapterInfo>(this.qddb, 'chapters', id, async (key, data) => {
if ('compressed' in data) {
const decompressed = await decompress(data.compressed);
const decoded = new TextDecoder().decode(decompressed);
return JSON.parse(decoded) as QdChapterInfo;
}
return data;
}, 'id', 'prevunique');
}
close() {
this.qddb.close();
}

View File

@@ -22,7 +22,17 @@ export interface Db {
* Primary key should be the latest (time is biggest) saved chapter.
* @param bookId Book ID
*/
getChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]>;
getQdChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]>;
/**
* Get chapter info by primary key. if not found, return undefined.
* @param key Primary key of the chapter, which is determined by the database implementation.
*/
getQdChapter(key: unknown): Promise<QdChapterInfo | undefined>;
/**
* Get the latest (which time is biggest) chapter of a chapter. if not found, return undefined.
* @param id Chapter ID
*/
getLatestQdChapter(id: number): Promise<QdChapterInfo | undefined>;
close(): void;
}

View File

@@ -218,7 +218,7 @@ export class PocketBaseDb implements Db {
});
return records.totalItems > 0 ? records.items[0].data : undefined;
}
async getChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]> {
async getQdChapterSimpleInfos(bookId: number): Promise<QdChapterSimpleInfo[]> {
// chapterId -> [index, time]
const currents: Map<number, [number, number]> = new Map();
const list = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getFullList({
@@ -239,6 +239,7 @@ export class PocketBaseDb implements Db {
id: item.chapterId,
name: data.chapterInfo.chapterName,
bookId: item.bookId,
time: item.time,
};
} else if (!oldValue) {
currents.set(key, value);
@@ -247,11 +248,28 @@ export class PocketBaseDb implements Db {
id: item.chapterId,
name: data.chapterInfo.chapterName,
bookId: item.bookId,
time: item.time,
});
}
}
return re;
}
async getQdChapter(key: unknown): Promise<QdChapterInfo | undefined> {
const k = String(key);
const record = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, {
filter: `id = "${k}"`,
fields: 'data',
});
return record.totalItems > 0 ? record.items[0].data : undefined;
}
async getLatestQdChapter(id: number): Promise<QdChapterInfo | undefined> {
const records = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, {
filter: `chapterId = ${id}`,
fields: 'data',
sort: '-time',
});
return records.totalItems > 0 ? records.items[0].data : undefined;
}
close(): void {
this.client.cancelAllRequests();
this.client.authStore.clear();

View File

@@ -9,6 +9,7 @@ import { Result } from 'antd';
import { DbContext } from "./manage/dbProvider";
import QdBook from "./manage/qd/Book";
import QdBookIndex from "./manage/qd/BookIndex";
import QdBookChapter from "./manage/qd/BookChapter";
const router = createHashRouter([
{
@@ -26,6 +27,10 @@ const router = createHashRouter([
{
index: true,
element: <QdBookIndex />
},
{
path: "chapter/:chapterId",
element: <QdBookChapter />
}
],
}

View File

@@ -5,6 +5,8 @@ import type { QdBookInfo } from "../../types";
import { useEffect, useState } from "react";
import { BookInfoContext } from "./BookInfoProvider";
import { BookStatusContext, createBookStatus } from "./BookStatusProvider";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
import isEqual from "lodash.isequal";
export default function Book() {
const db = useDb();
@@ -12,6 +14,7 @@ export default function Book() {
const [book, setBook] = useState<QdBookInfo | null>(null);
const [err, setErr] = useState<string | null>(null);
const [bookStatus, setBookStatus] = useState(createBookStatus());
const [items, setItems] = useState<ItemType[]>([]);
async function load() {
const data = await db.getQdBook(Number(id));
if (data) {
@@ -20,6 +23,11 @@ export default function Book() {
setErr("书籍不存在");
}
}
function setItemsIfNeeded(newItems: ItemType[]) {
if (!isEqual(items, newItems)) {
setItems(newItems);
}
}
function handle() {
load().catch(e => {
setErr(e instanceof Error ? e.message : String(e));
@@ -28,6 +36,7 @@ export default function Book() {
useEffect(() => {
handle();
}, [id]);
const title = book ? `书籍详情:${book.bookName}` : '书籍详情';
return (
<>
<Breadcrumb items={
@@ -38,8 +47,10 @@ export default function Book() {
}, {
title: <NavLink to="/qd/books"></NavLink>
}, {
title: book ? `书籍详情:${book.bookName}` : '书籍详情'
}]
title: items.length > 0 ? <NavLink to={`/qd/book/${id}`}>{title}</NavLink> : title
},
...items,
]
} />
{!book && !err && <Skeleton active />}
{err && <Result
@@ -49,7 +60,7 @@ export default function Book() {
extra={<Button type="primary" onClick={() => { setErr(null); handle(); }}></Button>} />}
{book && (<BookInfoContext.Provider value={book}>
<BookStatusContext.Provider value={[bookStatus, setBookStatus]}>
<Outlet />
<Outlet context={setItemsIfNeeded} />
</BookStatusContext.Provider>
</BookInfoContext.Provider>)}
</>

View File

@@ -0,0 +1,50 @@
import { Button, Result } from "antd";
import { useParams } from "react-router";
import { useBookContext, useBookStatus } from "./BookStatusProvider";
import { useEffect, useState } from "react";
import { useDb } from "../dbProvider";
import type { QdChapterInfo } from "../../types";
export default function BookChapter() {
const setItems = useBookContext();
const { chapterId } = useParams();
const id = parseInt(chapterId ?? '');
const [bookStatus, setBookStatus] = useBookStatus();
const db = useDb();
const [err, setErr] = useState<string | null>(null);
const [chapter, setChapter] = useState<QdChapterInfo | null>(null);
async function load() {
const primaryKey = bookStatus.chapterLists?.find(chapter => chapter.id === id)?.primaryKey;
const data = await (primaryKey ? db.getQdChapter(primaryKey) : db.getLatestQdChapter(id));
if (data) {
setChapter(data);
} else {
setErr("章节不存在");
}
}
useEffect(() => {
if (isNaN(id)) {
return;
}
load().catch(e => {
setErr(e instanceof Error ? e.message : String(e));
});
}, [id]);
setItems([{
title: chapter ? `章节详情:${chapter.chapterInfo.chapterName}` : '章节详情'
}])
if (isNaN(id)) {
return <Result
status="error"
title="章节ID无效"
/>;
}
if (err) {
return <Result
status="error"
title="数据加载失败"
subTitle={err}
extra={<Button type="primary" onClick={() => { setErr(null); load(); }}></Button>} />;
}
return (<></>)
}

View File

@@ -1,7 +1,7 @@
import { Affix, Flex, Space, Tag, Typography, Switch, Skeleton, Result, Button } from "antd";
import { useBookInfo } from "./BookInfoProvider";
import styles from './BookIndex.module.css';
import { loadChapterListsIfNeeded, useBookStatus } from "./BookStatusProvider";
import { loadChapterListsIfNeeded, useBookContext, useBookStatus } from "./BookStatusProvider";
import VolumesList from "./VolumesList";
import { useEffect, useState } from "react";
import { useDb } from "../dbProvider";
@@ -16,6 +16,7 @@ export default function BookIndex() {
const bookInfo = useBookInfo();
const db = useDb();
const [bookStatus, setBookStatus] = useBookStatus();
const setItems = useBookContext();
const [err, setErr] = useState<string | null>(null);
function setShowSavedOnly(showSavedOnly: boolean) {
setBookStatus({ ...bookStatus, showSavedOnly });
@@ -32,6 +33,7 @@ export default function BookIndex() {
useEffect(() => {
handle();
}, [bookInfo.id]);
setItems([]);
let vols: Volume[] = bookInfo.volumes;
if (bookStatus.chapterLists) {
vols = get_new_volumes(bookStatus.chapterLists, bookInfo.volumes, !bookStatus.showSavedOnly);

View File

@@ -1,6 +1,8 @@
import { createContext, useContext, Dispatch, SetStateAction } from "react";
import { QdChapterSimpleInfo } from "../../types";
import { Db } from "../../db/interfaces";
import { useOutletContext } from "react-router";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
export type BookStatus = {
showSavedOnly: boolean;
@@ -14,7 +16,7 @@ export function createBookStatus(): BookStatus {
}
export async function loadChapterLists(bookId: number, setBookStatus: Dispatch<SetStateAction<BookStatus>>, db: Db) {
const list = await db.getChapterSimpleInfos(bookId);
const list = await db.getQdChapterSimpleInfos(bookId);
setBookStatus((status) => ({ ...status, chapterLists: list }));
}
@@ -30,3 +32,7 @@ export function useBookStatus() {
return useContext(BookStatusContext);
}
export function useBookContext() {
return useOutletContext<Dispatch<ItemType[]>>();
}

View File

@@ -1,8 +1,7 @@
import { Collapse, Flex, Typography } from "antd";
import { Collapse, Flex } from "antd";
import type { Volume } from "../../qdtypes";
import styles from './VolumesList.module.css';
const { Text } = Typography;
import { Link } from "react-router";
export type VolumesListProps = {
volumes: Volume[];
@@ -18,7 +17,7 @@ export default function VolumesList({ volumes, bookId }: VolumesListProps) {
extra: v.isVip ? <span style={{ color: 'red' }}>VIP卷</span> : null,
children: <Flex wrap>
{v.chapters.map(chapter => (
<Text className={styles.ch} key={chapter.id}>{chapter.name}</Text>
<Link to={`/qd/book/${bookId}/chapter/${chapter.id}`} className={styles.ch} key={chapter.id}>{chapter.name}</Link>
))}
</Flex>
}

View File

@@ -27,6 +27,7 @@ export type QdChapterSimpleInfo = {
id: number;
name: string;
bookId: number;
time: number;
}
export type QdBookInfo = {

View File

@@ -2007,6 +2007,11 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"