mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-05-20 12:57:29 +08:00
feat: add websocket search
This commit is contained in:
182
src/app/api/search/ws/route.ts
Normal file
182
src/app/api/search/ws/route.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { ChevronUp, Search, X } from 'lucide-react';
|
import { ChevronUp, Search, X } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useEffect, useMemo, useRef, useState, startTransition } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addSearchHistory,
|
addSearchHistory,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
subscribeToDataUpdates,
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
import { yellowWords } from '@/lib/yellow';
|
|
||||||
|
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||||
@@ -34,6 +34,11 @@ function SearchPageClient() {
|
|||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const [totalSources, setTotalSources] = useState(0);
|
||||||
|
const [completedSources, setCompletedSources] = useState(0);
|
||||||
|
const pendingResultsRef = useRef<SearchResult[]>([]);
|
||||||
|
const flushTimerRef = useRef<number | null>(null);
|
||||||
// 过滤器:非聚合与聚合
|
// 过滤器:非聚合与聚合
|
||||||
const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||||
source: 'all',
|
source: 'all',
|
||||||
@@ -295,7 +300,101 @@ function SearchPageClient() {
|
|||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
setSearchQuery(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);
|
setShowSuggestions(false);
|
||||||
|
|
||||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||||
@@ -306,42 +405,20 @@ function SearchPageClient() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const fetchSearchResults = async (query: string) => {
|
// 组件卸载时,关闭可能存在的连接
|
||||||
// 在函数开始时缓存查询参数
|
useEffect(() => {
|
||||||
const cachedQuery = query.trim();
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
try {
|
try { eventSourceRef.current.close(); } catch { }
|
||||||
setIsLoading(true);
|
eventSourceRef.current = null;
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (flushTimerRef.current) {
|
||||||
// 在 setSearchResults 之前检查当前页面的 query 与缓存的查询是否一致
|
clearTimeout(flushTimerRef.current);
|
||||||
if (currentQueryRef.current !== cachedQuery) {
|
flushTimerRef.current = null;
|
||||||
// 查询已经改变,不需要设置结果,直接返回
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
pendingResultsRef.current = [];
|
||||||
// 不在这里进行排序,让 filteredAllResults 和 filteredAggResults 处理所有排序逻辑
|
};
|
||||||
setSearchResults(results);
|
}, []);
|
||||||
setShowResults(true);
|
|
||||||
} catch (error) {
|
|
||||||
setSearchResults([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 输入框内容变化时触发,显示搜索建议
|
// 输入框内容变化时触发,显示搜索建议
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -375,11 +452,7 @@ function SearchPageClient() {
|
|||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
|
||||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
// 直接发请求
|
// 其余由 searchParams 变化的 effect 处理
|
||||||
fetchSearchResults(trimmed);
|
|
||||||
|
|
||||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
|
||||||
addSearchHistory(trimmed);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggestionSelect = (suggestion: string) => {
|
const handleSuggestionSelect = (suggestion: string) => {
|
||||||
@@ -391,8 +464,7 @@ function SearchPageClient() {
|
|||||||
setShowResults(true);
|
setShowResults(true);
|
||||||
|
|
||||||
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
|
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
|
||||||
fetchSearchResults(suggestion);
|
// 其余由 searchParams 变化的 effect 处理
|
||||||
addSearchHistory(suggestion);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 返回顶部功能
|
// 返回顶部功能
|
||||||
@@ -440,15 +512,23 @@ function SearchPageClient() {
|
|||||||
|
|
||||||
{/* 搜索结果或搜索历史 */}
|
{/* 搜索结果或搜索历史 */}
|
||||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||||
{isLoading ? (
|
{showResults ? (
|
||||||
<div className='flex justify-center items-center h-40'>
|
|
||||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
|
||||||
</div>
|
|
||||||
) : showResults ? (
|
|
||||||
<section className='mb-12'>
|
<section className='mb-12'>
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<div className='mb-4'>
|
<div className='mb-4'>
|
||||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>搜索结果</h2>
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
搜索结果
|
||||||
|
{searchResults.length > 0 && totalSources > 0 && (
|
||||||
|
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
|
||||||
|
{completedSources}/{totalSources}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{searchResults.length > 0 && isLoading && (
|
||||||
|
<span className='ml-2 inline-block align-middle'>
|
||||||
|
<span className='inline-block h-3 w-3 border-2 border-gray-300 border-t-green-500 rounded-full animate-spin'></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{/* 筛选器 + 聚合开关 同行 */}
|
{/* 筛选器 + 聚合开关 同行 */}
|
||||||
<div className='mb-8 flex items-center justify-between gap-3'>
|
<div className='mb-8 flex items-center justify-between gap-3'>
|
||||||
@@ -482,56 +562,63 @@ function SearchPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
{searchResults.length === 0 ? (
|
||||||
key={`search-results-${viewMode}`}
|
isLoading ? (
|
||||||
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
|
<div className='flex justify-center items-center h-40'>
|
||||||
>
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||||
{viewMode === 'agg'
|
</div>
|
||||||
? filteredAggResults.map(([mapKey, group]) => {
|
) : (
|
||||||
return (
|
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||||
<div key={`agg-${mapKey}`} className='w-full'>
|
未找到相关结果
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={`search-results-${viewMode}`}
|
||||||
|
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
|
||||||
|
>
|
||||||
|
{viewMode === 'agg'
|
||||||
|
? filteredAggResults.map(([mapKey, group]) => {
|
||||||
|
return (
|
||||||
|
<div key={`agg-${mapKey}`} className='w-full'>
|
||||||
|
<VideoCard
|
||||||
|
from='search'
|
||||||
|
items={group}
|
||||||
|
query={
|
||||||
|
searchQuery.trim() !== group[0].title
|
||||||
|
? searchQuery.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: filteredAllResults.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`all-${item.source}-${item.id}`}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
<VideoCard
|
<VideoCard
|
||||||
from='search'
|
id={item.id}
|
||||||
items={group}
|
title={item.title}
|
||||||
|
poster={item.poster}
|
||||||
|
episodes={item.episodes.length}
|
||||||
|
source={item.source}
|
||||||
|
source_name={item.source_name}
|
||||||
|
douban_id={item.douban_id}
|
||||||
query={
|
query={
|
||||||
searchQuery.trim() !== group[0].title
|
searchQuery.trim() !== item.title
|
||||||
? searchQuery.trim()
|
? searchQuery.trim()
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
year={item.year}
|
||||||
|
from='search'
|
||||||
|
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})
|
</div>
|
||||||
: filteredAllResults.map((item) => (
|
)}
|
||||||
<div
|
|
||||||
key={`all-${item.source}-${item.id}`}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
<VideoCard
|
|
||||||
id={item.id}
|
|
||||||
title={item.title}
|
|
||||||
poster={item.poster}
|
|
||||||
episodes={item.episodes.length}
|
|
||||||
source={item.source}
|
|
||||||
source_name={item.source_name}
|
|
||||||
douban_id={item.douban_id}
|
|
||||||
query={
|
|
||||||
searchQuery.trim() !== item.title
|
|
||||||
? searchQuery.trim()
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
year={item.year}
|
|
||||||
from='search'
|
|
||||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{searchResults.length === 0 && (
|
|
||||||
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
|
||||||
未找到相关结果
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
) : searchHistory.length > 0 ? (
|
) : searchHistory.length > 0 ? (
|
||||||
// 搜索历史
|
// 搜索历史
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
|
import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
@@ -38,7 +38,7 @@ interface VideoCardProps {
|
|||||||
isBangumi?: boolean;
|
isBangumi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoCard({
|
function VideoCard({
|
||||||
id,
|
id,
|
||||||
title = '',
|
title = '',
|
||||||
query = '',
|
query = '',
|
||||||
@@ -478,3 +478,40 @@ export default function VideoCard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user