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/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/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')!; 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('离线时无法读取阅读记录')); +});