漫画搜索增加流式输出功能

This commit is contained in:
mtvpls
2026-05-16 11:01:21 +08:00
parent 4f0a0db0fd
commit 0d3731f07c
3 changed files with 409 additions and 81 deletions
+114
View File
@@ -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<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 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',
},
});
}
+206 -18
View File
@@ -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<EventSource | null>(null);
const currentSearchKeyRef = useRef('');
const pendingResultsRef = useRef<MangaSearchItem[]>([]);
const flushTimerRef = useRef<number | null>(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() {
</form>
<section>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-lg font-semibold'></h2>
<div className='mb-4 flex items-center justify-between gap-3'>
<h2 className='text-lg font-semibold'>{results.length > 0 ? `${results.length}` : ''}</h2>
{loading && useFluidSearch && totalSources > 0 && (
<span className='text-xs text-gray-500 dark:text-gray-400'>
{completedSources}/{totalSources}
</span>
)}
</div>
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
{loading ? (
{loading && results.length === 0 ? (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{Array.from({ length: 12 }).map((_, index) => (
<MangaCardSkeleton key={index} withButton />
+89 -63
View File
@@ -338,26 +338,39 @@ export class SuwayomiClient {
}));
}
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
async getSearchSources(sourceId?: string): Promise<Array<{ id: string; displayName?: string; name?: string }>> {
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<string>();
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<MangaSearchResult> {
const sources = await this.getSearchSources(sourceId);
const results: MangaSearchItem[] = [];
const failedSources: MangaSearchFailure[] = [];
const seen = new Set<string>();
@@ -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);
}
}