feat: remove aggregate page, use douban_id to assist search

This commit is contained in:
shinya
2025-07-08 00:34:56 +08:00
parent 33dd8dc4e9
commit a589a85921
6 changed files with 117 additions and 413 deletions

View File

@@ -1,332 +0,0 @@
/* 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>
);
}

View File

@@ -55,8 +55,12 @@ function PlayPageClient() {
});
// 视频基本信息
const [videoType, setVideoType] = useState(searchParams.get('type') || '');
const [videoDoubanId, setVideoDoubanId] = useState(
searchParams.get('douban_id') || ''
);
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
const videoYear = searchParams.get('year') || '';
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
const [videoCover, setVideoCover] = useState('');
// 当前源和ID
const [currentSource, setCurrentSource] = useState(
@@ -228,48 +232,77 @@ function PlayPageClient() {
// 获取视频详情
useEffect(() => {
if (!currentSource || !currentId) {
setError('缺少必要参数');
setLoading(false);
return;
}
const fetchDetail = async () => {
try {
const detailData = await fetchVideoDetail({
source: currentSource,
id: currentId,
fallbackTitle: videoTitle.trim(),
fallbackYear: videoYear,
});
// 更新状态保存详情
setVideoTitle(detailData.title || videoTitle);
setVideoCover(detailData.poster);
setDetail(detailData);
// 确保集数索引在有效范围内
if (currentEpisodeIndex >= detailData.episodes.length) {
console.log('currentEpisodeIndex', currentEpisodeIndex);
setCurrentEpisodeIndex(0);
}
// 清理URL参数移除index参数
if (searchParams.has('index')) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('index');
newUrl.searchParams.delete('position');
window.history.replaceState({}, '', newUrl.toString());
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取视频详情失败');
} finally {
const fetchDetailAsync = async () => {
if (!currentSource && !currentId && !videoTitle) {
setError('缺少必要参数');
setLoading(false);
return;
}
if (!currentSource && !currentId) {
// 只包含视频标题,搜索视频
setLoading(true);
const searchResults = await handleSearchSources(videoTitle);
console.log('searchResults', searchResults);
if (searchResults.length == 0) {
setError('未找到匹配结果');
setLoading(false);
return;
}
setCurrentSource(searchResults[0].source);
setCurrentId(searchResults[0].id);
setVideoYear(searchResults[0].year);
setVideoType('');
setVideoDoubanId(''); // 清空豆瓣ID
// 替换URL参数
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', searchResults[0].source);
newUrl.searchParams.set('id', searchResults[0].id);
newUrl.searchParams.set('year', searchResults[0].year);
newUrl.searchParams.delete('douban_id');
window.history.replaceState({}, '', newUrl.toString());
return;
}
const fetchDetail = async () => {
try {
const detailData = await fetchVideoDetail({
source: currentSource,
id: currentId,
fallbackTitle: videoTitle.trim(),
fallbackYear: videoYear,
});
// 更新状态保存详情
setVideoTitle(detailData.title || videoTitle);
setVideoCover(detailData.poster);
setDetail(detailData);
// 确保集数索引在有效范围内
if (currentEpisodeIndex >= detailData.episodes.length) {
console.log('currentEpisodeIndex', currentEpisodeIndex);
setCurrentEpisodeIndex(0);
}
// 清理URL参数移除index参数
if (searchParams.has('index')) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('index');
newUrl.searchParams.delete('position');
window.history.replaceState({}, '', newUrl.toString());
}
} catch (err) {
console.error('获取视频详情失败:', err);
} finally {
setLoading(false);
}
};
fetchDetail();
};
fetchDetail();
}, [currentSource]);
fetchDetailAsync();
}, [currentSource, currentId]);
// 播放记录处理
useEffect(() => {
@@ -332,10 +365,12 @@ function PlayPageClient() {
// 换源搜索与切换
// ---------------------------------------------------------------------------
// 处理换源搜索
const handleSearchSources = async (query: string) => {
const handleSearchSources = async (
query: string
): Promise<SearchResult[]> => {
if (!query.trim()) {
setAvailableSources([]);
return;
return [];
}
setSourceSearchLoading(true);
@@ -370,27 +405,37 @@ function PlayPageClient() {
if (results.length === 0) return;
// 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配
const exactMatch = results.find(
const exactMatchs = results.filter(
(result) =>
result.title.toLowerCase() === videoTitle.toLowerCase() &&
(videoYear
? result.year.toLowerCase() === videoYear.toLowerCase()
: true) &&
detail?.episodes.length &&
((detail?.episodes.length === 1 && result.episodes.length === 1) ||
(detail?.episodes.length > 1 && result.episodes.length > 1))
(detail
? (detail.episodes.length === 1 &&
result.episodes.length === 1) ||
(detail.episodes.length > 1 && result.episodes.length > 1)
: true) &&
(videoDoubanId && result.douban_id
? result.douban_id.toString() === videoDoubanId
: true) &&
(videoType
? (videoType === 'movie' && result.episodes.length === 1) ||
(videoType === 'tv' && result.episodes.length > 1)
: true)
);
if (exactMatch) {
processedResults.push(exactMatch);
return;
if (exactMatchs.length > 0) {
processedResults.push(...exactMatchs);
}
});
console.log('processedResults', processedResults);
setAvailableSources(processedResults);
return processedResults;
} catch (err) {
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
setAvailableSources([]);
return [];
} finally {
setSourceSearchLoading(false);
}
@@ -1142,10 +1187,16 @@ function PlayPageClient() {
{error}
</p>
<button
onClick={() => window.location.reload()}
onClick={() =>
videoTitle
? (window.location.href = `/search?q=${encodeURIComponent(
videoTitle
)}`)
: window.history.back()
}
className='px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors'
>
{videoTitle ? '返回搜索' : '返回'}
</button>
</div>
</div>

View File

@@ -186,11 +186,7 @@ function SearchPageClient() {
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<AggregateCard
items={group}
query={searchQuery}
year={group[0].year}
/>
<AggregateCard items={group} year={group[0].year} />
</div>
);
})

View File

@@ -19,7 +19,6 @@ interface SearchResult {
interface AggregateCardProps {
/** 同一标题下的多个搜索结果 */
query?: string;
year?: string;
items: SearchResult[];
}
@@ -58,11 +57,7 @@ function PlayCircleSolid({
* 点击播放按钮 -> 跳到第一个源播放
* 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate)
*/
const AggregateCard: React.FC<AggregateCardProps> = ({
query = '',
year = 0,
items,
}) => {
const AggregateCard: React.FC<AggregateCardProps> = ({ year = 0, items }) => {
// 使用列表中的第一个结果做展示 & 播放
const first = items[0];
const [playHover, setPlayHover] = useState(false);
@@ -118,11 +113,9 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
return (
<Link
href={`/aggregate?q=${encodeURIComponent(
query.trim()
)}&title=${encodeURIComponent(first.title)}${
year ? `&year=${encodeURIComponent(year)}` : ''
}&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`}
href={`/play?source=${first.source}&id=${
first.id
}&title=${encodeURIComponent(first.title)}${year ? `&year=${year}` : ''}`}
>
<div className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'>
{/* 封面图片 2:3 */}
@@ -162,7 +155,7 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
first.id
}&title=${encodeURIComponent(first.title)}${
year ? `&year=${year}` : ''
}&from=aggregate`
}`
);
}}
onMouseEnter={() => setPlayHover(true)}

View File

@@ -1,4 +1,4 @@
import { Link as LinkIcon, Search } from 'lucide-react';
import { Link as LinkIcon } from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import React, { useRef, useState } from 'react';
@@ -13,7 +13,7 @@ interface DemoCardProps {
type?: string;
}
function SearchCircle({
function PlayCircleSolid({
className = '',
fillColor = 'none',
}: {
@@ -37,11 +37,7 @@ function SearchCircle({
strokeWidth='1.5'
fill={fillColor}
/>
<foreignObject x='0' y='0' width='44' height='44'>
<div className='w-full h-full flex items-center justify-center'>
<Search className='h-5 w-5 text-white' strokeWidth={2} />
</div>
</foreignObject>
<polygon points='19,15 19,29 29,22' fill='white' />
</svg>
);
}
@@ -54,7 +50,9 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
const handleClick = () => {
router.push(
`/aggregate?q=${encodeURIComponent(title.trim())}&type=${type}`
`/play?title=${encodeURIComponent(
title.trim()
)}&douban_id=${id}&type=${type}`
);
};
@@ -103,7 +101,7 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
hover ? 'scale-110 rotate-12' : 'scale-90'
}`}
>
<SearchCircle fillColor={hover ? '#22c55e' : 'none'} />
<PlayCircleSolid fillColor={hover ? '#22c55e' : 'none'} />
</div>
</div>

View File

@@ -14,9 +14,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
return (
<div className='w-full min-h-screen'>
{/* 移动端头部 */}
<MobileHeader
showBackButton={['/play', '/aggregate'].includes(activePath)}
/>
<MobileHeader showBackButton={['/play'].includes(activePath)} />
{/* 主要布局容器 */}
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
@@ -28,7 +26,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
{/* 主内容区域 */}
<div className='relative min-w-0 flex-1 transition-all duration-300'>
{/* 桌面端左上角返回按钮 */}
{['/play', '/aggregate'].includes(activePath) && (
{['/play'].includes(activePath) && (
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
<BackButton />
</div>