This commit is contained in:
truewhile
2026-10-04 12:35:33 +08:00
parent 6656f9c398
commit e85df5fb6b
4 changed files with 96 additions and 5 deletions
+26 -4
View File
@@ -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<number> => {
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() {
<Trash2 size={13} className="mr-1 inline" /> 移出书架
</button>
) : (
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<button type="button" onClick={() => startReading()} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<BookOpen size={13} className="mr-1 inline" /> 加入书架
</button>
)}
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
<button type="button" onClick={() => startReading()} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
</button>
@@ -318,6 +330,16 @@ export default function ReaderBookPage() {
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
{c.is_volume ? (
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
) : c.url ? (
// 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。
<button
type="button"
disabled={busy}
onClick={() => startReading(c)}
className="block w-full truncate text-left transition-colors hover:text-brand-600 disabled:opacity-50"
>
{c.title}
</button>
) : (
c.title
)}
+4 -1
View File
@@ -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(那是明确的跳转)。
@@ -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> = {}): 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')
+20
View File
@@ -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
}