mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-26 22:24:42 +08:00
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps, no-console */
|
||
|
||
'use client';
|
||
|
||
import { Heart, LinkIcon } from 'lucide-react';
|
||
import Image from 'next/image';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { Suspense, useEffect, useState } from 'react';
|
||
|
||
import { isFavorited, toggleFavorite } from '@/lib/db.client';
|
||
import { SearchResult } from '@/lib/types';
|
||
|
||
import PageLayout from '@/components/PageLayout';
|
||
|
||
function AggregatePageClient() {
|
||
const searchParams = useSearchParams();
|
||
const query = searchParams.get('q') || '';
|
||
const title = searchParams.get('title') || '';
|
||
const year = searchParams.get('year') || '';
|
||
const type = searchParams.get('type') || '';
|
||
|
||
const [results, setResults] = useState<SearchResult[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
if (!query) {
|
||
setError('缺少搜索关键词');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const fetchData = async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||
);
|
||
if (!res.ok) {
|
||
throw new Error('搜索失败');
|
||
}
|
||
const data = await res.json();
|
||
const all: SearchResult[] = data.results || [];
|
||
const map = new Map<string, SearchResult[]>();
|
||
all.forEach((r) => {
|
||
// 根据传入参数进行精确匹配:
|
||
// 1. 如果提供了 title,则按 title 精确匹配,否则按 query 精确匹配;
|
||
// 2. 如果还提供了 year,则额外按 year 精确匹配。
|
||
const titleMatch = title ? r.title === title : r.title === query;
|
||
const yearMatch = year ? r.year === year : true;
|
||
if (!titleMatch || !yearMatch) {
|
||
return;
|
||
}
|
||
// 如果还传入了 type,则按 type 精确匹配
|
||
if (type === 'tv' && r.episodes.length === 1) {
|
||
return;
|
||
}
|
||
if (type === 'movie' && r.episodes.length !== 1) {
|
||
return;
|
||
}
|
||
const key = `${r.title}-${r.year}`;
|
||
const arr = map.get(key) || [];
|
||
arr.push(r);
|
||
map.set(key, arr);
|
||
});
|
||
if (map.size === 0 && type) {
|
||
// 无匹配,忽略 type 做重新匹配
|
||
all.forEach((r) => {
|
||
const titleMatch = title ? r.title === title : r.title === query;
|
||
const yearMatch = year ? r.year === year : true;
|
||
if (!titleMatch || !yearMatch) {
|
||
return;
|
||
}
|
||
const key = `${r.title}-${r.year}`;
|
||
const arr = map.get(key) || [];
|
||
arr.push(r);
|
||
map.set(key, arr);
|
||
});
|
||
}
|
||
if (map.size == 1) {
|
||
setResults(Array.from(map.values()).flat());
|
||
} else if (map.size > 1) {
|
||
// 存在多个匹配,跳转到搜索页
|
||
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : '搜索失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchData();
|
||
}, [query, router]);
|
||
|
||
// 选出信息最完整的字段
|
||
const chooseString = (vals: (string | undefined)[]): string | undefined => {
|
||
return vals.reduce<string | undefined>((best, v) => {
|
||
if (!v) return best;
|
||
if (!best) return v;
|
||
return v.length > best.length ? v : best;
|
||
}, undefined);
|
||
};
|
||
// 出现次数最多的非 0 数字
|
||
const chooseNumber = (vals: (number | undefined)[]): number | undefined => {
|
||
const countMap = new Map<number, number>();
|
||
vals.forEach((v) => {
|
||
if (v !== undefined && v !== 0) {
|
||
countMap.set(v, (countMap.get(v) || 0) + 1);
|
||
}
|
||
});
|
||
let selected: number | undefined = undefined;
|
||
let maxCount = 0;
|
||
countMap.forEach((cnt, num) => {
|
||
if (cnt > maxCount) {
|
||
maxCount = cnt;
|
||
selected = num;
|
||
}
|
||
});
|
||
return selected;
|
||
};
|
||
|
||
const aggregatedInfo = {
|
||
title: title || query,
|
||
cover: chooseString(results.map((d) => d.poster)),
|
||
desc: chooseString(results.map((d) => d.desc)),
|
||
type: chooseString(results.map((d) => d.type_name)),
|
||
year: chooseString(results.map((d) => d.year)),
|
||
remarks: chooseString(results.map((d) => d.class)),
|
||
douban_id: chooseNumber(results.map((d) => d.douban_id)),
|
||
};
|
||
|
||
const infoReady = Boolean(
|
||
aggregatedInfo.cover ||
|
||
aggregatedInfo.desc ||
|
||
aggregatedInfo.type ||
|
||
aggregatedInfo.year ||
|
||
aggregatedInfo.remarks
|
||
);
|
||
|
||
const uniqueSources = Array.from(
|
||
new Map(results.map((r) => [r.source, r])).values()
|
||
);
|
||
|
||
// 详情映射,便于快速获取每个源的集数
|
||
const sourceDetailMap = new Map(results.map((d) => [d.source, d]));
|
||
|
||
// 新增:播放源卡片组件,包含收藏逻辑
|
||
const SourceCard = ({ src }: { src: SearchResult }) => {
|
||
const d = sourceDetailMap.get(src.source);
|
||
const epCount = d ? d.episodes.length : src.episodes.length;
|
||
|
||
const [favorited, setFavorited] = useState(false);
|
||
|
||
// 初次加载检查收藏状态
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const fav = await isFavorited(src.source, src.id);
|
||
setFavorited(fav);
|
||
} catch {
|
||
/* 忽略错误 */
|
||
}
|
||
})();
|
||
}, [src.source, src.id]);
|
||
|
||
// 切换收藏状态
|
||
const handleToggleFavorite = async (
|
||
e: React.MouseEvent<HTMLSpanElement | SVGElement, MouseEvent>
|
||
) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
try {
|
||
const newState = await toggleFavorite(src.source, src.id, {
|
||
title: src.title,
|
||
source_name: src.source_name,
|
||
year: src.year,
|
||
cover: src.poster,
|
||
total_episodes: src.episodes.length,
|
||
save_time: Date.now(),
|
||
});
|
||
setFavorited(newState);
|
||
} catch {
|
||
/* 忽略错误 */
|
||
}
|
||
};
|
||
|
||
return (
|
||
<a
|
||
key={src.source}
|
||
href={`/play?source=${src.source}&id=${
|
||
src.id
|
||
}&title=${encodeURIComponent(src.title.trim())}${
|
||
src.year ? `&year=${src.year}` : ''
|
||
}&from=aggregate`}
|
||
className='group relative flex items-center justify-center w-full h-14 bg-gray-500/80 hover:bg-green-500 dark:bg-gray-700/80 dark:hover:bg-green-600 rounded-lg transition-colors'
|
||
>
|
||
{/* 收藏爱心 */}
|
||
<span
|
||
onClick={handleToggleFavorite}
|
||
title={favorited ? '移除收藏' : '加入收藏'}
|
||
className={`absolute top-[2px] left-1 inline-flex items-center justify-center cursor-pointer transition-opacity duration-200 ${
|
||
favorited ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||
}`}
|
||
>
|
||
<Heart
|
||
className={`w-5 h-5 ${
|
||
favorited ? 'text-red-500' : 'text-white/90'
|
||
}`}
|
||
strokeWidth={2}
|
||
fill={favorited ? 'currentColor' : 'none'}
|
||
/>
|
||
</span>
|
||
|
||
{/* 名称 */}
|
||
<span className='px-1 text-white text-sm font-medium truncate whitespace-nowrap'>
|
||
{src.source_name}
|
||
</span>
|
||
{/* 集数徽标 */}
|
||
{epCount && epCount > 1 ? (
|
||
<span className='absolute top-[2px] right-1 text-[10px] font-semibold text-green-900 bg-green-300/90 rounded-full px-1 pointer-events-none'>
|
||
{epCount}集
|
||
</span>
|
||
) : null}
|
||
</a>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<PageLayout activePath='/aggregate'>
|
||
<div className='flex flex-col min-h-full px-2 sm:px-10 pt-4 sm:pt-8 pb-[calc(3.5rem+env(safe-area-inset-bottom))] overflow-visible'>
|
||
{loading ? (
|
||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||
</div>
|
||
) : error ? (
|
||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||
<div className='text-red-500 text-center'>
|
||
<div className='text-lg font-semibold mb-2'>加载失败</div>
|
||
<div className='text-sm'>{error}</div>
|
||
</div>
|
||
</div>
|
||
) : !infoReady ? (
|
||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||
<div className='text-gray-500 text-center'>
|
||
<div className='text-lg font-semibold mb-2'>未找到匹配结果</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className='max-w-[95%] mx-auto'>
|
||
{/* 主信息区:左图右文 */}
|
||
<div className='relative flex flex-col md:flex-row gap-8 mb-0 sm:mb-8 bg-transparent rounded-xl p-2 sm:p-6 md:items-start'>
|
||
{/* 封面 */}
|
||
<div className='flex-shrink-0 w-full max-w-[200px] sm:max-w-none md:w-72 mx-auto'>
|
||
<Image
|
||
src={aggregatedInfo.cover || '/images/placeholder.png'}
|
||
alt={aggregatedInfo.title}
|
||
width={288}
|
||
height={432}
|
||
className='w-full rounded-xl object-cover'
|
||
style={{ aspectRatio: '2/3' }}
|
||
priority
|
||
unoptimized
|
||
/>
|
||
</div>
|
||
{/* 右侧信息 */}
|
||
<div
|
||
className='flex-1 flex flex-col min-h-0'
|
||
style={{ height: '430px' }}
|
||
>
|
||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||
{aggregatedInfo.title}
|
||
{aggregatedInfo.douban_id && (
|
||
<a
|
||
href={`https://movie.douban.com/subject/${aggregatedInfo.douban_id}/`}
|
||
target='_blank'
|
||
rel='noopener noreferrer'
|
||
onClick={(e) => e.stopPropagation()}
|
||
className='ml-2'
|
||
>
|
||
<LinkIcon className='w-4 h-4' strokeWidth={2} />
|
||
</a>
|
||
)}
|
||
</h1>
|
||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||
{aggregatedInfo.remarks && (
|
||
<span className='text-green-600 font-semibold'>
|
||
{aggregatedInfo.remarks}
|
||
</span>
|
||
)}
|
||
{aggregatedInfo.year && <span>{aggregatedInfo.year}</span>}
|
||
{aggregatedInfo.type && <span>{aggregatedInfo.type}</span>}
|
||
</div>
|
||
<div
|
||
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||
style={{ whiteSpace: 'pre-line' }}
|
||
>
|
||
{aggregatedInfo.desc}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 选播放源 */}
|
||
{uniqueSources.length > 0 && (
|
||
<div className='mt-0 sm:mt-8 bg-transparent rounded-xl p-2 sm:p-6'>
|
||
<div className='flex items-center gap-2 mb-4'>
|
||
<div className='text-xl font-semibold'>选择播放源</div>
|
||
<div className='text-gray-400 ml-2'>
|
||
共 {uniqueSources.length} 个
|
||
</div>
|
||
</div>
|
||
<div className='grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,_minmax(6rem,_1fr))] sm:gap-4 justify-start'>
|
||
{uniqueSources.map((src) => (
|
||
<SourceCard key={src.source} src={src} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
export default function AggregatePage() {
|
||
return (
|
||
<Suspense>
|
||
<AggregatePageClient />
|
||
</Suspense>
|
||
);
|
||
}
|