mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
feat(web): 阅读记录 in 书籍信息 brings back an overwritten reading position
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
842331684a
commit
21e2509c86
8 files changed
+124
-3
No files matched your search
@@ -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,可调整或按书清理;不自动淘汰,也不删除原书、进度、笔记。服务端缓存与当前设备缓存独立,只有本设备完成下载的内容才能保证断网读取。图片目录暂不支持整书下载。
|
||||
|
||||
## 进一步阅读
|
||||
|
||||
@@ -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