diff --git a/eslint.config.js b/eslint.config.js index ece1be7..1de2d0b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,4 +40,7 @@ export default defineConfig([ "@typescript-eslint/no-empty-object-type": "off", } }, + { + ignores: ['build.js'], + } ]); diff --git a/package.json b/package.json index e506222..c2e9d10 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/db/indexedDb.ts b/src/db/indexedDb.ts index 43e1ffe..4ae605f 100644 --- a/src/db/indexedDb.ts +++ b/src/db/indexedDb.ts @@ -61,7 +61,37 @@ function get_datas(db: IDBDatabase, storeName: string, key?: IDBValidKey | ID }); } -function get_data_with_convert(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise | void , key?: IDBValidKey | IDBKeyRange, index?: string): Promise { +function get_data_with_convert(db: IDBDatabase, storeName: string, key: IDBValidKey | IDBKeyRange, convert: (key: IDBValidKey, data: T) => Promise | U, index?: string, direction?: IDBCursorDirection): Promise { + return new Promise((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(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise | void, key?: IDBValidKey | IDBKeyRange, index?: string): Promise { return new Promise((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 { return await get_data(this.qddb, 'books', id); } - async getChapterSimpleInfos(bookId: number): Promise { + async getQdChapterSimpleInfos(bookId: number): Promise { // chapterId-> [index, time] const currents: Map = new Map(); - return await get_data_with_convert(this.qddb, 'chapters', async (key, data, list) => { + return await get_datas_with_convert(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 { + const k = key as QdChapterKey; + const data = await get_data(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 { + return await get_data_with_convert(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(); } diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index f8e9cea..616d191 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -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; + getQdChapterSimpleInfos(bookId: number): Promise; + /** + * 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; + /** + * Get the latest (which time is biggest) chapter of a chapter. if not found, return undefined. + * @param id Chapter ID + */ + getLatestQdChapter(id: number): Promise; close(): void; } diff --git a/src/db/pocketBase.ts b/src/db/pocketBase.ts index fc7b6df..f7b067e 100644 --- a/src/db/pocketBase.ts +++ b/src/db/pocketBase.ts @@ -218,7 +218,7 @@ export class PocketBaseDb implements Db { }); return records.totalItems > 0 ? records.items[0].data : undefined; } - async getChapterSimpleInfos(bookId: number): Promise { + async getQdChapterSimpleInfos(bookId: number): Promise { // chapterId -> [index, time] const currents: Map = 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 { + 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 { + 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(); diff --git a/src/manage.tsx b/src/manage.tsx index 4606e0c..ab8f4b4 100644 --- a/src/manage.tsx +++ b/src/manage.tsx @@ -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: + }, + { + path: "chapter/:chapterId", + element: } ], } diff --git a/src/manage/qd/Book.tsx b/src/manage/qd/Book.tsx index 6a83a4e..308b801 100644 --- a/src/manage/qd/Book.tsx +++ b/src/manage/qd/Book.tsx @@ -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(null); const [err, setErr] = useState(null); const [bookStatus, setBookStatus] = useState(createBookStatus()); + const [items, setItems] = useState([]); 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 ( <> 按书籍管理 }, { - title: book ? `书籍详情:${book.bookName}` : '书籍详情' - }] + title: items.length > 0 ? {title} : title + }, + ...items, + ] } /> {!book && !err && } {err && { setErr(null); handle(); }}>重试} />} {book && ( - + )} diff --git a/src/manage/qd/BookChapter.tsx b/src/manage/qd/BookChapter.tsx new file mode 100644 index 0000000..e8fd748 --- /dev/null +++ b/src/manage/qd/BookChapter.tsx @@ -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(null); + const [chapter, setChapter] = useState(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 ; + } + if (err) { + return { setErr(null); load(); }}>重试} />; + } + return (<>) +} diff --git a/src/manage/qd/BookIndex.tsx b/src/manage/qd/BookIndex.tsx index e68d7f6..3e0e9e0 100644 --- a/src/manage/qd/BookIndex.tsx +++ b/src/manage/qd/BookIndex.tsx @@ -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(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); diff --git a/src/manage/qd/BookStatusProvider.ts b/src/manage/qd/BookStatusProvider.ts index 23bd597..072fbd8 100644 --- a/src/manage/qd/BookStatusProvider.ts +++ b/src/manage/qd/BookStatusProvider.ts @@ -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>, 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>(); +} + diff --git a/src/manage/qd/VolumesList.tsx b/src/manage/qd/VolumesList.tsx index 51000e8..d412215 100644 --- a/src/manage/qd/VolumesList.tsx +++ b/src/manage/qd/VolumesList.tsx @@ -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 ? VIP卷 : null, children: {v.chapters.map(chapter => ( - {chapter.name} + {chapter.name} ))} } diff --git a/src/types.ts b/src/types.ts index d2ac0c2..e51339d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export type QdChapterSimpleInfo = { id: number; name: string; bookId: number; + time: number; } export type QdBookInfo = { diff --git a/yarn.lock b/yarn.lock index 944dc6b..7e27b65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"