From ab46dcc2c1c35928d1dffc59e21ebd4909cf7aea Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sun, 15 Feb 2026 22:15:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EPocketBase=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/config.ts | 49 ++++++++++++++ src/db/interfaces.ts | 8 ++- src/db/pocketBase.ts | 129 ++++++++++++++++++++++++++++++++++++ src/settings/DbSettings.tsx | 75 ++++++++++++++++++++- yarn.lock | 5 ++ 6 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/db/pocketBase.ts diff --git a/package.json b/package.json index 9fe4d94..70231e7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "antd": "^6.3.0", "colors": "^1.4.0", "esbuild": "^0.27.3", + "pocketbase": "^0.26.8", "react": "^19.2.4", "react-dom": "^19.2.4" }, diff --git a/src/config.ts b/src/config.ts index f581019..bf94536 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,13 +36,22 @@ type IndexedDbConfigData = { compress?: boolean; } +type PocketBaseConfigData = { + url?: string; + username?: string; + password?: string; + prefix?: string; +} + export enum DbType { IndexedDb, + PocketBase, } type DbConfigData = { IndexedDb?: IndexedDbConfigData; DbType?: DbType; + PocketBase?: PocketBaseConfigData; } export class IndexedDbConfig { @@ -58,6 +67,37 @@ export class IndexedDbConfig { } } +export class PocketBaseConfig { + config: PocketBaseConfigData; + constructor(config: PocketBaseConfigData) { + this.config = config; + } + get url(): string { + return this.config.url ?? 'http://localhost:8090'; + } + set url(value: string) { + this.config.url = value; + } + get username(): string { + return this.config.username ?? ''; + } + set username(value: string) { + this.config.username = value; + } + get password(): string { + return this.config.password ?? ''; + } + set password(value: string) { + this.config.password = value; + } + get prefix(): string { + return this.config.prefix ?? ''; + } + set prefix(value: string) { + this.config.prefix = value; + } +} + export class DbConfig { static STORAGE_KEY = 'db_config'; config?: DbConfigData; @@ -93,4 +133,13 @@ export class DbConfig { } this.config.DbType = value; } + get PocketBase(): PocketBaseConfig { + if (!this.config) { + throw new Error('Config not initialized'); + } + if (!this.config.PocketBase) { + this.config.PocketBase = {}; + } + return new PocketBaseConfig(this.config.PocketBase); + } } diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 91728b6..5a82541 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -1,5 +1,6 @@ import { DbConfig, DbType } from "../config"; import { IndexedDb } from "./indexedDb"; +import { PocketBaseDb } from "./pocketBase"; import type { QdChapterInfo } from "../types"; export interface Db { @@ -17,8 +18,11 @@ export async function createDb(): Promise { await config.init(); switch (config.DbType) { case DbType.IndexedDb: - const db = new IndexedDb(config.IndexedDb); - return db; + const db1 = new IndexedDb(config.IndexedDb); + return db1; + case DbType.PocketBase: + const db2 = new PocketBaseDb(config.PocketBase); + return db2; default: throw new Error('Unsupported database type'); } diff --git a/src/db/pocketBase.ts b/src/db/pocketBase.ts new file mode 100644 index 0000000..88983c1 --- /dev/null +++ b/src/db/pocketBase.ts @@ -0,0 +1,129 @@ +import type { Db } from "./interfaces"; +import PocketBase from "pocketbase"; +import type { CollectionModel } from "pocketbase"; +import { PocketBaseConfig } from "../config"; +import { QdChapterInfo } from "../types"; +import { hash_qdchapter_info } from "../utils/qd"; + +const QD_CHAPTERS_FIELDS = [ + { + 'name': 'chapterId', + 'type': 'number', + 'required': true, + }, + { + 'name': 'bookId', + 'type': 'number', + 'required': true, + }, + { + 'name': 'time', + 'type': 'number', + 'required': true, + }, + { + 'name': 'hash', + 'type': 'text', + 'required': true, + }, + { + 'name': 'data', + 'type': 'json', + 'required': true, + } +]; +const QD_CHAPTERS_INDEXES = [ + 'CREATE INDEX `idx_cid` ON `{name}` (chapterId)', + 'CREATE INDEX `idx_bid` ON `{name}` (bookId)', + 'CREATE INDEX `idx_hash` ON `{name}` (chapterId,bookId,hash)', +] + +export class PocketBaseDb implements Db { + client: PocketBase; + cfg: PocketBaseConfig; + constructor(cfg: PocketBaseConfig) { + this.cfg = cfg; + this.client = new PocketBase(cfg.url); + } + async init() { + await this.client.collection('_superusers').authWithPassword(this.cfg.username, this.cfg.password); + if (!this.client.authStore.isValid) { + throw new Error('Failed to authenticate with PocketBase. Please check your credentials.'); + } + const collections = await this.client.collections.getFullList({filter: this.cfg.prefix ? `name ~ "${this.cfg.prefix}%"` : undefined}); + const collectionNames = new Set(collections.map(c => c.name)); + if (!collectionNames.has(`${this.cfg.prefix}qd_chapters`)) { + await this.createCollection('qd_chapters', QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES); + } else { + const target = collections.find(c => c.name === `${this.cfg.prefix}qd_chapters`)!; + if (!this.checkCollection(target, QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES)) { + await this.updateCollection('qd_chapters', QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES); + } + } + } + async createCollection(name: string, fields: Record[], indexes: string[]) { + await this.client.collections.create({ + name: `${this.cfg.prefix}${name}`, + type: 'base', + fields: fields, + indexes: indexes, + }); + } + async updateCollection(name: string, fields: Record[], indexes: string[]) { + const nidexes = indexes.map(i => i.replace('{name}', `${this.cfg.prefix}${name}`)); + await this.client.collections.update(`${this.cfg.prefix}${name}`, { + fields: fields, + indexes: nidexes, + }); + } + checkCollection(col: CollectionModel, fields: Record[], indexes: string[]) { + for (const field of fields) { + const name = field.name; + const target = col.fields.find(f => f.name === name); + if (!target) { + return false; + } + for (const key in field) { + if (field[key] !== target[key]) { + return false; + } + } + } + for (const index of indexes) { + const tindex = index.replace('{name}', col.name); + if (!col.indexes.includes(tindex)) { + return false; + } + } + return true; + } + async saveQdChapter(info: QdChapterInfo) { + const hash = hash_qdchapter_info(info); + const existed = await this.hasQdChapter(info.id, info.bookId, hash); + if (existed) { + console.log(`Chapter ${info.id} of book ${info.bookId} already exists in database, skipping`); + return; + } + info.hash = undefined; + const re = await this.client.collection(`${this.cfg.prefix}qd_chapters`).create({ + chapterId: info.id, + bookId: info.bookId, + time: info.time, + hash: hash, + data: info, + }); + console.log(re); + } + async hasQdChapter(id: number, bookId: number, hash: string) { + const records = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, { + filter: `chapterId = ${id} && bookId = ${bookId} && hash = "${hash}"`, + fields: 'id', + }); + return records.totalItems > 0; + } + close(): void { + this.client.cancelAllRequests(); + this.client.authStore.clear(); + } +} + diff --git a/src/settings/DbSettings.tsx b/src/settings/DbSettings.tsx index e2cabab..77e7b38 100644 --- a/src/settings/DbSettings.tsx +++ b/src/settings/DbSettings.tsx @@ -1,9 +1,12 @@ -import { FloatButton, Affix, Button, Space, Select, Input } from "antd"; +import { FloatButton, Affix, Button, Space, Select, Input, Typography, Alert } from "antd"; import { SaveTwoTone, SaveOutlined, SyncOutlined } from "@ant-design/icons"; import { useEffect, useState } from "react"; -import { DbConfig, DbType, IndexedDbConfig } from "../config"; +import { DbConfig, DbType, IndexedDbConfig, PocketBaseConfig } from "../config"; import AlertWarn from "../components/AlertWarn"; import SwitchLabel from "../components/SwitchLabel"; +import PocketBase from "pocketbase"; + +const { Text } = Typography; function IndexedDbSettings({config}: { config: IndexedDbConfig }) { const [compress, setCompress] = useState(false); @@ -19,6 +22,68 @@ function IndexedDbSettings({config}: { config: IndexedDbConfig }) { ) } +async function testPocketBaseConnection(config: PocketBaseConfig) { + const client = new PocketBase(config.url); + const authData = await client.collection('_superusers').authWithPassword(config.username, config.password); + return client.authStore.isValid; +} + +function PocketBaseSettings({config}: { config: PocketBaseConfig }) { + const [url, setUrl] = useState('http://localhost:8090'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [prefix, setPrefix] = useState(''); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + useEffect(() => { + setUrl(config.url); + setUsername(config.username); + setPassword(config.password); + setPrefix(config.prefix); + }, [config]); + async function handleTestConnection() { + setTesting(true); + setTestResult(null); + try { + const result = await testPocketBaseConnection(config); + setTestResult(result ? "连接成功!" : "连接失败!"); + } catch (e) { + setTestResult(e instanceof Error ? e.message : "未知错误"); + } finally { + setTesting(false); + } + } + function handleUrlChange(value: string) { + setUrl(value); + config.url = value; + } + function handleUsernameChange(value: string) { + setUsername(value); + config.username = value; + } + function handlePasswordChange(value: string) { + setPassword(value); + config.password = value; + } + function handlePrefixChange(value: string) { + setPrefix(value); + config.prefix = value; + } + return ( + 服务器地址 + handleUrlChange(e.target.value)} allowClear /> + + 用户名 + handleUsernameChange(e.target.value)} allowClear /> + 密码 + handlePasswordChange(e.target.value)} allowClear /> + 前缀(可选) + handlePrefixChange(e.target.value)} allowClear /> + + {testResult &&
{testResult}
} +
) +} + export default function DbSettings() { const [container, setContainer] = useState(null); const [config] = useState(new DbConfig()); @@ -56,13 +121,19 @@ export default function DbSettings() {
+ 数据库类型