diff --git a/src/app/api/books/search/ws/route.ts b/src/app/api/books/search/ws/route.ts new file mode 100644 index 0000000..5c40f65 --- /dev/null +++ b/src/app/api/books/search/ws/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { opdsClient } from '@/lib/opds.client'; + +import { getAuthorizedBooksUsername } 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 getAuthorizedBooksUsername(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; + + 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 opdsClient.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 opdsClient.searchBooksSource(q, source); + completedSources += 1; + totalResults += result.results.length; + send({ + type: 'source_result', + sourceId: source.id, + sourceName: source.name, + results: result.results, + completedSources, + totalSources: sources.length, + }); + } catch (error) { + const failure = { + sourceId: source.id, + sourceName: source.name, + error: error instanceof Error ? error.message : '未知错误', + }; + completedSources += 1; + 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 client disconnect races + } + } + }, + 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/books/search/page.tsx b/src/app/books/search/page.tsx index cf4eb2d..82ec067 100644 --- a/src/app/books/search/page.tsx +++ b/src/app/books/search/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { startTransition, useCallback, useEffect, useRef, useState } from 'react'; import BookCard from '@/components/books/BookCard'; import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client'; @@ -26,18 +26,31 @@ function SearchSkeleton() { } const BOOK_SEARCH_STATE_KEY = 'book_search_state'; +const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] }; export default function BooksSearchPage() { const router = useRouter(); const searchParams = useSearchParams(); - const [q, setQ] = useState(searchParams.get('q') || ''); - const [sourceId, setSourceId] = useState(searchParams.get('sourceId') || ''); + const urlQuery = searchParams.get('q') || ''; + const urlSourceId = searchParams.get('sourceId') || ''; + + const [q, setQ] = useState(urlQuery); + const [sourceId, setSourceId] = useState(urlSourceId); const [sources, setSources] = useState([]); - const [result, setResult] = useState({ results: [], failedSources: [] }); + const [result, setResult] = useState(EMPTY_RESULT); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [hasSearched, setHasSearched] = useState(false); + const [totalSources, setTotalSources] = useState(0); + const [completedSources, setCompletedSources] = useState(0); + const [useFluidSearch, setUseFluidSearch] = useState(true); + const restoredRef = useRef(false); + const forceNextUrlSearchRef = useRef(false); + const eventSourceRef = useRef(null); + const currentSearchKeyRef = useRef(''); + const pendingResultsRef = useRef([]); + const flushTimerRef = useRef(null); const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => `book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`, []); @@ -58,6 +71,47 @@ export default function BooksSearchPage() { } catch {} }, [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 {} + return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + }, []); + + const closeEventSource = useCallback(() => { + if (eventSourceRef.current) { + try { + eventSourceRef.current.close(); + } catch {} + eventSourceRef.current = null; + } + }, []); + + const clearPendingResults = useCallback(() => { + pendingResultsRef.current = []; + if (flushTimerRef.current) { + window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + }, []); + + const appendBufferedResults = useCallback((nextResults: BookListItem[]) => { + if (nextResults.length === 0) return; + pendingResultsRef.current.push(...nextResults); + if (!flushTimerRef.current) { + flushTimerRef.current = window.setTimeout(() => { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + startTransition(() => { + setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) })); + }); + flushTimerRef.current = null; + }, 80); + } + }, []); + const saveSearchState = useCallback((nextState: { q: string; sourceId: string; result: BookSearchResult }) => { if (typeof window === 'undefined') return; try { @@ -78,44 +132,152 @@ export default function BooksSearchPage() { const performSearch = useCallback(async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => { const trimmed = keyword.trim(); if (!trimmed) return; + const normalizedSourceId = selectedSourceId || ''; + const searchKey = `${normalizedSourceId}::${trimmed}`; const forceRefresh = options?.forceRefresh === true; + + closeEventSource(); + clearPendingResults(); + currentSearchKeyRef.current = searchKey; setLoading(true); setError(''); setHasSearched(true); - setResult({ results: [], failedSources: [] }); + setTotalSources(0); + setCompletedSources(0); - const cached = forceRefresh ? null : getCachedResult(trimmed, selectedSourceId); + const cached = forceRefresh ? null : getCachedResult(trimmed, normalizedSourceId); if (cached) { setResult(cached); - saveSearchState({ q: trimmed, sourceId: selectedSourceId, result: cached }); + saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: cached }); setLoading(false); + setTotalSources(1); + setCompletedSources(1); + return; + } + + setResult(EMPTY_RESULT); + + const currentFluidSearch = readFluidSearchSetting(); + setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch)); + + const params = new URLSearchParams({ q: trimmed }); + if (normalizedSourceId) params.set('sourceId', normalizedSourceId); + + if (currentFluidSearch) { + const es = new EventSource(`/api/books/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 BookListItem[]); + } + 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': { + const finalFailedSources: BookSearchResult['failedSources'] = []; + 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(() => { + setResult((prev) => { + const nextResult = { results: prev.results.concat(toAppend), failedSources: finalFailedSources }; + setCachedResult(trimmed, normalizedSourceId, nextResult); + saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult }); + return nextResult; + }); + }); + } else { + setResult((prev) => { + const nextResult = { results: prev.results, failedSources: finalFailedSources }; + setCachedResult(trimmed, normalizedSourceId, nextResult); + saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult }); + return nextResult; + }); + } + setLoading(false); + closeEventSource(); + break; + } + } + } catch {} + }; + + 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(() => { + setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) })); + }); + } + setLoading(false); + closeEventSource(); + }; return; } try { - const params = new URLSearchParams({ q: trimmed, ...(selectedSourceId ? { sourceId: selectedSourceId } : {}) }); const res = await fetch(`/api/books/search?${params.toString()}`); const json = await res.json(); + if (currentSearchKeyRef.current !== searchKey) return; if (!res.ok) throw new Error(json.error || '搜索失败'); - const nextResult: BookSearchResult = { results: json.results || [], failedSources: json.failedSources || [] }; + const nextResult: BookSearchResult = { results: json.results || [], failedSources: [] }; setResult(nextResult); - setCachedResult(trimmed, selectedSourceId, nextResult); - saveSearchState({ q: trimmed, sourceId: selectedSourceId, result: nextResult }); + setTotalSources(1); + setCompletedSources(1); + setCachedResult(trimmed, normalizedSourceId, nextResult); + saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult }); } catch (err) { + if (currentSearchKeyRef.current !== searchKey) return; setError((err as Error).message || '搜索失败'); - setResult({ results: [], failedSources: [] }); + setResult(EMPTY_RESULT); } finally { - setLoading(false); + if (currentSearchKeyRef.current === searchKey) { + setLoading(false); + } } - }, [getCachedResult, saveSearchState, setCachedResult]); + }, [appendBufferedResults, clearPendingResults, closeEventSource, getCachedResult, readFluidSearchSetting, saveSearchState, setCachedResult]); useEffect(() => { + setUseFluidSearch(readFluidSearchSetting()); fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])).catch(() => undefined); - }, []); + return () => { + closeEventSource(); + clearPendingResults(); + }; + }, [clearPendingResults, closeEventSource, readFluidSearchSetting]); useEffect(() => { - const keyword = searchParams.get('q') || ''; - const source = searchParams.get('sourceId') || ''; + const keyword = urlQuery; + const source = urlSourceId; if (!restoredRef.current) { restoredRef.current = true; @@ -124,7 +286,7 @@ export default function BooksSearchPage() { if (cachedState?.q?.trim()) { setQ(cachedState.q); setSourceId(cachedState.sourceId || ''); - setResult(cachedState.result || { results: [], failedSources: [] }); + setResult(cachedState.result || EMPTY_RESULT); setHasSearched(true); } return; @@ -134,18 +296,42 @@ export default function BooksSearchPage() { setQ(keyword); setSourceId(source); if (!keyword) { - setResult({ results: [], failedSources: [] }); + closeEventSource(); + clearPendingResults(); + setResult(EMPTY_RESULT); + setLoading(false); setHasSearched(false); + setTotalSources(0); + setCompletedSources(0); setError(''); return; } - void performSearch(keyword, source); - }, [performSearch, restoreSearchState, searchParams]); + + const forceRefresh = forceNextUrlSearchRef.current; + forceNextUrlSearchRef.current = false; + void performSearch(keyword, source, { forceRefresh }); + }, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = q.trim(); + if (!trimmed) return; + const params = new URLSearchParams(); + params.set('q', trimmed); + if (sourceId) params.set('sourceId', sourceId); + const nextUrl = `/books/search?${params.toString()}`; + if (urlQuery === trimmed && urlSourceId === sourceId) { + await performSearch(trimmed, sourceId, { forceRefresh: true }); + } else { + forceNextUrlSearchRef.current = true; + router.replace(nextUrl); + } + }; return (
-
{ e.preventDefault(); const trimmed = q.trim(); if (!trimmed) return; const params = new URLSearchParams(); params.set('q', trimmed); if (sourceId) params.set('sourceId', sourceId); router.replace(`/books/search?${params.toString()}`); await performSearch(trimmed, sourceId, { forceRefresh: true }); }} className='space-y-3'> + setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />