电子书馆搜索增加流式输出功能
This commit is contained in:
@@ -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<Uint8Array>({
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<BookSource[]>([]);
|
||||
const [result, setResult] = useState<BookSearchResult>({ results: [], failedSources: [] });
|
||||
const [result, setResult] = useState<BookSearchResult>(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<EventSource | null>(null);
|
||||
const currentSearchKeyRef = useRef('');
|
||||
const pendingResultsRef = useRef<BookListItem[]>([]);
|
||||
const flushTimerRef = useRef<number | null>(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 {
|
||||
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 (
|
||||
<div className='space-y-6'>
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<form onSubmit={async (e) => { 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'>
|
||||
<form onSubmit={handleSubmit} className='space-y-3'>
|
||||
<input value={q} onChange={(e) => 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' />
|
||||
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
|
||||
<option value=''>全部书源</option>
|
||||
@@ -154,9 +340,16 @@ export default function BooksSearchPage() {
|
||||
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>搜索</button>
|
||||
</form>
|
||||
</section>
|
||||
{loading ? <SearchSkeleton /> : null}
|
||||
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果{result.results.length > 0 ? `(${result.results.length})` : ''}</h2>
|
||||
{loading && useFluidSearch && totalSources > 0 ? (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>搜索中 {completedSources}/{totalSources}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading && result.results.length === 0 ? <SearchSkeleton /> : null}
|
||||
{error ? <div className='rounded-2xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/20 dark:text-red-300'>{error}</div> : null}
|
||||
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join(';')}</div> : null}
|
||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
|
||||
</section>
|
||||
|
||||
@@ -484,17 +484,26 @@ export class OPDSClient {
|
||||
};
|
||||
}
|
||||
|
||||
async getSearchSources(sourceId?: string): Promise<BookSource[]> {
|
||||
return sourceId ? [await getSourceById(sourceId)] : (await resolveOPDSConfig()).sources;
|
||||
}
|
||||
|
||||
async searchBooksSource(q: string, source: BookSource): Promise<{ source: BookSource; results: BookListItem[] }> {
|
||||
const targetUrl = await resolveSearchTargetUrl(source, q);
|
||||
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
||||
const feed = await getFeed(source, targetUrl);
|
||||
return { source, results: feed.entries.map((entry) => mapEntryToItem(source, entry)) };
|
||||
}
|
||||
|
||||
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||
const sources = sourceId ? [await getSourceById(sourceId)] : (await resolveOPDSConfig()).sources;
|
||||
const sources = await this.getSearchSources(sourceId);
|
||||
const results: BookListItem[] = [];
|
||||
const failedSources: BookSearchFailure[] = [];
|
||||
|
||||
await Promise.all(sources.map(async (source) => {
|
||||
try {
|
||||
const targetUrl = await resolveSearchTargetUrl(source, q);
|
||||
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
||||
const feed = await getFeed(source, targetUrl);
|
||||
results.push(...feed.entries.map((entry) => mapEntryToItem(source, entry)));
|
||||
const sourceResult = await this.searchBooksSource(q, source);
|
||||
results.push(...sourceResult.results);
|
||||
} catch (error) {
|
||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user