新增影视搜索列表视图
This commit is contained in:
+728
-260
@@ -1,9 +1,26 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
|
||||
'use client';
|
||||
|
||||
import { ChevronUp, Film, HardDrive, Magnet,RefreshCw, Search, X } from 'lucide-react';
|
||||
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 React, {
|
||||
startTransition,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import {
|
||||
@@ -14,12 +31,16 @@ import {
|
||||
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 SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||
import SearchResultFilter, {
|
||||
SearchFilterCategory,
|
||||
} from '@/components/SearchResultFilter';
|
||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
|
||||
@@ -30,13 +51,17 @@ function SearchPageClient() {
|
||||
// 返回顶部按钮显示状态
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
// 选项卡状态: 'video' 或 'pansou' 或 'acg'
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>('video');
|
||||
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 [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(
|
||||
null
|
||||
);
|
||||
// 繁体转简体转换器
|
||||
const converterRef = useRef<((text: string) => string) | null>(null);
|
||||
// 转换器是否已初始化
|
||||
@@ -57,8 +82,15 @@ function SearchPageClient() {
|
||||
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 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);
|
||||
// 是否使用了缓存结果
|
||||
@@ -127,11 +159,16 @@ function SearchPageClient() {
|
||||
let max = 0;
|
||||
let res = 0;
|
||||
countMap.forEach((v, k) => {
|
||||
if (v > max) { max = v; res = 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 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>();
|
||||
@@ -143,7 +180,10 @@ function SearchPageClient() {
|
||||
let max = 0;
|
||||
let res: number | undefined;
|
||||
countMap.forEach((v, k) => {
|
||||
if (v > max) { max = v; res = k; }
|
||||
if (v > max) {
|
||||
max = v;
|
||||
res = k;
|
||||
}
|
||||
});
|
||||
return res;
|
||||
})();
|
||||
@@ -151,13 +191,23 @@ function SearchPageClient() {
|
||||
return { episodes, source_names, douban_id };
|
||||
};
|
||||
// 过滤器:非聚合与聚合
|
||||
const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||
const [filterAll, setFilterAll] = useState<{
|
||||
source: string;
|
||||
title: string;
|
||||
year: string;
|
||||
yearOrder: 'none' | 'asc' | 'desc';
|
||||
}>({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
yearOrder: 'none',
|
||||
});
|
||||
const [filterAgg, setFilterAgg] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||
const [filterAgg, setFilterAgg] = useState<{
|
||||
source: string;
|
||||
title: string;
|
||||
year: string;
|
||||
yearOrder: 'none' | 'asc' | 'desc';
|
||||
}>({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
@@ -178,6 +228,24 @@ function SearchPageClient() {
|
||||
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[]) => {
|
||||
@@ -200,7 +268,11 @@ function SearchPageClient() {
|
||||
};
|
||||
|
||||
// 简化的年份排序:unknown/空值始终在最后
|
||||
const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => {
|
||||
const compareYear = (
|
||||
aYear: string,
|
||||
bYear: string,
|
||||
order: 'none' | 'asc' | 'desc'
|
||||
) => {
|
||||
// 如果是无排序状态,返回0(保持原顺序)
|
||||
if (order === 'none') return 0;
|
||||
|
||||
@@ -230,7 +302,11 @@ function SearchPageClient() {
|
||||
// 辅助函数:获取视频类型
|
||||
const getType = (item: SearchResult): 'movie' | 'tv' => {
|
||||
// 1. Emby 和 OpenList 源:使用 type_name(基于 TMDB,最可靠)
|
||||
if (item.source === 'emby' || item.source?.startsWith('emby_') || item.source === 'openlist') {
|
||||
if (
|
||||
item.source === 'emby' ||
|
||||
item.source?.startsWith('emby_') ||
|
||||
item.source === 'openlist'
|
||||
) {
|
||||
return item.type_name === '电影' ? 'movie' : 'tv';
|
||||
}
|
||||
|
||||
@@ -238,14 +314,21 @@ function SearchPageClient() {
|
||||
const typeName = item.type_name?.toLowerCase() || '';
|
||||
|
||||
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
|
||||
if (typeName.includes('电影') || typeName.includes('movie') ||
|
||||
typeName.endsWith('片') && !typeName.includes('动漫')) {
|
||||
if (
|
||||
typeName.includes('电影') ||
|
||||
typeName.includes('movie') ||
|
||||
(typeName.endsWith('片') && !typeName.includes('动漫'))
|
||||
) {
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
|
||||
if (typeName.includes('剧') || typeName.includes('动漫') ||
|
||||
typeName.includes('综艺') || typeName.includes('anime')) {
|
||||
if (
|
||||
typeName.includes('剧') ||
|
||||
typeName.includes('动漫') ||
|
||||
typeName.includes('综艺') ||
|
||||
typeName.includes('anime')
|
||||
) {
|
||||
return 'tv';
|
||||
}
|
||||
|
||||
@@ -275,7 +358,9 @@ function SearchPageClient() {
|
||||
const aggregatedResults = useMemo(() => {
|
||||
// 首先应用精确搜索过滤
|
||||
const filteredResults = exactSearch
|
||||
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||
? searchResults.filter((item) =>
|
||||
titleContainsQuery(item.title, currentQueryRef.current)
|
||||
)
|
||||
: searchResults;
|
||||
|
||||
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
||||
@@ -297,16 +382,21 @@ function SearchPageClient() {
|
||||
|
||||
preliminaryMap.forEach((group, preliminaryKey) => {
|
||||
// 分离有年份和无年份的结果
|
||||
const withYear = new Map<string, SearchResult[]>();
|
||||
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)) {
|
||||
if (
|
||||
year &&
|
||||
year.trim() !== '' &&
|
||||
year !== 'unknown' &&
|
||||
/^\d{4}$/.test(year)
|
||||
) {
|
||||
// 有有效年份
|
||||
const arr = withYear.get(year) || [];
|
||||
const arr = withYear.get(year) || [];
|
||||
arr.push(item);
|
||||
withYear.set(year, arr);
|
||||
} else {
|
||||
@@ -334,7 +424,9 @@ function SearchPageClient() {
|
||||
});
|
||||
|
||||
// 按出现顺序返回聚合结果
|
||||
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
|
||||
return keyOrder.map(
|
||||
(key) => [key, finalMap.get(key)!] as [string, SearchResult[]]
|
||||
);
|
||||
}, [searchResults, exactSearch]);
|
||||
|
||||
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
||||
@@ -373,7 +465,12 @@ function SearchPageClient() {
|
||||
const yearsSet = new Set<string>();
|
||||
|
||||
searchResults.forEach((item) => {
|
||||
if (item.source && item.source_name && item.source.trim() !== '' && item.source_name.trim() !== '') {
|
||||
if (
|
||||
item.source &&
|
||||
item.source_name &&
|
||||
item.source.trim() !== '' &&
|
||||
item.source_name.trim() !== ''
|
||||
) {
|
||||
sourcesSet.set(item.source, item.source_name);
|
||||
}
|
||||
if (item.title && item.title.trim() !== '') titlesSet.add(item.title);
|
||||
@@ -415,7 +512,9 @@ function SearchPageClient() {
|
||||
|
||||
// 年份: 将 unknown 放末尾
|
||||
const years = Array.from(yearsSet.values());
|
||||
const knownYears = years.filter((y) => y !== 'unknown').sort((a, b) => parseInt(b) - parseInt(a));
|
||||
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' },
|
||||
@@ -444,7 +543,9 @@ function SearchPageClient() {
|
||||
|
||||
// 首先应用精确搜索过滤
|
||||
const exactSearchFiltered = exactSearch
|
||||
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||
? searchResults.filter((item) =>
|
||||
titleContainsQuery(item.title, currentQueryRef.current)
|
||||
)
|
||||
: searchResults;
|
||||
|
||||
const filtered = exactSearchFiltered.filter((item) => {
|
||||
@@ -472,9 +573,9 @@ function SearchPageClient() {
|
||||
if (!aExactMatch && bExactMatch) return 1;
|
||||
|
||||
// 最后按标题排序,正序时字母序,倒序时反字母序
|
||||
return yearOrder === 'asc' ?
|
||||
a.title.localeCompare(b.title) :
|
||||
b.title.localeCompare(a.title);
|
||||
return yearOrder === 'asc'
|
||||
? a.title.localeCompare(b.title)
|
||||
: b.title.localeCompare(a.title);
|
||||
});
|
||||
}, [searchResults, filterAll, searchQuery, exactSearch]);
|
||||
|
||||
@@ -484,7 +585,8 @@ function SearchPageClient() {
|
||||
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);
|
||||
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;
|
||||
@@ -513,26 +615,228 @@ function SearchPageClient() {
|
||||
// 最后按标题排序,正序时字母序,倒序时反字母序
|
||||
const aTitle = a[1][0].title;
|
||||
const bTitle = b[1][0].title;
|
||||
return yearOrder === 'asc' ?
|
||||
aTitle.localeCompare(bTitle) :
|
||||
bTitle.localeCompare(aTitle);
|
||||
return yearOrder === 'asc'
|
||||
? aTitle.localeCompare(bTitle)
|
||||
: bTitle.localeCompare(aTitle);
|
||||
});
|
||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||
|
||||
const useVirtualGrid = useMemo(() => {
|
||||
const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length;
|
||||
return cardCount >= 100;
|
||||
}, [viewMode, filteredAggResults.length, filteredAllResults.length]);
|
||||
const cardCount =
|
||||
viewMode === 'agg'
|
||||
? filteredAggResults.length
|
||||
: filteredAllResults.length;
|
||||
return resultDisplayMode === 'card' && cardCount >= 100;
|
||||
}, [
|
||||
viewMode,
|
||||
resultDisplayMode,
|
||||
filteredAggResults.length,
|
||||
filteredAllResults.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'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={processImageUrl(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);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
}
|
||||
// 如果切换到 ACG 磁力搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||
if (activeTab === 'acg' && searchQuery.trim() && showResults) {
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -552,9 +856,9 @@ function SearchPageClient() {
|
||||
// 延迟触发搜索,确保组件已经切换到正确的标签页
|
||||
setTimeout(() => {
|
||||
if (typeParam === 'pansou') {
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
} else if (typeParam === 'acg') {
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
@@ -574,20 +878,22 @@ function SearchPageClient() {
|
||||
|
||||
// 初始化繁体转简体转换器
|
||||
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);
|
||||
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,避免阻塞
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('加载 opencc-js 失败:', error);
|
||||
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setConverterReady(true);
|
||||
}
|
||||
@@ -670,7 +976,9 @@ function SearchPageClient() {
|
||||
|
||||
// 如果开启了繁体转简体,进行转换
|
||||
if (query && typeof window !== 'undefined') {
|
||||
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const searchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
|
||||
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||
try {
|
||||
@@ -681,7 +989,13 @@ function SearchPageClient() {
|
||||
if (originalQuery !== query) {
|
||||
const trimmedConverted = query.trim();
|
||||
// 使用 replace 而不是 push,避免在历史记录中留下繁体版本
|
||||
router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`);
|
||||
router.replace(
|
||||
`/search?q=${encodeURIComponent(trimmedConverted)}${
|
||||
searchParams.get('type')
|
||||
? `&type=${searchParams.get('type')}`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
return; // 等待 URL 更新后重新触发此 effect
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -696,7 +1010,7 @@ function SearchPageClient() {
|
||||
setSearchQuery(query);
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
|
||||
// 检查是否有缓存且不是强制刷新
|
||||
if (!forceRefresh) {
|
||||
const cachedResults = getCachedResults(trimmed);
|
||||
@@ -714,19 +1028,21 @@ function SearchPageClient() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果是强制刷新,清除缓存
|
||||
if (forceRefresh) {
|
||||
clearCachedResults(trimmed);
|
||||
setForceRefresh(false);
|
||||
}
|
||||
|
||||
|
||||
// 开始新搜索时,重置缓存标记
|
||||
setIsFromCache(false);
|
||||
|
||||
|
||||
// 新搜索:关闭旧连接并清空结果
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
try {
|
||||
eventSourceRef.current.close();
|
||||
} catch {}
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// 先设置加载状态,再清空结果,避免短暂显示"暂无搜索结果"
|
||||
@@ -749,7 +1065,8 @@ function SearchPageClient() {
|
||||
if (savedFluidSearch !== null) {
|
||||
currentFluidSearch = JSON.parse(savedFluidSearch);
|
||||
} else {
|
||||
const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
currentFluidSearch = defaultFluidSearch;
|
||||
}
|
||||
}
|
||||
@@ -761,7 +1078,9 @@ function SearchPageClient() {
|
||||
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索:打开新的流式连接
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
const es = new EventSource(
|
||||
`/api/search/ws?q=${encodeURIComponent(trimmed)}`
|
||||
);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
@@ -776,9 +1095,15 @@ function SearchPageClient() {
|
||||
break;
|
||||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
if (
|
||||
Array.isArray(payload.results) &&
|
||||
payload.results.length > 0
|
||||
) {
|
||||
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const activeYearOrder =
|
||||
viewMode === 'agg'
|
||||
? filterAgg.yearOrder
|
||||
: filterAll.yearOrder;
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(payload.results as SearchResult[])
|
||||
@@ -826,13 +1151,15 @@ function SearchPageClient() {
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
try {
|
||||
es.close();
|
||||
} catch {}
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
@@ -849,7 +1176,9 @@ function SearchPageClient() {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
try { es.close(); } catch { }
|
||||
try {
|
||||
es.close();
|
||||
} catch {}
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
@@ -857,12 +1186,13 @@ function SearchPageClient() {
|
||||
} else {
|
||||
// 传统搜索:使用普通接口
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
.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 activeYearOrder =
|
||||
viewMode === 'agg' ? filterAgg.yearOrder : filterAll.yearOrder;
|
||||
const results: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(data.results as SearchResult[])
|
||||
@@ -894,7 +1224,9 @@ function SearchPageClient() {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
try {
|
||||
eventSourceRef.current.close();
|
||||
} catch {}
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (flushTimerRef.current) {
|
||||
@@ -932,7 +1264,9 @@ function SearchPageClient() {
|
||||
|
||||
// 如果开启了繁体转简体,进行转换
|
||||
if (typeof window !== 'undefined') {
|
||||
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const searchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||
try {
|
||||
trimmed = converterRef.current(trimmed);
|
||||
@@ -957,11 +1291,11 @@ function SearchPageClient() {
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=pansou`);
|
||||
setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索
|
||||
setTriggerPansouSearch((prev) => !prev); // 切换状态来触发搜索
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=acg`);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -970,7 +1304,9 @@ function SearchPageClient() {
|
||||
|
||||
// 如果开启了繁体转简体,进行转换
|
||||
if (typeof window !== 'undefined') {
|
||||
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const searchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||
try {
|
||||
processedSuggestion = converterRef.current(suggestion);
|
||||
@@ -991,16 +1327,22 @@ function SearchPageClient() {
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=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);
|
||||
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);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`
|
||||
);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1025,7 +1367,9 @@ function SearchPageClient() {
|
||||
// 如果有搜索关键词,更新 URL
|
||||
const currentQuery = searchParams.get('q');
|
||||
if (currentQuery) {
|
||||
router.push(`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1044,7 +1388,7 @@ function SearchPageClient() {
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧...'
|
||||
autoComplete="off"
|
||||
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'
|
||||
/>
|
||||
|
||||
@@ -1112,7 +1456,9 @@ function SearchPageClient() {
|
||||
: []),
|
||||
]}
|
||||
active={activeTab}
|
||||
onChange={(value) => handleTabChange(value as 'video' | 'pansou' | 'acg')}
|
||||
onChange={(value) =>
|
||||
handleTabChange(value as 'video' | 'pansou' | 'acg')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1126,178 +1472,284 @@ function SearchPageClient() {
|
||||
{/* 影视搜索结果 */}
|
||||
{/* 标题 */}
|
||||
<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}
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
) : (
|
||||
(() => {
|
||||
const gridClassName =
|
||||
'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';
|
||||
|
||||
const gridChildren =
|
||||
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>
|
||||
));
|
||||
|
||||
if (useVirtualGrid) {
|
||||
return (
|
||||
<VirtualScrollableGrid
|
||||
key={`search-results-virtual-${viewMode}`}
|
||||
gridClassName={gridClassName}
|
||||
</h2>
|
||||
{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='强制刷新搜索结果'
|
||||
>
|
||||
{gridChildren}
|
||||
</VirtualScrollableGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`search-results-${viewMode}`}
|
||||
className={gridClassName}
|
||||
>
|
||||
{gridChildren}
|
||||
<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' ? (
|
||||
<>
|
||||
@@ -1357,20 +1809,26 @@ function SearchPageClient() {
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=video`
|
||||
`/search?q=${encodeURIComponent(
|
||||
item.trim()
|
||||
)}&type=video`
|
||||
);
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=pansou`
|
||||
`/search?q=${encodeURIComponent(
|
||||
item.trim()
|
||||
)}&type=pansou`
|
||||
);
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=acg`
|
||||
`/search?q=${encodeURIComponent(
|
||||
item.trim()
|
||||
)}&type=acg`
|
||||
);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
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'
|
||||
@@ -1397,13 +1855,23 @@ function SearchPageClient() {
|
||||
</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'
|
||||
}`}
|
||||
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' />
|
||||
|
||||
Reference in New Issue
Block a user