Add manage page

This commit is contained in:
2026-02-16 23:23:34 +08:00
parent e24c839c05
commit fbc9269e81
14 changed files with 386 additions and 5 deletions

View File

@@ -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']);

View File

@@ -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": {

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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
View 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
View 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
View 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>
</>
)
}

View 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
View 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>} />}
</>
);
}

View 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
View 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>
</>
)
}

View File

@@ -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[];
}

View File

@@ -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"