mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 07:46:37 +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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
|
||||||
|
// 「Cannot read properties of null」。空目录统一给 []。
|
||||||
|
if chapters == nil {
|
||||||
|
chapters = []reader.TocChapter{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL)
|
chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL)
|
||||||
if err != nil {
|
if err != nil || len(chapters) == 0 {
|
||||||
return nil, err
|
// 给的目录地址抓不到章节。典型情形是聚合类书源(光遇聚合的 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。
|
// 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。
|
||||||
// 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。
|
// 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。
|
||||||
@@ -1032,6 +1044,40 @@ func latestChapterTitleOf(chapters []TocChapter) string {
|
|||||||
return ""
|
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 把目录阶段得到的信息写回书架记录:
|
// applyTocMeta 把目录阶段得到的信息写回书架记录:
|
||||||
// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时);
|
// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时);
|
||||||
// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。
|
// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。
|
||||||
@@ -1348,8 +1394,12 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
|||||||
case 2: // 漫画/图片
|
case 2: // 漫画/图片
|
||||||
out.Type = "image"
|
out.Type = "image"
|
||||||
for _, line := range splitURLLines(content) {
|
for _, line := range splitURLLines(content) {
|
||||||
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
|
// 漫画源的正文规则常直接给 <img src="…"> 的 HTML(一个标签一行)。
|
||||||
out.Images = append(out.Images, abs)
|
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图,所以先抽 src。
|
||||||
|
for _, ref := range imageRefsInLine(line) {
|
||||||
|
if abs := rule.GetAbsoluteURL(lastFinalURL, ref); abs != "" {
|
||||||
|
out.Images = append(out.Images, abs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.ImageStyle = SPtr(cr.ImageStyle)
|
out.ImageStyle = SPtr(cr.ImageStyle)
|
||||||
@@ -1359,6 +1409,35 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
|||||||
return out, nil
|
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 {
|
func splitURLLines(s string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, line := range strings.Split(s, "\n") {
|
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),
|
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 }) =>
|
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
|
||||||
api
|
api
|
||||||
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
.get<{ chapters: ReaderTocChapter[] | null }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||||
.then((r) => r.data.chapters),
|
// 后端把 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 }) =>
|
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
|
||||||
api
|
api
|
||||||
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
|
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||||
.then((r) => r.data),
|
.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 }) =>
|
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
|
||||||
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
||||||
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
||||||
@@ -331,7 +333,9 @@ export const readerAPI = {
|
|||||||
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
||||||
api.put(`/reader/books/${id}/audio-config`, body),
|
api.put(`/reader/books/${id}/audio-config`, body),
|
||||||
listChapters: (id: string) =>
|
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 }),
|
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||||
bookContent: (id: string, chapter: number) =>
|
bookContent: (id: string, chapter: number) =>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Virtuoso } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
|
|
||||||
import type { ReaderChapter } from '../../api/reader'
|
import type { ReaderChapter } from '../../api/reader'
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
AUDIO_TIMERS,
|
AUDIO_TIMERS,
|
||||||
useReaderSettingsStore,
|
useReaderSettingsStore,
|
||||||
} from '../../stores/readerSettings'
|
} from '../../stores/readerSettings'
|
||||||
|
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||||
import { readerCoverSrc } from '../../utils/readerCover'
|
import { readerCoverSrc } from '../../utils/readerCover'
|
||||||
|
|
||||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||||
@@ -48,6 +49,11 @@ interface ReaderAudioPanelProps {
|
|||||||
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
||||||
transcoding?: boolean
|
transcoding?: boolean
|
||||||
onProgress: (seconds: number) => void
|
onProgress: (seconds: number) => void
|
||||||
|
/**
|
||||||
|
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
|
||||||
|
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
|
||||||
|
*/
|
||||||
|
onCommitProgress: (seconds: number) => void
|
||||||
onPrevChapter: () => void
|
onPrevChapter: () => void
|
||||||
onNextChapter: () => void
|
onNextChapter: () => void
|
||||||
onSelectChapter: (index: number) => void
|
onSelectChapter: (index: number) => void
|
||||||
@@ -91,6 +97,7 @@ export function ReaderAudioPanel({
|
|||||||
hasNext,
|
hasNext,
|
||||||
transcoding = false,
|
transcoding = false,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
onCommitProgress,
|
||||||
onPrevChapter,
|
onPrevChapter,
|
||||||
onNextChapter,
|
onNextChapter,
|
||||||
onSelectChapter,
|
onSelectChapter,
|
||||||
@@ -102,6 +109,8 @@ export function ReaderAudioPanel({
|
|||||||
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||||
const restoredRef = useRef(false)
|
const restoredRef = useRef(false)
|
||||||
const skippedEndRef = useRef(false)
|
const skippedEndRef = useRef(false)
|
||||||
|
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
|
||||||
|
const tocRef = useRef<VirtuosoHandle>(null)
|
||||||
|
|
||||||
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||||
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||||
@@ -115,6 +124,16 @@ export function ReaderAudioPanel({
|
|||||||
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||||
const [audioError, setAudioError] = useState('')
|
const [audioError, setAudioError] = useState('')
|
||||||
const [sheet, setSheet] = useState<Sheet>('none')
|
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)
|
const [coverOK, setCoverOK] = useState(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,6 +164,8 @@ export function ReaderAudioPanel({
|
|||||||
setAudioError('')
|
setAudioError('')
|
||||||
setCur(0)
|
setCur(0)
|
||||||
setDur(0)
|
setDur(0)
|
||||||
|
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
|
||||||
|
lastTimeRef.current = 0
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let hls: { destroy: () => void } | null = null
|
let hls: { destroy: () => void } | null = null
|
||||||
|
|
||||||
@@ -184,6 +205,27 @@ export function ReaderAudioPanel({
|
|||||||
if (audio) audio.playbackRate = speed
|
if (audio) audio.playbackRate = speed
|
||||||
}, [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(
|
const applyTimer = useCallback(
|
||||||
(minutes: number) => {
|
(minutes: number) => {
|
||||||
@@ -277,13 +319,33 @@ export function ReaderAudioPanel({
|
|||||||
|
|
||||||
const closeSheet = () => setSheet('none')
|
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(
|
const chapterList = useMemo(
|
||||||
() => chapters.map((c, i) => ({ ...c, i })),
|
() => chapters.map((c, i) => ({ ...c, i })),
|
||||||
[chapters],
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full flex-col overflow-hidden"
|
className="relative flex h-full flex-col overflow-hidden"
|
||||||
@@ -504,18 +566,46 @@ export function ReaderAudioPanel({
|
|||||||
{sheet === 'chapters' && (
|
{sheet === 'chapters' && (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div
|
<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' }}
|
style={{ borderColor: theme.text + '22' }}
|
||||||
>
|
>
|
||||||
<span>章节({chapters.length})</span>
|
<span className="shrink-0">章节({chapters.length})</span>
|
||||||
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
|
{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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[45vh] min-h-0">
|
<div className="h-[45vh] min-h-0">
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
ref={tocRef}
|
||||||
data={chapterList}
|
data={chapterList}
|
||||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||||
|
rangeChanged={(range) =>
|
||||||
|
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
|
||||||
|
}
|
||||||
itemContent={(_i, row) => {
|
itemContent={(_i, row) => {
|
||||||
const isCurrent = row.i === chapterIndex
|
const isCurrent = row.i === chapterIndex
|
||||||
return (
|
return (
|
||||||
@@ -717,6 +807,7 @@ export function ReaderAudioPanel({
|
|||||||
onTimeUpdate={(e) => {
|
onTimeUpdate={(e) => {
|
||||||
const audio = e.currentTarget
|
const audio = e.currentTarget
|
||||||
const t = audio.currentTime
|
const t = audio.currentTime
|
||||||
|
lastTimeRef.current = t
|
||||||
setCur(t)
|
setCur(t)
|
||||||
onProgress(t)
|
onProgress(t)
|
||||||
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
Sun,
|
Sun,
|
||||||
} from 'lucide-react'
|
} 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 { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
||||||
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||||
|
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||||
import { ReaderComic } from './ReaderComic'
|
import { ReaderComic } from './ReaderComic'
|
||||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||||
@@ -71,6 +72,8 @@ export default function ReaderViewPage() {
|
|||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
|
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
|
||||||
|
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
|
||||||
|
const [tocGroupIndex, setTocGroupIndex] = useState(0)
|
||||||
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
|
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
|
||||||
const [switchOpen, setSwitchOpen] = useState(false)
|
const [switchOpen, setSwitchOpen] = useState(false)
|
||||||
const [switchLoading, setSwitchLoading] = useState(false)
|
const [switchLoading, setSwitchLoading] = useState(false)
|
||||||
@@ -85,6 +88,8 @@ export default function ReaderViewPage() {
|
|||||||
const wheelLastEventRef = useRef(0)
|
const wheelLastEventRef = useRef(0)
|
||||||
const wheelLastTurnRef = useRef(0)
|
const wheelLastTurnRef = useRef(0)
|
||||||
const wheelSwipeLockedRef = useRef(false)
|
const wheelSwipeLockedRef = useRef(false)
|
||||||
|
/** 目录面板虚拟列表句柄:区间下拉靠它整段跳转。 */
|
||||||
|
const tocRef = useRef<VirtuosoHandle>(null)
|
||||||
|
|
||||||
// 分页状态
|
// 分页状态
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -339,6 +344,35 @@ export default function ReaderViewPage() {
|
|||||||
setChapterIndex(idx)
|
setChapterIndex(idx)
|
||||||
}, [chapters.length])
|
}, [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(() => {
|
const hasNextAudioChapter = useMemo(() => {
|
||||||
if (chapterIndex === null) return false
|
if (chapterIndex === null) return false
|
||||||
@@ -621,6 +655,7 @@ export default function ReaderViewPage() {
|
|||||||
hasNext={hasNextAudioChapter}
|
hasNext={hasNextAudioChapter}
|
||||||
transcoding={media.transcoding ?? false}
|
transcoding={media.transcoding ?? false}
|
||||||
onProgress={throttledMediaSave}
|
onProgress={throttledMediaSave}
|
||||||
|
onCommitProgress={savePos}
|
||||||
onPrevChapter={() => goChapter(-1)}
|
onPrevChapter={() => goChapter(-1)}
|
||||||
onNextChapter={() => goChapter(1)}
|
onNextChapter={() => goChapter(1)}
|
||||||
onSelectChapter={jumpToChapter}
|
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 }}>
|
<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: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
|
||||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
|
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||||
{
|
{
|
||||||
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
||||||
label: settings.night ? '日间' : '夜间',
|
label: settings.night ? '日间' : '夜间',
|
||||||
@@ -927,15 +962,39 @@ export default function ReaderViewPage() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<p className="flex-1 truncate text-sm font-bold">
|
<p className="min-w-0 flex-1 truncate text-sm font-bold">
|
||||||
{book?.name ?? '目录'}
|
{book?.name ?? '目录'}
|
||||||
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
|
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
|
||||||
</p>
|
</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>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
ref={tocRef}
|
||||||
data={chapters}
|
data={chapters}
|
||||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||||
|
rangeChanged={(range) =>
|
||||||
|
setTocGroupIndex(chapterGroupIndexOf(range.startIndex, chapters.length))
|
||||||
|
}
|
||||||
itemContent={(index, ch) => {
|
itemContent={(index, ch) => {
|
||||||
const isCurrent = index === chapterIndex
|
const isCurrent = index === chapterIndex
|
||||||
return (
|
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