(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() {
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
{([
- { icon:
, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
- { icon:
, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
+ { icon:
, label: '目录', action: () => openPanel('toc') },
+ { icon:
, label: '界面', action: () => openPanel('style') },
{
icon: settings.night ?
:
,
label: settings.night ? '日间' : '夜间',
@@ -927,15 +962,39 @@ export default function ReaderViewPage() {
>
-
+
{book?.name ?? '目录'}
目录({chapters.length} 章)
+ {tocGroups.length > 1 && (
+
+ )}
+ setTocGroupIndex(chapterGroupIndexOf(range.startIndex, chapters.length))
+ }
itemContent={(index, ch) => {
const isCurrent = index === chapterIndex
return (
diff --git a/web/src/utils/chapterGroups.test.ts b/web/src/utils/chapterGroups.test.ts
new file mode 100644
index 0000000..b7ffa94
--- /dev/null
+++ b/web/src/utils/chapterGroups.test.ts
@@ -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')
diff --git a/web/src/utils/chapterGroups.ts b/web/src/utils/chapterGroups.ts
new file mode 100644
index 0000000..854e9db
--- /dev/null
+++ b/web/src/utils/chapterGroups.ts
@@ -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)
+}