diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index b3af624..22499d3 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -249,4 +249,5 @@ - 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。 - 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。 - 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。 +- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。 - 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。 diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 7df9769..19ede15 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -362,13 +362,15 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc { Key string `json:"key" binding:"required"` // SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。 SourceIDs []string `json:"source_ids"` + // Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。 + Page int `json:"page"` } return func(c *gin.Context) { if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs) + books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/internal/service/reader/login_test.go b/internal/service/reader/login_test.go index fd48526..8e1529a 100644 --- a/internal/service/reader/login_test.go +++ b/internal/service/reader/login_test.go @@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) { sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) // ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ── - if books, _, err := svc.Search(ctx, "会员", nil); err != nil { + if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil { t.Fatal(err) } else if len(books) != 0 { t.Fatalf("未登录时不应搜到结果: %+v", books) @@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) { } // ── 登录后搜索:应携带 Cookie 并成功 ── - books, skipped, err := svc.Search(ctx, "会员", nil) + books, skipped, err := svc.Search(ctx, "会员", nil, 1) if err != nil { t.Fatal(err) } @@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) { if info.LoggedIn || len(info.Cookies) != 0 { t.Fatalf("登出后不应残留登录态: %+v", info) } - if books, _, err := svc.Search(ctx, "会员", nil); err != nil { + if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil { t.Fatal(err) } else if len(books) != 0 { t.Fatalf("登出后不应还能搜到结果: %+v", books) @@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) { }` sourceID := prepareLoginSource(t, svc, srcJSON) - if _, _, err := svc.Search(ctx, "任意", nil); err != nil { + if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil { t.Fatal(err) } // 自动捕获被关闭:不应出现 auto=from-response @@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) { if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil { t.Fatal(err) } - if _, _, err := svc.Search(ctx, "任意", nil); err != nil { + if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil { t.Fatal(err) } if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") { diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 4931441..983f741 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -766,7 +766,14 @@ type searchHit struct { // 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。 // 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底), // 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。 -func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) { +// +// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发, +// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码; +// searchUrl 里没有 {{page}} 的源会重复返回首页结果,由调用方按「书名+作者」去重。 +func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) { + if page < 1 { + page = 1 + } sources, err := s.repo.ListSources(ctx) if err != nil { return nil, nil, err @@ -790,7 +797,7 @@ func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []stri g.Go(func() error { gctxSrc, cancel := context.WithTimeout(gctx, perSourceTimeout) defer cancel() - books, err := s.searchInSource(gctxSrc, &src, nil, key, 1) + books, err := s.searchInSource(gctxSrc, &src, nil, key, page) mu.Lock() defer mu.Unlock() if err != nil { diff --git a/internal/service/reader/search_page_test.go b/internal/service/reader/search_page_test.go new file mode 100644 index 0000000..b8d22ae --- /dev/null +++ b/internal/service/reader/search_page_test.go @@ -0,0 +1,98 @@ +package reader + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// 本文件:搜索分页。 +// +// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到 +// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并 +// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。 + +func pagedSearchSourceJSON(serverURL string) string { + src := map[string]any{ + "bookSourceUrl": serverURL, + "bookSourceName": "分页源", + "searchUrl": serverURL + "/search?q={{key}}&page={{page}}", + "ruleSearch": map[string]any{ + "bookList": "$.data", + "name": "$.book_name", + "author": "$.author", + "bookUrl": "$.book_url", + }, + } + out, _ := json.Marshal(src) + return string(out) +} + +func TestSearchUsesRequestedPage(t *testing.T) { + var gotPage string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPage = r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + payload := map[string]any{"data": []map[string]string{{ + "book_name": fmt.Sprintf("第%s页的书", gotPage), + "author": "作者", + "book_url": "/book/" + gotPage, + }}} + _ = json.NewEncoder(w).Encode(payload) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + _ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL)) + + for _, tc := range []struct { + in int + want string + }{ + {1, "1"}, + {2, "2"}, + {5, "5"}, + {0, "1"}, // 非法页码退回第 1 页 + {-3, "1"}, // 负数同理 + } { + books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in) + if err != nil { + t.Fatalf("page=%d 搜索失败: %v", tc.in, err) + } + if len(skipped) > 0 { + t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped) + } + if len(books) != 1 { + t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books)) + } + if gotPage != tc.want { + t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want) + } + if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) { + t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0]) + } + } +} + +// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时 +// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。 +func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + _ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL)) + + books, _, err := svc.Search(t.Context(), "书", nil, 3) + if err != nil { + t.Fatalf("搜索失败: %v", err) + } + if len(books) != 1 || books[0].Name != "单页书" { + t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books) + } +} diff --git a/internal/service/reader/search_query_test.go b/internal/service/reader/search_query_test.go index d16d803..84069e1 100644 --- a/internal/service/reader/search_query_test.go +++ b/internal/service/reader/search_query_test.go @@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) { svc, _ := newLoginTestService(t) _ = prepareLoginSource(t, svc, queryEchoSource(srv.URL)) - books, skipped, err := svc.Search(t.Context(), "宠魅", nil) + books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1) if err != nil { t.Fatalf("搜索失败: %v", err) } @@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) { svc, _ := newLoginTestService(t) _ = prepareLoginSource(t, svc, queryEchoSource(srv.URL)) - books, skipped, err := svc.Search(t.Context(), "宠魅", nil) + books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1) if err != nil { t.Fatalf("搜索失败: %v", err) } @@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) { out, _ := json.Marshal(src) _ = prepareLoginSource(t, svc, string(out)) - if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil { + if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil { t.Fatalf("搜索失败: %v", err) } if gotQuery != "宠魅" { diff --git a/internal/service/reader/search_scope_test.go b/internal/service/reader/search_scope_test.go index 6f919fc..534d91e 100644 --- a/internal/service/reader/search_scope_test.go +++ b/internal/service/reader/search_scope_test.go @@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) { } // 指定单个源:只应搜到该源的结果 - books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}) + books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1) if err != nil { t.Fatal(err) } @@ -87,7 +87,7 @@ func TestSearchRespectsSourceScope(t *testing.T) { } // 空范围:默认全部启用书源,两个源都应命中 - books, _, err = svc.Search(t.Context(), "书", nil) + books, _, err = svc.Search(t.Context(), "书", nil, 1) if err != nil { t.Fatal(err) } @@ -96,7 +96,7 @@ func TestSearchRespectsSourceScope(t *testing.T) { } // 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到 - books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}) + books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1) if err != nil { t.Fatal(err) } diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts index 3031422..852e53f 100644 --- a/web/src/api/reader.ts +++ b/web/src/api/reader.ts @@ -268,11 +268,12 @@ export const readerAPI = { // ── 搜索 / 详情 / 目录 / 正文 ── // 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认); // 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。 - search: (key: string, sourceIds: string[] = []) => + // page 从 1 开始:书源 searchUrl 里的 {{page}},前端滚到底逐页取再增量合并。 + search: (key: string, sourceIds: string[] = [], page = 1) => api .post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>( '/reader/search', - { key, source_ids: sourceIds }, + { key, source_ids: sourceIds, page }, longOpts, ) .then((r) => r.data), diff --git a/web/src/components/LoadMoreSentinel.tsx b/web/src/components/LoadMoreSentinel.tsx new file mode 100644 index 0000000..212ed84 --- /dev/null +++ b/web/src/components/LoadMoreSentinel.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react' + +type LoadMoreSentinelProps = { + /** 还有下一页;false 时整个哨兵不渲染(没有更多了)。 */ + hasMore: boolean + loadingMore: boolean + onLoadMore: () => void + /** 上一页加载失败的原因:停掉自动加载、改成手动重试,见下方 observer 注释。 */ + error?: string + onRetry?: () => void + label?: string + loadingLabel?: string +} + +/** + * 触底加载哨兵:自身进入滚动容器(应用主滚动区 #app-main-scroll)附近时回调 + * onLoadMore,同时留一个可点的按钮兜底(观察器不可用、或用户想手动催一下)。 + * + * 失败时必须停掉观察器:失败后 loadingMore 会回到 false,观察器一旦重建就会 + * 立刻再次命中(哨兵还在视口里),变成「失败即重试」的死循环;所以 error 非空 + * 时只渲染重试按钮,等用户点了、错误清掉再重新挂观察器。 + */ +export function LoadMoreSentinel({ + hasMore, + loadingMore, + onLoadMore, + error = '', + onRetry, + label = '加载更多', + loadingLabel = '加载中…', +}: LoadMoreSentinelProps) { + const ref = useRef(null) + + useEffect(() => { + const element = ref.current + if (!element || !hasMore || error) return + // 观察根是主滚动容器而不是视口:页面是嵌在 #app-main-scroll 里滚的。 + const root = document.getElementById('app-main-scroll') + const observer = new IntersectionObserver( + (entries) => { + if (!loadingMore && entries.some((entry) => entry.isIntersecting)) { + onLoadMore() + } + }, + { root, rootMargin: '600px 0px', threshold: 0 }, + ) + observer.observe(element) + return () => observer.disconnect() + }, [hasMore, loadingMore, onLoadMore, error]) + + if (!hasMore) return null + + const showRetry = !!error && !!onRetry + return ( +
+ {error &&

{error}

} + +
+ ) +} diff --git a/web/src/pages/LibraryMediaSections.tsx b/web/src/pages/LibraryMediaSections.tsx index 447b81e..9f4b68b 100644 --- a/web/src/pages/LibraryMediaSections.tsx +++ b/web/src/pages/LibraryMediaSections.tsx @@ -1,6 +1,7 @@ -import { memo, useEffect, useRef, type ReactNode } from 'react' +import { memo, type ReactNode } from 'react' import { Film } from 'lucide-react' +import { LoadMoreSentinel } from '../components/LoadMoreSentinel' import { MediaCard } from '../components/MediaCard' import { MEDIA_GRID_CLASS, VirtualMediaGrid } from '../components/VirtualMediaGrid' import type { Media } from '../types' @@ -67,57 +68,11 @@ export function LibraryMediaSections({ )} - + ) } -function LoadMoreSentinel({ - hasMore, - loadingMore, - onLoadMore, -}: { - hasMore: boolean - loadingMore: boolean - onLoadMore: () => void -}) { - const ref = useRef(null) - - useEffect(() => { - const element = ref.current - if (!element || !hasMore) return - const root = document.getElementById('app-main-scroll') - const observer = new IntersectionObserver( - (entries) => { - if (!loadingMore && entries.some((entry) => entry.isIntersecting)) { - onLoadMore() - } - }, - { root, rootMargin: '600px 0px', threshold: 0 }, - ) - observer.observe(element) - return () => observer.disconnect() - }, [hasMore, loadingMore, onLoadMore]) - - if (!hasMore) return null - return ( -
- -
- ) -} - // 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用, // 父级重渲染不会穿透到每张剧集卡片。 const SeriesCardItem = memo(function SeriesCardItem({ diff --git a/web/src/pages/reader/ReaderSearchPage.tsx b/web/src/pages/reader/ReaderSearchPage.tsx index aa6c65f..b437c5d 100644 --- a/web/src/pages/reader/ReaderSearchPage.tsx +++ b/web/src/pages/reader/ReaderSearchPage.tsx @@ -4,26 +4,26 @@ import toast from 'react-hot-toast' import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react' import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader' +import { LoadMoreSentinel } from '../../components/LoadMoreSentinel' import ReaderBookCover from '../../components/ReaderBookCover' import { useReaderSettingsStore } from '../../stores/readerSettings' import { splitKindTags } from '../../utils/kindTags' +import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from '../../utils/searchBooks' import { SearchScopeDialog } from './SearchScopeDialog' import { SourcePickerDialog } from './SourcePickerDialog' // 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。 // 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。 // 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。 +// 结果按页取:滚到底自动请求下一页并增量合并,直到某一页不再带来新书。 -/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */ -function shelfBookKey(name: string, author: string): string { - const n = name.trim() - const a = author.trim() - return a ? `${n}|${a}` : n -} +/** 单次搜索最多翻到第几页。书源分页规则千奇百怪,个别源会一直返回新条目, + * 不设上限时用户一直往下滚就会一直发请求(每次都要把所有源再搜一遍)。 */ +const MAX_SEARCH_PAGES = 20 -/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */ +/** 这本搜索结果是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */ function isOnShelf(book: ReaderSearchBook, shelf: Set): boolean { - if (shelf.has(shelfBookKey(book.name, book.author))) return true + if (shelf.has(searchBookKey(book.name, book.author))) return true if (book.book_url && shelf.has(book.book_url)) return true return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url)) } @@ -39,6 +39,16 @@ export default function ReaderSearchPage() { const [skipped, setSkipped] = useState([]) const [showSkipped, setShowSkipped] = useState(false) const [adding, setAdding] = useState('') + // 滚动加载:page 是已加载到的页码;hasMore 为 false 表示到底了(或还没搜过)。 + const [page, setPage] = useState(0) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [moreError, setMoreError] = useState('') + // loadMore 的在途标记(见 loadMore 里的说明),比 loadingMore 状态更早生效。 + const loadingMoreRef = useRef(false) + // 每次新搜索 +1:在飞的 loadMore 回来时若已经换了关键词(searchSeq 已变), + // 就把那一页结果丢掉,避免旧关键词的第 N 页被并进新列表。 + const searchSeq = useRef(0) const inputRef = useRef(null) // 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。 @@ -69,7 +79,7 @@ export default function ReaderSearchPage() { .then((books) => { const keys = new Set() for (const b of books) { - keys.add(shelfBookKey(b.name, b.author)) + keys.add(searchBookKey(b.name, b.author)) if (b.book_url) keys.add(b.book_url) } setShelfKeys(keys) @@ -89,17 +99,26 @@ export default function ReaderSearchPage() { const doSearch = async () => { const kw = key.trim() if (!kw || searching) return + searchSeq.current++ setSearching(true) setShowSkipped(false) + setLoadingMore(false) + setMoreError('') try { // 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用, // 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。 - const res = await readerAPI.search(kw, scopeIds) - setBooks(res.books ?? []) + const res = await readerAPI.search(kw, scopeIds, 1) + const list = res.books ?? [] + setBooks(list) // 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组, // 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。 setSkipped(res.skipped ?? []) - if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果') + setPage(1) + // 首页一条都没有 → 后面也不会有;有结果就先假定还有下一页。 + // 书源是否支持分页只有它自己知道({{page}} 也可能藏在 URL 的 里), + // 所以这里不预判:多花一次「第 2 页」请求,由 loadMore 发现这一页没带来新书时收口。 + setHasMore(list.length > 0) + if (list.length === 0) toast.error('所有书源都没有找到结果') } catch (e) { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败' toast.error(msg) @@ -108,6 +127,47 @@ export default function ReaderSearchPage() { } } + // 滚动到底加载下一页。每页都会把范围内的书源重新搜一遍(后端按页下发), + // 因此这里要注意两件事: + // 1. 结果必须增量合并而不是替换——不支持 {{page}} 的源会在每一页重复返回首页结果; + // 2. 合并后没有新书(重复内容或该页为空)就说明到底了,否则用户会一直滚一直转圈。 + const loadMore = async () => { + const kw = key.trim() + if (!kw || !hasMore || loadingMore || searching || books === null) return + // loadingMore 是渲染后才生效的状态:同一帧里观察器可能回调两次,用 ref 兜住, + // 避免把同一页请求两次(每次翻页都要把所有源再搜一遍,重复请求不便宜)。 + if (loadingMoreRef.current) return + loadingMoreRef.current = true + const next = page + 1 + if (next > MAX_SEARCH_PAGES) { + setHasMore(false) + loadingMoreRef.current = false + return + } + setLoadingMore(true) + setMoreError('') + const seq = searchSeq.current + try { + const res = await readerAPI.search(kw, scopeIds, next) + // 期间用户又发起了一次搜索:这一页属于旧关键词,直接丢弃。 + if (searchSeq.current !== seq) return + const incoming = res.books ?? [] + const merged = mergeSearchBooks(books, incoming) + setBooks(merged) + setSkipped((prev) => mergeSearchSkipped(prev, res.skipped ?? [])) + setPage(next) + if (merged.length === books.length) setHasMore(false) + } catch (e) { + if (searchSeq.current !== seq) return + // 不弹 toast 打断浏览:把失败原因挂在哨兵上,改由用户点重试。 + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载下一页失败' + setMoreError(msg) + } finally { + loadingMoreRef.current = false + setLoadingMore(false) + } + } + // 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜) const autoSearchedRef = useRef(false) useEffect(() => { @@ -127,7 +187,7 @@ export default function ReaderSearchPage() { // 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。 setShelfKeys((prev) => { const next = new Set(prev) - next.add(shelfBookKey(book.name, book.author)) + next.add(searchBookKey(book.name, book.author)) if (origin.book_url) next.add(origin.book_url) return next }) @@ -277,6 +337,19 @@ export default function ReaderSearchPage() { ))} + {/* 滚动到底自动加载下一页;失败时改成手动重试(见 LoadMoreSentinel)。 */} + + {!hasMore && !moreError && visibleBooks.length > 0 && ( +

已经到底了

+ )} + {skipped.length > 0 && (