mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 04:26:36 +08:00
bug处理
This commit is contained in:
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 <img src="…"> 的 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", `<img src="https://cdn.example.com/2.webp">`, []string{"https://cdn.example.com/2.webp"}},
|
||||
{"单引号 img", `<img src='https://cdn.example.com/3.webp'/>`, []string{"https://cdn.example.com/3.webp"}},
|
||||
{"属性顺序无关", `<img data-x="1" src="https://cdn.example.com/4.webp" class="a">`, []string{"https://cdn.example.com/4.webp"}},
|
||||
{"一行多个标签", `<p><img src="/a/1.webp"><img src='/a/2.webp'></p>`, []string{"/a/1.webp", "/a/2.webp"}},
|
||||
{"img 没有 src 时跳过", `<img alt="空">`, nil},
|
||||
{"非 img 的 HTML 片段跳过", `</div>`, 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 必须是真正的图片地址,
|
||||
// 不能把 <img> 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。
|
||||
func TestGetContentForBookExtractsImageSrc(t *testing.T) {
|
||||
page := `<html><body><div class="pages">
|
||||
<img src="/img/1.webp"><img src="/img/2.webp">
|
||||
</div></body></html>`
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
// 漫画源的正文规则常直接给 <img src="…"> 的 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 取出一行正文里的图片地址。
|
||||
//
|
||||
// - 正文规则给的就是地址 → 原样返回;
|
||||
// - 给的是 <img src="…">(一行可能有多个标签)→ 按标签抽 src,
|
||||
// legado 的 ruleContent.imageStyle 缺省也是这个语义;
|
||||
// - 给的是别的 HTML 片段(<div>/</div> 之类,规则返回的 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") {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 即当播完)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user