mirror of
https://github.com/lifegpc/bookdownload.git
synced 2026-07-08 01:31:31 +08:00
Add manage page
This commit is contained in:
2
build.js
2
build.js
@@ -92,4 +92,4 @@ fs.rmSync('dist', { recursive: true, force: true });
|
||||
fs.mkdirSync('dist', { recursive: true });
|
||||
await build('qdchapter');
|
||||
await build('qdbook');
|
||||
await buildTsx(['popup', 'settings'], ['background']);
|
||||
await buildTsx(['popup', 'settings', 'manage'], ['background']);
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"esbuild": "^0.27.3",
|
||||
"pocketbase": "^0.26.8",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.13.0"
|
||||
},
|
||||
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IndexedDbConfig } from "../config";
|
||||
import type { QdChapterInfo, QdBookInfo } from "../types";
|
||||
import type { QdChapterInfo, QdBookInfo, PagedData } from "../types";
|
||||
import { compress, isServiceWorker } from "../utils";
|
||||
import { hash_qdchapter_info } from "../utils/qd";
|
||||
import type { Db } from "./interfaces";
|
||||
@@ -74,6 +74,76 @@ async function get_keys<K extends IDBValidKey = IDBValidKey>(db: IDBDatabase, st
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function get_count(db: IDBDatabase, storeName: string, key?: IDBValidKey | IDBKeyRange, index?: string): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = index ? store.index(index).count(key) : store.count(key);
|
||||
req.onsuccess = () => {
|
||||
resolve(req.result);
|
||||
}
|
||||
req.onerror = () => {
|
||||
reject(req.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function get_paged_data<T>(db: IDBDatabase, storeName: string, page: number, pageSize: number, key?: IDBValidKey | IDBKeyRange, index?: string): Promise<PagedData<T>> {
|
||||
const count = await get_count(db, storeName, key, index);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
if (page > totalPages || page < 1 || count === 0) {
|
||||
return {
|
||||
total: count,
|
||||
page,
|
||||
totalPages,
|
||||
pageSize,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
return new Promise<PagedData<T>>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = index ? store.index(index).openCursor(key) : store.openCursor(key);
|
||||
const items: T[] = [];
|
||||
let advanced = false;
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (cursor) {
|
||||
if (!advanced && offset > 0) {
|
||||
cursor.advance(offset);
|
||||
advanced = true;
|
||||
} else {
|
||||
items.push(cursor.value);
|
||||
}
|
||||
if (items.length < pageSize) {
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve({
|
||||
total: count,
|
||||
page,
|
||||
totalPages,
|
||||
pageSize,
|
||||
items,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
total: count,
|
||||
page,
|
||||
totalPages,
|
||||
pageSize,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
req.onerror = () => {
|
||||
reject(req.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type CompressedQdChapterInfo = {
|
||||
compressed: Uint8Array;
|
||||
bookId: number;
|
||||
@@ -151,6 +221,12 @@ export class IndexedDb implements Db {
|
||||
async saveQdBook(info: QdBookInfo) {
|
||||
await save_data(this.qddb, 'books', info);
|
||||
}
|
||||
async getQdBooks(page: number, pageSize: number): Promise<PagedData<QdBookInfo>> {
|
||||
return await get_paged_data(this.qddb, 'books', page, pageSize);
|
||||
}
|
||||
async getQdBook(id: number): Promise<QdBookInfo | undefined> {
|
||||
return await get_data(this.qddb, 'books', id);
|
||||
}
|
||||
close() {
|
||||
this.qddb.close();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DbConfig, DbType } from "../config";
|
||||
import { IndexedDb } from "./indexedDb";
|
||||
import { PocketBaseDb } from "./pocketBase";
|
||||
import type { QdChapterInfo, QdBookInfo } from "../types";
|
||||
import type { QdChapterInfo, QdBookInfo, PagedData } from "../types";
|
||||
|
||||
export interface Db {
|
||||
init(): Promise<void>;
|
||||
@@ -15,6 +15,8 @@ export interface Db {
|
||||
* @param info Book info to save. if id is matched in the database, update the existing record.
|
||||
*/
|
||||
saveQdBook(info: QdBookInfo): Promise<void>;
|
||||
getQdBook(id: number): Promise<QdBookInfo | undefined>;
|
||||
getQdBooks(page: number, pageSize: number): Promise<PagedData<QdBookInfo>>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Db } from "./interfaces";
|
||||
import PocketBase from "pocketbase";
|
||||
import type { CollectionModel } from "pocketbase";
|
||||
import { PocketBaseConfig } from "../config";
|
||||
import { QdChapterInfo, QdBookInfo } from "../types";
|
||||
import { QdChapterInfo, QdBookInfo, PagedData } from "../types";
|
||||
import { hash_qdchapter_info } from "../utils/qd";
|
||||
|
||||
const QD_CHAPTERS_FIELDS = [
|
||||
@@ -173,6 +173,26 @@ export class PocketBaseDb implements Db {
|
||||
});
|
||||
return records.totalItems > 0;
|
||||
}
|
||||
async getQdBooks(page: number, pageSize: number): Promise<PagedData<QdBookInfo>> {
|
||||
const records = await this.client.collection(`${this.cfg.prefix}qd_books`).getList(page, pageSize, {
|
||||
fields: 'data',
|
||||
sort: 'bookId',
|
||||
});
|
||||
return {
|
||||
total: records.totalItems,
|
||||
page,
|
||||
totalPages: records.totalPages,
|
||||
pageSize,
|
||||
items: records.items.map(item => item.data),
|
||||
};
|
||||
}
|
||||
async getQdBook(id: number): Promise<QdBookInfo | undefined> {
|
||||
const records = await this.client.collection(`${this.cfg.prefix}qd_books`).getList(1, 1, {
|
||||
filter: `bookId = ${id}`,
|
||||
fields: 'data',
|
||||
});
|
||||
return records.totalItems > 0 ? records.items[0].data : undefined;
|
||||
}
|
||||
close(): void {
|
||||
this.client.cancelAllRequests();
|
||||
this.client.authStore.clear();
|
||||
|
||||
13
src/manage.html
Normal file
13
src/manage.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>管理数据库</title>
|
||||
<link rel="stylesheet" href="./manage.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./manage.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
src/manage.tsx
Normal file
59
src/manage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createHashRouter } from "react-router";
|
||||
import { RouterProvider } from "react-router/dom";
|
||||
import { Db, createDb } from "./db/interfaces";
|
||||
import Home from "./manage/Home";
|
||||
import QdBooks from "./manage/qd/Books";
|
||||
import { Result } from 'antd';
|
||||
import { DbContext } from "./manage/dbProvider";
|
||||
import QdBook from "./manage/qd/Book";
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/qd/books",
|
||||
element: <QdBooks />,
|
||||
},
|
||||
{
|
||||
path: "/qd/book/:id",
|
||||
element: <QdBook />,
|
||||
}
|
||||
]);
|
||||
|
||||
async function connectDb() {
|
||||
const db = await createDb();
|
||||
await db.init();
|
||||
return db;
|
||||
}
|
||||
|
||||
function Manage() {
|
||||
const [db, setDb] = useState<Db | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
connectDb().then(setDb).catch(e => {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}, []);
|
||||
if (err) {
|
||||
return <Result
|
||||
status="error"
|
||||
title="数据库连接失败"
|
||||
subTitle={err}
|
||||
/>
|
||||
}
|
||||
if (!db) {
|
||||
return <Result
|
||||
status="info"
|
||||
title="数据库连接中"
|
||||
/>
|
||||
}
|
||||
return <DbContext.Provider value={db}>
|
||||
<RouterProvider router={router} />
|
||||
</DbContext.Provider>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<Manage />);
|
||||
23
src/manage/Home.tsx
Normal file
23
src/manage/Home.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Breadcrumb, Button, Card, Divider, Space, Typography } from 'antd';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb items={
|
||||
[{
|
||||
title: '主页'
|
||||
}]
|
||||
} />
|
||||
<Divider />
|
||||
<Space orientation="vertical" size="large">
|
||||
<Card title="起点">
|
||||
<Button type="primary" onClick={() => navigate("/qd/books")}>按书籍管理</Button>
|
||||
</Card>
|
||||
</Space>
|
||||
</>
|
||||
)
|
||||
}
|
||||
8
src/manage/dbProvider.tsx
Normal file
8
src/manage/dbProvider.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Db } from "../db/interfaces";
|
||||
|
||||
export const DbContext = createContext<Db>(null as any);
|
||||
|
||||
export function useDb() {
|
||||
return useContext(DbContext);
|
||||
}
|
||||
49
src/manage/qd/Book.tsx
Normal file
49
src/manage/qd/Book.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Breadcrumb, Result, Button, Skeleton } from "antd";
|
||||
import { NavLink, useParams } from "react-router";
|
||||
import { useDb } from "../dbProvider";
|
||||
import type { QdBookInfo } from "../../types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Book() {
|
||||
const db = useDb();
|
||||
const { id } = useParams();
|
||||
const [book, setBook] = useState<QdBookInfo | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
async function load() {
|
||||
const data = await db.getQdBook(Number(id));
|
||||
if (data) {
|
||||
setBook(data);
|
||||
} else {
|
||||
setErr("书籍不存在");
|
||||
}
|
||||
}
|
||||
function handle() {
|
||||
load().catch(e => {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
handle();
|
||||
}, [id]);
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb items={
|
||||
[{
|
||||
title: <NavLink to="/">主页</NavLink>
|
||||
}, {
|
||||
title: '起点'
|
||||
}, {
|
||||
title: <NavLink to="/qd/books">按书籍管理</NavLink>
|
||||
}, {
|
||||
title: book ? `书籍详情:${book.bookName}` : '书籍详情'
|
||||
}]
|
||||
} />
|
||||
{!book && !err && <Skeleton active />}
|
||||
{err && <Result
|
||||
status="error"
|
||||
title="数据加载失败"
|
||||
subTitle={err}
|
||||
extra={<Button type="primary" onClick={() => { setErr(null); handle(); }}>重试</Button>} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
src/manage/qd/Books.module.css
Normal file
20
src/manage/qd/Books.module.css
Normal file
@@ -0,0 +1,20 @@
|
||||
.books-card {
|
||||
margin: 8px;
|
||||
max-width: 400px;
|
||||
min-width: 350px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.books-card .name {
|
||||
margin: 0 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.books-card .author {
|
||||
margin: 0 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.books-card .intro {
|
||||
margin-left: 8px;
|
||||
}
|
||||
81
src/manage/qd/Books.tsx
Normal file
81
src/manage/qd/Books.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Breadcrumb, Divider, Affix, Pagination, Skeleton, Result, Button, Card, Flex, Row, Col, Image, Typography } from 'antd';
|
||||
import { NavLink, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useDb } from '../dbProvider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PagedData, QdBookInfo } from '../../types';
|
||||
import styles from './Books.module.css';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
export default function Books() {
|
||||
const db = useDb();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [page, setPage] = useState(Number(searchParams.get('page') || '1'));
|
||||
const [pageSize, setPageSize] = useState(Number(searchParams.get('pageSize') || '10'));
|
||||
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||
const [pageData, setPageData] = useState<PagedData<QdBookInfo> | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
async function loadPage() {
|
||||
const data = await db.getQdBooks(page, pageSize);
|
||||
setPageData(data);
|
||||
setTotalCount(data.total);
|
||||
}
|
||||
function handle() {
|
||||
setPageData(null);
|
||||
loadPage().catch(e => {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
handle();
|
||||
}, [page, pageSize]);
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb items={
|
||||
[{
|
||||
title: <NavLink to="/">主页</NavLink>
|
||||
}, {
|
||||
title: '起点'
|
||||
}, {
|
||||
title: '按书籍管理'
|
||||
}]
|
||||
} />
|
||||
<Divider />
|
||||
<div ref={setContainer}>
|
||||
{totalCount !== null && (<Affix target={() => container}>
|
||||
<Pagination
|
||||
total={totalCount}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onChange={(page, pageSize) => { setPage(page); setPageSize(pageSize); setSearchParams({ page: String(page), pageSize: String(pageSize) }); }}
|
||||
showTotal={(total, range) => `共 ${total} 条,当前显示 ${range[0]} - ${range[1]} 条`}
|
||||
showSizeChanger={true}
|
||||
/>
|
||||
</Affix>)}
|
||||
{!pageData && !err && <Skeleton active />}
|
||||
{err && <Result
|
||||
status="error"
|
||||
title="数据加载失败"
|
||||
extra={<Button type="primary" onClick={() => { setErr(null); handle(); }}>重试</Button>} />}
|
||||
{pageData && <Flex>
|
||||
{pageData.items.map(book => (
|
||||
<Card key={book.id} className={styles['books-card']} onClick={() => navigate(`/qd/book/${book.id}`)}>
|
||||
<Row>
|
||||
<Col span={6}>
|
||||
<Image src={book.bookInfo.imgUrl} preview={false} />
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<h3 className={styles.name}>{book.bookName}</h3>
|
||||
<Paragraph className={styles.author}>{book.bookInfo.pageJson.authorInfo.authorName} 著</Paragraph>
|
||||
<Paragraph className={styles.intro} ellipsis={{rows: 3}}>{book.intro}</Paragraph>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Flex>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
11
src/types.ts
11
src/types.ts
@@ -73,3 +73,14 @@ export type UrlParamsMap = {
|
||||
}
|
||||
|
||||
export type UrlParams = DiscriminatedUnion<"page", UrlParamsMap>;
|
||||
|
||||
export type PagedData<T> = {
|
||||
/**Total number of items */
|
||||
total: number;
|
||||
/**Start at 1*/
|
||||
page: number;
|
||||
/**Total number of pages */
|
||||
totalPages: number;
|
||||
pageSize: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
18
yarn.lock
18
yarn.lock
@@ -742,6 +742,11 @@ compute-scroll-into-view@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz#02c3386ec531fb6a9881967388e53e8564f3e9aa"
|
||||
integrity sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==
|
||||
|
||||
cookie@^1.0.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
|
||||
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
|
||||
|
||||
csstype@^3.1.3, csstype@^3.2.2:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
|
||||
@@ -813,6 +818,14 @@ react-is@^18.2.0:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-router@^7.13.0:
|
||||
version "7.13.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
|
||||
integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
|
||||
react@^19.2.4:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
|
||||
@@ -830,6 +843,11 @@ scroll-into-view-if-needed@^3.1.0:
|
||||
dependencies:
|
||||
compute-scroll-into-view "^3.0.2"
|
||||
|
||||
set-cookie-parser@^2.6.0:
|
||||
version "2.7.2"
|
||||
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
|
||||
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
|
||||
|
||||
string-convert@^0.2.0:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
|
||||
|
||||
Reference in New Issue
Block a user