From e85df5fb6b494aba7e94610c5e7cb73682204c05 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:35:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/reader/ReaderBookPage.tsx | 30 +++++++++++-- web/src/pages/reader/ReaderViewPage.tsx | 5 ++- web/src/pages/reader/bookDetailModel.test.ts | 46 ++++++++++++++++++++ web/src/pages/reader/bookDetailModel.ts | 20 +++++++++ 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 web/src/pages/reader/bookDetailModel.test.ts create mode 100644 web/src/pages/reader/bookDetailModel.ts diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx index 739e48e..83e92f6 100644 --- a/web/src/pages/reader/ReaderBookPage.tsx +++ b/web/src/pages/reader/ReaderBookPage.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, R import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader' import ReaderBookCover from '../../components/ReaderBookCover' +import { pickChapterIndex } from './bookDetailModel' import { SourcePickerDialog } from './SourcePickerDialog' // 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。 @@ -86,7 +87,16 @@ export default function ReaderBookPage() { const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters]) - const startReading = async () => { + /** + * 目录点章:把点中的章节换算成阅读器要的序号(换算规则与理由见 bookDetailModel)。 + */ + const resolveChapterIndex = async (bookID: string, chapter: ReaderTocChapter): Promise => { + const cached = await readerAPI.listChapters(bookID).catch(() => []) + return pickChapterIndex(cached, chapter) + } + + // chapter 为空=开始/继续阅读(沿用服务端进度);给了章节=目录点章跳读。 + const startReading = async (chapter?: ReaderTocChapter) => { setBusy(true) try { let book = shelfBook @@ -105,7 +115,9 @@ export default function ReaderBookPage() { cover_url: cover, }) } - navigate(`/reader/view/${book.id}`) + // 目录点章:带上阅读器认识的 chapter 序号跳进去(没有它阅读器就用服务端进度)。 + const index = chapter ? await resolveChapterIndex(book.id, chapter) : null + navigate(index === null ? `/reader/view/${book.id}` : `/reader/view/${book.id}?chapter=${index}`) } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败') } finally { @@ -273,11 +285,11 @@ export default function ReaderBookPage() { 移出书架 ) : ( - )} - @@ -318,6 +330,16 @@ export default function ReaderBookPage() {
{c.is_volume ? (

{c.title}

+ ) : c.url ? ( + // 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。 + ) : ( c.title )} diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index f30d9a7..7661b75 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -220,7 +220,10 @@ export default function ReaderViewPage() { else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) { idx = b.dur_chapter_index } - pendingPosRef.current = b.dur_chapter_pos ?? 0 + // 位置只在「接着上次那一章读」时恢复:URL 显式跳到别的章时,b.dur_chapter_pos + // 属于另一章,套到新章上会从中间开始(音频表现为一进去就从错的时间点续播)。 + const resumingSavedChapter = !explicitChapter || idx === b.dur_chapter_index + pendingPosRef.current = resumingSavedChapter ? (b.dur_chapter_pos ?? 0) : 0 // 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在 // 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书 // 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。 diff --git a/web/src/pages/reader/bookDetailModel.test.ts b/web/src/pages/reader/bookDetailModel.test.ts new file mode 100644 index 0000000..e849026 --- /dev/null +++ b/web/src/pages/reader/bookDetailModel.test.ts @@ -0,0 +1,46 @@ +import type { ReaderChapter, ReaderTocChapter } from '../../api/reader.ts' +import { pickChapterIndex } from './bookDetailModel.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`bookDetailModel: ${name}`) +} + +function cached(index: number, url: string): ReaderChapter { + return { index, title: `第${index}章`, url, is_volume: false } +} + +function toc(index: number, url: string, over: Partial = {}): ReaderTocChapter { + return { index, title: `第${index}章`, url, is_volume: false, update_time: '', ...over } +} + +// 目录一致(正常情况):按 url 定位到的序号与现抓序号相同 +{ + const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')] + check('same url keeps the same index', pickChapterIndex(list, toc(2, 'https://s/3')) === 2) +} + +// 站点在开头插了新章:同一章在现抓目录里是 2,在缓存目录里是 1——必须用缓存里的 1, +// 照搬现抓的 2 会跳到下一章。 +{ + const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')] + check('inserted chapter maps back to the cached index', pickChapterIndex(list, toc(2, 'https://s/2')) === 1) +} + +// 缓存目录里没有这一章(站点改版、或两边取的不是同一份目录):退回现抓的序号 +{ + const list = [cached(0, 'https://s/1')] + check('unknown url falls back to the fetched index', pickChapterIndex(list, toc(7, 'https://s/7')) === 7) +} + +// 缓存为空(第一次打开这本书):退回现抓序号,阅读器随后会把这份目录存下来 +{ + check('empty cache falls back to the fetched index', pickChapterIndex([], toc(5, 'https://s/5')) === 5) +} + +// 章节没有 url(拿不到地址):退回现抓序号,不做 url 匹配 +{ + const list = [cached(0, ''), cached(1, '')] + check('chapter without url falls back to the fetched index', pickChapterIndex(list, toc(1, '')) === 1) +} + +console.log('bookDetailModel.test.ts ok') diff --git a/web/src/pages/reader/bookDetailModel.ts b/web/src/pages/reader/bookDetailModel.ts new file mode 100644 index 0000000..9f2481b --- /dev/null +++ b/web/src/pages/reader/bookDetailModel.ts @@ -0,0 +1,20 @@ +// 书籍详情页点目录跳读的纯逻辑(不依赖 React,便于单测)。 + +import type { ReaderChapter, ReaderTocChapter } from '../../api/reader' + +/** + * 把详情页点中的章节换算成阅读器要的序号。 + * + * 阅读器打开一本书时优先用库里缓存的目录(`/books/:id/chapters`),而详情页显示的 + * 目录是现抓的:站点更新过目录(插入或调整过章节)时两者会错位,直接照搬现抓目录 + * 里的序号会跳到别的章。url 是章节的稳定标识,所以先按 url 在缓存目录里定位; + * 找不到(缓存为空,或两边取的根本不是同一份目录)才退回现抓的序号——缓存为空时 + * 阅读器本来就会把这次现抓的目录存下来,序号自然对得上。 + */ +export function pickChapterIndex(cached: ReaderChapter[], chapter: ReaderTocChapter): number { + if (chapter.url) { + const hit = cached.findIndex((c) => c.url === chapter.url) + if (hit >= 0) return hit + } + return chapter.index +}