diff --git a/src/app/aggregate/page.tsx b/src/app/aggregate/page.tsx deleted file mode 100644 index 67275b4..0000000 --- a/src/app/aggregate/page.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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(); - 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((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(); - 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 - ) => { - 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 ( - - {/* 收藏爱心 */} - - - - - {/* 名称 */} - - {src.source_name} - - {/* 集数徽标 */} - {epCount && epCount > 1 ? ( - - {epCount}集 - - ) : null} - - ); - }; - - return ( - -
- {loading ? ( -
-
-
- ) : error ? ( -
-
-
加载失败
-
{error}
-
-
- ) : !infoReady ? ( -
-
-
未找到匹配结果
-
-
- ) : ( -
- {/* 主信息区:左图右文 */} -
- {/* 封面 */} -
- {aggregatedInfo.title} -
- {/* 右侧信息 */} -
-

- {aggregatedInfo.title} - {aggregatedInfo.douban_id && ( - e.stopPropagation()} - className='ml-2' - > - - - )} -

-
- {aggregatedInfo.remarks && ( - - {aggregatedInfo.remarks} - - )} - {aggregatedInfo.year && {aggregatedInfo.year}} - {aggregatedInfo.type && {aggregatedInfo.type}} -
-
- {aggregatedInfo.desc} -
-
-
- {/* 选播放源 */} - {uniqueSources.length > 0 && ( -
-
-
选择播放源
-
- 共 {uniqueSources.length} 个 -
-
-
- {uniqueSources.map((src) => ( - - ))} -
-
- )} -
- )} -
-
- ); -} - -export default function AggregatePage() { - return ( - - - - ); -} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c015acf..e7b2aee 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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 => { 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}

diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 80325af..958f3c8 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -186,11 +186,7 @@ function SearchPageClient() { ? aggregatedResults.map(([mapKey, group]) => { return (
- +
); }) diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index e48249a..fa79569 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -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 = ({ - query = '', - year = 0, - items, -}) => { +const AggregateCard: React.FC = ({ year = 0, items }) => { // 使用列表中的第一个结果做展示 & 播放 const first = items[0]; const [playHover, setPlayHover] = useState(false); @@ -118,11 +113,9 @@ const AggregateCard: React.FC = ({ return ( 1 ? 'tv' : 'movie'}`} + href={`/play?source=${first.source}&id=${ + first.id + }&title=${encodeURIComponent(first.title)}${year ? `&year=${year}` : ''}`} >
{/* 封面图片 2:3 */} @@ -162,7 +155,7 @@ const AggregateCard: React.FC = ({ first.id }&title=${encodeURIComponent(first.title)}${ year ? `&year=${year}` : '' - }&from=aggregate` + }` ); }} onMouseEnter={() => setPlayHover(true)} diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index f5a19ee..e062afc 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -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} /> - -
- -
-
+ ); } @@ -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' }`} > - +
diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 4c8ce49..993ceb5 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -14,9 +14,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { return (
{/* 移动端头部 */} - + {/* 主要布局容器 */}
@@ -28,7 +26,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { {/* 主内容区域 */}
{/* 桌面端左上角返回按钮 */} - {['/play', '/aggregate'].includes(activePath) && ( + {['/play'].includes(activePath) && (