diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index ae2f008..b4d4a12 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -373,6 +373,11 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句 + // 「Cannot read properties of null」。空目录统一给 []。 + if chapters == nil { + chapters = []reader.TocChapter{} + } c.JSON(http.StatusOK, gin.H{"chapters": chapters}) } } diff --git a/internal/service/reader/comic_image_test.go b/internal/service/reader/comic_image_test.go new file mode 100644 index 0000000..6d14609 --- /dev/null +++ b/internal/service/reader/comic_image_test.go @@ -0,0 +1,107 @@ +package reader + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 的 HTML。 +// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图;必须按标签抽 src。 +func TestImageRefsInLine(t *testing.T) { + cases := []struct { + name string + line string + want []string + }{ + {"纯地址原样返回", "https://cdn.example.com/1.webp", []string{"https://cdn.example.com/1.webp"}}, + {"双引号 img", ``, []string{"https://cdn.example.com/2.webp"}}, + {"单引号 img", ``, []string{"https://cdn.example.com/3.webp"}}, + {"属性顺序无关", ``, []string{"https://cdn.example.com/4.webp"}}, + {"一行多个标签", `

`, []string{"/a/1.webp", "/a/2.webp"}}, + {"img 没有 src 时跳过", `空`, nil}, + {"非 img 的 HTML 片段跳过", ``, nil}, + } + for _, c := range cases { + got := imageRefsInLine(c.line) + if strings.Join(got, ",") != strings.Join(c.want, ",") { + t.Fatalf("%s: got %q want %q", c.name, got, c.want) + } + } +} + +// 端到端:图片型书籍走 GetContentForBook 时,Images 必须是真正的图片地址, +// 不能把 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。 +func TestGetContentForBookExtractsImageSrc(t *testing.T) { + page := `
+ +
` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(page)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + srcJSON := fmt.Sprintf(`{ + "bookSourceUrl": %q, + "bookSourceName": "漫画测试源", + "bookSourceType": 64, + "ruleContent": { "content": "class.pages@html" } +}`, srv.URL) + sourceID := importTestSource(t, svc, srcJSON, srv.URL) + + book := &model.ReaderBook{ + UserID: "u1", + Origin: srv.URL, + OriginName: "漫画测试源", + BookURL: srv.URL + "/book/1", + Name: "航海王", + Type: 2, // 图片 + } + if err := svc.repo.CreateBook(ctx, book); err != nil { + t.Fatalf("创建书籍失败: %v", err) + } + if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{ + {Index: 0, Title: "第1话", URL: srv.URL + "/book/1/c1.html"}, + }); err != nil { + t.Fatalf("写入章节失败: %v", err) + } + _ = sourceID + + out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0) + if err != nil { + t.Fatalf("取正文失败: %v", err) + } + if out.Type != "image" { + t.Fatalf("类型 = %q,期望 image", out.Type) + } + if len(out.Images) != 2 { + t.Fatalf("图片数 = %d,期望 2(%v)", len(out.Images), out.Images) + } + for i, img := range out.Images { + // 书架维度返回的是签名代理地址(浏览器取图带不上防盗链头), + // 把里面的原始地址解出来核对,顺便确认没有被塞进 HTML 标签。 + if !strings.HasPrefix(img, "/api/reader/media?") { + t.Fatalf("第 %d 张不是代理地址:%q", i, img) + } + encoded := img[strings.Index(img, "u=")+2:] + if end := strings.IndexByte(encoded, '&'); end >= 0 { + encoded = encoded[:end] + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("第 %d 张地址解不开:%q", i, img) + } + want := fmt.Sprintf("%s/img/%d.webp", srv.URL, i+1) + if string(raw) != want { + t.Fatalf("第 %d 张解析出 %q,期望 %q", i, raw, want) + } + } +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 6e38894..6dd5a76 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -1011,8 +1011,20 @@ func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, return nil, err } chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL) - if err != nil { - return nil, err + if err != nil || len(chapters) == 0 { + // 给的目录地址抓不到章节。典型情形是聚合类书源(光遇聚合的 gydetail 信封): + // 加书架时只存了 book_url,调用方又把 book_url 当目录地址传进来,规则返回的 + // 是书籍详情(没有章节),表现为「目录为空」——漫画就是卡在这里。 + // 对齐 legado:目录地址在详情结果里,补走一次详情规则取 tocUrl 再抓,并写回书架。 + if detailTocURL := s.deriveTocURLFromDetail(ctx, src, bs, bookURL, tocURL); detailTocURL != "" { + if retried, declared2, retryErr := s.getTocFrom(ctx, src, bs, bookURL, detailTocURL); retryErr == nil && len(retried) > 0 { + chapters, declared, err = retried, declared2, nil + s.persistBookTocURL(ctx, userID, src.SourceURL, bookURL, detailTocURL) + } + } + if err != nil { + return nil, err + } } // 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。 // 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。 @@ -1032,6 +1044,40 @@ func latestChapterTitleOf(chapters []TocChapter) string { return "" } +// deriveTocURLFromDetail 走一次详情规则,取书源声明的目录地址。 +// +// 详情规则没配、或解析出的 tocUrl 与 bookUrl 相同(说明书源就是用书籍页当目录页)时 +// 返回空串,调用方按原样处理、不做多余请求。 +func (s *ReaderService) deriveTocURLFromDetail( + ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, currentTocURL string, +) string { + detail, err := s.getBookInfoFrom(ctx, src, bs, bookURL) + if err != nil || detail == nil { + return "" + } + derived := strings.TrimSpace(detail.TocURL) + if derived == "" || derived == strings.TrimSpace(bookURL) || derived == strings.TrimSpace(currentTocURL) { + return "" + } + return derived +} + +// persistBookTocURL 把详情里解析出的目录地址写回书架记录。 +// 只有第一次抓目录要多走一次详情,之后书架、阅读页、更新目录都直接用这个地址。 +func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, bookURL, tocURL string) { + if userID == "" || origin == "" || bookURL == "" || tocURL == "" { + return + } + book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL) + if err != nil || book == nil || book.TocURL == tocURL { + return + } + book.TocURL = tocURL + if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { + s.log.Warn("reader: 写回目录地址失败", zap.String("book", book.ID), zap.Error(err)) + } +} + // applyTocMeta 把目录阶段得到的信息写回书架记录: // - 书源在规则 JS 里声明的书籍类型(declared >= 0 时); // - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。 @@ -1348,8 +1394,12 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo case 2: // 漫画/图片 out.Type = "image" for _, line := range splitURLLines(content) { - if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" { - out.Images = append(out.Images, abs) + // 漫画源的正文规则常直接给 的 HTML(一个标签一行)。 + // 整行当地址会被代理成一堆取不回的「图」,页面上全是破图,所以先抽 src。 + for _, ref := range imageRefsInLine(line) { + if abs := rule.GetAbsoluteURL(lastFinalURL, ref); abs != "" { + out.Images = append(out.Images, abs) + } } } out.ImageStyle = SPtr(cr.ImageStyle) @@ -1359,6 +1409,35 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo return out, nil } +// imageRefsInLine 取出一行正文里的图片地址。 +// +// - 正文规则给的就是地址 → 原样返回; +// - 给的是 (一行可能有多个标签)→ 按标签抽 src, +// legado 的 ruleContent.imageStyle 缺省也是这个语义; +// - 给的是别的 HTML 片段(
/
之类,规则返回的 outerHTML 换行后很常见) +// → 跳过,否则会被当成相对地址拼出一个取不回的「图」。 +func imageRefsInLine(line string) []string { + matches := imgTagPattern.FindAllStringSubmatch(line, -1) + if len(matches) == 0 { + if strings.ContainsRune(line, '<') { + return nil + } + return []string{line} + } + out := make([]string, 0, len(matches)) + for _, m := range matches { + // imgTagPattern 的捕获组:整段标签 / 双引号内的 src / 单引号内的 src + ref := strings.TrimSpace(m[2]) + if ref == "" { + ref = strings.TrimSpace(m[3]) + } + if ref != "" { + out = append(out, ref) + } + } + return out +} + func splitURLLines(s string) []string { var out []string for _, line := range strings.Split(s, "\n") { diff --git a/internal/service/reader/toc_url_test.go b/internal/service/reader/toc_url_test.go new file mode 100644 index 0000000..826012f --- /dev/null +++ b/internal/service/reader/toc_url_test.go @@ -0,0 +1,99 @@ +package reader + +import ( + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// newTocFallbackBook 建一本书架记录:只存 book_url,toc_url 留空, +// 模拟聚合类书源加入书架时的形态。 +func newTocFallbackBook(t *testing.T, svc *ReaderService, serverURL string) *model.ReaderBook { + t.Helper() + book := &model.ReaderBook{ + UserID: "u1", + Origin: serverURL, + OriginName: "测试源", + BookURL: serverURL + "/book/1", + Name: "斗破苍穹", + } + if err := svc.repo.CreateBook(t.Context(), book); err != nil { + t.Fatalf("创建书籍失败: %v", err) + } + return book +} + +// 聚合类书源(光遇聚合一类)把目录地址放在详情结果里,加书架时只存了 book_url。 +// 调用方又习惯把 book_url 当目录地址兜底(前端 toc_url || book_url),于是目录规则 +// 拿到的是书籍详情页 → 0 章 → 阅读页显示「目录为空」,漫画根本进不去。 +// +// GetToc 必须自己补一次详情、用详情里的 tocUrl 重抓,并把地址写回书架。 +func TestGetTocFallsBackToBookInfoTocURL(t *testing.T) { + srv := e2eServer() + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + book := newTocFallbackBook(t, svc, srv.URL) + + chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, "") + if err != nil { + t.Fatalf("抓目录失败: %v", err) + } + if len(chapters) != 2 { + t.Fatalf("应回退到详情里的 tocUrl 抓到 2 章,实际 %d 章", len(chapters)) + } + + // 目录地址要写回书架:之后书架/阅读页/更新目录都不用再走一次详情 + stored, err := svc.repo.GetBook(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stored.TocURL, "/book/1/toc") { + t.Fatalf("目录地址未写回: %q", stored.TocURL) + } +} + +// 前端会把空 toc_url 兜底成 book_url 传进来,这条路径同样要能拿到目录。 +func TestGetTocTreatsBookURLAsMissingTocURL(t *testing.T) { + srv := e2eServer() + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + book := newTocFallbackBook(t, svc, srv.URL) + + chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.BookURL) + if err != nil { + t.Fatalf("抓目录失败: %v", err) + } + if len(chapters) != 2 { + t.Fatalf("book_url 兜底的目录地址也应回退到详情,实际 %d 章", len(chapters)) + } +} + +// 正常源(详情给了独立目录页、调用方也传了)不能被这次改动影响。 +func TestGetTocKeepsGivenTocURL(t *testing.T) { + srv := e2eServer() + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + book := newTocFallbackBook(t, svc, srv.URL) + book.TocURL = srv.URL + "/book/1/toc.html" + if err := svc.repo.UpdateBook(ctx, book); err != nil { + t.Fatal(err) + } + + chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.TocURL) + if err != nil { + t.Fatalf("抓目录失败: %v", err) + } + if len(chapters) != 2 { + t.Fatalf("应正常抓到 2 章,实际 %d 章", len(chapters)) + } +} diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts index 259b9a0..3a80dfe 100644 --- a/web/src/api/reader.ts +++ b/web/src/api/reader.ts @@ -257,15 +257,17 @@ export const readerAPI = { api.get('/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('/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('/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) => diff --git a/web/src/pages/reader/ReaderAudioPanel.tsx b/web/src/pages/reader/ReaderAudioPanel.tsx index 491b89c..ff37f13 100644 --- a/web/src/pages/reader/ReaderAudioPanel.tsx +++ b/web/src/pages/reader/ReaderAudioPanel.tsx @@ -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(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('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 (
- 章节({chapters.length}) -
+ 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 即当播完) diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index 331e733..8de9a1a 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -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(null) // 分页状态 const viewportRef = useRef(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) +}