Add history model

This commit is contained in:
2026-02-28 23:24:23 +08:00
parent cb46e6c85e
commit 68c0fa7977
7 changed files with 194 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
import { IndexedDbConfig } from "../config";
import type { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo } from "../types";
import type { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo, QdChapterHistoryInfo } from "../types";
import { compress, decompress, isServiceWorker } from "../utils";
import { hash_qdchapter_info } from "../utils/qd";
import type { Db } from "./interfaces";
@@ -105,12 +105,12 @@ function get_data_with_convert<T, U>(db: IDBDatabase, storeName: string, key: ID
});
}
function get_datas_with_convert<T, U>(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise<void> | void, key?: IDBValidKey | IDBKeyRange, index?: string): Promise<U[]> {
function get_datas_with_convert<T, U>(db: IDBDatabase, storeName: string, convert: (key: IDBValidKey, data: T, list: U[]) => Promise<void> | void, key?: IDBValidKey | IDBKeyRange, index?: string, direction?: IDBCursorDirection): Promise<U[]> {
return new Promise<U[]>((resolve, reject) => {
const list: U[] = [];
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = index ? store.index(index).openCursor(key) : store.openCursor(key);
const req = index ? store.index(index).openCursor(key, direction) : store.openCursor(key, direction);
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
@@ -371,6 +371,20 @@ export class IndexedDb implements Db {
}
return data;
}
async getQdChapterHistory(chapterId: number): Promise<QdChapterHistoryInfo[]> {
return await get_datas_with_convert<CompressedQdChapterInfo | QdChapterInfo, QdChapterHistoryInfo>(this.qddb, 'chapters', async (key, data, list) => {
if ('compressed' in data) {
const decompressed = await decompress(data.compressed);
const decoded = new TextDecoder().decode(decompressed);
data = JSON.parse(decoded) as QdChapterInfo;
}
list.push({
primaryKey: key,
name: data.chapterInfo.chapterName,
time: data.time,
})
}, chapterId, 'id', 'prev');
}
async getLatestQdChapter(id: number): Promise<QdChapterInfo | undefined> {
return await get_data_with_convert<CompressedQdChapterInfo | QdChapterInfo, QdChapterInfo>(this.qddb, 'chapters', id, async (key, data) => {
if ('compressed' in data) {
@@ -379,7 +393,32 @@ export class IndexedDb implements Db {
return JSON.parse(decoded) as QdChapterInfo;
}
return data;
}, 'id', 'prevunique');
}, 'id', 'prev');
}
async setAsLatestQdChapter(key: unknown): Promise<unknown> {
const chapter = await this.getQdChapter(key);
if (!chapter) {
return undefined;
}
chapter.time = Date.now();
const hash = hash_qdchapter_info(chapter);
if (this.compress) {
chapter.hash = undefined;
const data = JSON.stringify(chapter);
const encoded = new TextEncoder().encode(data);
const compressed = await compress(encoded);
const compressedInfo: CompressedQdChapterInfo = {
compressed,
bookId: chapter.bookId,
id: chapter.id,
time: chapter.time,
hash,
}
return await save_data(this.qddb, 'chapters', compressedInfo);
} else {
chapter.hash = hash;
return await save_data(this.qddb, 'chapters', chapter);
}
}
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, PagedData, QdChapterSimpleInfo } from "../types";
import type { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo, QdChapterHistoryInfo } from "../types";
export interface Db {
init(): Promise<void>;
@@ -18,7 +18,7 @@ export interface Db {
/**
* Update chapter info in database.
* @param info The chapter info to update. time will be updated to current time in database implementation so mannual update is not needed. Primary key was chapterId, bookId and time.
* @return Primary key of the updated chapter, which is determined by the database implementation.
* @returns Primary key of the updated chapter, which is determined by the database implementation.
*/
updateQdChapter(info: QdChapterInfo): Promise<unknown>;
getQdBook(id: number): Promise<QdBookInfo | undefined>;
@@ -34,11 +34,23 @@ export interface Db {
* @param key Primary key of the chapter, which is determined by the database implementation.
*/
getQdChapter(key: unknown): Promise<QdChapterInfo | undefined>;
/**
* Get chapter history by chapter ID. if not found, return empty array.
* @param chapterId Chapter ID
* @returns Chapter history, sorted by time in descending order (latest first)
*/
getQdChapterHistory(chapterId: number): Promise<QdChapterHistoryInfo[]>;
/**
* Get the latest (which time is biggest) chapter of a chapter. if not found, return undefined.
* @param id Chapter ID
*/
getLatestQdChapter(id: number): Promise<QdChapterInfo | undefined>;
/**
* Set the latest chapter by primary key. This function will update the time of the chapter.
* @param key Primary key of the chapter, which is determined by the database implementation.
* @returns Primary key of the chapter set as latest, which is determined by the database implementation. if the chapter is not found, return undefined.
*/
setAsLatestQdChapter(key: unknown): Promise<unknown>;
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, PagedData, QdChapterSimpleInfo } from "../types";
import { QdChapterInfo, QdBookInfo, PagedData, QdChapterSimpleInfo, QdChapterHistoryInfo } from "../types";
import { hash_qdchapter_info } from "../utils/qd";
import { loadConfig, saveConfig } from "../utils";
@@ -279,17 +279,43 @@ export class PocketBaseDb implements Db {
const k = String(key);
const record = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, {
filter: `id = "${k}"`,
fields: 'data',
fields: 'data,time',
});
return record.totalItems > 0 ? record.items[0].data : undefined;
const re = record.totalItems > 0 ? record.items[0].data : undefined;
if (re) {
re.time = record.items[0].time;
}
return re;
}
async getQdChapterHistory(chapterId: number): Promise<QdChapterHistoryInfo[]> {
const records = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getFullList({
filter: `chapterId = ${chapterId}`,
fields: 'id,time,data.chapterInfo.chapterName',
sort: '-time',
});
return records.map(item => ({
primaryKey: item.id,
name: item.data.chapterInfo.chapterName,
time: item.time,
}));
}
async getLatestQdChapter(id: number): Promise<QdChapterInfo | undefined> {
const records = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, {
filter: `chapterId = ${id}`,
fields: 'data',
fields: 'data,time',
sort: '-time',
});
return records.totalItems > 0 ? records.items[0].data : undefined;
const record = records.totalItems > 0 ? records.items[0].data : undefined;
if (record) {
record.time = records.items[0].time;
}
return record;
}
async setAsLatestQdChapter(key: unknown): Promise<unknown> {
await this.client.collection(`${this.cfg.prefix}qd_chapters`).update(String(key), {
time: Date.now(),
});
return key;
}
close(): void {
this.client.cancelAllRequests();

View File

@@ -100,7 +100,7 @@ export default function BookChapter() {
title="数据加载失败"
subTitle={err}
extra={<Button type="primary" onClick={() => { setErr(null); handle_load(); }}></Button>} />}
{chapter && <ChapterEditor ref={editorRef} chapter={chapter} onChapterSaveAs={setChapter} />}
{chapter && <ChapterEditor ref={editorRef} chapter={chapter} onChapterSaveAs={setChapter} history />}
{!chapter && !err && <Skeleton active />}
</Splitter.Panel>
</Splitter>

View File

@@ -95,3 +95,32 @@
.reset:hover {
color: #4096ff;
}
.showHistory {
color: gray;
cursor: pointer;
margin: 8px;
}
.showHistory:hover {
color: #4096ff;
}
.showHistory.disabled {
color: lightgray;
cursor: not-allowed;
}
.showHistory.disabled:hover {
color: lightgray;
}
.history .item {
width: 100%;
margin: 0 8px;
}
.history .current {
color: #4096ff;
margin-left: auto;
}

View File

@@ -1,8 +1,8 @@
import { Component, createRef } from "react";
import { QdChapterInfo } from "../../types";
import { QdChapterHistoryInfo, QdChapterInfo } from "../../types";
import MonacoEditor, { MonacoEditorHandle } from 'react-monaco-editor';
import styles from './ChapterEditor.module.css';
import { Flex, Tooltip, Typography } from "antd";
import { Button, Card, Empty, Drawer, Flex, Skeleton, Tooltip, Typography } from "antd";
import Icon from "../../components/Icon";
import EditOutlined from "../../../node_modules/@material-icons/svg/svg/edit/outline.svg";
import SaveOutlined from "../../../node_modules/@material-icons/svg/svg/save/outline.svg";
@@ -11,12 +11,15 @@ import ReplayOutlined from "../../../node_modules/@material-icons/svg/svg/replay
import { DbContext } from "../dbProvider";
import Notification from "../../components/Notification";
import SaveAsOutlined from "../../../node_modules/@material-icons/svg/svg/save_as/outline.svg";
import type { Db } from "../../db/interfaces";
import HistoryOutlined from "../../../node_modules/@material-icons/svg/svg/history/outline.svg";
const { Text } = Typography;
export interface ChapterEditorProps {
chapter: QdChapterInfo;
onChapterSaveAs?: (chapter: QdChapterInfo) => void;
history?: boolean;
}
export interface ChapterEditorState {
@@ -25,10 +28,15 @@ export interface ChapterEditorState {
editingChapterName: boolean;
eChapterName?: string;
changed: boolean;
history?: QdChapterHistoryInfo[];
loadingHistory: boolean;
loadHistoryFailed: boolean;
historyPanelOpen: boolean;
}
export default class ChapterEditor extends Component<ChapterEditorProps, ChapterEditorState> {
ref;
db?: Db;
constructor(props: ChapterEditorProps) {
super(props);
this.ref = createRef<MonacoEditorHandle>();
@@ -37,6 +45,9 @@ export default class ChapterEditor extends Component<ChapterEditorProps, Chapter
chapterName: props.chapter.chapterInfo.chapterName,
editingChapterName: false,
changed: false,
loadingHistory: false,
loadHistoryFailed: false,
historyPanelOpen: false,
};
}
componentDidUpdate(prevProps: Readonly<ChapterEditorProps>, _prevState: Readonly<ChapterEditorState>, _snapshot?: unknown): void {
@@ -47,6 +58,23 @@ export default class ChapterEditor extends Component<ChapterEditorProps, Chapter
editingChapterName: false,
eChapterName: undefined,
changed: false,
history: undefined,
loadingHistory: false,
loadHistoryFailed: false,
});
} else if (prevProps.history && !this.props.history) {
this.setState({ history: undefined, loadingHistory: false, loadHistoryFailed: false });
}
if (this.props.history && !this.state.history && !this.state.loadingHistory && this.db && !this.state.loadHistoryFailed) {
this.setState({ loadingHistory: true });
this.db.getQdChapterHistory(this.props.chapter.id).then(history => {
console.log(history);
this.setState({ history, loadingHistory: false });
}).catch(err => {
console.warn(err);
const errmsg = err instanceof Error ? err.message : String(err);
Notification(`加载章节历史失败: ${errmsg}`, 'error');
this.setState({ loadingHistory: false, loadHistoryFailed: true });
});
}
}
@@ -62,9 +90,13 @@ export default class ChapterEditor extends Component<ChapterEditorProps, Chapter
if (!this.state.changed) {
chapterSaveClass += ` ${styles.disabled}`;
}
let showHistoryClass = styles.showHistory;
if (this.state.loadingHistory) {
showHistoryClass += ` ${styles.disabled}`;
}
return (<>
<DbContext.Consumer>
{ (db) => <>
{ (db) => {this.db = db; return <>
<Flex vertical className={styles.container}>
<Flex className={styles.header}>
<Flex className={nameClass}>
@@ -128,6 +160,40 @@ export default class ChapterEditor extends Component<ChapterEditorProps, Chapter
changed: false,
});
}} /></Icon></Tooltip>
{this.props.history && <Tooltip title="查看章节历史" placement="left"><Icon><HistoryOutlined fill="currentColor" className={showHistoryClass} onClick={this.state.loadingHistory ? undefined : () => {
this.setState({ historyPanelOpen: true });
}} /></Icon></Tooltip>}
{this.props.history && <Drawer
title="章节历史"
placement="right"
onClose={() => this.setState({ historyPanelOpen: false })}
open={this.state.historyPanelOpen}
>
<Flex vertical align="center" className={styles.history}>
{this.state.history && this.state.history.length === 0 && <Empty description="没有历史记录" />}
{this.state.history && this.state.history.length > 0 && this.state.history.map((item, ind) => (
<Card key={String(item.primaryKey)} className={styles.item}>
<Flex align="center">
<Text>{item.name}</Text>
{item.time === this.props.chapter.time && <Text className={styles.current}>()</Text>}
</Flex>
<Text>: {new Date(item.time).toLocaleString()}</Text>
<Flex align="center">
{ind !== 0 && <Button onClick={() => {
db.setAsLatestQdChapter(item.primaryKey).then(() => {
this.setState({ loadHistoryFailed: false, history: undefined, loadingHistory: false, });
}).catch((err) => {
console.warn(err);
const errmsg = err instanceof Error ? err.message : String(err);
Notification(`设为最新版本失败: ${errmsg}`, 'error');
});
}}></Button>}
</Flex>
</Card>
))}
{this.state.loadingHistory && <Skeleton active />}
</Flex>
</Drawer>}
</Flex>
</Flex>
<div className={styles.editor}>
@@ -144,7 +210,7 @@ export default class ChapterEditor extends Component<ChapterEditorProps, Chapter
/>
</div>
</Flex>
</>}
</>}}
</DbContext.Consumer>
</>);
}

View File

@@ -30,6 +30,12 @@ export type QdChapterSimpleInfo = {
time: number;
}
export type QdChapterHistoryInfo = {
primaryKey: unknown;
name: string;
time: number;
}
export type QdBookInfo = {
bookInfo: QdTypes.BookGData;
bookName: string;