mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
This commit is contained in:
@@ -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/字段一一对应。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "宠魅" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user