This commit is contained in:
truewhile
2026-10-04 11:46:17 +08:00
parent dacfec7492
commit 6656f9c398
13 changed files with 474 additions and 77 deletions
+1
View File
@@ -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/字段一一对应。
+3 -1
View File
@@ -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
+5 -5
View File
@@ -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") {
+9 -2
View File
@@ -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 {
@@ -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)
}
}
+3 -3
View File
@@ -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 != "宠魅" {
+3 -3
View File
@@ -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)
}
+3 -2
View File
@@ -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),
+67
View File
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="flex flex-col items-center gap-1.5 py-8">
{error && <p className="text-2xs text-[var(--app-muted)]">{error}</p>}
<button
type="button"
disabled={loadingMore}
onClick={showRetry ? onRetry : onLoadMore}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
>
{loadingMore ? loadingLabel : label}
</button>
</div>
)
}
+3 -48
View File
@@ -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({
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
)}
<LoadMoreSentinel
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
/>
<LoadMoreSentinel hasMore={hasMore} loadingMore={loadingMore} onLoadMore={onLoadMore} />
</>
)
}
function LoadMoreSentinel({
hasMore,
loadingMore,
onLoadMore,
}: {
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}) {
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="flex justify-center py-8">
<button
type="button"
disabled={loadingMore}
onClick={onLoadMore}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
>
{loadingMore ? '加载中…' : '加载更多'}
</button>
</div>
)
}
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
// 父级重渲染不会穿透到每张剧集卡片。
const SeriesCardItem = memo(function SeriesCardItem({
+86 -13
View File
@@ -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<string>): 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<ReaderSearchSkipped[]>([])
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<HTMLInputElement>(null)
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
@@ -69,7 +79,7 @@ export default function ReaderSearchPage() {
.then((books) => {
const keys = new Set<string>()
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 的 <js> 里),
// 所以这里不预判:多花一次「第 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() {
</div>
))}
{/* 滚动到底自动加载下一页;失败时改成手动重试(见 LoadMoreSentinel)。 */}
<LoadMoreSentinel
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
error={moreError}
onRetry={loadMore}
loadingLabel={`正在搜索第 ${page + 1} 页…`}
/>
{!hasMore && !moreError && visibleBooks.length > 0 && (
<p className="py-2 text-center text-2xs text-[var(--app-subtle)]">已经到底了</p>
)}
{skipped.length > 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
<button
+109
View File
@@ -0,0 +1,109 @@
import type { ReaderSearchBook, ReaderSearchOrigin } from '../api/reader.ts'
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from './searchBooks.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`searchBooks: ${name}`)
}
function origin(sourceId: string, bookUrl: string, originName = sourceId): ReaderSearchOrigin {
return { source_id: sourceId, origin: `https://${sourceId}.example.com`, origin_name: originName, origin_type: 0, book_url: bookUrl, latest_chapter: '' }
}
function book(over: Partial<ReaderSearchBook> & { name: string; author: string }): ReaderSearchBook {
return {
kind: '',
word_count: '',
latest_chapter: '',
intro: '',
cover_url: '',
book_url: '',
origins: [],
...over,
}
}
// 聚合键:作者缺省时只用书名(和后端 name|author 的拼法一致)
check('key uses name and author', searchBookKey('全球高武', '懒人') === '全球高武|懒人')
check('key falls back to name without author', searchBookKey('全球高武', '') === '全球高武')
check('key trims blanks', searchBookKey(' 全球高武 ', ' 懒人 ') === '全球高武|懒人')
check('key treats blank author as missing', searchBookKey('全球高武', ' ') === '全球高武')
// 跨页去重:不支持分页的源在每一页都返回首页结果,不能重复追加一条书
{
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: 'http://c/1.jpg' })]
const page2 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
const merged = mergeSearchBooks(page1, page2)
check('duplicate across pages is merged', merged.length === 1)
check('duplicate origin is not re-added', merged[0].origins.length === 1)
check('existing fields survive the merge', merged[0].cover_url === 'http://c/1.jpg')
}
// 新一页的书接在已有书后面,顺序不倒
{
const page1 = [book({ name: '甲', author: '作者', origins: [origin('a', '/a')] })]
const page2 = [book({ name: '乙', author: '作者', origins: [origin('a', '/b')] })]
const merged = mergeSearchBooks(page1, page2)
check('new page appends', merged.length === 2)
check('first page keeps its position', merged[0].name === '甲')
check('second page follows', merged[1].name === '乙')
}
// 同书多源:书源并进同一项(决定「N 源可换」),且不会补齐覆盖已有的有效值
{
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: '', intro: '第一页的简介' })]
const page2 = [
book({
name: '全球高武',
author: '懒人',
origins: [origin('b', '/2')],
cover_url: 'http://c/2.jpg',
intro: '第二页的简介',
kind: '玄幻',
}),
]
const merged = mergeSearchBooks(page1, page2)
check('same book merges origins', merged[0].origins.length === 2)
check('missing cover is filled', merged[0].cover_url === 'http://c/2.jpg')
check('missing kind is filled', merged[0].kind === '玄幻')
check('existing intro is not overwritten', merged[0].intro === '第一页的简介')
}
// 不同作者的同名书不能被合并成一条
{
const merged = mergeSearchBooks(
[book({ name: '同名书', author: '甲', origins: [origin('a', '/1')] })],
[book({ name: '同名书', author: '乙', origins: [origin('a', '/2')] })],
)
check('same name different author stays separate', merged.length === 2)
}
// 无名条目没有聚合键,直接丢弃(后端也会跳过 Name 为空的项)
{
const merged = mergeSearchBooks([], [book({ name: ' ', author: '作者' })])
check('nameless item is dropped', merged.length === 0)
}
// 不原地改动上一份状态:prev 的数组与对象都不能被写坏
{
const prev = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
const snapshot = JSON.stringify(prev)
mergeSearchBooks(prev, [book({ name: '全球高武', author: '懒人', origins: [origin('b', '/2')] })])
check('prev state is not mutated', JSON.stringify(prev) === snapshot)
}
// 失败书源按 source_id 去重,原因取最新一页的
{
const merged = mergeSearchSkipped(
[{ source_id: 'a', origin_name: '源A', reason: '超时' }],
[
{ source_id: 'a', origin_name: '源A', reason: '限流' },
{ source_id: 'b', origin_name: '源B', reason: '解析失败' },
],
)
check('skipped is deduped by source', merged.length === 2)
check('skipped reason comes from the newest page', merged[0].reason === '限流')
check('new failed source is appended', merged[1].source_id === 'b')
check('empty incoming keeps the same array', mergeSearchSkipped(merged, []) === merged)
}
console.log('searchBooks.test.ts ok')
+84
View File
@@ -0,0 +1,84 @@
import type { ReaderSearchBook, ReaderSearchOrigin, ReaderSearchSkipped } from '../api/reader'
/**
* 同名同作者的聚合键——与后端 mergeSearchResults 的 `name|author` 一致:
* 同一本书多源命中时合并成一行;作者缺省时只用书名(否则作者为空的源会被拆成两条)。
*/
export function searchBookKey(name: string, author: string): string {
const n = name.trim()
const a = author.trim()
return a ? `${n}|${a}` : n
}
/** 两条命中书源是否指向同一个源的同一个地址(跨页重复返回时用于去重)。 */
function sameOrigin(a: ReaderSearchOrigin, b: ReaderSearchOrigin): boolean {
return a.source_id === b.source_id && a.book_url === b.book_url
}
/** 补齐缺失字段(对应后端 fillMissingSearchBookFields):先到的空值不能挡掉后面源的有效值。 */
function fillMissing(dst: ReaderSearchBook, src: ReaderSearchBook): void {
if (!dst.cover_url) dst.cover_url = src.cover_url
if (!dst.intro) dst.intro = src.intro
if (!dst.kind) dst.kind = src.kind
if (!dst.word_count) dst.word_count = src.word_count
if (!dst.latest_chapter) dst.latest_chapter = src.latest_chapter
}
/**
* 把新一页的搜索结果并入已加载的列表(滚动加载用)。
*
* 每一页都是后端独立聚合出来的,跨页去重只能在前端做:书源 searchUrl 里没有
* {{page}} 时它会重复返回首页结果,同一个源的同一本书就可能在每一页都出现,
* 直接 append 会让列表堆满重复行。这里按 searchBookKey 合并成一项:
* 并书源(决定「N 源可换」)、补缺失字段;顺序保持「先到的在前」,
* 于是第 2 页的新书接在第 1 页后面。
*
* 返回新数组与新对象,调用方可以直接交给 setState,不必担心原地改动上一份状态。
*/
export function mergeSearchBooks(prev: ReaderSearchBook[], incoming: ReaderSearchBook[]): ReaderSearchBook[] {
const out = prev.map((b) => ({ ...b, origins: [...b.origins] }))
const byKey = new Map<string, ReaderSearchBook>()
for (const b of out) byKey.set(searchBookKey(b.name, b.author), b)
for (const b of incoming) {
// 无名条目没有可用的聚合键(后端也会跳过 Name 为空的项),直接丢弃。
if (!b.name.trim()) continue
const key = searchBookKey(b.name, b.author)
const existing = byKey.get(key)
if (!existing) {
const copy: ReaderSearchBook = { ...b, origins: [...b.origins] }
out.push(copy)
byKey.set(key, copy)
continue
}
fillMissing(existing, b)
for (const origin of b.origins) {
if (!existing.origins.some((o) => sameOrigin(o, origin))) existing.origins.push(origin)
}
}
return out
}
/**
* 合并搜索失败的书源列表(滚动加载时每页失败的源可能不同)。
* 按 source_id 去重,原因是「任何一页失败过就算失败」——排查页面关心的是哪些源
* 有问题,而不是它这一页恰好成功;同一个源在多页里原因不同时取最新一页的。
*/
export function mergeSearchSkipped(
prev: ReaderSearchSkipped[],
incoming: ReaderSearchSkipped[],
): ReaderSearchSkipped[] {
if (incoming.length === 0) return prev
const out = [...prev]
const index = new Map(out.map((s, i) => [s.source_id, i]))
for (const s of incoming) {
const at = index.get(s.source_id)
if (at === undefined) {
index.set(s.source_id, out.length)
out.push(s)
} else {
out[at] = s
}
}
return out
}