mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 05:16:38 +08:00
优化
This commit is contained in:
@@ -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