2025 lines
73 KiB
TypeScript
2025 lines
73 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
|
|
'use client';
|
|
|
|
import {
|
|
ChevronUp,
|
|
Film,
|
|
Grid2x2,
|
|
HardDrive,
|
|
List,
|
|
Magnet,
|
|
RefreshCw,
|
|
Search,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import React, {
|
|
startTransition,
|
|
Suspense,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
|
import {
|
|
addSearchHistory,
|
|
clearSearchHistory,
|
|
deleteSearchHistory,
|
|
getSearchHistory,
|
|
subscribeToDataUpdates,
|
|
} from '@/lib/db.client';
|
|
import { SearchResult } from '@/lib/types';
|
|
import { processImageUrl } from '@/lib/utils';
|
|
|
|
import AcgSearch from '@/components/AcgSearch';
|
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
|
import ImageViewer from '@/components/ImageViewer';
|
|
import PageLayout from '@/components/PageLayout';
|
|
import PansouSearch from '@/components/PansouSearch';
|
|
import ProxyImage from '@/components/ProxyImage';
|
|
import SearchResultFilter, {
|
|
SearchFilterCategory,
|
|
} from '@/components/SearchResultFilter';
|
|
import SearchSuggestions from '@/components/SearchSuggestions';
|
|
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
|
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
|
|
|
|
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 submittedSearchQuery = searchParams.get('q')?.trim() || '';
|
|
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 [exactSearch, setExactSearch] = useState(true);
|
|
|
|
// 生成缓存键
|
|
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 [resultDisplayMode, setResultDisplayMode] = useState<'card' | 'list'>(
|
|
() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem('searchResultDisplayMode');
|
|
if (saved === 'card' || saved === 'list') {
|
|
return saved;
|
|
}
|
|
}
|
|
return 'card';
|
|
}
|
|
);
|
|
const [expandedSourceTags, setExpandedSourceTags] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [previewImage, setPreviewImage] = useState<{
|
|
url: string;
|
|
alt: string;
|
|
} | null>(null);
|
|
|
|
// 在“无排序”场景用于每个源批次的预排序:完全匹配标题优先,其次年份倒序,未知年份最后
|
|
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 titleContainsQuery = (title: string, query: string): boolean => {
|
|
if (!exactSearch) return true; // 如果未开启精确搜索,不过滤
|
|
if (!query || !title) return true; // 如果没有搜索词或标题,不过滤
|
|
|
|
const normalizedTitle = title.toLowerCase();
|
|
const normalizedQuery = query.toLowerCase();
|
|
|
|
return normalizedTitle.includes(normalizedQuery);
|
|
};
|
|
|
|
const allExactSearchResults = useMemo(() => {
|
|
if (!exactSearch) return searchResults;
|
|
|
|
return searchResults.filter((item) =>
|
|
titleContainsQuery(item.title, submittedSearchQuery)
|
|
);
|
|
}, [searchResults, submittedSearchQuery, exactSearch]);
|
|
|
|
// 聚合后的结果(按标题和年份分组)
|
|
const aggregatedResults = useMemo(() => {
|
|
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
|
const preliminaryMap = new Map<string, SearchResult[]>();
|
|
|
|
allExactSearchResults.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[]]
|
|
);
|
|
}, [allExactSearchResults]);
|
|
|
|
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 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 exactSearchFiltered = exactSearch
|
|
? searchResults.filter((item) =>
|
|
titleContainsQuery(item.title, currentQueryRef.current)
|
|
)
|
|
: searchResults;
|
|
|
|
const buildSourceOptions = (
|
|
sourceEntries: Array<{ source: string; source_name: string }>
|
|
) => [
|
|
{ label: '全部来源', value: 'all' },
|
|
...Array.from(
|
|
new Map(
|
|
sourceEntries
|
|
.filter(
|
|
(item) =>
|
|
item.source &&
|
|
item.source_name &&
|
|
item.source.trim() !== '' &&
|
|
item.source_name.trim() !== ''
|
|
)
|
|
.map((item) => [item.source, item.source_name])
|
|
).entries()
|
|
)
|
|
.sort((a, b) => {
|
|
const aIsOpenList = a[0] === 'openlist';
|
|
const bIsOpenList = b[0] === 'openlist';
|
|
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby_');
|
|
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby_');
|
|
|
|
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 buildTitleOptions = (titles: string[]) => [
|
|
{ label: '全部标题', value: 'all' },
|
|
...Array.from(new Set(titles))
|
|
.filter((title) => title && title.trim() !== '')
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.map((title) => ({ label: title, value: title })),
|
|
];
|
|
|
|
const buildYearOptions = (years: string[]) => {
|
|
const yearSet = Array.from(
|
|
new Set(years.filter((year) => year && year.trim() !== ''))
|
|
);
|
|
const knownYears = yearSet
|
|
.filter((year) => year !== 'unknown')
|
|
.sort((a, b) => parseInt(b) - parseInt(a));
|
|
const hasUnknown = yearSet.includes('unknown');
|
|
|
|
return [
|
|
{ label: '全部年份', value: 'all' },
|
|
...knownYears.map((year) => ({ label: year, value: year })),
|
|
...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []),
|
|
];
|
|
};
|
|
|
|
const allForSourceOptions = exactSearchFiltered.filter((item) => {
|
|
if (filterAll.title !== 'all' && item.title !== filterAll.title)
|
|
return false;
|
|
if (filterAll.year !== 'all' && item.year !== filterAll.year)
|
|
return false;
|
|
return true;
|
|
});
|
|
|
|
const allForTitleOptions = exactSearchFiltered.filter((item) => {
|
|
if (filterAll.source !== 'all' && item.source !== filterAll.source)
|
|
return false;
|
|
if (filterAll.year !== 'all' && item.year !== filterAll.year)
|
|
return false;
|
|
return true;
|
|
});
|
|
|
|
const allForYearOptions = exactSearchFiltered.filter((item) => {
|
|
if (filterAll.source !== 'all' && item.source !== filterAll.source)
|
|
return false;
|
|
if (filterAll.title !== 'all' && item.title !== filterAll.title)
|
|
return false;
|
|
return true;
|
|
});
|
|
|
|
const aggForSourceOptions = aggregatedResults.filter(([_, group]) => {
|
|
const gTitle = group[0]?.title ?? '';
|
|
const gYear = group[0]?.year ?? 'unknown';
|
|
if (filterAgg.title !== 'all' && gTitle !== filterAgg.title) return false;
|
|
if (filterAgg.year !== 'all' && gYear !== filterAgg.year) return false;
|
|
return true;
|
|
});
|
|
|
|
const aggForTitleOptions = aggregatedResults.filter(([_, group]) => {
|
|
const gYear = group[0]?.year ?? 'unknown';
|
|
const hasSource =
|
|
filterAgg.source === 'all'
|
|
? true
|
|
: group.some((item) => item.source === filterAgg.source);
|
|
if (!hasSource) return false;
|
|
if (filterAgg.year !== 'all' && gYear !== filterAgg.year) return false;
|
|
return true;
|
|
});
|
|
|
|
const aggForYearOptions = aggregatedResults.filter(([_, group]) => {
|
|
const gTitle = group[0]?.title ?? '';
|
|
const hasSource =
|
|
filterAgg.source === 'all'
|
|
? true
|
|
: group.some((item) => item.source === filterAgg.source);
|
|
if (!hasSource) return false;
|
|
if (filterAgg.title !== 'all' && gTitle !== filterAgg.title) return false;
|
|
return true;
|
|
});
|
|
|
|
const categoriesAll: SearchFilterCategory[] = [
|
|
{
|
|
key: 'source',
|
|
label: '来源',
|
|
options: buildSourceOptions(
|
|
allForSourceOptions.map((item) => ({
|
|
source: item.source,
|
|
source_name: item.source_name,
|
|
}))
|
|
),
|
|
},
|
|
{
|
|
key: 'title',
|
|
label: '标题',
|
|
options: buildTitleOptions(
|
|
allForTitleOptions.map((item) => item.title)
|
|
),
|
|
},
|
|
{
|
|
key: 'year',
|
|
label: '年份',
|
|
options: buildYearOptions(allForYearOptions.map((item) => item.year)),
|
|
},
|
|
];
|
|
|
|
const categoriesAgg: SearchFilterCategory[] = [
|
|
{
|
|
key: 'source',
|
|
label: '来源',
|
|
options: buildSourceOptions(
|
|
aggForSourceOptions.flatMap(([_, group]) =>
|
|
group.map((item) => ({
|
|
source: item.source,
|
|
source_name: item.source_name,
|
|
}))
|
|
)
|
|
),
|
|
},
|
|
{
|
|
key: 'title',
|
|
label: '标题',
|
|
options: buildTitleOptions(
|
|
aggForTitleOptions.map(([_, group]) => group[0]?.title ?? '')
|
|
),
|
|
},
|
|
{
|
|
key: 'year',
|
|
label: '年份',
|
|
options: buildYearOptions(
|
|
aggForYearOptions.map(([_, group]) => group[0]?.year ?? 'unknown')
|
|
),
|
|
},
|
|
];
|
|
|
|
return { categoriesAll, categoriesAgg };
|
|
}, [searchResults, aggregatedResults, exactSearch, filterAll, filterAgg]);
|
|
|
|
// 非聚合:应用筛选与排序
|
|
const filteredAllResults = useMemo(() => {
|
|
const { source, title, year, yearOrder } = filterAll;
|
|
|
|
const filtered = allExactSearchResults.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);
|
|
});
|
|
}, [allExactSearchResults, 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]);
|
|
|
|
const useVirtualGrid = useMemo(() => {
|
|
const cardCount =
|
|
viewMode === 'agg'
|
|
? filteredAggResults.length
|
|
: filteredAllResults.length;
|
|
return resultDisplayMode === 'card' && cardCount >= 100;
|
|
}, [
|
|
viewMode,
|
|
resultDisplayMode,
|
|
filteredAggResults.length,
|
|
filteredAllResults.length,
|
|
]);
|
|
|
|
const resultCountMeta = useMemo(() => {
|
|
const isAggregateView = viewMode === 'agg';
|
|
const visibleCount = isAggregateView
|
|
? filteredAggResults.length
|
|
: filteredAllResults.length;
|
|
const totalCount = isAggregateView
|
|
? aggregatedResults.length
|
|
: allExactSearchResults.length;
|
|
|
|
return {
|
|
visibleCount,
|
|
totalCount,
|
|
isFiltered: visibleCount !== totalCount,
|
|
modeLabel: isAggregateView ? '聚合结果' : '搜索结果',
|
|
unit: isAggregateView ? '组' : '条',
|
|
};
|
|
}, [
|
|
viewMode,
|
|
filteredAggResults.length,
|
|
filteredAllResults.length,
|
|
aggregatedResults.length,
|
|
allExactSearchResults.length,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('searchResultDisplayMode', resultDisplayMode);
|
|
}
|
|
}, [resultDisplayMode]);
|
|
|
|
const getSearchResultUrl = (params: {
|
|
title: string;
|
|
year?: string;
|
|
type?: string;
|
|
source?: string;
|
|
id?: string;
|
|
query?: string;
|
|
isAggregate?: boolean;
|
|
}) => {
|
|
const yearParam =
|
|
params.year && params.year !== 'unknown' ? `&year=${params.year}` : '';
|
|
const queryParam = params.query
|
|
? `&stitle=${encodeURIComponent(params.query.trim())}`
|
|
: '';
|
|
const typeParam = params.type ? `&stype=${params.type}` : '';
|
|
const preferParam = params.isAggregate ? '&prefer=true' : '';
|
|
|
|
if (params.isAggregate || !params.source || !params.id) {
|
|
return `/play?title=${encodeURIComponent(
|
|
params.title.trim()
|
|
)}${yearParam}${typeParam}${preferParam}${queryParam}`;
|
|
}
|
|
|
|
return `/play?source=${params.source}&id=${
|
|
params.id
|
|
}&title=${encodeURIComponent(
|
|
params.title.trim()
|
|
)}${yearParam}${preferParam}${queryParam}${typeParam}`;
|
|
};
|
|
|
|
const renderTag = (label: string, className: string) => (
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${className}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
|
|
const renderListItem = (item: {
|
|
key: string;
|
|
title: string;
|
|
poster: string;
|
|
year?: string;
|
|
type: 'movie' | 'tv';
|
|
episodes?: number;
|
|
sourceName?: string;
|
|
sourceNames?: string[];
|
|
doubanId?: number;
|
|
desc?: string;
|
|
vodRemarks?: string;
|
|
isAggregate?: boolean;
|
|
source?: string;
|
|
id?: string;
|
|
query?: string;
|
|
}) => {
|
|
const yearText = item.year && item.year !== 'unknown' ? item.year : '';
|
|
const sourceTags = item.isAggregate
|
|
? Array.from(new Set(item.sourceNames || []))
|
|
: item.sourceName
|
|
? [item.sourceName]
|
|
: [];
|
|
const isExpanded = !!expandedSourceTags[item.key];
|
|
const maxVisibleSourceTags = 3;
|
|
const visibleSourceTags = isExpanded
|
|
? sourceTags
|
|
: sourceTags.slice(0, maxVisibleSourceTags);
|
|
const hiddenSourceCount = Math.max(
|
|
0,
|
|
sourceTags.length - visibleSourceTags.length
|
|
);
|
|
const description = (item.desc || '').trim();
|
|
const itemUrl = getSearchResultUrl({
|
|
title: item.title,
|
|
year: item.year,
|
|
type: item.type,
|
|
source: item.source,
|
|
id: item.id,
|
|
query: item.query,
|
|
isAggregate: item.isAggregate,
|
|
});
|
|
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type='button'
|
|
onClick={() => router.push(itemUrl)}
|
|
className='group w-full rounded-2xl border border-gray-200/80 bg-white/90 p-3 text-left shadow-sm transition-all hover:border-green-300 hover:shadow-md dark:border-gray-700 dark:bg-gray-900/70 dark:hover:border-green-700'
|
|
>
|
|
<div className='flex items-start gap-4'>
|
|
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
|
|
<ProxyImage
|
|
originalSrc={item.poster}
|
|
alt={item.title}
|
|
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
|
loading='lazy'
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setPreviewImage({
|
|
url: processImageUrl(item.poster),
|
|
alt: item.title,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className='min-w-0 flex-1'>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<h3 className='line-clamp-2 text-base font-semibold text-gray-900 dark:text-gray-100'>
|
|
{item.title}
|
|
</h3>
|
|
<div className='mt-2 flex flex-wrap gap-2'>
|
|
{renderTag(
|
|
item.type === 'movie' ? '电影' : '剧集',
|
|
'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
|
)}
|
|
{yearText &&
|
|
renderTag(
|
|
yearText,
|
|
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
|
)}
|
|
{item.episodes &&
|
|
item.episodes > 0 &&
|
|
renderTag(
|
|
`${item.episodes}集`,
|
|
'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
|
)}
|
|
{item.vodRemarks &&
|
|
renderTag(
|
|
item.vodRemarks,
|
|
'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
|
)}
|
|
{item.doubanId &&
|
|
item.doubanId > 0 &&
|
|
renderTag(
|
|
'豆瓣',
|
|
'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{description && (
|
|
<p className='mt-3 line-clamp-3 text-sm leading-6 text-gray-600 dark:text-gray-400'>
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{sourceTags.length > 0 && (
|
|
<div
|
|
className={`mt-3 flex gap-2 ${
|
|
isExpanded ? 'flex-wrap' : 'flex-nowrap overflow-hidden'
|
|
}`}
|
|
>
|
|
{visibleSourceTags.map((sourceName) => (
|
|
<span
|
|
key={`${item.key}-${sourceName}`}
|
|
className='inline-flex max-w-full shrink-0 items-center truncate rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
|
title={sourceName}
|
|
>
|
|
{sourceName}
|
|
</span>
|
|
))}
|
|
{hiddenSourceCount > 0 && (
|
|
<button
|
|
type='button'
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpandedSourceTags((prev) => ({
|
|
...prev,
|
|
[item.key]: true,
|
|
}));
|
|
}}
|
|
className='inline-flex shrink-0 items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 transition-colors hover:bg-green-100 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
|
|
aria-label={`展开剩余${hiddenSourceCount}个来源`}
|
|
>
|
|
+{hiddenSourceCount}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// 监听选项卡切换,自动执行搜索
|
|
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 savedExactSearch = localStorage.getItem('exactSearch');
|
|
if (savedExactSearch !== null) {
|
|
setExactSearch(savedExactSearch === 'true');
|
|
}
|
|
}
|
|
|
|
// 监听搜索历史更新事件
|
|
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;
|
|
}
|
|
// 先设置加载状态,再清空结果,避免短暂显示"暂无搜索结果"
|
|
setIsLoading(true);
|
|
setShowResults(true);
|
|
setSearchResults([]);
|
|
setTotalSources(0);
|
|
setCompletedSources(0);
|
|
// 清理缓冲
|
|
pendingResultsRef.current = [];
|
|
if (flushTimerRef.current) {
|
|
clearTimeout(flushTimerRef.current);
|
|
flushTimerRef.current = null;
|
|
}
|
|
|
|
// 每次搜索时重新读取设置,确保使用最新的配置
|
|
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);
|
|
// 立即设置加载状态,避免显示"未找到相关结果"
|
|
setIsLoading(true);
|
|
|
|
// 根据当前选项卡执行不同的搜索
|
|
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);
|
|
// 立即设置加载状态,避免显示"未找到相关结果"
|
|
setIsLoading(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-start justify-between gap-4'>
|
|
<div className='min-w-0'>
|
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
|
搜索结果
|
|
{isFromCache ? (
|
|
<span className='ml-2 rounded-md bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-900/30 dark:text-green-400'>
|
|
缓存
|
|
</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 animate-spin rounded-full border-2 border-gray-300 border-t-green-500'></span>
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</h2>
|
|
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs'>
|
|
<span className='inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200'>
|
|
{resultCountMeta.modeLabel}{' '}
|
|
{resultCountMeta.visibleCount.toLocaleString()}{' '}
|
|
{resultCountMeta.unit}
|
|
</span>
|
|
{resultCountMeta.isFiltered && (
|
|
<span className='inline-flex items-center rounded-full bg-white/80 px-2.5 py-1 font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900/70 dark:text-gray-400 dark:ring-gray-700'>
|
|
筛选前{' '}
|
|
{resultCountMeta.totalCount.toLocaleString()}{' '}
|
|
{resultCountMeta.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => {
|
|
setForceRefresh(true);
|
|
}}
|
|
disabled={isLoading}
|
|
className='flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-green-50 hover:text-green-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-700/50 dark:hover:text-green-400'
|
|
aria-label='强制刷新搜索结果'
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${
|
|
isLoading ? 'animate-spin' : ''
|
|
}`}
|
|
/>
|
|
<span>刷新</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className='mb-4 flex items-center gap-3'>
|
|
<div className='min-w-0 flex-1'>
|
|
{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>
|
|
<div className='flex shrink-0 items-center justify-end self-center'>
|
|
<label className='flex shrink-0 cursor-pointer select-none items-center gap-2'>
|
|
<span className='text-xs text-gray-700 dark:text-gray-300 sm:text-sm'>
|
|
聚合
|
|
</span>
|
|
<div className='relative'>
|
|
<input
|
|
type='checkbox'
|
|
className='peer sr-only'
|
|
checked={viewMode === 'agg'}
|
|
onChange={() =>
|
|
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
|
|
}
|
|
/>
|
|
<div className='h-5 w-9 rounded-full bg-gray-300 transition-colors peer-checked:bg-green-500 dark:bg-gray-600'></div>
|
|
<div className='absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform peer-checked:translate-x-4'></div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className='mb-8 flex justify-center'>
|
|
<div className='inline-flex items-center rounded-xl border border-gray-200 bg-white p-1 shadow-sm dark:border-gray-700 dark:bg-gray-900'>
|
|
<button
|
|
type='button'
|
|
onClick={() => setResultDisplayMode('card')}
|
|
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
|
resultDisplayMode === 'card'
|
|
? 'bg-green-500 text-white'
|
|
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
|
}`}
|
|
aria-label='切换为卡片视图'
|
|
>
|
|
<Grid2x2 className='h-4 w-4' />
|
|
<span>卡片</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
onClick={() => setResultDisplayMode('list')}
|
|
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
|
resultDisplayMode === 'list'
|
|
? 'bg-green-500 text-white'
|
|
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
|
}`}
|
|
aria-label='切换为列表视图'
|
|
>
|
|
<List className='h-4 w-4' />
|
|
<span>列表</span>
|
|
</button>
|
|
</div>
|
|
</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>
|
|
)
|
|
) : (
|
|
(() => {
|
|
const gridClassName =
|
|
'justify-start grid grid-cols-3 gap-x-2 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-2';
|
|
|
|
const listClassName = 'space-y-4';
|
|
|
|
const resultChildren =
|
|
viewMode === 'agg'
|
|
? filteredAggResults.map(([mapKey, group]) => {
|
|
const title = group[0]?.title || '';
|
|
const poster = group[0]?.poster || '';
|
|
const year = group[0]?.year || 'unknown';
|
|
const desc =
|
|
group.find((entry) => entry.desc?.trim())
|
|
?.desc || '';
|
|
const vodRemarks =
|
|
group.find((entry) => entry.vod_remarks?.trim())
|
|
?.vod_remarks || '';
|
|
const { episodes, source_names, douban_id } =
|
|
computeGroupStats(group);
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
if (resultDisplayMode === 'list') {
|
|
return renderListItem({
|
|
key: `agg-${mapKey}`,
|
|
title,
|
|
poster,
|
|
year,
|
|
type,
|
|
episodes,
|
|
sourceNames: source_names,
|
|
doubanId: douban_id,
|
|
desc,
|
|
vodRemarks,
|
|
isAggregate: true,
|
|
query:
|
|
searchQuery.trim() !== title
|
|
? searchQuery.trim()
|
|
: '',
|
|
});
|
|
}
|
|
|
|
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) => {
|
|
const type =
|
|
item.episodes.length > 1 ? 'tv' : 'movie';
|
|
|
|
if (resultDisplayMode === 'list') {
|
|
return renderListItem({
|
|
key: `all-${item.source}-${item.id}`,
|
|
id: item.id,
|
|
title: item.title,
|
|
poster: item.poster,
|
|
episodes: item.episodes.length,
|
|
source: item.source,
|
|
sourceName: item.source_name,
|
|
doubanId: item.douban_id,
|
|
query:
|
|
searchQuery.trim() !== item.title
|
|
? searchQuery.trim()
|
|
: '',
|
|
year: item.year,
|
|
type,
|
|
desc: item.desc,
|
|
vodRemarks: item.vod_remarks,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<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={type}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
if (useVirtualGrid) {
|
|
return (
|
|
<VirtualScrollableGrid
|
|
key={`search-results-virtual-${viewMode}`}
|
|
gridClassName={gridClassName}
|
|
>
|
|
{resultChildren}
|
|
</VirtualScrollableGrid>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={`search-results-${viewMode}-${resultDisplayMode}`}
|
|
className={
|
|
resultDisplayMode === 'list'
|
|
? listClassName
|
|
: gridClassName
|
|
}
|
|
>
|
|
{resultChildren}
|
|
</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);
|
|
// 立即设置加载状态,避免显示"未找到相关结果"
|
|
setIsLoading(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>
|
|
|
|
{previewImage && (
|
|
<ImageViewer
|
|
isOpen={!!previewImage}
|
|
onClose={() => setPreviewImage(null)}
|
|
imageUrl={previewImage.url}
|
|
alt={previewImage.alt}
|
|
/>
|
|
)}
|
|
|
|
{/* 返回顶部悬浮按钮 */}
|
|
<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>
|
|
);
|
|
}
|