From 842331684a8a00ea1edc74a137706aeced718e2e Mon Sep 17 00:00:00 2001 From: hectorqin Date: Sat, 31 Oct 2026 03:51:42 +0000 Subject: [PATCH 1/2] feat(server): keep a per-book reading-progress history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reading_progress keeps one row per book and last-writer-wins, which answers "where am I" and loses "where was I": a stale device, a mistapped 目录 entry or a clock running ahead overwrote the place for good (P0: 进度历史). Every valid progress write now also lands in reading_progress_history, including a write that lost the merge. A device reading on through one chapter updates its latest entry (same chapter, within 5% and 30 minutes) instead of adding rows; another chapter, a jump, another device or an out-of-order write is a new entry. The newest 50 per book are kept. GET /api/v1/sync/progress/:bookId/history lists them, newest first, for the signed-in account only. The table is new (CREATE TABLE IF NOT EXISTS), so the schema change is additive and older builds sharing the database ignore it. Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 8 +++++ server/src/db/schema.ts | 19 +++++++++++ server/src/http/routes/sync.ts | 7 ++++ server/src/services/sync.ts | 60 ++++++++++++++++++++++++++++++++++ server/test/api.test.ts | 29 ++++++++++++++++ 5 files changed, 123 insertions(+) diff --git a/docs/api.md b/docs/api.md index c551841..1468a6a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -829,6 +829,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** → `{ "progress": {...} | null }` +### `GET /sync/progress/:bookId/history` + +`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。 + +阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。 +同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。 +每本书保留最新 50 条,只返回当前账号自己的记录。 + ### `PUT /sync/progress/:bookId` 单个进度上报的便捷写法: diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 9573c1f..d3e32b5 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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 ( diff --git a/server/src/http/routes/sync.ts b/server/src/http/routes/sync.ts index 4aa9e42..e92c316 100644 --- a/server/src/http/routes/sync.ts +++ b/server/src/http/routes/sync.ts @@ -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 }; diff --git a/server/src/services/sync.ts b/server/src/services/sync.ts index 179c52a..b79e8ad 100644 --- a/server/src/services/sync.ts +++ b/server/src/services/sync.ts @@ -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 { diff --git a/server/test/api.test.ts b/server/test/api.test.ts index b588396..efa0d0c 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -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')!; From 21e2509c86bf07472fb77040ac94ff8dd4aff656 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Sat, 31 Oct 2026 03:51:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20=E9=98=85=E8=AF=BB=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20in=20=E4=B9=A6=E7=B1=8D=E4=BF=A1=E6=81=AF=20brings?= =?UTF-8?q?=20back=20an=20overwritten=20reading=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the title opens 书籍信息, which now ends with 阅读记录: the places this account has been in the book on any device, newest first, each with its chapter, percentage, device and time. The position on screen is pushed before the list is read, so its entry is marked 当前; pressing another entry goes back there and saves it as the position. Offline the section says the history cannot be read; a failed read offers 重试. Co-Authored-By: Claude Opus 5.5 --- docs/p0-progress-2026-09-23.md | 2 +- docs/ui.md | 1 + docs/user-guide.zh-CN.md | 2 ++ web/src/api/client.ts | 9 ++++++++ web/src/styles/reader.css | 9 ++++++++ web/src/ui/reader-chrome.tsx | 40 ++++++++++++++++++++++++++++++++- web/src/ui/reader-screen.tsx | 38 ++++++++++++++++++++++++++++++- web/test/chapter-reader.test.ts | 26 +++++++++++++++++++++ 8 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/p0-progress-2026-09-23.md b/docs/p0-progress-2026-09-23.md index 9ce7112..9848dfa 100644 --- a/docs/p0-progress-2026-09-23.md +++ b/docs/p0-progress-2026-09-23.md @@ -17,7 +17,7 @@ ## 未完成的 P0 范围 -- 进度历史和用户主动恢复入口;多设备系统时间偏差下的冲突策略。 +- ~~进度历史和用户主动恢复入口~~:已提供(服务端 `reading_progress_history`,阅读器「书籍信息 → 阅读记录」可回到任一记录位置)。多设备系统时间偏差下的冲突策略仍未处理;时间超前的设备仍会在合并中胜出,但被覆盖的位置可从阅读记录找回。 - iOS Safari 与 Android 真机的手势、跨章、旋转、关闭/强制退出测试。本地 jsdom 测试不能替代真实渲染与系统生命周期。 - 备份/恢复图形界面和运行中一致性快照;本批只提供需要停机的维护命令。 - 首次接入向导、旧版数据自动迁移、数据损坏诊断界面。 diff --git a/docs/ui.md b/docs/ui.md index 1b77754..db7d867 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -687,6 +687,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 - 底部:上一章、本章页数滑杆、下一章;下一行是工具(书内搜索、笔记与书签)、目录、朗读、外观、设置。朗读进行中时“朗读”按钮显示“朗读中”(按下态),因为关掉朗读面板不会停止朗读。 - 滚动模式下手动滑到章末后,从章末开始继续上拉(触屏超过 72px,或滚轮在章末继续滚动)进入下一章;在章首下拉回到上一章。手势必须从边缘开始,只是滑到章末不会翻章;翻章后有约 1 秒冷却,滚轮惯性不会连翻两章。 - 中间滑杆控制本章页数,不能变成全书进度。例外是漫画:它每“章”就是一页,本章页数恒为 1,所以滑杆改为全书页码(“第 7/200 页”),拖动时只更新标签,松手才打开那一页。 +- 「书籍信息」(点顶部书名)末尾是「阅读记录」:本账号在这本书里去过的位置,最新在前,每条写章节、进度、设备与相对时间;当前位置标「当前」且不可点,点其他条目回到那里并成为新的保存位置。离线时说明无法读取,读取失败给「重试」。 - 书打不开时(服务端错误、未缓存的离线书),舞台上一直显示原因和“重试”“返回”,不只弹一条会消失的提示。直接打开阅读链接、没有上一页时,“返回”回到书架。 - 键盘:方向键与 PageUp/PageDown 翻页,空格/Shift+空格翻下/上一页,S 开始或暂停朗读,Esc 收起工具栏;右→左的漫画方向键随方向镜像。焦点在按钮上时空格和回车只按下那个按钮。 - 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。 diff --git a/docs/user-guide.zh-CN.md b/docs/user-guide.zh-CN.md index 96b5387..7c84ec5 100644 --- a/docs/user-guide.zh-CN.md +++ b/docs/user-guide.zh-CN.md @@ -84,6 +84,8 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口 首个账号是管理员,后续成员默认由管理员创建。每个账号的书架、进度、笔记和高亮独立。阅读界面参数属于当前设备,不随账号覆盖其他设备的设置。 +阅读进度在设备间以最后一次写入为准。如果被另一台设备或误点的目录项带到了别处,点阅读器顶部的书名打开「书籍信息」,在「阅读记录」里可以看到最近去过的位置(章节、进度、设备、时间),点一条即可回去;离线时无法读取阅读记录。 + 离线时可使用已经缓存的内容,重新连通后同步阅读状态。在阅读器底部「工具 → 离线缓存」可下载TXT 的指定范围,或下载完整 EPUB、PDF、CBZ;自定义目录的 TXT 下载完整文件。任务支持暂停、继续和取消,关闭面板会暂停,已下载内容保留。默认设备账号配额为 256 MiB,可调整或按书清理;不自动淘汰,也不删除原书、进度、笔记。服务端缓存与当前设备缓存独立,只有本设备完成下载的内容才能保证断网读取。图片目录暂不支持整书下载。 ## 进一步阅读 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 69d33bf..1ad991a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + 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 { const result = await this.call<{ progress: Progress | null }>( `/api/v1/sync/progress/${encodeURIComponent(progress.bookId)}`, diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index f2a9490..ee3bddc 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index 32d4ca4..e504942 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -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 {(state.sourceName || state.chapterUrl) &&
{state.sourceName || '当前书源'}{state.chapterUrl && {state.chapterUrl}}
} - {state.bookInfoOpen && handlers.onBookInfo?.()}>

{state.title}

{state.author || '作者未知'}

书源
{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}
当前章节
{state.chapterLabel || '暂无信息'}

内容简介

{state.description || '暂无简介'}

} + {state.bookInfoOpen && handlers.onBookInfo?.()}>

{state.title}

{state.author || '作者未知'}

书源
{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}
当前章节
{state.chapterLabel || '暂无信息'}

内容简介

{state.description || '暂无简介'}

} {state.aiSummary && handlers.onAiSummary?.()}>

{state.aiSummary}

} @@ -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 ( +
+

阅读记录

+ {state.historyState === 'loading' &&

正在读取阅读记录…

} + {state.historyState === 'offline' &&

离线时无法读取阅读记录,连上服务端后再试。

} + {state.historyState === 'error' &&

阅读记录读取失败。

} + {state.historyState === 'ready' && (state.history?.length + ?
    {state.history.map((entry, index) => { + const current = entry.locator === state.historyCurrent; + return
  • + +
  • ; + })}
+ :

还没有阅读记录。

)} +
+ ); +} + /** * 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 diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx index 6ece0e9..3bf535d 100644 --- a/web/src/ui/reader-screen.tsx +++ b/web/src/ui/reader-screen.tsx @@ -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 { + 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 { + 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 []; diff --git a/web/test/chapter-reader.test.ts b/web/test/chapter-reader.test.ts index e26f523..1e59fd9 100644 --- a/web/test/chapter-reader.test.ts +++ b/web/test/chapter-reader.test.ts @@ -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 }, '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('.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('离线时无法读取阅读记录')); +});