bug处理

This commit is contained in:
truewhile
2026-10-02 12:02:29 +08:00
parent 5acfa6292a
commit b8648f0049
9 changed files with 563 additions and 17 deletions
+8 -4
View File
@@ -257,15 +257,17 @@ export const readerAPI = {
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
api
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data.chapters),
.get<{ chapters: ReaderTocChapter[] | null }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
// 后端把 nil 目录编码成 null;调用方一律按数组处理,
// 否则 `chapters.length` 会抛 TypeError(页面只剩一句 JS 报错)。
.then((r) => r.data.chapters ?? []),
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
api
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data),
// ── 书架 ──
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
listBooks: () => api.get<{ books: ReaderBook[] | null }>('/reader/books').then((r) => r.data.books ?? []),
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
@@ -331,7 +333,9 @@ export const readerAPI = {
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
api.put(`/reader/books/${id}/audio-config`, body),
listChapters: (id: string) =>
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
api
.get<{ chapters: ReaderChapter[] | null }>(`/reader/books/${id}/chapters`)
.then((r) => r.data.chapters ?? []),
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
bookContent: (id: string, chapter: number) =>
+96 -5
View File
@@ -12,7 +12,7 @@ import {
Timer,
X,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { ReaderChapter } from '../../api/reader'
import {
@@ -21,6 +21,7 @@ import {
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { readerCoverSrc } from '../../utils/readerCover'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
@@ -48,6 +49,11 @@ interface ReaderAudioPanelProps {
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
transcoding?: boolean
onProgress: (seconds: number) => void
/**
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
*/
onCommitProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onSelectChapter: (index: number) => void
@@ -91,6 +97,7 @@ export function ReaderAudioPanel({
hasNext,
transcoding = false,
onProgress,
onCommitProgress,
onPrevChapter,
onNextChapter,
onSelectChapter,
@@ -102,6 +109,8 @@ export function ReaderAudioPanel({
const hlsRef = useRef<{ destroy: () => void } | null>(null)
const restoredRef = useRef(false)
const skippedEndRef = useRef(false)
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
const tocRef = useRef<VirtuosoHandle>(null)
const speed = useReaderSettingsStore((s) => s.audioSpeed)
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
@@ -115,6 +124,16 @@ export function ReaderAudioPanel({
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
const [audioError, setAudioError] = useState('')
const [sheet, setSheet] = useState<Sheet>('none')
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
const [groupIndex, setGroupIndex] = useState(0)
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
const lastTimeRef = useRef(0)
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
const commitRef = useRef(onCommitProgress)
useEffect(() => {
commitRef.current = onCommitProgress
}, [onCommitProgress])
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
const [coverOK, setCoverOK] = useState(true)
useEffect(() => {
@@ -145,6 +164,8 @@ export function ReaderAudioPanel({
setAudioError('')
setCur(0)
setDur(0)
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
lastTimeRef.current = 0
let cancelled = false
let hls: { destroy: () => void } | null = null
@@ -184,6 +205,27 @@ export function ReaderAudioPanel({
if (audio) audio.playbackRate = speed
}, [speed])
// ── 离开这一章时补报进度 ──
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
// 因此不会把上一章的位置写到新章节头上。
useEffect(() => {
const flush = () => {
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush()
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pagehide', flush)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pagehide', flush)
flush()
}
}, [])
// ── 定时关闭 ──
const applyTimer = useCallback(
(minutes: number) => {
@@ -277,13 +319,33 @@ export function ReaderAudioPanel({
const closeSheet = () => setSheet('none')
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
const openSheet = (next: Sheet) => {
if (sheet === next) {
setSheet('none')
return
}
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
// 续读已到第 12 组,沿用旧值会显示错误的区间。
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
setSheet(next)
}
const chapterList = useMemo(
() => chapters.map((c, i) => ({ ...c, i })),
[chapters],
)
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
const chapterGroups = useMemo(() => buildChapterGroups(chapterList.length), [chapterList.length])
const jumpToChapterGroup = (next: number) => {
const group = chapterGroups[next]
if (!group) return
setGroupIndex(next)
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
}
return (
<div
className="relative flex h-full flex-col overflow-hidden"
@@ -504,18 +566,46 @@ export function ReaderAudioPanel({
{sheet === 'chapters' && (
<div className="flex h-full flex-col">
<div
className="flex items-center justify-between border-b px-4 py-3 text-xs font-bold"
className="flex items-center gap-2 border-b px-4 py-3 text-xs font-bold"
style={{ borderColor: theme.text + '22' }}
>
<span>章节({chapters.length})</span>
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
<span className="shrink-0">章节({chapters.length})</span>
{chapterGroups.length > 1 && (
<select
value={groupIndex}
onChange={(e) => jumpToChapterGroup(Number(e.target.value))}
aria-label="按区间快速定位章节"
title="按区间快速定位章节"
className="min-w-0 flex-1 rounded-lg border bg-transparent px-2 py-1 text-2xs font-normal outline-none"
style={{ borderColor: theme.text + '33', color: theme.text }}
>
{chapterGroups.map((group) => (
<option
key={group.index}
value={group.index}
style={{ color: '#111827', backgroundColor: '#ffffff' }}
>
{group.label}
</option>
))}
</select>
)}
<button
type="button"
onClick={closeSheet}
className="shrink-0 opacity-70 hover:opacity-100"
>
<X size={16} />
</button>
</div>
<div className="h-[45vh] min-h-0">
<Virtuoso
ref={tocRef}
data={chapterList}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
rangeChanged={(range) =>
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
}
itemContent={(_i, row) => {
const isCurrent = row.i === chapterIndex
return (
@@ -717,6 +807,7 @@ export function ReaderAudioPanel({
onTimeUpdate={(e) => {
const audio = e.currentTarget
const t = audio.currentTime
lastTimeRef.current = t
setCur(t)
onProgress(t)
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
+63 -4
View File
@@ -14,10 +14,11 @@ import {
ScrollText,
Sun,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
@@ -71,6 +72,8 @@ export default function ReaderViewPage() {
const [menuOpen, setMenuOpen] = useState(false)
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
const [tocGroupIndex, setTocGroupIndex] = useState(0)
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
const [switchOpen, setSwitchOpen] = useState(false)
const [switchLoading, setSwitchLoading] = useState(false)
@@ -85,6 +88,8 @@ export default function ReaderViewPage() {
const wheelLastEventRef = useRef(0)
const wheelLastTurnRef = useRef(0)
const wheelSwipeLockedRef = useRef(false)
/** 目录面板虚拟列表句柄:区间下拉靠它整段跳转。 */
const tocRef = useRef<VirtuosoHandle>(null)
// 分页状态
const viewportRef = useRef<HTMLDivElement>(null)
@@ -339,6 +344,35 @@ export default function ReaderViewPage() {
setChapterIndex(idx)
}, [chapters.length])
// ── 目录面板的分段跳转(对齐听书面板的区间下拉) ──
// 上千章的目录按 100 章一组切开,顶部下拉选区间即整段跳转(见 utils/chapterGroups.ts)。
// 目录不足一组(≤100 章)时不显示下拉——一屏能扫完,多一个控件只是噪声。
const tocGroups = useMemo(() => buildChapterGroups(chapters.length), [chapters.length])
const jumpToTocGroup = useCallback(
(next: number) => {
const group = tocGroups[next]
if (!group) return
setTocGroupIndex(next)
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
},
[tocGroups],
)
// 面板开关:打开目录时把区间下拉对齐到当前章节所在组。上次可能停在第 3 组,
// 续读已到第 12 组,沿用旧值会显示错误的区间。
const openPanel = useCallback(
(next: 'none' | 'toc' | 'style') => {
if (panel === next) {
setPanel('none')
return
}
if (next === 'toc') setTocGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
setPanel(next)
},
[chapterIndex, chapters.length, panel],
)
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
const hasNextAudioChapter = useMemo(() => {
if (chapterIndex === null) return false
@@ -621,6 +655,7 @@ export default function ReaderViewPage() {
hasNext={hasNextAudioChapter}
transcoding={media.transcoding ?? false}
onProgress={throttledMediaSave}
onCommitProgress={savePos}
onPrevChapter={() => goChapter(-1)}
onNextChapter={() => goChapter(1)}
onSelectChapter={jumpToChapter}
@@ -814,8 +849,8 @@ export default function ReaderViewPage() {
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
{
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
label: settings.night ? '日间' : '夜间',
@@ -927,15 +962,39 @@ export default function ReaderViewPage() {
>
<ArrowLeft size={18} />
</button>
<p className="flex-1 truncate text-sm font-bold">
<p className="min-w-0 flex-1 truncate text-sm font-bold">
{book?.name ?? '目录'}
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
</p>
{tocGroups.length > 1 && (
<select
value={tocGroupIndex}
onChange={(e) => jumpToTocGroup(Number(e.target.value))}
aria-label="按区间快速定位章节"
title="按区间快速定位章节"
className="shrink-0 rounded-lg border bg-transparent px-2 py-1 text-2xs outline-none"
style={{ borderColor: theme.text + '33', color: theme.text }}
>
{tocGroups.map((group) => (
<option
key={group.index}
value={group.index}
style={{ color: '#111827', backgroundColor: '#ffffff' }}
>
{group.label}
</option>
))}
</select>
)}
</div>
<div className="min-h-0 flex-1">
<Virtuoso
ref={tocRef}
data={chapters}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
rangeChanged={(range) =>
setTocGroupIndex(chapterGroupIndexOf(range.startIndex, chapters.length))
}
itemContent={(index, ch) => {
const isCurrent = index === chapterIndex
return (
+53
View File
@@ -0,0 +1,53 @@
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`chapterGroups: ${name}`)
}
check('默认组大小是 100', CHAPTER_GROUP_SIZE === 100)
// ── 切组 ──
check('空目录没有分组', buildChapterGroups(0).length === 0)
check('负数没有分组', buildChapterGroups(-5).length === 0)
check('overflow 值没有分组', buildChapterGroups(Number.POSITIVE_INFINITY).length === 0)
check('不足一组时只有一组', (() => {
const g = buildChapterGroups(37)
return g.length === 1 && g[0].start === 0 && g[0].end === 36 && g[0].label === '1-37'
})())
check('正好一组时只有一组', buildChapterGroups(100).length === 1)
check('101 条切成两组', (() => {
const g = buildChapterGroups(101)
return g.length === 2 && g[1].start === 100 && g[1].end === 100 && g[1].label === '101-101'
})())
check('1317 条切成 14 组', (() => {
const g = buildChapterGroups(1317)
return g.length === 14 && g[13].label === '1301-1317' && g[13].end === 1316
})())
check('相邻组首尾相接不重叠', (() => {
const g = buildChapterGroups(1317)
return g.every((item, i) => i === 0 || item.start === g[i - 1].end + 1)
})())
check('组序号连续且从 0 起', buildChapterGroups(250).map((x) => x.index).join(',') === '0,1,2')
check('自定义组大小生效', (() => {
const g = buildChapterGroups(10, 4)
return g.length === 3 && g[1].label === '5-8' && g[2].label === '9-10'
})())
check('非法组大小不产生分组', buildChapterGroups(10, 0).length === 0)
// ── 下标 → 组 ──
check('第 0 条在第 0 组', chapterGroupIndexOf(0, 1317) === 0)
check('第 99 条仍在第 0 组', chapterGroupIndexOf(99, 1317) === 0)
check('第 100 条进入第 1 组', chapterGroupIndexOf(100, 1317) === 1)
check('末条落在末组', chapterGroupIndexOf(1316, 1317) === 13)
check('负数夹到第 0 组', chapterGroupIndexOf(-3, 1317) === 0)
check('越界夹到末组', chapterGroupIndexOf(99999, 1317) === 13)
check('非数字当作第 0 组', chapterGroupIndexOf(Number.NaN, 1317) === 0)
check('空目录返回 0 组', chapterGroupIndexOf(5, 0) === 0)
check('与切组分段一致', (() => {
const g = buildChapterGroups(1317)
return g.every((item) => chapterGroupIndexOf(item.start, 1317) === item.index)
})())
console.log('chapterGroups.test.ts ok')
+49
View File
@@ -0,0 +1,49 @@
// 超长目录的「按段分组」纯逻辑。
//
// 起因:一本书动辄上千章(如 1317 章),目录面板只能一屏十来个条目地滑,
// 想跳到中后段只能长按滚动条反复拖。这里把条目按固定大小切成若干组,
// 面板顶部给出一个区间下拉(1-100 / 101-200 ...),选中即整段跳转。
/** 每组条目数。100 章一屏下拉里可读,长度也够短。 */
export const CHAPTER_GROUP_SIZE = 100
export type ChapterGroup = {
/** 组序号,0 起。 */
index: number
/** 组内起始条目下标(含)。 */
start: number
/** 组内结束条目下标(含)。 */
end: number
/** 下拉展示用区间文案,如 "1-100";末组不足 100 时自然变短("1301-1317")。 */
label: string
}
/** 把 total 个条目按 size 切成若干组;total 非正数时返回空数组。 */
export function buildChapterGroups(total: number, size: number = CHAPTER_GROUP_SIZE): ChapterGroup[] {
if (!Number.isFinite(total) || total <= 0) return []
if (!Number.isFinite(size) || size <= 0) return []
const count = Math.ceil(total / size)
const groups: ChapterGroup[] = []
for (let i = 0; i < count; i += 1) {
const start = i * size
const end = Math.min(total - 1, start + size - 1)
groups.push({ index: i, start, end, label: `${start + 1}-${end + 1}` })
}
return groups
}
/**
* 条目下标落在第几组。用于两处:
* 打开面板时定位到当前章节所在组;滚动时把下拉显示同步到可见区间。
* 越界(负数 / 超过总数)一律夹到有效范围内,避免下拉出现空值。
*/
export function chapterGroupIndexOf(
itemIndex: number,
total: number,
size: number = CHAPTER_GROUP_SIZE,
): number {
const groupCount = Math.ceil(Math.max(0, total) / size)
if (groupCount <= 0) return 0
const raw = Math.floor((Number.isFinite(itemIndex) ? itemIndex : 0) / size)
return Math.min(Math.max(raw, 0), groupCount - 1)
}