diff --git a/README.md b/README.md index 945e7ae..57eb4a8 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ networks: | NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | | direct | | NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | +| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 154f2ba..4dcbffa 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -67,6 +67,7 @@ interface SiteConfig { DoubanImageProxyType: string; DoubanImageProxy: string; DisableYellowFilter: boolean; + FluidSearch: boolean; } // 视频源数据类型 @@ -1284,7 +1285,7 @@ const CategoryConfig = ({ }; // 新增配置文件组件 -const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminConfig | null; refreshConfig: () => Promise; role: 'owner' | 'admin' | null }) => { +const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => { const [configContent, setConfigContent] = useState(''); const [saving, setSaving] = useState(false); const [subscriptionUrl, setSubscriptionUrl] = useState(''); @@ -1292,8 +1293,7 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon const [fetching, setFetching] = useState(false); const [lastCheckTime, setLastCheckTime] = useState(''); - // 检查是否为站长 - const isOwner = role === 'owner'; + useEffect(() => { if (config?.ConfigFile) { @@ -1386,22 +1386,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon return (
- {/* 非站长用户权限提示 */} - {!isOwner && ( -
-
-
- ! -
-

- 配置文件模块仅站长可编辑,您只能查看配置内容 -

-
-
- )} - {/* 配置订阅区域 */} -
+

配置订阅 @@ -1422,8 +1408,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon value={subscriptionUrl} onChange={(e) => setSubscriptionUrl(e.target.value)} placeholder='https://example.com/config.json' - disabled={!isOwner} - className={`w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${!isOwner ? 'cursor-not-allowed bg-gray-100 dark:bg-gray-700' : ''}`} + disabled={false} + className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500' />

输入配置文件的订阅地址,要求 JSON 格式,且使用 Base58 编码 @@ -1434,8 +1420,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon

+ {/* 流式搜索 */} +
+
+ + +
+

+ 启用后搜索结果将实时流式返回,提升用户体验。 +

+
+ + {/* 操作按钮 */}
@@ -1860,9 +1854,8 @@ function PlayPageClient() { } >
{/* 播放器 */}
([]); const flushTimerRef = useRef(null); + const [useFluidSearch, setUseFluidSearch] = useState(true); // 聚合卡片 refs 与聚合统计缓存 const groupRefs = useRef>>(new Map()); const groupStatsRef = useRef>(new Map()); @@ -323,6 +323,18 @@ function SearchPageClient() { // 初始加载搜索历史 getSearchHistory().then(setSearchHistory); + // 读取流式搜索设置 + if (typeof window !== 'undefined') { + const savedFluidSearch = localStorage.getItem('fluidSearch'); + const defaultFluidSearch = + (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + if (savedFluidSearch !== null) { + setUseFluidSearch(JSON.parse(savedFluidSearch)); + } else if (defaultFluidSearch !== undefined) { + setUseFluidSearch(defaultFluidSearch); + } + } + // 监听搜索历史更新事件 const unsubscribe = subscribeToDataUpdates( 'searchHistoryUpdated', @@ -392,90 +404,134 @@ function SearchPageClient() { } 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) { - // 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁 - const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); - const incoming: SearchResult[] = - activeYearOrder === 'none' - ? sortBatchForNoOrder(payload.results as SearchResult[]) - : (payload.results as SearchResult[]); - pendingResultsRef.current.push(...incoming); - if (!flushTimerRef.current) { - flushTimerRef.current = window.setTimeout(() => { - let toAppend = pendingResultsRef.current; - pendingResultsRef.current = []; - startTransition(() => { - setSearchResults((prev) => prev.concat(toAppend)); - }); + // 每次搜索时重新读取设置,确保使用最新的配置 + let currentFluidSearch = useFluidSearch; + if (typeof window !== 'undefined') { + const savedFluidSearch = localStorage.getItem('fluidSearch'); + if (savedFluidSearch !== null) { + currentFluidSearch = JSON.parse(savedFluidSearch); + } else { + const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + currentFluidSearch = defaultFluidSearch; + } + } + + // 如果读取的配置与当前状态不同,更新状态 + if (currentFluidSearch !== useFluidSearch) { + setUseFluidSearch(currentFluidSearch); + } + + if (currentFluidSearch) { + // 流式搜索:打开新的流式连接 + 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) { + // 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁 + const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); + const incoming: SearchResult[] = + activeYearOrder === 'none' + ? sortBatchForNoOrder(payload.results as SearchResult[]) + : (payload.results as SearchResult[]); + pendingResultsRef.current.push(...incoming); + 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; - }, 80); + } + startTransition(() => { + setSearchResults((prev) => prev.concat(toAppend)); + }); } - } - break; + setIsLoading(false); + try { es.close(); } catch { } + if (eventSourceRef.current === es) { + eventSourceRef.current = null; + } + break; } - case 'source_error': - setCompletedSources((prev) => prev + 1); - break; - case 'complete': - setCompletedSources(payload.completedSources || totalSources); - // 完成前确保将缓冲写入 - if (pendingResultsRef.current.length > 0) { - let 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 { } - }; + } catch { } + }; - es.onerror = () => { - setIsLoading(false); - // 错误时也清空缓冲 - if (pendingResultsRef.current.length > 0) { - let toAppend = pendingResultsRef.current; - pendingResultsRef.current = []; - if (flushTimerRef.current) { - clearTimeout(flushTimerRef.current); - flushTimerRef.current = null; + 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)); + }); } - startTransition(() => { - setSearchResults((prev) => prev.concat(toAppend)); + try { es.close(); } catch { } + if (eventSourceRef.current === es) { + eventSourceRef.current = null; + } + }; + } else { + // 传统搜索:使用普通接口 + fetch(`/api/search?q=${encodeURIComponent(trimmed)}`) + .then(response => response.json()) + .then(data => { + if (currentQueryRef.current !== trimmed) return; + + if (data.results && Array.isArray(data.results)) { + const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); + const results: SearchResult[] = + activeYearOrder === 'none' + ? sortBatchForNoOrder(data.results as SearchResult[]) + : (data.results as SearchResult[]); + + setSearchResults(results); + setTotalSources(1); + setCompletedSources(1); + } + setIsLoading(false); + }) + .catch(() => { + setIsLoading(false); }); - } - try { es.close(); } catch { } - if (eventSourceRef.current === es) { - eventSourceRef.current = null; - } - }; + } setShowSuggestions(false); // 保存到搜索历史 (事件监听会自动更新界面) @@ -599,12 +655,12 @@ function SearchPageClient() {

搜索结果 - {searchResults.length > 0 && totalSources > 0 && ( + {searchResults.length > 0 && totalSources > 0 && useFluidSearch && ( {completedSources}/{totalSources} )} - {searchResults.length > 0 && isLoading && ( + {searchResults.length > 0 && isLoading && useFluidSearch && ( diff --git a/src/components/SearchResultFilter.tsx b/src/components/SearchResultFilter.tsx index bb4d91a..0c9e773 100644 --- a/src/components/SearchResultFilter.tsx +++ b/src/components/SearchResultFilter.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ArrowDownWideNarrow, ArrowUpNarrowWide, ArrowUpDown } from 'lucide-react'; +import { ArrowDownWideNarrow, ArrowUpDown,ArrowUpNarrowWide } from 'lucide-react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; diff --git a/src/components/SearchSuggestions.tsx b/src/components/SearchSuggestions.tsx index b33df4c..d47f1db 100644 --- a/src/components/SearchSuggestions.tsx +++ b/src/components/SearchSuggestions.tsx @@ -22,7 +22,6 @@ export default function SearchSuggestions({ onClose, }: SearchSuggestionsProps) { const [suggestions, setSuggestions] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(-1); const containerRef = useRef(null); // 防抖定时器 @@ -55,7 +54,6 @@ export default function SearchSuggestions({ }) ); setSuggestions(apiSuggestions); - setSelectedIndex(-1); } } catch (err: unknown) { // 类型保护判断 err 是否是 Error 类型 @@ -63,12 +61,10 @@ export default function SearchSuggestions({ if (err.name !== 'AbortError') { // 不是取消请求导致的错误才清空 setSuggestions([]); - setSelectedIndex(-1); } } else { // 如果 err 不是 Error 类型,也清空提示 setSuggestions([]); - setSelectedIndex(-1); } } }, []); @@ -84,7 +80,6 @@ export default function SearchSuggestions({ fetchSuggestionsFromAPI(searchQuery); } else { setSuggestions([]); - setSelectedIndex(-1); } }, 300); //300ms }, @@ -94,7 +89,6 @@ export default function SearchSuggestions({ useEffect(() => { if (!query.trim() || !isVisible) { setSuggestions([]); - setSelectedIndex(-1); return; } debouncedFetchSuggestions(query); @@ -107,43 +101,6 @@ export default function SearchSuggestions({ }; }, [query, isVisible, debouncedFetchSuggestions]); - // 键盘导航 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!isVisible || suggestions.length === 0) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex((prev) => - prev < suggestions.length - 1 ? prev + 1 : 0 - ); - break; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : suggestions.length - 1 - ); - break; - case 'Enter': - e.preventDefault(); - if (selectedIndex >= 0 && selectedIndex < suggestions.length) { - onSelect(suggestions[selectedIndex].text); - } else { - onSelect(query); - } - break; - case 'Escape': - e.preventDefault(); - onClose(); - break; - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isVisible, query, suggestions, selectedIndex, onSelect, onClose]); - // 点击外部关闭 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -171,14 +128,11 @@ export default function SearchSuggestions({ ref={containerRef} className='absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto' > - {suggestions.map((suggestion, index) => ( + {suggestions.map((suggestion) => (

{/* 底部说明 */} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 4a70401..47474d3 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -4,13 +4,13 @@ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { + forwardRef, memo, useCallback, useEffect, + useImperativeHandle, useMemo, useState, - forwardRef, - useImperativeHandle, } from 'react'; import { diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 8cc0f04..8cddf28 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -15,6 +15,7 @@ export interface AdminConfig { DoubanImageProxyType: string; DoubanImageProxy: string; DisableYellowFilter: boolean; + FluidSearch: boolean; }; UserConfig: { AllowRegister: boolean; diff --git a/src/lib/config.ts b/src/lib/config.ts index d8a313e..7abc88d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -168,6 +168,8 @@ async function getInitConfig(configFile: string, subConfig: { DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '', DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true', + FluidSearch: + process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', }, UserConfig: { AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts index 509e613..1d50407 100644 --- a/src/lib/downstream.ts +++ b/src/lib/downstream.ts @@ -1,7 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { API_CONFIG, ApiSite, getConfig } from '@/lib/config'; +import { getCachedSearchPage, setCachedSearchPage } from '@/lib/search-cache'; import { SearchResult } from '@/lib/types'; import { cleanHtmlTags } from '@/lib/utils'; -import { getCachedSearchPage, setCachedSearchPage } from '@/lib/search-cache'; interface ApiSearchItem { vod_id: string; @@ -24,17 +26,14 @@ async function searchWithCache( query: string, page: number, url: string, - timeoutMs: number = 5000 + timeoutMs = 5000 ): Promise<{ results: SearchResult[]; pageCount?: number }> { // 先查缓存 const cached = getCachedSearchPage(apiSite.key, query, page); if (cached) { if (cached.status === 'ok') { - console.log(`🎯 缓存命中 [${apiSite.key}] query="${query}" page=${page} status=ok results=${cached.data.length}`); return { results: cached.data, pageCount: cached.pageCount }; } else { - console.log(`🚫 缓存命中 [${apiSite.key}] query="${query}" page=${page} status=${cached.status} - 返回空结果`); - // timeout / forbidden 命中缓存,直接返回空 return { results: [] }; } } @@ -144,7 +143,7 @@ export async function searchFromApi( // 使用新的缓存搜索函数处理第一页 const firstPageResult = await searchWithCache(apiSite, query, 1, apiUrl, 5000); - let results = firstPageResult.results; + const results = firstPageResult.results; const pageCountFromFirst = firstPageResult.pageCount; const config = await getConfig(); diff --git a/src/lib/search-cache.ts b/src/lib/search-cache.ts index be3bb19..2b154f4 100644 --- a/src/lib/search-cache.ts +++ b/src/lib/search-cache.ts @@ -66,10 +66,7 @@ export function setCachedSearchPage( // 惰性清理:每次写入时检查是否需要清理 const now = Date.now(); if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) { - const stats = performCacheCleanup(); - if (stats.expired > 0 || stats.sizeLimited > 0) { - console.log(`🧹 惰性缓存清理: 删除过期${stats.expired}项,删除超限${stats.sizeLimited}项,剩余${stats.total}项`); - } + performCacheCleanup(); } const key = makeSearchCacheKey(sourceKey, query, page); @@ -87,7 +84,6 @@ export function setCachedSearchPage( function ensureAutoCleanupStarted(): void { if (!cleanupTimer) { startAutoCleanup(); - console.log(`🚀 启动自动缓存清理,间隔${CACHE_CLEANUP_INTERVAL_MS / 1000}秒,最大缓存${MAX_CACHE_SIZE}项`); } } @@ -138,10 +134,7 @@ function startAutoCleanup(): void { if (cleanupTimer) return; // 避免重复启动 cleanupTimer = setInterval(() => { - const stats = performCacheCleanup(); - if (stats.expired > 0 || stats.sizeLimited > 0) { - console.log(`🧹 自动缓存清理: 删除过期${stats.expired}项,删除超限${stats.sizeLimited}项,剩余${stats.total}项`); - } + performCacheCleanup(); }, CACHE_CLEANUP_INTERVAL_MS); // 在 Node.js 环境中避免阻止程序退出