This commit is contained in:
truewhile
2026-10-29 22:11:28 +08:00
parent db516ded61
commit 0ab4693b92
7 changed files with 380 additions and 45 deletions
+4
View File
@@ -408,6 +408,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
// 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始
// 地址写回,再做下发时的解密代理改写。
userID := c.GetString(middleware.CtxUserID)
svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL)
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
@@ -0,0 +1,69 @@
package reader
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 上游限流时详情解析不到封面,规则会把页面地址当封面回退。这类记录被存进书架后
// 会一直显示破图;打开一次详情页应当用真实封面把它修好。
func TestRepairShelfCoverReplacesFallbackURL(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
const origin = "https://api.example.com/book"
const bookURL = "https://api.example.com/book?bookId=1"
const cover = "https://img.example.com/c.jpg"
book := &model.ReaderBook{
UserID: "u1",
Origin: origin,
BookURL: bookURL,
Name: "测试书",
CoverURL: bookURL, // 规则取空后回退的产物
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatal(err)
}
svc.RepairShelfCover(ctx, "u1", origin, bookURL, cover)
stored, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if stored.CoverURL != cover {
t.Fatalf("封面应修为 %q,实际 %q", cover, stored.CoverURL)
}
}
// 已经有有效封面的书不能被动覆盖(可能是换源得到的、或用户自己挑的)。
func TestRepairShelfCoverKeepsValidCover(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
const origin = "https://api.example.com/book"
const bookURL = "https://api.example.com/book?bookId=1"
book := &model.ReaderBook{
UserID: "u1",
Origin: origin,
BookURL: bookURL,
Name: "测试书",
CoverURL: "https://img.example.com/old.jpg",
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatal(err)
}
svc.RepairShelfCover(ctx, "u1", origin, bookURL, "https://img.example.com/new.jpg")
stored, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if stored.CoverURL != "https://img.example.com/old.jpg" {
t.Fatalf("有效封面不应被覆盖,实际 %q", stored.CoverURL)
}
}
+29 -13
View File
@@ -8,30 +8,46 @@ func TestNormalizeCoverURL(t *testing.T) {
cases := []struct {
name string
in string
baseURL string
want string
}{
{"empty", "", ""},
{"blank", " ", ""},
{"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
{"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
{"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
{"empty", "", "", ""},
{"blank", " ", "", ""},
{"https", "https://img.example.com/a.jpg", "", "https://img.example.com/a.jpg"},
{"http", "http://img.example.com/a.jpg", "", "http://img.example.com/a.jpg"},
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "", "HTTPS://img.example.com/a.jpg"},
{"trimmed", " https://img.example.com/a.jpg ", "", "https://img.example.com/a.jpg"},
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "", "data:image/png;base64,iVBORw0KGgo="},
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "", "/api/reader/local/asset?b=1&p=2&s=3"},
{
"search params envelope",
"data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
"",
"",
},
{"text data uri", "data:text/plain;base64,aGk=", "", ""},
{"source name", "光遇聚合", "", ""},
{"javascript url", "javascript:alert(1)", "", ""},
{
// 详情规则取不到 $.meta.cover 时按 legado 语义回退成页面地址,
// 这个接口地址不是图片,必须丢掉。
"fallback to page url",
"https://api.example.com/book?bookId=1",
"https://api.example.com/book?bookId=1",
"",
},
{
"real cover with page url base",
"https://img.example.com/a.jpg",
"https://api.example.com/book?bookId=1",
"https://img.example.com/a.jpg",
},
{"text data uri", "data:text/plain;base64,aGk=", ""},
{"source name", "光遇聚合", ""},
{"javascript url", "javascript:alert(1)", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeCoverURL(tc.in); got != tc.want {
t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
if got := normalizeCoverURL(tc.in, tc.baseURL); got != tc.want {
t.Fatalf("normalizeCoverURL(%q, %q) = %q, want %q", tc.in, tc.baseURL, got, tc.want)
}
})
}
+18
View File
@@ -80,6 +80,10 @@ func parseConcurrentRate(rate string) time.Duration {
const (
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
maxRequestAttempts = 5
// implicitRetryAttempts 书源没声明 retry 时,GET 请求在可重试状态码上的默认尝试次数。
// 实测这类聚合源的单次 429 概率接近一半,两次尝试仍有约两成失败,三次降到一成左右;
// 再往上加只会让被限流的接口更难恢复。退避总共不到 1 秒,详情/目录请求扛得住。
implicitRetryAttempts = 3
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
requestRetryDelay = 300 * time.Millisecond
)
@@ -98,6 +102,20 @@ func retryableStatus(code int) bool {
}
}
// implicitRetryableStatus 书源没声明 retry 时,值得自动补一次的失败。
//
// 只覆盖「上游临时顶不住」的情形:429 是限流/并发上限,502/503/504 是网关或
// 实例暂时不可用,歇一轮基本会恢复。403(风控)重试同一份请求多半还是 403,
// 500 可能是源站自身 bug(确定性失败),都不在这里浪费一次往返。
func implicitRetryableStatus(code int) bool {
switch code {
case 429, 502, 503, 504:
return true
default:
return false
}
}
// mediaRetryableStatus 媒体拉取专用的可重试判定。
//
// 与规则请求不同,CDN 直链的 410 表示「对象已下线」,与 403/429 一样属于
+96 -12
View File
@@ -425,12 +425,25 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
attempts := 1
if req.Retry != nil && *req.Retry > 0 {
declaredRetry := req.Retry != nil && *req.Retry > 0
if declaredRetry {
attempts += *req.Retry
if attempts > maxRequestAttempts {
attempts = maxRequestAttempts
}
}
// 书源没声明 retry 时,GET 也对上游瞬时失败(429/5xx)自动补一次。
// 很多聚合源跑在免费/低配后端上,限流是常态(懒人听书源实测约四成请求返回
// HTTP 429),而 429 的响应体根本不是书源数据,交给规则只会解析出空目录、
// 空详情。POST 可能有副作用,仍然只按书源声明重试。
if !declaredRetry && (req.Method == "" || req.Method == http.MethodGet) {
attempts = implicitRetryAttempts
}
// 自动补的一次只认「上游临时顶不住」的状态码,见 implicitRetryableStatus。
retryAllowed := retryableStatus
if !declaredRetry {
retryAllowed = implicitRetryableStatus
}
var (
resp *http.Response
data []byte
@@ -464,7 +477,7 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
}
return "", "", resp.StatusCode, err
}
if attempt < attempts && retryableStatus(resp.StatusCode) {
if attempt < attempts && retryAllowed(resp.StatusCode) {
if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
return "", "", resp.StatusCode, nil
}
@@ -536,6 +549,25 @@ func charsetFromContentType(ct string) string {
return strings.Trim(cs, "\"")
}
// upstreamStatusError 把书源接口的失败状态码翻译成可读错误。
//
// 上游限流/过载时返回的响应体不是书源数据:交给规则解析只会得到空目录、空详情,
// 页面就表现成「目录 0 章」而没有任何报错。这里让调用方拿到明确错误,
// 前端才能提示重试,而不是把「抓不到」当成「这本书没有章节」。
func upstreamStatusError(code int) error {
if code < 400 {
return nil
}
switch code {
case http.StatusTooManyRequests:
return fmt.Errorf("书源接口限流(HTTP 429),请稍后重试")
case http.StatusForbidden:
return fmt.Errorf("书源接口拒绝访问(HTTP 403),可能触发了风控")
default:
return fmt.Errorf("书源接口返回 HTTP %d", code)
}
}
// ─── 规则执行辅助 ───────────────────────────────────────────────────────────
// sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与
@@ -1131,10 +1163,13 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
if err := upstreamStatusError(code); err != nil {
return nil, err
}
ar := sess.newAnalyzer(key, page, body, finalURL)
elements, err := ar.GetElements(SPtr(sr.BookList))
@@ -1164,7 +1199,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
WordCount: wordCount,
LatestChapter: lastChapter,
Intro: intro,
CoverURL: normalizeCoverURL(cover),
CoverURL: normalizeCoverURL(cover, finalURL),
BookURL: bookURL,
Origins: []SearchOrigin{{
SourceID: src.ID,
@@ -1221,11 +1256,20 @@ func firstNonEmpty(vals ...string) string {
// 于是封面上会落一串 data: 文本,<img> 按 text/plain 处理必然破图。
// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面
// (/api/reader/local/asset?...)。
func normalizeCoverURL(raw string) string {
//
// baseURL 是规则解析时的页面地址(详情页就是书本地址)。取值为空时规则会原样
// 回退成它,回退结果同样不是图片:比如懒人听书源被上游限流(HTTP 429)时,
// 详情 JSON 里没有 meta.cover,封面就会变成 .../lrts-book?bookId=xxx 这个接口地址。
// 这类值必须一并丢掉,否则会被存进书架,之后一直显示破图。
func normalizeCoverURL(raw, baseURL string) string {
u := strings.TrimSpace(raw)
if u == "" {
return ""
}
// 规则取空后回退成页面地址的产物:不是图片,也不是用户要的封面。
if base := strings.TrimSpace(baseURL); base != "" && u == base {
return ""
}
lower := strings.ToLower(u)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
@@ -1282,10 +1326,13 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
if err := upstreamStatusError(code); err != nil {
return nil, err
}
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, book, "", 0)
@@ -1319,7 +1366,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
info.Intro = v
}
if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" {
info.CoverURL = normalizeCoverURL(v)
info.CoverURL = normalizeCoverURL(v, finalURL)
}
if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" {
info.TocURL = v
@@ -1569,6 +1616,37 @@ func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, b
}
}
// RepairShelfCover 用详情页刚解析出的封面修复书架记录。
//
// 只修「明显坏掉」的旧记录:封面为空,或等于书本地址/书源地址(规则取空后回退
// baseUrl 的产物)。上游限流(HTTP 429)时详情里没有封面,旧版本会把接口地址
// 存进书架,之后一直显示破图;打开一次详情页就能自愈。已有有效封面的记录不动,
// 避免覆盖换源或用户数据。
func (s *ReaderService) RepairShelfCover(ctx context.Context, userID, origin, bookURL, cover string) {
if s.repo == nil || userID == "" || origin == "" || bookURL == "" {
return
}
// 书架记录里的 BookURL 与调用方传入的一致(可能带 ",{...}" 选项段),
// 查找用原值;只在校验封面是否为「回退地址」时用去掉选项段的形态。
pageURL := tocBookTarget(bookURL)
cover = normalizeCoverURL(cover, pageURL)
if cover == "" {
return
}
book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL)
if err != nil || book == nil {
return
}
current := strings.TrimSpace(book.CoverURL)
if current != "" && current != pageURL && current != strings.TrimSpace(origin) {
return
}
book.CoverURL = cover
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)。
@@ -1695,10 +1773,13 @@ func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, b
return nil, nil, -1, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, nil, -1, err
}
if err := upstreamStatusError(code); err != nil {
return nil, nil, -1, err
}
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, nil, "", 0)
@@ -1821,7 +1902,7 @@ func (s *ReaderService) ProxyCoverURL(id, rawURL string) string {
//
// id 为空时按书源 URL 签(搜索结果);传入书籍 ID 时按书签。
func (s *ReaderService) RewriteBookCover(ctx context.Context, id, origin, coverURL string) string {
cover := normalizeCoverURL(coverURL)
cover := normalizeCoverURL(coverURL, "")
if cover == "" || !strings.HasPrefix(cover, "http") {
return cover
}
@@ -2082,10 +2163,13 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
if err := upstreamStatusError(code); err != nil {
return nil, err
}
lastFinalURL = finalURL
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, sess.book, "", 0)
@@ -2550,7 +2634,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
BookURL: origin.BookURL,
Name: name,
Author: author,
CoverURL: normalizeCoverURL(coverURL),
CoverURL: normalizeCoverURL(coverURL, origin.BookURL),
Type: origin.OriginType,
}
if err := s.repo.CreateBook(ctx, book); err != nil {
@@ -2611,7 +2695,7 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string,
if info != nil {
book.TocURL = strings.TrimSpace(info.TocURL)
if book.CoverURL == "" {
book.CoverURL = normalizeCoverURL(info.CoverURL)
book.CoverURL = normalizeCoverURL(info.CoverURL, target)
}
if info.LatestChapter != "" {
book.LatestChapterTitle = info.LatestChapter
@@ -0,0 +1,82 @@
package reader
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// 上游限流(HTTP 429)在免费/低配后端上是常态。书源没声明 retry 时,
// GET 也会自动补一次:第一次 429、第二次正常时,详情必须能拿到数据。
func TestGetBookInfoRetriesUpstream429(t *testing.T) {
upstream := e2eServer()
defer upstream.Close()
var mu sync.Mutex
hits := map[string]int{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
hits[r.URL.Path]++
n := hits[r.URL.Path]
mu.Unlock()
if n == 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
return
}
resp, err := http.Get(upstream.URL + r.URL.RequestURI())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, _ = w.Write(buf[:n])
}
if err != nil {
return
}
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
if err != nil {
t.Fatalf("第一次 429、重试后应成功: %v", err)
}
if info.Name == "" {
t.Fatalf("重试后详情应有书名,实际为空")
}
}
// 持续限流时必须报错,而不是把 429 的响应体解析成「空目录 / 0 章」。
func TestGetTocReportsUpstream429(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
_, err := svc.GetToc(t.Context(), "u1", sourceID, srv.URL, srv.URL+"/book/1", srv.URL+"/book/1")
if err == nil {
t.Fatalf("持续 429 时应返回错误,而不是空目录")
}
if !strings.Contains(err.Error(), "429") {
t.Fatalf("错误信息应包含 429,实际: %v", err)
}
}
+79 -17
View File
@@ -23,6 +23,9 @@ export default function ReaderBookPage() {
const [info, setInfo] = useState<ReaderBookInfo | null>(null)
const [infoError, setInfoError] = useState('')
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
// 目录拉取失败的原因(书源限流/失联)。有它时不再把空列表显示成「0 章」,
// 否则用户以为这本书真的没有目录。
const [tocError, setTocError] = useState('')
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
const [busy, setBusy] = useState(false)
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
@@ -46,19 +49,48 @@ export default function ReaderBookPage() {
useEffect(loadInfo, [bookURL])
// 书架状态 + 目录
//
// 两个请求都依赖书源上游,而聚合源的免费后端经常限流(HTTP 429)。目录失败时
// 不再静默显示「0 章」:先退回书架里的目录缓存(至少能看章节、点开阅读),
// 没有缓存才把失败原因交给目录区展示,用户点「重试」即可。
useEffect(() => {
if (!bookURL) return
readerAPI
.listBooks()
.then((books) => {
const hit = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
setShelfBook(hit)
})
.catch(() => undefined)
readerAPI
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
.then(setChapters)
.catch(() => setChapters([]))
let alive = true
setChapters(null)
setTocError('')
;(async () => {
let shelf: ReaderBook | null = null
try {
const books = await readerAPI.listBooks()
shelf = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
} catch {
// 书架读取失败不影响详情页展示
}
if (!alive) return
setShelfBook(shelf)
try {
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
if (!alive) return
setChapters(list)
} catch (e) {
if (!alive) return
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录加载失败'
if (shelf) {
const cached = await readerAPI.listChapters(shelf.id).catch(() => [])
if (!alive) return
if (cached.length > 0) {
setChapters(cached.map((c) => ({ ...c, update_time: '' })))
setTocError(`${msg}(已显示书架缓存)`)
return
}
}
setChapters([])
setTocError(msg)
}
})()
return () => {
alive = false
}
}, [bookURL, sourceURL])
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
@@ -77,7 +109,10 @@ export default function ReaderBookPage() {
if (!tocURL || tocURL === bookURL) return
readerAPI
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: tocURL })
.then(setChapters)
.then((list) => {
setChapters(list)
setTocError('')
})
.catch(() => undefined)
}, [info?.toc_url, sourceURL, bookURL])
@@ -142,9 +177,12 @@ export default function ReaderBookPage() {
try {
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: info?.toc_url || bookURL })
setChapters(list)
setTocError('')
toast.success(`目录已刷新,共 ${list.length} 章`)
} catch {
toast.error('目录刷新失败')
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录刷新失败'
setTocError(msg)
toast.error(msg)
} finally {
setBusy(false)
}
@@ -256,7 +294,7 @@ export default function ReaderBookPage() {
</p>
)}
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
最新:<span className="text-[var(--app-text)]">{info?.latest_chapter || '加载中…'}</span>
最新:<span className="text-[var(--app-text)]">{info?.latest_chapter || (info ? '—' : '加载中…')}</span>
</p>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">书源:{originName || sourceURL}</p>
{(info?.intro || infoError) && (
@@ -264,7 +302,15 @@ export default function ReaderBookPage() {
<p className={`text-xs leading-5 text-[var(--app-subtle)] ${introExpanded ? '' : 'line-clamp-3'}`}>
{infoError || info?.intro}
</p>
{!infoError && (
{infoError ? (
<button
type="button"
onClick={loadInfo}
className="mt-1 inline-flex items-center text-2xs font-bold text-brand-600"
>
重试
</button>
) : (
<button
type="button"
onClick={() => setIntroExpanded((v) => !v)}
@@ -308,7 +354,10 @@ export default function ReaderBookPage() {
<div className="mt-8">
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-2">
<p className="text-sm font-bold text-[var(--app-text)]">
目录 {chapters !== null && <span className="text-xs font-normal text-[var(--app-muted)]">({chapters.length} 章)</span>}
目录{' '}
{chapters !== null && (chapters.length > 0 || !tocError) && (
<span className="text-xs font-normal text-[var(--app-muted)]">({chapters.length} 章)</span>
)}
</p>
{chapters !== null && chapters.length > 20 && (
<button
@@ -320,6 +369,19 @@ export default function ReaderBookPage() {
</button>
)}
</div>
{tocError && (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
<span className="min-w-0 flex-1">{tocError}</span>
<button
type="button"
onClick={refreshToc}
disabled={busy}
className="shrink-0 font-bold text-brand-600 disabled:opacity-50"
>
重试
</button>
</div>
)}
{chapters === null ? (
<div className="flex justify-center py-10 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={20} />