mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
优化
This commit is contained in:
@@ -333,13 +333,15 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
var body struct {
|
var body struct {
|
||||||
Key string `json:"key" binding:"required"`
|
Key string `json:"key" binding:"required"`
|
||||||
|
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||||
|
SourceIDs []string `json:"source_ids"`
|
||||||
}
|
}
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
|
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
|||||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||||
|
|
||||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
} else if len(books) != 0 {
|
} else if len(books) != 0 {
|
||||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||||
books, skipped, err := svc.Search(ctx, "会员")
|
books, skipped, err := svc.Search(ctx, "会员", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
|||||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||||
}
|
}
|
||||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
} else if len(books) != 0 {
|
} else if len(books) != 0 {
|
||||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
|||||||
}`
|
}`
|
||||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||||
|
|
||||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// 自动捕获被关闭:不应出现 auto=from-response
|
// 自动捕获被关闭:不应出现 auto=from-response
|
||||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
|||||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||||
|
|||||||
@@ -737,17 +737,17 @@ type searchHit struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search 多源聚合搜索(同步返回,P1 改为 WS 流式推送)。
|
// Search 多源聚合搜索(同步返回,P1 改为 WS 流式推送)。
|
||||||
func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, []SearchSkipped, error) {
|
//
|
||||||
|
// sourceIDs 是本次的搜索范围(对应 legado SearchScope.getBookSourceParts):
|
||||||
|
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
|
||||||
|
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
|
||||||
|
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
|
||||||
|
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
|
||||||
sources, err := s.repo.ListSources(ctx)
|
sources, err := s.repo.ListSources(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var enabled []model.ReaderBookSource
|
enabled := enabledSourcesForScope(sources, sourceIDs)
|
||||||
for _, src := range sources {
|
|
||||||
if src.Enabled {
|
|
||||||
enabled = append(enabled, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(enabled) == 0 {
|
if len(enabled) == 0 {
|
||||||
return nil, nil, fmt.Errorf("没有已启用的书源")
|
return nil, nil, fmt.Errorf("没有已启用的书源")
|
||||||
}
|
}
|
||||||
@@ -783,6 +783,34 @@ func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, [
|
|||||||
return mergeSearchResults(hits, key), skipped, nil
|
return mergeSearchResults(hits, key), skipped, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enabledSourcesForScope 按搜索范围挑出可搜的书源(保持 ListSources 的 customOrder)。
|
||||||
|
// 空范围 = 全部启用;范围里的书源不存在或已停用时忽略;全被忽略则退回全部启用。
|
||||||
|
func enabledSourcesForScope(sources []model.ReaderBookSource, sourceIDs []string) []model.ReaderBookSource {
|
||||||
|
enabled := make([]model.ReaderBookSource, 0, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
if src.Enabled {
|
||||||
|
enabled = append(enabled, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sourceIDs) == 0 {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
want := make(map[string]struct{}, len(sourceIDs))
|
||||||
|
for _, id := range sourceIDs {
|
||||||
|
want[id] = struct{}{}
|
||||||
|
}
|
||||||
|
scoped := make([]model.ReaderBookSource, 0, len(enabled))
|
||||||
|
for _, src := range enabled {
|
||||||
|
if _, ok := want[src.ID]; ok {
|
||||||
|
scoped = append(scoped, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(scoped) == 0 {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
return scoped
|
||||||
|
}
|
||||||
|
|
||||||
// SearchSkipped 搜索失败的书源与原因(书源管理调试用)。
|
// SearchSkipped 搜索失败的书源与原因(书源管理调试用)。
|
||||||
type SearchSkipped struct {
|
type SearchSkipped struct {
|
||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
|
|||||||
svc, _ := newLoginTestService(t)
|
svc, _ := newLoginTestService(t)
|
||||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||||
|
|
||||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("搜索失败: %v", err)
|
t.Fatalf("搜索失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
|||||||
svc, _ := newLoginTestService(t)
|
svc, _ := newLoginTestService(t)
|
||||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||||
|
|
||||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("搜索失败: %v", err)
|
t.Fatalf("搜索失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
|||||||
out, _ := json.Marshal(src)
|
out, _ := json.Marshal(src)
|
||||||
_ = prepareLoginSource(t, svc, string(out))
|
_ = prepareLoginSource(t, svc, string(out))
|
||||||
|
|
||||||
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
|
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
|
||||||
t.Fatalf("搜索失败: %v", err)
|
t.Fatalf("搜索失败: %v", err)
|
||||||
}
|
}
|
||||||
if gotQuery != "宠魅" {
|
if gotQuery != "宠魅" {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
|
||||||
|
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
|
||||||
|
|
||||||
|
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
|
||||||
|
t.Helper()
|
||||||
|
src := map[string]any{
|
||||||
|
"bookSourceUrl": serverURL,
|
||||||
|
"bookSourceName": name,
|
||||||
|
"searchUrl": serverURL + "/search?q={{key}}",
|
||||||
|
"ruleSearch": map[string]any{
|
||||||
|
"bookList": "$.data",
|
||||||
|
"name": "$.book_name",
|
||||||
|
"author": "$.author",
|
||||||
|
"bookUrl": "$.book_url",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(src)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
payload := map[string]any{
|
||||||
|
"data": []map[string]string{{
|
||||||
|
"book_name": bookName,
|
||||||
|
"author": "作者",
|
||||||
|
"book_url": "/book/1",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchRespectsSourceScope(t *testing.T) {
|
||||||
|
srvA := scopeEchoServer(t, "甲书")
|
||||||
|
defer srvA.Close()
|
||||||
|
srvB := scopeEchoServer(t, "乙书")
|
||||||
|
defer srvB.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
for _, raw := range []string{
|
||||||
|
scopeEchoSourceJSON(t, "源A", srvA.URL),
|
||||||
|
scopeEchoSourceJSON(t, "源B", srvB.URL),
|
||||||
|
} {
|
||||||
|
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources, err := svc.ListSources(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
idByName := map[string]string{}
|
||||||
|
for _, s := range sources {
|
||||||
|
idByName[s.Name] = s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
names := func(books []SearchBook) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, b := range books {
|
||||||
|
out[b.Name] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指定单个源:只应搜到该源的结果
|
||||||
|
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
t.Fatalf("源A 不应失败: %+v", skipped)
|
||||||
|
}
|
||||||
|
if got := names(books); len(got) != 1 || !got["甲书"] {
|
||||||
|
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空范围:默认全部启用书源,两个源都应命中
|
||||||
|
books, _, err = svc.Search(t.Context(), "书", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
|
||||||
|
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||||
|
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := names(books); len(got) != 2 {
|
||||||
|
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,11 +245,13 @@ export const readerAPI = {
|
|||||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||||
|
|
||||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||||
search: (key: string) =>
|
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||||
|
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||||
|
search: (key: string, sourceIds: string[] = []) =>
|
||||||
api
|
api
|
||||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||||
'/reader/search',
|
'/reader/search',
|
||||||
{ key },
|
{ key, source_ids: sourceIds },
|
||||||
longOpts,
|
longOpts,
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
|
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
|
||||||
|
|
||||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
|
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
|
||||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||||
|
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||||
import { splitKindTags } from '../../utils/kindTags'
|
import { splitKindTags } from '../../utils/kindTags'
|
||||||
|
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||||
|
|
||||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
// 多源聚合搜索页(仿 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||||
|
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||||
|
if (shelf.has(shelfBookKey(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))
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReaderSearchPage() {
|
export default function ReaderSearchPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -23,13 +41,60 @@ export default function ReaderSearchPage() {
|
|||||||
const [adding, setAdding] = useState('')
|
const [adding, setAdding] = useState('')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
|
||||||
|
const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds)
|
||||||
|
const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds)
|
||||||
|
const [sources, setSources] = useState<ReaderSource[]>([])
|
||||||
|
const [showScope, setShowScope] = useState(false)
|
||||||
|
const enabledSources = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||||
|
// 已保存范围里仍有效的书源(删源/停源后自动剔除,全失效时等同于全部)。
|
||||||
|
const activeScopeIds = useMemo(
|
||||||
|
() => scopeIds.filter((id) => enabledSources.some((s) => s.id === id)),
|
||||||
|
[scopeIds, enabledSources],
|
||||||
|
)
|
||||||
|
// 书源列表还没加载完(sources 为空)时不能判断有效性,先按已存 ID 数显示,
|
||||||
|
// 避免已保存子集在加载瞬间被错标成「全部书源」。
|
||||||
|
const scopeAll = scopeIds.length === 0 || (sources.length > 0 && activeScopeIds.length === 0)
|
||||||
|
const scopeLabel = scopeAll ? '全部书源' : `已选 ${activeScopeIds.length || scopeIds.length} 个书源`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
readerAPI.listSources().then(setSources).catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 书架命中集合:已在书架的书不参与结果展示(这里只找新书)。
|
||||||
|
const [shelfKeys, setShelfKeys] = useState<Set<string>>(() => new Set())
|
||||||
|
useEffect(() => {
|
||||||
|
readerAPI
|
||||||
|
.listBooks()
|
||||||
|
.then((books) => {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const b of books) {
|
||||||
|
keys.add(shelfBookKey(b.name, b.author))
|
||||||
|
if (b.book_url) keys.add(b.book_url)
|
||||||
|
}
|
||||||
|
setShelfKeys(keys)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 展示用结果 = 原始结果剔除已在书架的书。用派生值而不是在 doSearch 里过滤:
|
||||||
|
// 书架是异步加载的,派生能保证「书架先到还是结果先到」都能正确隐藏,
|
||||||
|
// 且刚加进书架的书也会立刻从列表消失。
|
||||||
|
const visibleBooks = useMemo(
|
||||||
|
() => (books === null ? null : books.filter((b) => !isOnShelf(b, shelfKeys))),
|
||||||
|
[books, shelfKeys],
|
||||||
|
)
|
||||||
|
const hiddenOnShelf = books === null ? 0 : books.length - (visibleBooks?.length ?? 0)
|
||||||
|
|
||||||
const doSearch = async () => {
|
const doSearch = async () => {
|
||||||
const kw = key.trim()
|
const kw = key.trim()
|
||||||
if (!kw || searching) return
|
if (!kw || searching) return
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
setShowSkipped(false)
|
setShowSkipped(false)
|
||||||
try {
|
try {
|
||||||
const res = await readerAPI.search(kw)
|
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||||
|
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||||
|
const res = await readerAPI.search(kw, scopeIds)
|
||||||
setBooks(res.books ?? [])
|
setBooks(res.books ?? [])
|
||||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||||
@@ -52,12 +117,20 @@ export default function ReaderSearchPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
|
const addToShelf = async (book: ReaderSearchBook) => {
|
||||||
|
const origin = book.origins[0]
|
||||||
if (!origin) return
|
if (!origin) return
|
||||||
setAdding(book.book_url)
|
setAdding(book.book_url)
|
||||||
try {
|
try {
|
||||||
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
|
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
|
||||||
toast.success(`《${book.name}》已加入书架`)
|
toast.success(`《${book.name}》已加入书架`)
|
||||||
|
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
|
||||||
|
setShelfKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(shelfBookKey(book.name, book.author))
|
||||||
|
if (origin.book_url) next.add(origin.book_url)
|
||||||
|
return next
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
|
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
@@ -113,6 +186,22 @@ export default function ReaderSearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */}
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowScope(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-1.5 text-2xs font-bold text-[var(--app-muted)] transition hover:border-brand-500/40 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ListFilter size={13} />
|
||||||
|
搜索范围
|
||||||
|
<span className="text-brand-600">{scopeLabel}</span>
|
||||||
|
</button>
|
||||||
|
{scopeAll && enabledSources.length > 0 && (
|
||||||
|
<span className="text-2xs text-[var(--app-subtle)]">共 {enabledSources.length} 个启用书源</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{searching && (
|
{searching && (
|
||||||
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
|
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
|
||||||
<Loader2 className="animate-spin" size={22} />
|
<Loader2 className="animate-spin" size={22} />
|
||||||
@@ -120,9 +209,19 @@ export default function ReaderSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!searching && books !== null && (
|
{!searching && visibleBooks !== null && (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{books.map((book) => (
|
{hiddenOnShelf > 0 && visibleBooks.length > 0 && (
|
||||||
|
<p className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
|
||||||
|
已隐藏 {hiddenOnShelf} 本已在书架里的书
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visibleBooks.length === 0 && (
|
||||||
|
<p className="py-16 text-center text-xs text-[var(--app-muted)]">
|
||||||
|
{hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visibleBooks.map((book) => (
|
||||||
<div
|
<div
|
||||||
key={`${book.name}|${book.author}`}
|
key={`${book.name}|${book.author}`}
|
||||||
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
|
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
|
||||||
@@ -225,6 +324,18 @@ export default function ReaderSearchPage() {
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showScope && (
|
||||||
|
<SearchScopeDialog
|
||||||
|
sources={sources}
|
||||||
|
selectedIds={scopeIds}
|
||||||
|
onConfirm={(ids) => {
|
||||||
|
setSearchScopeIds(ids)
|
||||||
|
setShowScope(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setShowScope(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Check, Minus, Search, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { ReaderSource } from '../../api/reader'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchScopeDialog 搜索范围弹窗(对应 legado 搜索页的 SearchScopeDialog)。
|
||||||
|
*
|
||||||
|
* 只列出书源维度:默认全选所有「已启用」书源(对应 legado SearchScope("") 的
|
||||||
|
* 「全部书源」语义),取消勾选即可把本次搜索收敛到指定书源。已停用的书源会显示
|
||||||
|
* 但不可勾选,让用户知道它们不会被搜索。分组标题支持整组勾选/取消(kind 批量选择)。
|
||||||
|
*
|
||||||
|
* 确认时若「全部启用书源」都被选中,回传空数组表示「全部」——与后端
|
||||||
|
* enabledSourcesForScope 的空范围语义一致,也让后续新增的书源自动纳入搜索。
|
||||||
|
*/
|
||||||
|
export function SearchScopeDialog({
|
||||||
|
sources,
|
||||||
|
selectedIds,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
sources: ReaderSource[]
|
||||||
|
/** 已保存的搜索范围;空数组 = 全部启用书源(默认全选)。 */
|
||||||
|
selectedIds: string[]
|
||||||
|
onConfirm: (ids: string[]) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const enabled = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||||
|
const enabledIds = useMemo(() => enabled.map((s) => s.id), [enabled])
|
||||||
|
|
||||||
|
// 初始选中:保存过范围就用它(过滤掉已删除/停用的),否则默认全选已启用。
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||||
|
const valid = selectedIds.filter((id) => enabledIds.includes(id))
|
||||||
|
return new Set(valid.length > 0 ? valid : enabledIds)
|
||||||
|
})
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
const keyword = filter.trim().toLowerCase()
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
sources.filter(
|
||||||
|
(s) => !keyword || s.name.toLowerCase().includes(keyword) || (s.group ?? '').toLowerCase().includes(keyword),
|
||||||
|
),
|
||||||
|
[sources, keyword],
|
||||||
|
)
|
||||||
|
// 按分组聚合并保持书源原顺序(ListSources 已按 customOrder 排好)。
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, ReaderSource[]>()
|
||||||
|
for (const s of visible) {
|
||||||
|
const key = (s.group ?? '').trim() || '未分组'
|
||||||
|
const list = map.get(key)
|
||||||
|
if (list) list.push(s)
|
||||||
|
else map.set(key, [s])
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([name, items]) => ({ name, items }))
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
const pickedCount = enabledIds.filter((id) => selected.has(id)).length
|
||||||
|
const allSelected = pickedCount === enabledIds.length && enabledIds.length > 0
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleGroup = (ids: string[]) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
const allIn = ids.every((id) => next.has(id))
|
||||||
|
ids.forEach((id) => (allIn ? next.delete(id) : next.add(id)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全选/清空只作用于当前可见(可能被搜索词过滤)的已启用书源。
|
||||||
|
const visibleEnabledIds = visible.filter((s) => s.enabled).map((s) => s.id)
|
||||||
|
const selectVisible = () => setSelected((prev) => new Set([...prev, ...visibleEnabledIds]))
|
||||||
|
const clearVisible = () =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
visibleEnabledIds.forEach((id) => next.delete(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const picked = enabledIds.filter((id) => selected.has(id))
|
||||||
|
// 全选(含边界:启用源为空)→ 空数组 = 全部,新增书源自动纳入。
|
||||||
|
onConfirm(picked.length === enabledIds.length ? [] : picked)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-[var(--app-text)]">搜索范围</h3>
|
||||||
|
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||||
|
默认搜索全部启用书源 · 已启用 {enabledIds.length} 个
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-4 py-3">
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5">
|
||||||
|
<Search size={13} className="shrink-0 text-[var(--app-muted)]" />
|
||||||
|
<input
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="过滤书源名 / 分组"
|
||||||
|
className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={selectVisible} className="btn-outline text-2xs">
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={clearVisible} className="btn-outline text-2xs">
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-xs text-[var(--app-muted)]">还没有书源,先去书源管理导入</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-xs text-[var(--app-muted)]">没有匹配「{filter.trim()}」的书源</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const groupEnabledIds = group.items.filter((s) => s.enabled).map((s) => s.id)
|
||||||
|
const groupChecked = groupEnabledIds.length > 0 && groupEnabledIds.every((id) => selected.has(id))
|
||||||
|
const groupPartial = !groupChecked && groupEnabledIds.some((id) => selected.has(id))
|
||||||
|
return (
|
||||||
|
<div key={group.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={groupEnabledIds.length === 0}
|
||||||
|
onClick={() => toggleGroup(groupEnabledIds)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left hover:bg-[var(--app-hover)] disabled:cursor-default"
|
||||||
|
>
|
||||||
|
<CheckMark checked={groupChecked} partial={groupPartial} disabled={groupEnabledIds.length === 0} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-2xs font-bold uppercase tracking-wide text-[var(--app-muted)]">
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-2xs text-[var(--app-subtle)]">{group.items.length}</span>
|
||||||
|
</button>
|
||||||
|
<div className="mt-0.5 space-y-0.5">
|
||||||
|
{group.items.map((src) => (
|
||||||
|
<button
|
||||||
|
key={src.id}
|
||||||
|
type="button"
|
||||||
|
disabled={!src.enabled}
|
||||||
|
onClick={() => toggle(src.id)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)] disabled:cursor-default disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckMark checked={src.enabled && selected.has(src.id)} disabled={!src.enabled} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-text)]">{src.name}</span>
|
||||||
|
{!src.enabled && (
|
||||||
|
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1.5 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
|
||||||
|
已禁用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 border-t border-[var(--app-border)] px-5 py-3">
|
||||||
|
<span className="flex-1 text-2xs text-[var(--app-muted)]">
|
||||||
|
{allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="btn-outline text-xs">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={confirm} disabled={pickedCount === 0} className="btn-primary text-xs disabled:opacity-50">
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */
|
||||||
|
function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
|
||||||
|
checked || partial ? 'border-brand-500 bg-brand-500 text-white' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||||
|
} ${disabled ? 'opacity-40' : ''}`}
|
||||||
|
>
|
||||||
|
{checked && <Check size={11} strokeWidth={3} />}
|
||||||
|
{!checked && partial && <Minus size={11} strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,6 +81,15 @@ interface ReaderSettingsState {
|
|||||||
comicDoublePage: boolean
|
comicDoublePage: boolean
|
||||||
setComicDoublePage: (on: boolean) => void
|
setComicDoublePage: (on: boolean) => void
|
||||||
|
|
||||||
|
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
|
||||||
|
/**
|
||||||
|
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
|
||||||
|
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
|
||||||
|
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
|
||||||
|
*/
|
||||||
|
searchScopeIds: string[]
|
||||||
|
setSearchScopeIds: (ids: string[]) => void
|
||||||
|
|
||||||
fontSize: number
|
fontSize: number
|
||||||
setFontSize: (size: number) => void
|
setFontSize: (size: number) => void
|
||||||
|
|
||||||
@@ -147,6 +156,9 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
|||||||
comicDoublePage: true,
|
comicDoublePage: true,
|
||||||
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
|
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
|
||||||
|
|
||||||
|
searchScopeIds: [],
|
||||||
|
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
|
||||||
|
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
|
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user