mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
Merge branch 'feat/progress-history'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bbc0ec0ee9
13 files changed
+247
-3
No files matched your search
@@ -829,6 +829,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
→ `{ "progress": {...} | null }`
|
||||
|
||||
### `GET /sync/progress/:bookId/history`
|
||||
|
||||
`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。
|
||||
|
||||
阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。
|
||||
同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。
|
||||
每本书保留最新 50 条,只返回当前账号自己的记录。
|
||||
|
||||
### `PUT /sync/progress/:bookId`
|
||||
|
||||
单个进度上报的便捷写法:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## 未完成的 P0 范围
|
||||
|
||||
- 进度历史和用户主动恢复入口;多设备系统时间偏差下的冲突策略。
|
||||
- ~~进度历史和用户主动恢复入口~~:已提供(服务端 `reading_progress_history`,阅读器「书籍信息 → 阅读记录」可回到任一记录位置)。多设备系统时间偏差下的冲突策略仍未处理;时间超前的设备仍会在合并中胜出,但被覆盖的位置可从阅读记录找回。
|
||||
- iOS Safari 与 Android 真机的手势、跨章、旋转、关闭/强制退出测试。本地 jsdom 测试不能替代真实渲染与系统生命周期。
|
||||
- 备份/恢复图形界面和运行中一致性快照;本批只提供需要停机的维护命令。
|
||||
- 首次接入向导、旧版数据自动迁移、数据损坏诊断界面。
|
||||
|
||||
@@ -687,6 +687,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
- 底部:上一章、本章页数滑杆、下一章;下一行是工具(书内搜索、笔记与书签)、目录、朗读、外观、设置。朗读进行中时“朗读”按钮显示“朗读中”(按下态),因为关掉朗读面板不会停止朗读。
|
||||
- 滚动模式下手动滑到章末后,从章末开始继续上拉(触屏超过 72px,或滚轮在章末继续滚动)进入下一章;在章首下拉回到上一章。手势必须从边缘开始,只是滑到章末不会翻章;翻章后有约 1 秒冷却,滚轮惯性不会连翻两章。
|
||||
- 中间滑杆控制本章页数,不能变成全书进度。例外是漫画:它每“章”就是一页,本章页数恒为 1,所以滑杆改为全书页码(“第 7/200 页”),拖动时只更新标签,松手才打开那一页。
|
||||
- 「书籍信息」(点顶部书名)末尾是「阅读记录」:本账号在这本书里去过的位置,最新在前,每条写章节、进度、设备与相对时间;当前位置标「当前」且不可点,点其他条目回到那里并成为新的保存位置。离线时说明无法读取,读取失败给「重试」。
|
||||
- 书打不开时(服务端错误、未缓存的离线书),舞台上一直显示原因和“重试”“返回”,不只弹一条会消失的提示。直接打开阅读链接、没有上一页时,“返回”回到书架。
|
||||
- 键盘:方向键与 PageUp/PageDown 翻页,空格/Shift+空格翻下/上一页,S 开始或暂停朗读,Esc 收起工具栏;右→左的漫画方向键随方向镜像。焦点在按钮上时空格和回车只按下那个按钮。
|
||||
- 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
|
||||
|
||||
@@ -84,6 +84,8 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
|
||||
|
||||
首个账号是管理员,后续成员默认由管理员创建。每个账号的书架、进度、笔记和高亮独立。阅读界面参数属于当前设备,不随账号覆盖其他设备的设置。
|
||||
|
||||
阅读进度在设备间以最后一次写入为准。如果被另一台设备或误点的目录项带到了别处,点阅读器顶部的书名打开「书籍信息」,在「阅读记录」里可以看到最近去过的位置(章节、进度、设备、时间),点一条即可回去;离线时无法读取阅读记录。
|
||||
|
||||
离线时可使用已经缓存的内容,重新连通后同步阅读状态。在阅读器底部「工具 → 离线缓存」可下载TXT 的指定范围,或下载完整 EPUB、PDF、CBZ;自定义目录的 TXT 下载完整文件。任务支持暂停、继续和取消,关闭面板会暂停,已下载内容保留。默认设备账号配额为 256 MiB,可调整或按书清理;不自动淘汰,也不删除原书、进度、笔记。服务端缓存与当前设备缓存独立,只有本设备完成下载的内容才能保证断网读取。图片目录暂不支持整书下载。
|
||||
|
||||
## 进一步阅读
|
||||
|
||||
@@ -237,6 +237,25 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_updated ON reading_progress(user_id, updated_at);
|
||||
|
||||
-- Where the reader has been in a book, so a jump can be undone (P0: 进度历史).
|
||||
-- reading_progress keeps one row and last-writer-wins, which is right for "where am
|
||||
-- I" and wrong for "where was I": a stale device, a mistapped 目录 entry or a device
|
||||
-- whose clock runs fast all overwrite the place for good. Every valid progress write
|
||||
-- lands here too — including one that lost the merge — and a device reading on
|
||||
-- through one chapter updates its latest entry instead of adding rows. A new table,
|
||||
-- so older builds that share this database simply do not see it.
|
||||
CREATE TABLE IF NOT EXISTS reading_progress_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||
locator TEXT NOT NULL DEFAULT '',
|
||||
percentage REAL NOT NULL DEFAULT 0,
|
||||
chapter_title TEXT NOT NULL DEFAULT '',
|
||||
device TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_history ON reading_progress_history(user_id, book_id, updated_at);
|
||||
|
||||
-- Notes, highlights and bookmarks. deleted is a tombstone rather than a hard
|
||||
-- delete so that an offline client cannot resurrect a note it removed elsewhere.
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
|
||||
@@ -55,6 +55,13 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void
|
||||
return { progress: ctx.sync.progressFor(user.id, bookId) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/sync/progress/:bookId/history', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
const { bookId } = request.params as { bookId: string };
|
||||
const limit = Number((request.query as { limit?: string }).limit) || 30;
|
||||
return { history: ctx.sync.progressHistory(user.id, bookId, limit) };
|
||||
});
|
||||
|
||||
app.put('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
const { bookId } = request.params as { bookId: string };
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { BookDto, ShelfService } from './shelf.ts';
|
||||
* offline for a week can push its local changes and receive only what changed
|
||||
* on the server since `since`.
|
||||
*/
|
||||
/** Progress history: entries kept per book, and what still counts as reading on in one place. */
|
||||
const HISTORY_KEEP = 50;
|
||||
const HISTORY_SESSION_MS = 30 * 60 * 1000;
|
||||
const HISTORY_JUMP = 0.05;
|
||||
|
||||
export interface ProgressRecord {
|
||||
bookId: string;
|
||||
/** Opaque locator: EPUB CFI, pagination index or PDF page. Client-defined. */
|
||||
@@ -123,6 +128,61 @@ export class SyncService {
|
||||
userId, record.bookId, record.locator ?? '', percentage,
|
||||
record.chapterTitle ?? '', record.device ?? '', record.updatedAt,
|
||||
);
|
||||
this.recordHistory(userId, record, percentage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a progress write to the book's history (schema: reading_progress_history).
|
||||
*
|
||||
* Reading on through one chapter on one device is one entry that moves forward,
|
||||
* not a row per page: the latest entry of the same device is updated when the
|
||||
* write is newer, in the same chapter, within 5% of it and within half an hour.
|
||||
* Anything else — another chapter, a jump, another device, a write that arrives
|
||||
* out of order — is a new entry, because those are exactly the places a reader may
|
||||
* want back. The newest 50 per book are kept.
|
||||
*/
|
||||
private recordHistory(userId: string, record: ProgressRecord, percentage: number): void {
|
||||
const device = record.device ?? '', chapter = record.chapterTitle ?? '';
|
||||
const latest = this.db.get<{ id: number; chapter_title: string; percentage: number; updated_at: number }>(
|
||||
`SELECT id, chapter_title, percentage, updated_at FROM reading_progress_history
|
||||
WHERE user_id = ? AND book_id = ? AND device = ? ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||
userId, record.bookId, device,
|
||||
);
|
||||
const continues = latest
|
||||
&& record.updatedAt >= latest.updated_at
|
||||
&& record.updatedAt - latest.updated_at <= HISTORY_SESSION_MS
|
||||
&& latest.chapter_title === chapter
|
||||
&& Math.abs(latest.percentage - percentage) < HISTORY_JUMP;
|
||||
if (continues) {
|
||||
this.db.run(
|
||||
'UPDATE reading_progress_history SET locator = ?, percentage = ?, updated_at = ? WHERE id = ?',
|
||||
record.locator ?? '', percentage, record.updatedAt, latest.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.db.run(
|
||||
`INSERT INTO reading_progress_history (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
userId, record.bookId, record.locator ?? '', percentage, chapter, device, record.updatedAt,
|
||||
);
|
||||
this.db.run(
|
||||
`DELETE FROM reading_progress_history WHERE user_id = ? AND book_id = ? AND id NOT IN (
|
||||
SELECT id FROM reading_progress_history WHERE user_id = ? AND book_id = ?
|
||||
ORDER BY updated_at DESC, id DESC LIMIT ?)`,
|
||||
userId, record.bookId, userId, record.bookId, HISTORY_KEEP,
|
||||
);
|
||||
}
|
||||
|
||||
/** The book's reading history, newest first. */
|
||||
progressHistory(userId: string, bookId: string, limit = 30): ProgressRecord[] {
|
||||
return this.db.all<{ locator: string; percentage: number; chapter_title: string; device: string; updated_at: number }>(
|
||||
`SELECT locator, percentage, chapter_title, device, updated_at FROM reading_progress_history
|
||||
WHERE user_id = ? AND book_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?`,
|
||||
userId, bookId, Math.max(1, Math.min(HISTORY_KEEP, limit)),
|
||||
).map((row) => ({
|
||||
bookId, locator: row.locator, percentage: row.percentage,
|
||||
chapterTitle: row.chapter_title, device: row.device, updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
private upsertNote(userId: string, record: NoteRecord): void {
|
||||
|
||||
@@ -294,6 +294,35 @@ test('a stale offline device cannot roll back newer progress', async () => {
|
||||
assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh');
|
||||
});
|
||||
|
||||
test('progress history keeps the places a write replaced, one entry per stretch of reading', async () => {
|
||||
const owner = await login('owner');
|
||||
const member = await login('family');
|
||||
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
||||
const push = (items: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>, token = owner.token) =>
|
||||
app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(token), payload: { progress: items.map(item => ({ bookId: book.id, ...item })) } });
|
||||
const minute = 60_000, base = 1_000_000_000_000;
|
||||
// Reading on through chapter 3 on the phone: one entry that moves forward.
|
||||
await push([{ locator: 'c3-a', percentage: 0.30, chapterTitle: '第三章', device: 'phone', updatedAt: base }]);
|
||||
await push([{ locator: 'c3-b', percentage: 0.31, chapterTitle: '第三章', device: 'phone', updatedAt: base + minute }]);
|
||||
// A mistapped 目录 entry jumps to chapter 40: a new entry, and chapter 3 stays restorable.
|
||||
await push([{ locator: 'c40', percentage: 0.90, chapterTitle: '第四十章', device: 'phone', updatedAt: base + 2 * minute }]);
|
||||
// A stale tablet loses the merge, but where it was is history too.
|
||||
await push([{ locator: 'c2', percentage: 0.20, chapterTitle: '第二章', device: 'tablet', updatedAt: base - minute }]);
|
||||
const read = async (token = owner.token) => (await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history`, headers: auth(token) })).json() as { history: Array<{ locator: string; device: string }> };
|
||||
// Newest first; earlier tests in this file left older entries for the same book behind.
|
||||
assert.deepEqual((await read()).history.slice(0, 3).map(item => [item.locator, item.device]), [['c40', 'phone'], ['c3-b', 'phone'], ['c2', 'tablet']]);
|
||||
const current = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
|
||||
assert.equal((current.json() as { progress: { locator: string } }).progress.locator, 'c40');
|
||||
// Another account sees none of it (only its own places).
|
||||
assert.ok((await read(member.token)).history.every(item => !['c40', 'c3-b', 'c2'].includes(item.locator)));
|
||||
// Kept bounded: the newest 50.
|
||||
await push(Array.from({ length: 60 }, (_, index) => ({ locator: `x${index}`, percentage: index / 60, chapterTitle: `第${index}章`, device: 'phone', updatedAt: base + (10 + index) * minute })));
|
||||
const all = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history?limit=100`, headers: auth(owner.token) });
|
||||
const entries = (all.json() as { history: Array<{ locator: string }> }).history;
|
||||
assert.equal(entries.length, 50);
|
||||
assert.equal(entries[0]!.locator, 'x59');
|
||||
});
|
||||
|
||||
test('deleting a note leaves a tombstone that a stale device cannot undo', async () => {
|
||||
const owner = await login('owner');
|
||||
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
||||
|
||||
@@ -687,6 +687,15 @@ export class ReaderApi {
|
||||
* Single progress write. Cheaper than a batch and used for the periodic
|
||||
* "still reading" report, where there is nothing to reconcile.
|
||||
*/
|
||||
/** Where this account has been in a book, newest first (server keeps the latest 50). */
|
||||
async progressHistory(bookId: string, options: RequestOptions = {}): Promise<Progress[]> {
|
||||
const result = await this.get<{ history: Progress[] }>(
|
||||
`/api/v1/sync/progress/${encodeURIComponent(bookId)}/history`,
|
||||
options,
|
||||
);
|
||||
return result.history;
|
||||
}
|
||||
|
||||
async putProgress(progress: ProgressInput, options: RequestOptions = {}): Promise<Progress | null> {
|
||||
const result = await this.call<{ progress: Progress | null }>(
|
||||
`/api/v1/sync/progress/${encodeURIComponent(progress.bookId)}`,
|
||||
|
||||
@@ -3880,6 +3880,15 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.book-detail-author { color: var(--reader-muted); }
|
||||
.book-detail dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .6rem 1rem; padding-block: .8rem; border-block: 1px solid var(--reader-line); }
|
||||
.book-detail dt { color: var(--reader-muted); }
|
||||
/* 阅读记录 in 书籍信息: one row per place, the one on screen marked and not pressable. */
|
||||
.reading-history ul { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
||||
.reading-history button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0 var(--space-2); width: 100%; min-height: 44px; padding: var(--space-1) var(--space-2); border: 1px solid var(--reader-line); border-radius: var(--radius-sm); background: transparent; color: var(--reader-ink); font: inherit; text-align: start; cursor: pointer; }
|
||||
.reading-history button strong { grid-column: 1; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.reading-history button small { grid-column: 1; color: var(--reader-muted); font-size: var(--text-meta); }
|
||||
.reading-history button:disabled { cursor: default; border-color: transparent; background: var(--color-brand-soft); }
|
||||
.reading-history button:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }
|
||||
.reading-history-current { grid-column: 2; grid-row: 1 / span 2; color: var(--reader-accent); font-size: var(--text-meta); }
|
||||
@media (hover: hover) and (pointer: fine) { .reading-history button:not(:disabled):hover { border-color: var(--reader-accent); } }
|
||||
.book-detail dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.book-detail-description { white-space: pre-wrap; line-height: 1.8; overflow-wrap: anywhere; }
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FloatingNotice } from './floating-notice.tsx';
|
||||
|
||||
import type { AppSettings } from '../store/settings.ts';
|
||||
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
|
||||
import { formatDate } from '../lib/format.ts';
|
||||
import { ReaderIndicators } from './reader-indicators.tsx';
|
||||
import type { SpeechEngineKind } from '../render/speech.ts';
|
||||
import { type ReactNode, type JSX, useState } from 'react';
|
||||
@@ -69,6 +70,11 @@ export interface ChromeState {
|
||||
statusState: string;
|
||||
/** Whether the server offers AI 章节总结; unknown (undefined) draws no button. */
|
||||
aiSummaryAvailable?: boolean;
|
||||
/** 阅读记录 in 书籍信息: where this account has been in the book, newest first. */
|
||||
historyState?: 'loading' | 'ready' | 'offline' | 'error';
|
||||
history?: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>;
|
||||
/** The locator on screen when the history was read, so that entry is marked 当前. */
|
||||
historyCurrent?: string;
|
||||
/** Whether the page on screen carries a bookmark; drives the top bar's 书签 toggle. */
|
||||
bookmarked?: boolean;
|
||||
/** Set when the book itself could not be opened: the stage is empty, so say why and offer a way on. */
|
||||
@@ -155,6 +161,9 @@ export interface ChromeHandlers {
|
||||
onTools?(): void;
|
||||
onBack(): void;
|
||||
onRetryOpen?(): void;
|
||||
/** Goes back to a place from 阅读记录. */
|
||||
onRestoreHistory?(locator: string): void;
|
||||
onRetryHistory?(): void;
|
||||
/** Adds a bookmark at the page on screen, or removes the one there. */
|
||||
onToggleBookmark?(): void;
|
||||
toggleToc(): void;
|
||||
@@ -253,7 +262,7 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
|
||||
</div>
|
||||
{(state.sourceName || state.chapterUrl) && <div className="reader-source-info"><span>{state.sourceName || '当前书源'}</span>{state.chapterUrl && <a href={state.chapterUrl} target="_blank" rel="noopener noreferrer" title={state.chapterUrl}>{state.chapterUrl}</a>}</div>}
|
||||
</div>
|
||||
{state.bookInfoOpen && <Sheet title="书籍信息" onClose={() => handlers.onBookInfo?.()}><article className="book-detail"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p></article></Sheet>}
|
||||
{state.bookInfoOpen && <Sheet title="书籍信息" onClose={() => handlers.onBookInfo?.()}><article className="book-detail"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p><ReadingHistory state={state} handlers={handlers} /></article></Sheet>}
|
||||
<FloatingNotice message={state.aiSummaryBusy ? '正在生成章节总结…' : ''} busy />
|
||||
{state.aiSummary && <Sheet title="AI 章节总结" onClose={() => handlers.onAiSummary?.()}><article className="book-detail"><p className="book-detail-description">{state.aiSummary}</p></article></Sheet>}
|
||||
<StageHost stage={stage} />
|
||||
@@ -384,6 +393,35 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
|
||||
* *where* the stage sits (between the status line and the footer, which is where
|
||||
* its stacking context comes from) and the screen owns its contents.
|
||||
*/
|
||||
/**
|
||||
* 阅读记录: the places this account has been in the book, on any device, newest first.
|
||||
* A jump — a mistapped 目录 entry, a stale device, a clock that ran ahead — overwrote the
|
||||
* one saved position for good; here the place before it is one press away.
|
||||
*/
|
||||
function ReadingHistory({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element | null {
|
||||
if (!state.historyState) return null;
|
||||
return (
|
||||
<section className="reading-history" aria-label="阅读记录">
|
||||
<h4>阅读记录</h4>
|
||||
{state.historyState === 'loading' && <p className="muted" role="status">正在读取阅读记录…</p>}
|
||||
{state.historyState === 'offline' && <p className="muted">离线时无法读取阅读记录,连上服务端后再试。</p>}
|
||||
{state.historyState === 'error' && <p className="muted" role="alert">阅读记录读取失败。 <Button onClick={() => handlers.onRetryHistory?.()}>重试</Button></p>}
|
||||
{state.historyState === 'ready' && (state.history?.length
|
||||
? <ul>{state.history.map((entry, index) => {
|
||||
const current = entry.locator === state.historyCurrent;
|
||||
return <li key={`${entry.updatedAt}-${index}`}>
|
||||
<button type="button" disabled={current} aria-current={current ? 'location' : undefined} onClick={() => handlers.onRestoreHistory?.(entry.locator)}>
|
||||
<strong>{entry.chapterTitle || '未命名章节'}</strong>
|
||||
<small>{Math.round(Math.max(0, Math.min(1, entry.percentage)) * 100)}% · {entry.device || '未知设备'} · {formatDate(entry.updatedAt)}</small>
|
||||
{current && <span className="reading-history-current">当前</span>}
|
||||
</button>
|
||||
</li>;
|
||||
})}</ul>
|
||||
: <p className="muted">还没有阅读记录。</p>)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the 目录 at the chapter being read: in a long serial the reader is in
|
||||
* chapter 800, and a list that opens at chapter 1 hides both where they are and
|
||||
|
||||
@@ -296,7 +296,9 @@ export class ReaderScreen {
|
||||
onSwitchEngine: (kind) => this.switchSpeechEngine(kind),
|
||||
onTocEntry: (ref) => void this.goToChapterRef(ref),
|
||||
onChapter: (delta) => void this.goToChapter(delta),
|
||||
onBookInfo: () => this.patch({ bookInfoOpen: !this.chrome.bookInfoOpen }),
|
||||
onBookInfo: () => { const open = !this.chrome.bookInfoOpen; this.patch({ bookInfoOpen: open }); if (open) void this.loadHistory(); },
|
||||
onRetryHistory: () => void this.loadHistory(),
|
||||
onRestoreHistory: (locator) => void this.restoreHistory(locator),
|
||||
onAiSummary: () => void this.generateAiSummary(),
|
||||
onRefresh: () => void this.refreshPublication(),
|
||||
onRefreshChapter: () => void this.refreshChapter(),
|
||||
@@ -1347,6 +1349,40 @@ export class ReaderScreen {
|
||||
* and the *write* is debounced below, because a scroll is a gesture in progress
|
||||
* rather than a sequence of decisions to persist.
|
||||
*/
|
||||
/**
|
||||
* Reads 阅读记录 for 书籍信息. The position on screen is pushed first, so the list
|
||||
* already holds where the reader is now and that entry can be marked 当前.
|
||||
*/
|
||||
private async loadHistory(): Promise<void> {
|
||||
const book = this.book;
|
||||
if (!book || typeof this.options.api.progressHistory !== 'function') return;
|
||||
this.patch({ historyState: 'loading' });
|
||||
try {
|
||||
await this.flushProgress().catch(() => undefined);
|
||||
if (typeof this.options.sync.flush === 'function') await this.options.sync.flush();
|
||||
const history = await this.options.api.progressHistory(book.id);
|
||||
if (this.disposed || this.book !== book) return;
|
||||
this.patch({ historyState: 'ready', history, historyCurrent: this.view?.position()?.locator ?? '' });
|
||||
} catch (error) {
|
||||
if (this.disposed || this.book !== book) return;
|
||||
if (error instanceof ApiError && error.isAuthFailure) { this.options.onSignedOut(); return; }
|
||||
this.patch({ historyState: error instanceof ApiError && error.isConnectivity ? 'offline' : 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Goes back to a place from 阅读记录; that place then becomes the saved position. */
|
||||
private async restoreHistory(locator: string): Promise<void> {
|
||||
this.patch({ bookInfoOpen: false });
|
||||
try {
|
||||
await this.navigateTool(locator);
|
||||
this.onPosition(this.view?.position() ?? null);
|
||||
await this.flushProgress();
|
||||
this.flashStatus('已回到这个位置', 1800);
|
||||
} catch (error) {
|
||||
this.setStatus('error', error instanceof Error ? error.message : '无法回到这个位置');
|
||||
}
|
||||
}
|
||||
|
||||
/** Bookmarks at this exact reading position — the same test 阅读工具 uses for 已加书签. */
|
||||
private bookmarksHere(locator: string | undefined): Note[] {
|
||||
if (!this.book || !locator) return [];
|
||||
|
||||
@@ -500,3 +500,29 @@ it('in scroll mode, pulling on past the end of a chapter opens the next one, and
|
||||
expect(turn).toHaveBeenLastCalledWith('next');
|
||||
clock.mockRestore();
|
||||
});
|
||||
|
||||
it('lists 阅读记录 in 书籍信息 and goes back to a place from it', async () => {
|
||||
const env = await setup(); serve(env.transport, content(['a', 'b']));
|
||||
await env.screen.open(book);
|
||||
const places = [
|
||||
{ bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.9, chapterTitle: '章节 b', device: 'phone', updatedAt: Date.now() - 60_000 },
|
||||
{ bookId: book.id, locator: 'r1:0.5000:chapter:a', percentage: 0.25, chapterTitle: '章节 a', device: 'tablet', updatedAt: Date.now() - 3_600_000 },
|
||||
];
|
||||
vi.spyOn(env.api, 'progressHistory').mockResolvedValue(places);
|
||||
const navigate = vi.spyOn(env.screen as unknown as { navigateTool(target: string): Promise<void> }, 'navigateTool').mockResolvedValue(undefined);
|
||||
await click(env.screen, '书籍信息');
|
||||
await vi.waitFor(() => expect(env.screen.element.querySelectorAll('.reading-history li')).toHaveLength(2));
|
||||
expect(env.screen.element.querySelector('.reading-history li small')?.textContent).toContain('90% · phone');
|
||||
const older = [...env.screen.element.querySelectorAll<HTMLButtonElement>('.reading-history button')].find(button => button.textContent?.includes('tablet'))!;
|
||||
older.click();
|
||||
await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith('r1:0.5000:chapter:a'));
|
||||
expect(env.screen.element.querySelector('.reading-history')).toBeNull();
|
||||
});
|
||||
|
||||
it('says 阅读记录 cannot be read offline instead of showing an empty list', async () => {
|
||||
const env = await setup(); serve(env.transport, content(['a']));
|
||||
await env.screen.open(book);
|
||||
vi.spyOn(env.api, 'progressHistory').mockRejectedValue(new ApiError('offline', 'offline'));
|
||||
await click(env.screen, '书籍信息');
|
||||
await vi.waitFor(() => expect(env.screen.element.querySelector('.reading-history')?.textContent).toContain('离线时无法读取阅读记录'));
|
||||
});
|
||||
Reference in new issue
Block a user