Files
MoonTVPlus/src/app/search/page.tsx
T
2026-01-12 23:28:48 +08:00

1358 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
'use client';
import { ChevronUp, RefreshCw, Search, X, Film, HardDrive, Magnet } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import {
addSearchHistory,
clearSearchHistory,
deleteSearchHistory,
getSearchHistory,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import PageLayout from '@/components/PageLayout';
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
import PansouSearch from '@/components/PansouSearch';
import AcgSearch from '@/components/AcgSearch';
import CapsuleSwitch from '@/components/CapsuleSwitch';
function SearchPageClient() {
// 搜索历史
const [searchHistory, setSearchHistory] = useState<string[]>([]);
// 返回顶部按钮显示状态
const [showBackToTop, setShowBackToTop] = useState(false);
// 选项卡状态: 'video' 或 'pansou' 或 'acg'
const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>('video');
// Pansou 搜索触发标志
const [triggerPansouSearch, setTriggerPansouSearch] = useState(false);
// ACG 搜索触发标志
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
// 用户权限
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null);
// 繁体转简体转换器
const converterRef = useRef<((text: string) => string) | null>(null);
// 转换器是否已初始化
const [converterReady, setConverterReady] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const currentQueryRef = useRef<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
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 [useFluidSearch, setUseFluidSearch] = useState(true);
// 聚合卡片 refs 与聚合统计缓存
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map());
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
// 强制刷新状态
const [forceRefresh, setForceRefresh] = useState(false);
// 是否使用了缓存结果
const [isFromCache, setIsFromCache] = useState(false);
// 生成缓存键
const getCacheKey = (query: string) => {
return `search_cache_${query.trim()}`;
};
// 从 sessionStorage 获取缓存的搜索结果
const getCachedResults = (query: string): SearchResult[] | null => {
if (typeof window === 'undefined') return null;
try {
const cacheKey = getCacheKey(query);
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
console.error('Failed to get cached results:', error);
}
return null;
};
// 保存搜索结果到 sessionStorage
const setCachedResults = (query: string, results: SearchResult[]) => {
if (typeof window === 'undefined') return;
try {
const cacheKey = getCacheKey(query);
sessionStorage.setItem(cacheKey, JSON.stringify(results));
} catch (error) {
console.error('Failed to cache results:', error);
}
};
// 清除指定查询的缓存
const clearCachedResults = (query: string) => {
if (typeof window === 'undefined') return;
try {
const cacheKey = getCacheKey(query);
sessionStorage.removeItem(cacheKey);
} catch (error) {
console.error('Failed to clear cached results:', error);
}
};
const getGroupRef = (key: string) => {
let ref = groupRefs.current.get(key);
if (!ref) {
ref = React.createRef<VideoCardHandle>();
groupRefs.current.set(key, ref);
}
return ref;
};
const computeGroupStats = (group: SearchResult[]) => {
const episodes = (() => {
const countMap = new Map<number, number>();
group.forEach((g) => {
const len = g.episodes?.length || 0;
if (len > 0) countMap.set(len, (countMap.get(len) || 0) + 1);
});
let max = 0;
let res = 0;
countMap.forEach((v, k) => {
if (v > max) { max = v; res = k; }
});
return res;
})();
const source_names = Array.from(new Set(group.map((g) => g.source_name).filter(Boolean))) as string[];
const douban_id = (() => {
const countMap = new Map<number, number>();
group.forEach((g) => {
if (g.douban_id && g.douban_id > 0) {
countMap.set(g.douban_id, (countMap.get(g.douban_id) || 0) + 1);
}
});
let max = 0;
let res: number | undefined;
countMap.forEach((v, k) => {
if (v > max) { max = v; res = k; }
});
return res;
})();
return { episodes, source_names, douban_id };
};
// 过滤器:非聚合与聚合
const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
source: 'all',
title: 'all',
year: 'all',
yearOrder: 'none',
});
const [filterAgg, setFilterAgg] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
source: 'all',
title: 'all',
year: 'all',
yearOrder: 'none',
});
// 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => {
if (typeof window !== 'undefined') {
const userSetting = localStorage.getItem('defaultAggregateSearch');
if (userSetting !== null) {
return JSON.parse(userSetting);
}
}
return true; // 默认启用聚合
};
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
return getDefaultAggregate() ? 'agg' : 'all';
});
// 在“无排序”场景用于每个源批次的预排序:完全匹配标题优先,其次年份倒序,未知年份最后
const sortBatchForNoOrder = (items: SearchResult[]) => {
const q = currentQueryRef.current.trim();
return items.slice().sort((a, b) => {
const aExact = (a.title || '').trim() === q;
const bExact = (b.title || '').trim() === q;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
const aNum = Number.parseInt(a.year as any, 10);
const bNum = Number.parseInt(b.year as any, 10);
const aValid = !Number.isNaN(aNum);
const bValid = !Number.isNaN(bNum);
if (aValid && !bValid) return -1;
if (!aValid && bValid) return 1;
if (aValid && bValid) return bNum - aNum; // 年份倒序
return 0;
});
};
// 简化的年份排序:unknown/空值始终在最后
const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => {
// 如果是无排序状态,返回0(保持原顺序)
if (order === 'none') return 0;
// 处理空值和unknown
const aIsEmpty = !aYear || aYear === 'unknown';
const bIsEmpty = !bYear || bYear === 'unknown';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1; // a 在后
if (bIsEmpty) return -1; // b 在后
// 都是有效年份,按数字比较
const aNum = parseInt(aYear, 10);
const bNum = parseInt(bYear, 10);
return order === 'asc' ? aNum - bNum : bNum - aNum;
};
// 规范化标题用于聚合(去除特殊符号、括号、空格和全角空格)
const normalizeTitle = (title: string) => {
return title
.replace(/[\s\u3000]/g, '') // 去除空格和全角空格
.replace(/[()()[\]【】{}「」『』<>《》]/g, '') // 去除各种括号
.replace(/[^\w\u4e00-\u9fa5]/g, ''); // 去除特殊符号,保留字母、数字、下划线和中文
};
// 辅助函数:获取视频类型
const getType = (item: SearchResult): 'movie' | 'tv' => {
// 1. Emby 和 OpenList 源:使用 type_name(基于 TMDB,最可靠)
if (item.source === 'emby' || item.source?.startsWith('emby_') || item.source === 'openlist') {
return item.type_name === '电影' ? 'movie' : 'tv';
}
// 2. API 采集源:综合判断
const typeName = item.type_name?.toLowerCase() || '';
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
if (typeName.includes('电影') || typeName.includes('movie') ||
typeName.endsWith('片') && !typeName.includes('动漫')) {
return 'movie';
}
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
if (typeName.includes('剧') || typeName.includes('动漫') ||
typeName.includes('综艺') || typeName.includes('anime')) {
return 'tv';
}
// 2.3 检查 episodes_titles:如果包含"第X集",判断为剧集
if (item.episodes_titles && item.episodes_titles.length > 0) {
const firstTitle = item.episodes_titles[0] || '';
if (/第\d+集|第\d+话|EP?\d+/i.test(firstTitle)) {
return 'tv';
}
}
// 2.4 兜底:使用 episodes.length(最不可靠)
return item.episodes.length === 1 ? 'movie' : 'tv';
};
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
const preliminaryMap = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
const normalizedTitle = normalizeTitle(item.title);
const type = getType(item);
const preliminaryKey = `${normalizedTitle}-${type}`;
const arr = preliminaryMap.get(preliminaryKey) || [];
arr.push(item);
preliminaryMap.set(preliminaryKey, arr);
});
//===== 阶段2:智能年份推断和最终分组 =====
const finalMap = new Map<string, SearchResult[]>();
const keyOrder: string[] = [];
preliminaryMap.forEach((group, preliminaryKey) => {
// 分离有年份和无年份的结果
const withYear = new Map<string, SearchResult[]>();
const withoutYear: SearchResult[] = [];
group.forEach((item) => {
const year = item.year;
// 判断是否为有效年份:必须是4位数字,且不能是空字符串或'unknown'
if (year && year.trim() !== '' && year !== 'unknown' && /^\d{4}$/.test(year)) {
// 有有效年份
const arr = withYear.get(year) || [];
arr.push(item);
withYear.set(year, arr);
} else {
// 无年份(包括空字符串、'unknown'、null、undefined等)
withoutYear.push(item);
}
});
// 如果有有效年份组
if (withYear.size > 0) {
// 将无年份的结果复制到每个有年份的组中
withYear.forEach((yearGroup, year) => {
const finalKey = `${preliminaryKey}-${year}`;
// 合并:有年份的 + 无年份的(复制)
const mergedGroup = [...yearGroup, ...withoutYear];
finalMap.set(finalKey, mergedGroup);
keyOrder.push(finalKey);
});
} else if (withoutYear.length > 0) {
// 如果完全没有年份信息,单独成组
const finalKey = `${preliminaryKey}-unknown`;
finalMap.set(finalKey, withoutYear);
keyOrder.push(finalKey);
}
});
// 按出现顺序返回聚合结果
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
}, [searchResults]);
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
useEffect(() => {
aggregatedResults.forEach(([mapKey, group]) => {
const stats = computeGroupStats(group);
const prev = groupStatsRef.current.get(mapKey);
if (!prev) {
// 第一次出现,记录初始值,不调用 ref(由初始 props 渲染)
groupStatsRef.current.set(mapKey, stats);
return;
}
// 对比变化并调用对应的 set 方法
const ref = groupRefs.current.get(mapKey);
if (ref && ref.current) {
if (prev.episodes !== stats.episodes) {
ref.current.setEpisodes(stats.episodes);
}
const prevNames = (prev.source_names || []).join('|');
const nextNames = (stats.source_names || []).join('|');
if (prevNames !== nextNames) {
ref.current.setSourceNames(stats.source_names);
}
if (prev.douban_id !== stats.douban_id) {
ref.current.setDoubanId(stats.douban_id);
}
groupStatsRef.current.set(mapKey, stats);
}
});
}, [aggregatedResults]);
// 构建筛选选项
const filterOptions = useMemo(() => {
const sourcesSet = new Map<string, string>();
const titlesSet = new Set<string>();
const yearsSet = new Set<string>();
searchResults.forEach((item) => {
if (item.source && item.source_name) {
sourcesSet.set(item.source, item.source_name);
}
if (item.title) titlesSet.add(item.title);
if (item.year) yearsSet.add(item.year);
});
const sourceOptions: { label: string; value: string }[] = [
{ label: '全部来源', value: 'all' },
...Array.from(sourcesSet.entries())
.sort((a, b) => {
// 判断是否为 openlist
const aIsOpenList = a[0] === 'openlist';
const bIsOpenList = b[0] === 'openlist';
// 判断是否为 emby 源(包括 emby 和 emby_xxx 格式)
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby_');
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby_');
// 优先级:OpenList(100) > Emby(90) > 其他(0)
const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0;
const bPriority = bIsOpenList ? 100 : bIsEmby ? 90 : 0;
if (aPriority !== bPriority) {
return bPriority - aPriority; // 降序排列
}
// 同优先级内按名称排序
return a[1].localeCompare(b[1]);
})
.map(([value, label]) => ({ label, value })),
];
const titleOptions: { label: string; value: string }[] = [
{ label: '全部标题', value: 'all' },
...Array.from(titlesSet.values())
.sort((a, b) => a.localeCompare(b))
.map((t) => ({ label: t, value: t })),
];
// 年份: 将 unknown 放末尾
const years = Array.from(yearsSet.values());
const knownYears = years.filter((y) => y !== 'unknown').sort((a, b) => parseInt(b) - parseInt(a));
const hasUnknown = years.includes('unknown');
const yearOptions: { label: string; value: string }[] = [
{ label: '全部年份', value: 'all' },
...knownYears.map((y) => ({ label: y, value: y })),
...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []),
];
const categoriesAll: SearchFilterCategory[] = [
{ key: 'source', label: '来源', options: sourceOptions },
{ key: 'title', label: '标题', options: titleOptions },
{ key: 'year', label: '年份', options: yearOptions },
];
const categoriesAgg: SearchFilterCategory[] = [
{ key: 'source', label: '来源', options: sourceOptions },
{ key: 'title', label: '标题', options: titleOptions },
{ key: 'year', label: '年份', options: yearOptions },
];
return { categoriesAll, categoriesAgg };
}, [searchResults]);
// 非聚合:应用筛选与排序
const filteredAllResults = useMemo(() => {
const { source, title, year, yearOrder } = filterAll;
const filtered = searchResults.filter((item) => {
if (source !== 'all' && item.source !== source) return false;
if (title !== 'all' && item.title !== title) return false;
if (year !== 'all' && item.year !== year) return false;
return true;
});
// 如果是无排序状态,直接返回过滤后的原始顺序
if (yearOrder === 'none') {
return filtered;
}
// 简化排序:1. 年份排序,2. 年份相同时精确匹配在前,3. 标题排序
return filtered.sort((a, b) => {
// 首先按年份排序
const yearComp = compareYear(a.year, b.year, yearOrder);
if (yearComp !== 0) return yearComp;
// 年份相同时,精确匹配在前
const aExactMatch = a.title === searchQuery.trim();
const bExactMatch = b.title === searchQuery.trim();
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 最后按标题排序,正序时字母序,倒序时反字母序
return yearOrder === 'asc' ?
a.title.localeCompare(b.title) :
b.title.localeCompare(a.title);
});
}, [searchResults, filterAll, searchQuery]);
// 聚合:应用筛选与排序
const filteredAggResults = useMemo(() => {
const { source, title, year, yearOrder } = filterAgg as any;
const filtered = aggregatedResults.filter(([_, group]) => {
const gTitle = group[0]?.title ?? '';
const gYear = group[0]?.year ?? 'unknown';
const hasSource = source === 'all' ? true : group.some((item) => item.source === source);
if (!hasSource) return false;
if (title !== 'all' && gTitle !== title) return false;
if (year !== 'all' && gYear !== year) return false;
return true;
});
// 如果是无排序状态,保持按关键字+年份+类型出现的原始顺序
if (yearOrder === 'none') {
return filtered;
}
// 简化排序:1. 年份排序,2. 年份相同时精确匹配在前,3. 标题排序
return filtered.sort((a, b) => {
// 首先按年份排序
const aYear = a[1][0].year;
const bYear = b[1][0].year;
const yearComp = compareYear(aYear, bYear, yearOrder);
if (yearComp !== 0) return yearComp;
// 年份相同时,精确匹配在前
const aExactMatch = a[1][0].title === searchQuery.trim();
const bExactMatch = b[1][0].title === searchQuery.trim();
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 最后按标题排序,正序时字母序,倒序时反字母序
const aTitle = a[1][0].title;
const bTitle = b[1][0].title;
return yearOrder === 'asc' ?
aTitle.localeCompare(bTitle) :
bTitle.localeCompare(aTitle);
});
}, [aggregatedResults, filterAgg, searchQuery]);
// 监听选项卡切换,自动执行搜索
useEffect(() => {
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
if (activeTab === 'pansou' && searchQuery.trim() && showResults) {
setTriggerPansouSearch(prev => !prev);
}
// 如果切换到 ACG 磁力搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
if (activeTab === 'acg' && searchQuery.trim() && showResults) {
setTriggerAcgSearch(prev => !prev);
}
}, [activeTab]);
useEffect(() => {
// 从 URL 读取搜索类型参数
const typeParam = searchParams.get('type');
const query = searchParams.get('q');
if (typeParam === 'pansou' || typeParam === 'acg') {
setActiveTab(typeParam);
// 如果有搜索关键词且显示结果,触发对应的搜索
if (query && query.trim()) {
setSearchQuery(query);
setShowResults(true);
// 延迟触发搜索,确保组件已经切换到正确的标签页
setTimeout(() => {
if (typeParam === 'pansou') {
setTriggerPansouSearch(prev => !prev);
} else if (typeParam === 'acg') {
setTriggerAcgSearch(prev => !prev);
}
}, 100);
}
} else if (typeParam === 'video') {
setActiveTab('video');
} else if (!typeParam && query) {
// 如果没有 type 参数但有查询,默认为 video
setActiveTab('video');
}
// 无搜索参数时聚焦搜索框
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
// 获取用户权限
const authInfo = getAuthInfoFromBrowserCookie();
setUserRole(authInfo?.role || null);
// 初始化繁体转简体转换器
if (typeof window !== 'undefined') {
import('opencc-js').then((module) => {
try {
const OpenCC = module.default || module;
const converter = OpenCC.Converter({ from: 'hk', to: 'cn' });
converterRef.current = converter;
setConverterReady(true);
} catch (error) {
console.error('初始化繁体转简体转换器失败:', error);
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
}
}).catch((error) => {
console.error('加载 opencc-js 失败:', error);
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
});
} else {
setConverterReady(true);
}
// 初始加载搜索历史
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',
(newHistory: string[]) => {
setSearchHistory(newHistory);
}
);
// 获取滚动位置的函数 - 专门针对 body 滚动
const getScrollTop = () => {
return document.body.scrollTop || 0;
};
// 使用 requestAnimationFrame 持续检测滚动位置
let isRunning = false;
const checkScrollPosition = () => {
if (!isRunning) return;
const scrollTop = getScrollTop();
const shouldShow = scrollTop > 300;
setShowBackToTop(shouldShow);
requestAnimationFrame(checkScrollPosition);
};
// 启动持续检测
isRunning = true;
checkScrollPosition();
// 监听 body 元素的滚动事件
const handleScroll = () => {
const scrollTop = getScrollTop();
setShowBackToTop(scrollTop > 300);
};
document.body.addEventListener('scroll', handleScroll, { passive: true });
return () => {
unsubscribe();
isRunning = false; // 停止 requestAnimationFrame 循环
// 移除 body 滚动事件监听器
document.body.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
// 等待转换器初始化完成
if (!converterReady) {
return;
}
// 当搜索参数变化时更新搜索状态
let query = searchParams.get('q') || '';
// 如果开启了繁体转简体,进行转换
if (query && typeof window !== 'undefined') {
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
try {
const originalQuery = query;
query = converterRef.current(query);
// 如果转换后的文本与原文本不同,更新 URL
if (originalQuery !== query) {
const trimmedConverted = query.trim();
// 使用 replace 而不是 push,避免在历史记录中留下繁体版本
router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`);
return; // 等待 URL 更新后重新触发此 effect
}
} catch (error) {
console.error('[URL参数监听] 繁体转简体转换失败:', error);
}
}
}
currentQueryRef.current = query.trim();
if (query) {
setSearchQuery(query);
const trimmed = query.trim();
// 检查是否有缓存且不是强制刷新
if (!forceRefresh) {
const cachedResults = getCachedResults(trimmed);
if (cachedResults && cachedResults.length > 0) {
// 使用缓存的结果
setIsLoading(false); // 先设置加载状态为 false
setSearchResults(cachedResults);
setShowResults(true);
setTotalSources(1);
setCompletedSources(1);
setShowSuggestions(false);
setIsFromCache(true); // 标记为缓存结果
// 保存到搜索历史
addSearchHistory(query);
return;
}
}
// 如果是强制刷新,清除缓存
if (forceRefresh) {
clearCachedResults(trimmed);
setForceRefresh(false);
}
// 开始新搜索时,重置缓存标记
setIsFromCache(false);
// 新搜索:关闭旧连接并清空结果
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);
// 每次搜索时重新读取设置,确保使用最新的配置
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;
}
startTransition(() => {
setSearchResults((prev) => {
const newResults = prev.concat(toAppend);
// 缓存完整的搜索结果
setCachedResults(trimmed, newResults);
return newResults;
});
});
} else {
// 即使没有待写入的缓冲,也缓存当前结果
setSearchResults((prev) => {
setCachedResults(trimmed, prev);
return prev;
});
}
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;
}
};
} 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);
// 缓存搜索结果
setCachedResults(trimmed, results);
setTotalSources(1);
setCompletedSources(1);
}
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}
setShowSuggestions(false);
// 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(query);
} else {
setShowResults(false);
setShowSuggestions(false);
}
}, [searchParams, forceRefresh, converterReady]);
// 组件卸载时,关闭可能存在的连接
useEffect(() => {
return () => {
if (eventSourceRef.current) {
try { eventSourceRef.current.close(); } catch { }
eventSourceRef.current = null;
}
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
pendingResultsRef.current = [];
};
}, []);
// 输入框内容变化时触发,显示搜索建议
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
if (value.trim()) {
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
};
// 搜索框聚焦时触发,显示搜索建议
const handleInputFocus = () => {
if (searchQuery.trim()) {
setShowSuggestions(true);
}
};
// 搜索表单提交时触发,处理搜索逻辑
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
let trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return;
// 如果开启了繁体转简体,进行转换
if (typeof window !== 'undefined') {
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
try {
trimmed = converterRef.current(trimmed);
} catch (error) {
console.error('繁体转简体转换失败:', error);
}
}
}
// 回显搜索框
setSearchQuery(trimmed);
setShowResults(true);
setShowSuggestions(false);
// 根据当前选项卡执行不同的搜索
if (activeTab === 'video') {
// 影视搜索
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=video`);
// 其余由 searchParams 变化的 effect 处理
} else if (activeTab === 'pansou') {
// 网盘搜索 - 触发搜索
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=pansou`);
setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索
} else if (activeTab === 'acg') {
// ACG 磁力搜索 - 触发搜索
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=acg`);
setTriggerAcgSearch(prev => !prev);
}
};
const handleSuggestionSelect = (suggestion: string) => {
let processedSuggestion = suggestion;
// 如果开启了繁体转简体,进行转换
if (typeof window !== 'undefined') {
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
try {
processedSuggestion = converterRef.current(suggestion);
} catch (error) {
console.error('繁体转简体转换失败:', error);
}
}
}
setSearchQuery(processedSuggestion);
setShowSuggestions(false);
// 自动执行搜索
setShowResults(true);
// 根据当前选项卡执行不同的搜索
if (activeTab === 'video') {
// 影视搜索
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`);
// 其余由 searchParams 变化的 effect 处理
} else if (activeTab === 'pansou') {
// 网盘搜索 - 触发搜索
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`);
setTriggerPansouSearch(prev => !prev);
} else if (activeTab === 'acg') {
// ACG 磁力搜索 - 触发搜索
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`);
setTriggerAcgSearch(prev => !prev);
}
};
// 返回顶部功能
const scrollToTop = () => {
try {
// 根据调试结果,真正的滚动容器是 document.body
document.body.scrollTo({
top: 0,
behavior: 'smooth',
});
} catch (error) {
// 如果平滑滚动完全失败,使用立即滚动
document.body.scrollTop = 0;
}
};
// 处理标签切换
const handleTabChange = (newTab: 'video' | 'pansou' | 'acg') => {
setActiveTab(newTab);
// 如果有搜索关键词,更新 URL
const currentQuery = searchParams.get('q');
if (currentQuery) {
router.push(`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`);
}
};
return (
<PageLayout activePath='/search'>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
{/* 搜索框 */}
<div className='mb-8'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
<div className='relative'>
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
<input
id='searchInput'
type='text'
value={searchQuery}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder='搜索电影、电视剧...'
autoComplete="off"
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
/>
{/* 清除按钮 */}
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
setShowSuggestions(false);
document.getElementById('searchInput')?.focus();
}}
className='absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors dark:text-gray-500 dark:hover:text-gray-300'
aria-label='清除搜索内容'
>
<X className='h-5 w-5' />
</button>
)}
{/* 搜索建议 */}
<SearchSuggestions
query={searchQuery}
isVisible={showSuggestions}
onSelect={handleSuggestionSelect}
onClose={() => setShowSuggestions(false)}
onEnterKey={() => {
// 当用户按回车键时,使用搜索框的实际内容进行搜索
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return;
// 回显搜索框
setSearchQuery(trimmed);
setShowResults(true);
setShowSuggestions(false);
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
}}
/>
</div>
</form>
{/* 选项卡 */}
<div className='flex justify-center mt-6'>
<CapsuleSwitch
options={[
{
label: '影视搜索',
value: 'video',
icon: <Film size={16} />,
},
{
label: '网盘搜索',
value: 'pansou',
icon: <HardDrive size={16} />,
},
// 仅管理员和站长显示 ACG 磁力搜索
...(userRole === 'admin' || userRole === 'owner'
? [
{
label: '动漫磁力',
value: 'acg' as const,
icon: <Magnet size={16} />,
},
]
: []),
]}
active={activeTab}
onChange={(value) => handleTabChange(value as 'video' | 'pansou' | 'acg')}
/>
</div>
</div>
{/* 搜索结果或搜索历史 */}
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
{showResults ? (
<section className='mb-12'>
{activeTab === 'video' ? (
<>
{/* 影视搜索结果 */}
{/* 标题 */}
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
搜索结果
{isFromCache ? (
<span className='ml-2 px-2 py-0.5 text-xs font-medium text-green-600 bg-green-50 rounded-md dark:text-green-400 dark:bg-green-900/30'>
缓存
</span>
) : (
<>
{totalSources > 0 && useFluidSearch && (
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
{completedSources}/{totalSources}
</span>
)}
{isLoading && useFluidSearch && (
<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>
{/* 强制刷新按钮 */}
{searchQuery && (
<button
onClick={() => {
setForceRefresh(true);
}}
disabled={isLoading}
className='flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-400 dark:hover:text-green-400 dark:hover:bg-gray-700/50'
aria-label='强制刷新搜索结果'
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
<span>刷新</span>
</button>
)}
</div>
{/* 筛选器 + 聚合开关 同行 */}
<div className='mb-8 flex items-center justify-between gap-3'>
<div className='flex-1 min-w-0'>
{viewMode === 'agg' ? (
<SearchResultFilter
categories={filterOptions.categoriesAgg}
values={filterAgg}
onChange={(v) => setFilterAgg(v as any)}
/>
) : (
<SearchResultFilter
categories={filterOptions.categoriesAll}
values={filterAll}
onChange={(v) => setFilterAll(v as any)}
/>
)}
</div>
{/* 聚合开关 */}
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
<span className='text-xs sm:text-sm text-gray-700 dark:text-gray-300'>聚合</span>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={viewMode === 'agg'}
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
/>
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
</div>
</label>
</div>
{searchResults.length === 0 ? (
isLoading ? (
<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>
) : (
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
未找到相关结果
</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]) => {
const title = group[0]?.title || '';
const poster = group[0]?.poster || '';
const year = group[0]?.year || 'unknown';
const { episodes, source_names, douban_id } = computeGroupStats(group);
// 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
const lastDashIndex = mapKey.lastIndexOf('-');
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
const type = secondLastDashIndex > 0
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
// 如果该聚合第一次出现,写入初始统计
if (!groupStatsRef.current.has(mapKey)) {
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
}
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
ref={getGroupRef(mapKey)}
from='search'
isAggregate={true}
title={title}
poster={poster}
year={year}
episodes={episodes}
source_names={source_names}
douban_id={douban_id}
query={
searchQuery.trim() !== title
? searchQuery.trim()
: ''
}
type={type}
/>
</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>
))}
</div>
)}
</>
) : activeTab === 'pansou' ? (
<>
{/* 网盘搜索结果 */}
<div className='mb-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
网盘搜索结果
</h2>
</div>
<PansouSearch
keyword={searchQuery}
triggerSearch={triggerPansouSearch}
/>
</>
) : (
<>
{/* ACG 磁力搜索结果 */}
<div className='mb-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
动漫磁力搜索结果
</h2>
</div>
<AcgSearch
keyword={searchQuery}
triggerSearch={triggerAcgSearch}
/>
</>
)}
</section>
) : searchHistory.length > 0 ? (
// 搜索历史
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
搜索历史
{searchHistory.length > 0 && (
<button
onClick={() => {
clearSearchHistory(); // 事件监听会自动更新界面
}}
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
>
清空
</button>
)}
</h2>
<div className='flex flex-wrap gap-2'>
{searchHistory.map((item) => (
<div key={item} className='relative group'>
<button
onClick={() => {
setSearchQuery(item);
setShowResults(true);
// 根据当前选项卡执行不同的搜索
if (activeTab === 'video') {
// 影视搜索
router.push(
`/search?q=${encodeURIComponent(item.trim())}&type=video`
);
} else if (activeTab === 'pansou') {
// 网盘搜索
router.push(
`/search?q=${encodeURIComponent(item.trim())}&type=pansou`
);
setTriggerPansouSearch(prev => !prev);
} else if (activeTab === 'acg') {
// ACG 磁力搜索
router.push(
`/search?q=${encodeURIComponent(item.trim())}&type=acg`
);
setTriggerAcgSearch(prev => !prev);
}
}}
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
>
{item}
</button>
{/* 删除按钮 */}
<button
aria-label='删除搜索历史'
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
deleteSearchHistory(item); // 事件监听会自动更新界面
}}
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
>
<X className='w-3 h-3' />
</button>
</div>
))}
</div>
</section>
) : null}
</div>
</div>
{/* 返回顶部悬浮按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${showBackToTop
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-4 pointer-events-none'
}`}
aria-label='返回顶部'
>
<ChevronUp className='w-6 h-6 transition-transform group-hover:scale-110' />
</button>
</PageLayout>
);
}
export default function SearchPage() {
return (
<Suspense>
<SearchPageClient />
</Suspense>
);
}