From e885705ecd5230addc3ec54e9aa4e87817590c63 Mon Sep 17 00:00:00 2001 From: shinya Date: Sun, 17 Aug 2025 00:23:15 +0800 Subject: [PATCH] feat: add websocket search --- src/app/api/search/ws/route.ts | 182 ++++++++++++++++++++++ src/app/search/page.tsx | 273 ++++++++++++++++++++++----------- src/components/VideoCard.tsx | 41 ++++- 3 files changed, 401 insertions(+), 95 deletions(-) create mode 100644 src/app/api/search/ws/route.ts diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts new file mode 100644 index 0000000..23047ef --- /dev/null +++ b/src/app/api/search/ws/route.ts @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest } from 'next/server'; + +import { getConfig } from '@/lib/config'; +import { searchFromApi } from '@/lib/downstream'; +import { yellowWords } from '@/lib/yellow'; + +export const runtime = 'edge'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + if (!query) { + return new Response( + JSON.stringify({ error: '搜索关键词不能为空' }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + const config = await getConfig(); + const apiSites = config.SourceConfig.filter((site) => !site.disabled); + + // 共享状态 + let streamClosed = false; + + // 创建可读流 + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + // 辅助函数:安全地向控制器写入数据 + const safeEnqueue = (data: Uint8Array) => { + try { + if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) { + // 流已标记为关闭或控制器已关闭 + return false; + } + controller.enqueue(data); + return true; + } catch (error) { + // 控制器已关闭或出现其他错误 + console.warn('Failed to enqueue data:', error); + streamClosed = true; + return false; + } + }; + + // 发送开始事件 + const startEvent = `data: ${JSON.stringify({ + type: 'start', + query, + totalSources: apiSites.length, + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(startEvent))) { + return; // 连接已关闭,提前退出 + } + + // 记录已完成的源数量 + let completedSources = 0; + let allResults: any[] = []; + + // 为每个源创建搜索 Promise + const searchPromises = apiSites.map(async (site, index) => { + try { + // 添加超时控制 + const searchPromise = Promise.race([ + searchFromApi(site, query), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${site.name} timeout`)), 10000) + ), + ]); + + const results = await searchPromise as any[]; + + // 过滤黄色内容 + let filteredResults = results; + if (!config.SiteConfig.DisableYellowFilter) { + filteredResults = results.filter((result) => { + const typeName = result.type_name || ''; + return !yellowWords.some((word: string) => typeName.includes(word)); + }); + } + + // 发送该源的搜索结果 + completedSources++; + + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: site.key, + sourceName: site.name, + results: filteredResults, + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(sourceEvent))) { + streamClosed = true; + return; // 连接已关闭,停止处理 + } + } + + if (filteredResults.length > 0) { + allResults.push(...filteredResults); + } + + } catch (error) { + console.warn(`搜索失败 ${site.name}:`, error); + + // 发送源错误事件 + completedSources++; + + if (!streamClosed) { + const errorEvent = `data: ${JSON.stringify({ + type: 'source_error', + source: site.key, + sourceName: site.name, + error: error instanceof Error ? error.message : '搜索失败', + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(errorEvent))) { + streamClosed = true; + return; // 连接已关闭,停止处理 + } + } + } + + // 检查是否所有源都已完成 + if (completedSources === apiSites.length) { + if (!streamClosed) { + // 发送最终完成事件 + const completeEvent = `data: ${JSON.stringify({ + type: 'complete', + totalResults: allResults.length, + completedSources, + timestamp: Date.now() + })}\n\n`; + + if (safeEnqueue(encoder.encode(completeEvent))) { + // 只有在成功发送完成事件后才关闭流 + try { + controller.close(); + } catch (error) { + console.warn('Failed to close controller:', error); + } + } + } + } + }); + + // 等待所有搜索完成 + await Promise.allSettled(searchPromises); + }, + + cancel() { + // 客户端断开连接时,标记流已关闭 + streamClosed = true; + console.log('Client disconnected, cancelling search stream'); + }, + }); + + // 返回流式响应 + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 8eb2895..5dcbcfc 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -3,7 +3,7 @@ import { ChevronUp, Search, X } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { Suspense, useEffect, useMemo, useRef, useState, startTransition } from 'react'; import { addSearchHistory, @@ -13,7 +13,7 @@ import { subscribeToDataUpdates, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; -import { yellowWords } from '@/lib/yellow'; + import PageLayout from '@/components/PageLayout'; import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter'; @@ -34,6 +34,11 @@ function SearchPageClient() { const [showResults, setShowResults] = useState(false); const [searchResults, setSearchResults] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); + const eventSourceRef = useRef(null); + const [totalSources, setTotalSources] = useState(0); + const [completedSources, setCompletedSources] = useState(0); + const pendingResultsRef = useRef([]); + const flushTimerRef = useRef(null); // 过滤器:非聚合与聚合 const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({ source: 'all', @@ -295,7 +300,101 @@ function SearchPageClient() { if (query) { setSearchQuery(query); - fetchSearchResults(query); + // 新搜索:关闭旧连接并清空结果 + if (eventSourceRef.current) { + try { eventSourceRef.current.close(); } catch { } + eventSourceRef.current = null; + } + setSearchResults([]); + setTotalSources(0); + setCompletedSources(0); + // 清理缓冲 + pendingResultsRef.current = []; + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + setIsLoading(true); + setShowResults(true); + // 打开新的流式连接 + const trimmed = query.trim(); + const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + if (!event.data) return; + try { + const payload = JSON.parse(event.data); + if (currentQueryRef.current !== trimmed) return; + switch (payload.type) { + case 'start': + setTotalSources(payload.totalSources || 0); + setCompletedSources(0); + break; + case 'source_result': { + setCompletedSources((prev) => prev + 1); + if (Array.isArray(payload.results) && payload.results.length > 0) { + // 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁 + pendingResultsRef.current.push(...payload.results); + if (!flushTimerRef.current) { + flushTimerRef.current = window.setTimeout(() => { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + startTransition(() => { + setSearchResults((prev) => prev.concat(toAppend)); + }); + flushTimerRef.current = null; + }, 80); + } + } + break; + } + case 'source_error': + setCompletedSources((prev) => prev + 1); + break; + case 'complete': + setCompletedSources(payload.completedSources || totalSources); + // 完成前确保将缓冲写入 + if (pendingResultsRef.current.length > 0) { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + startTransition(() => { + setSearchResults((prev) => prev.concat(toAppend)); + }); + } + setIsLoading(false); + try { es.close(); } catch { } + if (eventSourceRef.current === es) { + eventSourceRef.current = null; + } + break; + } + } catch { } + }; + + es.onerror = () => { + setIsLoading(false); + // 错误时也清空缓冲 + if (pendingResultsRef.current.length > 0) { + const toAppend = pendingResultsRef.current; + pendingResultsRef.current = []; + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + } + startTransition(() => { + setSearchResults((prev) => prev.concat(toAppend)); + }); + } + try { es.close(); } catch { } + if (eventSourceRef.current === es) { + eventSourceRef.current = null; + } + }; setShowSuggestions(false); // 保存到搜索历史 (事件监听会自动更新界面) @@ -306,42 +405,20 @@ function SearchPageClient() { } }, [searchParams]); - const fetchSearchResults = async (query: string) => { - // 在函数开始时缓存查询参数 - const cachedQuery = query.trim(); - - try { - setIsLoading(true); - const response = await fetch( - `/api/search?q=${encodeURIComponent(cachedQuery)}` - ); - const data = await response.json(); - let results = data.results; - if ( - typeof window !== 'undefined' && - !(window as any).RUNTIME_CONFIG?.DISABLE_YELLOW_FILTER - ) { - results = results.filter((result: SearchResult) => { - const typeName = result.type_name || ''; - return !yellowWords.some((word: string) => typeName.includes(word)); - }); + // 组件卸载时,关闭可能存在的连接 + useEffect(() => { + return () => { + if (eventSourceRef.current) { + try { eventSourceRef.current.close(); } catch { } + eventSourceRef.current = null; } - - // 在 setSearchResults 之前检查当前页面的 query 与缓存的查询是否一致 - if (currentQueryRef.current !== cachedQuery) { - // 查询已经改变,不需要设置结果,直接返回 - return; + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; } - - // 不在这里进行排序,让 filteredAllResults 和 filteredAggResults 处理所有排序逻辑 - setSearchResults(results); - setShowResults(true); - } catch (error) { - setSearchResults([]); - } finally { - setIsLoading(false); - } - }; + pendingResultsRef.current = []; + }; + }, []); // 输入框内容变化时触发,显示搜索建议 const handleInputChange = (e: React.ChangeEvent) => { @@ -375,11 +452,7 @@ function SearchPageClient() { setShowSuggestions(false); router.push(`/search?q=${encodeURIComponent(trimmed)}`); - // 直接发请求 - fetchSearchResults(trimmed); - - // 保存到搜索历史 (事件监听会自动更新界面) - addSearchHistory(trimmed); + // 其余由 searchParams 变化的 effect 处理 }; const handleSuggestionSelect = (suggestion: string) => { @@ -391,8 +464,7 @@ function SearchPageClient() { setShowResults(true); router.push(`/search?q=${encodeURIComponent(suggestion)}`); - fetchSearchResults(suggestion); - addSearchHistory(suggestion); + // 其余由 searchParams 变化的 effect 处理 }; // 返回顶部功能 @@ -440,15 +512,23 @@ function SearchPageClient() { {/* 搜索结果或搜索历史 */}
- {isLoading ? ( -
-
-
- ) : showResults ? ( + {showResults ? (
{/* 标题 */}
-

搜索结果

+

+ 搜索结果 + {searchResults.length > 0 && totalSources > 0 && ( + + {completedSources}/{totalSources} + + )} + {searchResults.length > 0 && isLoading && ( + + + + )} +

{/* 筛选器 + 聚合开关 同行 */}
@@ -482,56 +562,63 @@ function SearchPageClient() {
-
- {viewMode === 'agg' - ? filteredAggResults.map(([mapKey, group]) => { - return ( -
+ {searchResults.length === 0 ? ( + isLoading ? ( +
+
+
+ ) : ( +
+ 未找到相关结果 +
+ ) + ) : ( +
+ {viewMode === 'agg' + ? filteredAggResults.map(([mapKey, group]) => { + return ( +
+ +
+ ); + }) + : filteredAllResults.map((item) => ( +
1 ? 'tv' : 'movie'} />
- ); - }) - : filteredAllResults.map((item) => ( -
- 1 ? 'tv' : 'movie'} - /> -
- ))} - {searchResults.length === 0 && ( -
- 未找到相关结果 -
- )} -
+ ))} +
+ )} ) : searchHistory.length > 0 ? ( // 搜索历史 diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 0acbe6c..96e5584 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -3,7 +3,7 @@ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { deleteFavorite, @@ -38,7 +38,7 @@ interface VideoCardProps { isBangumi?: boolean; } -export default function VideoCard({ +function VideoCard({ id, title = '', query = '', @@ -478,3 +478,40 @@ export default function VideoCard({
); } + +// 自定义 props 比较,避免不必要的重渲染,减少卡片闪烁 +function arePropsEqual(prev: VideoCardProps, next: VideoCardProps) { + // 基础字段对比 + const basicEqual = + prev.id === next.id && + prev.title === next.title && + prev.query === next.query && + prev.poster === next.poster && + prev.episodes === next.episodes && + prev.source === next.source && + prev.source_name === next.source_name && + prev.year === next.year && + prev.from === next.from && + prev.type === next.type && + prev.douban_id === next.douban_id; + + if (!basicEqual) return false; + + // 聚合 items 仅对比长度与首个元素的关键标识,避免每次新数组导致重渲染 + const prevLen = prev.items?.length || 0; + const nextLen = next.items?.length || 0; + if (prevLen !== nextLen) return false; + if (prevLen === 0) return true; + + const prevFirst = prev.items![0]; + const nextFirst = next.items![0]; + return ( + prevFirst.id === nextFirst.id && + prevFirst.source === nextFirst.source && + prevFirst.title === nextFirst.title && + prevFirst.poster === nextFirst.poster && + prevFirst.year === nextFirst.year + ); +} + +export default memo(VideoCard, arePropsEqual);