diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index b4d4a12..2cc38f3 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -333,13 +333,15 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc { func readerSearchHandler(svc *service.Container) gin.HandlerFunc { var body struct { Key string `json:"key" binding:"required"` + // SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。 + SourceIDs []string `json:"source_ids"` } 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) + books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs) 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 b894946..fd48526 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, "会员"); err != nil { + if books, _, err := svc.Search(ctx, "会员", nil); 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, "会员") + books, skipped, err := svc.Search(ctx, "会员", nil) 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, "会员"); err != nil { + if books, _, err := svc.Search(ctx, "会员", nil); 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, "任意"); err != nil { + if _, _, err := svc.Search(ctx, "任意", nil); 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, "任意"); err != nil { + if _, _, err := svc.Search(ctx, "任意", nil); 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 1212c9b..bdab9b2 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -737,17 +737,17 @@ type searchHit struct { } // 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) if err != nil { return nil, nil, err } - var enabled []model.ReaderBookSource - for _, src := range sources { - if src.Enabled { - enabled = append(enabled, src) - } - } + enabled := enabledSourcesForScope(sources, sourceIDs) if len(enabled) == 0 { 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 } +// 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 搜索失败的书源与原因(书源管理调试用)。 type SearchSkipped struct { SourceID string `json:"source_id"` diff --git a/internal/service/reader/search_query_test.go b/internal/service/reader/search_query_test.go index f835902..d16d803 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(), "宠魅") + books, skipped, err := svc.Search(t.Context(), "宠魅", nil) 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(), "宠魅") + books, skipped, err := svc.Search(t.Context(), "宠魅", nil) 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(), "宠魅"); err != nil { + if _, _, err := svc.Search(t.Context(), "宠魅", nil); 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 new file mode 100644 index 0000000..6f919fc --- /dev/null +++ b/internal/service/reader/search_scope_test.go @@ -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) + } +} diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts index 3a80dfe..f25e11e 100644 --- a/web/src/api/reader.ts +++ b/web/src/api/reader.ts @@ -245,11 +245,13 @@ export const readerAPI = { setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }), // ── 搜索 / 详情 / 目录 / 正文 ── - search: (key: string) => + // 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认); + // 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。 + search: (key: string, sourceIds: string[] = []) => api .post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>( '/reader/search', - { key }, + { key, source_ids: sourceIds }, longOpts, ) .then((r) => r.data), diff --git a/web/src/pages/reader/ReaderSearchPage.tsx b/web/src/pages/reader/ReaderSearchPage.tsx index 3685e81..aa6c65f 100644 --- a/web/src/pages/reader/ReaderSearchPage.tsx +++ b/web/src/pages/reader/ReaderSearchPage.tsx @@ -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 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 { useReaderSettingsStore } from '../../stores/readerSettings' import { splitKindTags } from '../../utils/kindTags' +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 +} + +/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */ +function isOnShelf(book: ReaderSearchBook, shelf: Set): 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() { const navigate = useNavigate() @@ -23,13 +41,60 @@ export default function ReaderSearchPage() { const [adding, setAdding] = useState('') const inputRef = useRef(null) + // 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。 + const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds) + const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds) + const [sources, setSources] = useState([]) + 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>(() => new Set()) + useEffect(() => { + readerAPI + .listBooks() + .then((books) => { + const keys = new Set() + 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 kw = key.trim() if (!kw || searching) return setSearching(true) setShowSkipped(false) try { - const res = await readerAPI.search(kw) + // 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用, + // 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。 + const res = await readerAPI.search(kw, scopeIds) setBooks(res.books ?? []) // 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组, // 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。 @@ -52,12 +117,20 @@ export default function ReaderSearchPage() { // 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 setAdding(book.book_url) try { await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url }) 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) { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败' toast.error(msg) @@ -113,6 +186,22 @@ export default function ReaderSearchPage() { + {/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */} +
+ + {scopeAll && enabledSources.length > 0 && ( + 共 {enabledSources.length} 个启用书源 + )} +
+ {searching && (
@@ -120,9 +209,19 @@ export default function ReaderSearchPage() {
)} - {!searching && books !== null && ( + {!searching && visibleBooks !== null && (
- {books.map((book) => ( + {hiddenOnShelf > 0 && visibleBooks.length > 0 && ( +

+ 已隐藏 {hiddenOnShelf} 本已在书架里的书 +

+ )} + {visibleBooks.length === 0 && ( +

+ {hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'} +

+ )} + {visibleBooks.map((book) => (
setPicker(null)} /> )} + + {showScope && ( + { + setSearchScopeIds(ids) + setShowScope(false) + }} + onClose={() => setShowScope(false)} + /> + )}
) } diff --git a/web/src/pages/reader/SearchScopeDialog.tsx b/web/src/pages/reader/SearchScopeDialog.tsx new file mode 100644 index 0000000..54558b2 --- /dev/null +++ b/web/src/pages/reader/SearchScopeDialog.tsx @@ -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>(() => { + 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() + 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 ( +
+
event.stopPropagation()} + > +
+
+

搜索范围

+

+ 默认搜索全部启用书源 · 已启用 {enabledIds.length} 个 +

+
+ +
+ +
+
+ + setFilter(e.target.value)} + placeholder="过滤书源名 / 分组" + className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]" + /> +
+ + +
+ +
+ {sources.length === 0 ? ( +

还没有书源,先去书源管理导入

+ ) : groups.length === 0 ? ( +

没有匹配「{filter.trim()}」的书源

+ ) : ( +
+ {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 ( +
+ +
+ {group.items.map((src) => ( + + ))} +
+
+ ) + })} +
+ )} +
+ +
+ + {allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`} + + + +
+
+
+ ) +} + +/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */ +function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) { + return ( + + {checked && } + {!checked && partial && } + + ) +} diff --git a/web/src/stores/readerSettings.ts b/web/src/stores/readerSettings.ts index 7acdfac..e1de165 100644 --- a/web/src/stores/readerSettings.ts +++ b/web/src/stores/readerSettings.ts @@ -81,6 +81,15 @@ interface ReaderSettingsState { comicDoublePage: boolean setComicDoublePage: (on: boolean) => void + // ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)── + /** + * 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。 + * 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把 + * searchScope 存在本机 AppConfig),换设备重新默认全选即可。 + */ + searchScopeIds: string[] + setSearchScopeIds: (ids: string[]) => void + fontSize: number setFontSize: (size: number) => void @@ -147,6 +156,9 @@ export const useReaderSettingsStore = create()( comicDoublePage: true, setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }), + searchScopeIds: [], + setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }), + fontSize: 20, setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),