From 0d3731f07cb73cdae244460ec04677707677fb29 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 16 May 2026 11:01:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BC=AB=E7=94=BB=E6=90=9C=E7=B4=A2=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=B5=81=E5=BC=8F=E8=BE=93=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/manga/search/ws/route.ts | 114 ++++++++++++++ src/app/manga/search/page.tsx | 224 ++++++++++++++++++++++++--- src/lib/suwayomi.client.ts | 152 ++++++++++-------- 3 files changed, 409 insertions(+), 81 deletions(-) create mode 100644 src/app/api/manga/search/ws/route.ts diff --git a/src/app/api/manga/search/ws/route.ts b/src/app/api/manga/search/ws/route.ts new file mode 100644 index 0000000..e79a8e2 --- /dev/null +++ b/src/app/api/manga/search/ws/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { suwayomiClient } from '@/lib/suwayomi.client'; + +import { getAuthorizedUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +function sse(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +export async function GET(request: NextRequest) { + const username = await getAuthorizedUsername(request); + if (username instanceof NextResponse) return username; + + const { searchParams } = new URL(request.url); + const q = searchParams.get('q')?.trim(); + const sourceId = searchParams.get('sourceId')?.trim() || undefined; + const page = Number(searchParams.get('page') || '1'); + + if (!q) { + return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 }); + } + + const encoder = new TextEncoder(); + let closed = false; + + const stream = new ReadableStream({ + async start(controller) { + const send = (payload: unknown) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(sse(payload))); + } catch { + closed = true; + } + }; + + try { + const sources = await suwayomiClient.getSearchSources(sourceId); + let completedSources = 0; + let totalResults = 0; + const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = []; + + send({ type: 'start', totalSources: sources.length }); + + await Promise.all( + sources.map(async (source) => { + try { + const result = await suwayomiClient.searchMangaSource(q, source, page); + completedSources += 1; + totalResults += result.results.length; + send({ + type: 'source_result', + sourceId: String(source.id), + sourceName: source.displayName || source.name || String(source.id), + results: result.results, + completedSources, + totalSources: sources.length, + }); + } catch (error) { + const message = error instanceof Error ? error.message : '未知错误'; + completedSources += 1; + const failure = { + sourceId: String(source.id), + sourceName: source.displayName || source.name || String(source.id), + error: message, + }; + failedSources.push(failure); + send({ + type: 'source_error', + ...failure, + completedSources, + totalSources: sources.length, + }); + } + }) + ); + + send({ + type: 'complete', + completedSources, + totalSources: sources.length, + totalResults, + failedSources, + }); + } catch (error) { + send({ + type: 'error', + error: error instanceof Error ? error.message : '搜索失败', + }); + } finally { + closed = true; + try { + controller.close(); + } catch { + // ignore close races when the client disconnects + } + } + }, + cancel() { + closed = true; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/src/app/manga/search/page.tsx b/src/app/manga/search/page.tsx index 8036701..548ed79 100644 --- a/src/app/manga/search/page.tsx +++ b/src/app/manga/search/page.tsx @@ -2,7 +2,7 @@ import { Search } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client'; import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types'; @@ -43,6 +43,14 @@ export default function MangaSearchPage() { const [lastSearchedQuery, setLastSearchedQuery] = useState(''); const [lastSearchedSourceId, setLastSearchedSourceId] = useState(''); const restoredRef = useRef(false); + const forceNextUrlSearchRef = useRef(false); + const eventSourceRef = useRef(null); + const currentSearchKeyRef = useRef(''); + const pendingResultsRef = useRef([]); + const flushTimerRef = useRef(null); + const [totalSources, setTotalSources] = useState(0); + const [completedSources, setCompletedSources] = useState(0); + const [useFluidSearch, setUseFluidSearch] = useState(true); const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => { return `manga_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`; @@ -73,6 +81,52 @@ export default function MangaSearchPage() { [getCacheKey] ); + + const readFluidSearchSetting = useCallback(() => { + if (typeof window === 'undefined') return true; + try { + const savedFluidSearch = localStorage.getItem('fluidSearch'); + if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false; + } catch { + // ignore invalid localStorage values + } + return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + }, []); + + const closeEventSource = useCallback(() => { + if (eventSourceRef.current) { + try { + eventSourceRef.current.close(); + } catch { + // ignore close failures + } + eventSourceRef.current = null; + } + }, []); + + const clearPendingResults = useCallback(() => { + pendingResultsRef.current = []; + if (flushTimerRef.current) { + window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + }, []); + + const appendBufferedResults = useCallback((nextResults: MangaSearchItem[]) => { + if (nextResults.length === 0) return; + pendingResultsRef.current.push(...nextResults); + if (!flushTimerRef.current) { + flushTimerRef.current = window.setTimeout(() => { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + startTransition(() => { + setResults((prev) => prev.concat(toAppend)); + }); + flushTimerRef.current = null; + }, 80); + } + }, []); + const saveSearchState = useCallback((nextState: { query: string; sourceId: string; results: MangaSearchItem[] }) => { if (typeof window === 'undefined') return; try { @@ -99,52 +153,169 @@ export default function MangaSearchPage() { }, []); useEffect(() => { + setUseFluidSearch(readFluidSearchSetting()); + fetch('/api/manga/sources') .then((res) => res.json()) .then((data) => setSources(data.sources || [])) .catch(() => undefined); getAllMangaShelf().then(setShelf).catch(() => undefined); - }, []); + + return () => { + closeEventSource(); + clearPendingResults(); + }; + }, [clearPendingResults, closeEventSource, readFluidSearchSetting]); const performSearch = useCallback( async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => { const trimmedQuery = keyword.trim(); if (!trimmedQuery) return; + const normalizedSourceId = selectedSourceId || ''; + const searchKey = `${normalizedSourceId}::${trimmedQuery}`; const forceRefresh = options?.forceRefresh === true; + closeEventSource(); + clearPendingResults(); + currentSearchKeyRef.current = searchKey; + setLoading(true); setError(''); setHasSearched(true); setLastSearchedQuery(trimmedQuery); - setLastSearchedSourceId(selectedSourceId); + setLastSearchedSourceId(normalizedSourceId); + setTotalSources(0); + setCompletedSources(0); - const cached = forceRefresh ? null : getCachedResults(trimmedQuery, selectedSourceId); + const cached = forceRefresh ? null : getCachedResults(trimmedQuery, normalizedSourceId); if (cached) { setResults(cached); - saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached }); + saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: cached }); setLoading(false); + setTotalSources(1); + setCompletedSources(1); + return; + } + + setResults([]); + + const currentFluidSearch = readFluidSearchSetting(); + setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch)); + + const params = new URLSearchParams({ q: trimmedQuery }); + if (normalizedSourceId) params.set('sourceId', normalizedSourceId); + + if (currentFluidSearch) { + const es = new EventSource(`/api/manga/search/ws?${params.toString()}`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + if (!event.data || currentSearchKeyRef.current !== searchKey) return; + try { + const payload = JSON.parse(event.data); + switch (payload.type) { + case 'start': + setTotalSources(payload.totalSources || 0); + setCompletedSources(0); + break; + case 'source_result': + setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0)); + if (Array.isArray(payload.results) && payload.results.length > 0) { + appendBufferedResults(payload.results as MangaSearchItem[]); + } + break; + case 'source_error': + setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0)); + break; + case 'error': + setError(payload.error || '搜索失败'); + setLoading(false); + closeEventSource(); + break; + case 'complete': { + setCompletedSources(payload.completedSources || payload.totalSources || 0); + if (pendingResultsRef.current.length > 0) { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + if (flushTimerRef.current) { + window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + startTransition(() => { + setResults((prev) => { + const nextResults = prev.concat(toAppend); + setCachedResults(trimmedQuery, normalizedSourceId, nextResults); + saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: nextResults }); + return nextResults; + }); + }); + } else { + setResults((prev) => { + setCachedResults(trimmedQuery, normalizedSourceId, prev); + saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: prev }); + return prev; + }); + } + setLoading(false); + closeEventSource(); + break; + } + } + } catch { + // ignore malformed SSE payloads + } + }; + + es.onerror = () => { + if (currentSearchKeyRef.current !== searchKey) return; + if (pendingResultsRef.current.length > 0) { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + if (flushTimerRef.current) { + window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + startTransition(() => { + setResults((prev) => prev.concat(toAppend)); + }); + } + setLoading(false); + closeEventSource(); + }; return; } try { - const params = new URLSearchParams({ q: trimmedQuery }); - if (selectedSourceId) params.set('sourceId', selectedSourceId); const res = await fetch(`/api/manga/search?${params.toString()}`); const data = await res.json(); + if (currentSearchKeyRef.current !== searchKey) return; if (!res.ok) throw new Error(data.error || '搜索失败'); const nextResults = data.results || []; setResults(nextResults); - setCachedResults(trimmedQuery, selectedSourceId, nextResults); - saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: nextResults }); + setTotalSources(1); + setCompletedSources(1); + setCachedResults(trimmedQuery, normalizedSourceId, nextResults); + saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: nextResults }); } catch (err) { + if (currentSearchKeyRef.current !== searchKey) return; setError((err as Error).message); setResults([]); } finally { - setLoading(false); + if (currentSearchKeyRef.current === searchKey) { + setLoading(false); + } } }, - [getCachedResults, saveSearchState, setCachedResults] + [ + appendBufferedResults, + clearPendingResults, + closeEventSource, + getCachedResults, + readFluidSearchSetting, + saveSearchState, + setCachedResults, + ] ); useEffect(() => { @@ -169,16 +340,23 @@ export default function MangaSearchPage() { setSourceId(urlSourceId); if (!urlQuery) { + closeEventSource(); + clearPendingResults(); setResults([]); + setLoading(false); setHasSearched(false); setLastSearchedQuery(''); setLastSearchedSourceId(''); + setTotalSources(0); + setCompletedSources(0); setError(''); return; } - void performSearch(urlQuery, urlSourceId); - }, [performSearch, restoreSearchState, urlQuery, urlSourceId]); + const forceRefresh = forceNextUrlSearchRef.current; + forceNextUrlSearchRef.current = false; + void performSearch(urlQuery, urlSourceId, { forceRefresh }); + }, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]); const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); @@ -187,8 +365,13 @@ export default function MangaSearchPage() { const params = new URLSearchParams({ q: trimmedQuery }); if (sourceId) params.set('sourceId', sourceId); - router.replace(`/manga/search?${params.toString()}`); - await performSearch(trimmedQuery, sourceId, { forceRefresh: true }); + const nextUrl = `/manga/search?${params.toString()}`; + if (urlQuery === trimmedQuery && urlSourceId === sourceId) { + await performSearch(trimmedQuery, sourceId, { forceRefresh: true }); + } else { + forceNextUrlSearchRef.current = true; + router.replace(nextUrl); + } }; const returnTo = useMemo(() => { @@ -257,11 +440,16 @@ export default function MangaSearchPage() {
-
-

搜索结果

+
+

搜索结果{results.length > 0 ? `(${results.length})` : ''}

+ {loading && useFluidSearch && totalSources > 0 && ( + + 搜索中 {completedSources}/{totalSources} + + )}
{error &&
{error}
} - {loading ? ( + {loading && results.length === 0 ? (
{Array.from({ length: 12 }).map((_, index) => ( diff --git a/src/lib/suwayomi.client.ts b/src/lib/suwayomi.client.ts index 0ad288d..d00baa0 100644 --- a/src/lib/suwayomi.client.ts +++ b/src/lib/suwayomi.client.ts @@ -338,26 +338,39 @@ export class SuwayomiClient { })); } - async searchManga(keyword: string, sourceId?: string, page = 1): Promise { + async getSearchSources(sourceId?: string): Promise> { const resolved = await resolveSuwayomiConfig(this.options); - let sources: Array<{ id: string; displayName?: string; name?: string }>; + if (sourceId) { - sources = [{ id: sourceId, displayName: sourceId, name: sourceId }]; - } else { - try { - sources = (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources); - } catch (error) { - if (resolved.sourceIds.length === 0) { - throw error; - } - sources = resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({ - id, - displayName: id, - name: id, - })); - } + const matched = (await this.getSources()).find((item) => item.id === sourceId); + return [ + { + id: sourceId, + displayName: matched?.displayName || matched?.name || sourceId, + name: matched?.name || matched?.displayName || sourceId, + }, + ]; } + try { + return (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources); + } catch (error) { + if (resolved.sourceIds.length === 0) { + throw error; + } + return resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({ + id, + displayName: id, + name: id, + })); + } + } + + async searchMangaSource( + keyword: string, + source: { id: string; displayName?: string; name?: string }, + page = 1 + ): Promise<{ source: { id: string; displayName?: string; name?: string }; results: MangaSearchItem[] }> { const query = ` mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) { fetchSourceManga(input: $input) { @@ -376,6 +389,60 @@ export class SuwayomiClient { } `; + const data = await this.graphqlRequest<{ + fetchSourceManga?: { + mangas?: Array<{ + id: string | number; + title?: string; + thumbnailUrl?: string; + sourceId?: string | number; + description?: string; + author?: string; + artist?: string; + genre?: string; + status?: string; + }>; + }; + }>( + query, + { + input: { + type: 'SEARCH', + source: source.id, + query: keyword, + page, + }, + }, + 'GET_SOURCE_MANGAS_FETCH' + ); + + const seen = new Set(); + const sourceName = source.displayName || source.name || String(source.id); + const results = (data.fetchSourceManga?.mangas || []) + .filter((manga) => { + const key = `${source.id}:${manga.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map((manga) => ({ + id: String(manga.id), + sourceId: String(manga.sourceId || source.id), + sourceName, + title: manga.title || '未命名漫画', + cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''), + description: manga.description, + author: manga.author, + artist: manga.artist, + genre: manga.genre, + status: normalizeMangaStatus(manga.status), + })); + + return { source, results }; + } + + async searchManga(keyword: string, sourceId?: string, page = 1): Promise { + const sources = await this.getSearchSources(sourceId); const results: MangaSearchItem[] = []; const failedSources: MangaSearchFailure[] = []; const seen = new Set(); @@ -383,37 +450,7 @@ export class SuwayomiClient { const perSourceResults = await Promise.all( sources.map(async (source) => { try { - const data = await this.graphqlRequest<{ - fetchSourceManga?: { - mangas?: Array<{ - id: string | number; - title?: string; - thumbnailUrl?: string; - sourceId?: string | number; - description?: string; - author?: string; - artist?: string; - genre?: string; - status?: string; - }>; - }; - }>( - query, - { - input: { - type: 'SEARCH', - source: source.id, - query: keyword, - page, - }, - }, - 'GET_SOURCE_MANGAS_FETCH' - ); - - return { - source, - mangas: data.fetchSourceManga?.mangas || [], - }; + return await this.searchMangaSource(keyword, source, page); } catch (error) { const message = error instanceof Error ? error.message : '未知错误'; console.warn(`[Suwayomi] manga search source failed: ${source.id} - ${message}`); @@ -424,29 +461,18 @@ export class SuwayomiClient { }); return { source, - mangas: [], + results: [], }; } }) ); - for (const { source, mangas } of perSourceResults) { - for (const manga of mangas) { - const key = `${source.id}:${manga.id}`; + for (const { results: sourceResults } of perSourceResults) { + for (const manga of sourceResults) { + const key = `${manga.sourceId}:${manga.id}`; if (seen.has(key)) continue; seen.add(key); - results.push({ - id: String(manga.id), - sourceId: String(manga.sourceId || source.id), - sourceName: source.displayName || source.name || String(source.id), - title: manga.title || '未命名漫画', - cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''), - description: manga.description, - author: manga.author, - artist: manga.artist, - genre: manga.genre, - status: normalizeMangaStatus(manga.status), - }); + results.push(manga); } }