新增PocketBase支持

This commit is contained in:
2026-02-15 22:15:51 +08:00
parent e91ae58813
commit ab46dcc2c1
6 changed files with 263 additions and 4 deletions

View File

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

View File

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

View File

@@ -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<Db> {
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');
}

129
src/db/pocketBase.ts Normal file
View File

@@ -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<string, unknown>[], indexes: string[]) {
await this.client.collections.create({
name: `${this.cfg.prefix}${name}`,
type: 'base',
fields: fields,
indexes: indexes,
});
}
async updateCollection(name: string, fields: Record<string, unknown>[], 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<string, unknown>[], 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();
}
}

View File

@@ -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<string | null>(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 (<Space orientation="vertical">
<Text></Text>
<Input placeholder="服务器地址" value={url} onChange={e => handleUrlChange(e.target.value)} allowClear />
<Alert title="用户帐户需要是超级用户(superuser)才能正常工作" type="info" />
<Text></Text>
<Input placeholder="用户名" value={username} onChange={e => handleUsernameChange(e.target.value)} allowClear />
<Text></Text>
<Input.Password placeholder="密码" value={password} onChange={e => handlePasswordChange(e.target.value)} allowClear />
<Text></Text>
<Input placeholder="前缀(可选)" value={prefix} onChange={e => handlePrefixChange(e.target.value)} allowClear />
<Button onClick={handleTestConnection} disabled={testing}>{testing ? "测试中..." : "测试连接"}</Button>
{testResult && <div>{testResult}</div>}
</Space>)
}
export default function DbSettings() {
const [container, setContainer] = useState<HTMLElement | null>(null);
const [config] = useState(new DbConfig());
@@ -56,13 +121,19 @@ export default function DbSettings() {
</Affix>
<br />
<Space orientation="vertical">
<Text></Text>
<Select value={dbType} onChange={value => setDbType(value)} style={{ width: 200 }} options={[
{
label: "IndexedDb",
value: DbType.IndexedDb,
},
{
label: "PocketBase",
value: DbType.PocketBase,
},
]} />
{dbType === DbType.IndexedDb && <IndexedDbSettings config={indexedDbConfig} />}
{dbType === DbType.PocketBase && <PocketBaseSettings config={config.PocketBase} />}
</Space>
<FloatButton icon={<SaveTwoTone />} tooltip="保存设置" onClick={saveSettings} />
{alert && <AlertWarn title={alert.title} content={alert.content} onClose={() => setAlert(null)} />}

View File

@@ -796,6 +796,11 @@ json2mq@^0.2.0:
dependencies:
string-convert "^0.2.0"
pocketbase@^0.26.8:
version "0.26.8"
resolved "https://registry.yarnpkg.com/pocketbase/-/pocketbase-0.26.8.tgz#1b800b5f69f2ab08c3e3da45fce25f45c6724c45"
integrity sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==
react-dom@^19.2.4:
version "19.2.4"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"