diff --git a/src/app/aggregate/page.tsx b/src/app/aggregate/page.tsx index b0a5f03..f0fca45 100644 --- a/src/app/aggregate/page.tsx +++ b/src/app/aggregate/page.tsx @@ -2,6 +2,7 @@ 'use client'; +import { LinkIcon } from 'lucide-react'; import Image from 'next/image'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; @@ -15,6 +16,7 @@ function AggregatePageClient() { const query = searchParams.get('q')?.trim() || ''; const title = searchParams.get('title')?.trim() || ''; const year = searchParams.get('year')?.trim() || ''; + const type = searchParams.get('type')?.trim() || ''; const [results, setResults] = useState([]); const [loading, setLoading] = useState(true); @@ -46,11 +48,32 @@ function AggregatePageClient() { 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) { @@ -75,6 +98,24 @@ function AggregatePageClient() { 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, @@ -83,6 +124,7 @@ function AggregatePageClient() { 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( @@ -166,6 +208,17 @@ function AggregatePageClient() { >

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

{aggregatedInfo.remarks && ( diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0b95918..53be883 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -19,6 +19,7 @@ interface ApiSearchItem { vod_class?: string; vod_year?: string; vod_content?: string; + vod_douban_id?: number; type_name?: string; } @@ -48,7 +49,6 @@ async function searchFromApi( } const data = await response.json(); - if ( !data || !data.list || @@ -92,6 +92,7 @@ async function searchFromApi( year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || '' : '', desc: cleanHtmlTags(item.vod_content || ''), type_name: item.type_name, + douban_id: item.vod_douban_id, }; }); @@ -161,6 +162,7 @@ async function searchFromApi( : '', desc: cleanHtmlTags(item.vod_content || ''), type_name: item.type_name, + douban_id: item.vod_douban_id, }; }); } catch (error) { diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 8e6ac90..713e7a4 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Heart } from 'lucide-react'; +import { Heart, LinkIcon } from 'lucide-react'; import Image from 'next/image'; import { useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; @@ -173,6 +173,17 @@ function DetailPageClient() { >

{detail.title || fallbackTitle} + {detail.douban_id && ( + e.stopPropagation()} + className='ml-2' + > + + + )}

{detail.class && ( diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index 61f793d..92628be 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -203,6 +203,7 @@ function DoubanPageClient() { title={item.title} poster={item.poster} rate={item.rate} + type={type || 'movie'} />
))} diff --git a/src/app/page.tsx b/src/app/page.tsx index 25c1c17..b77515a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -197,6 +197,7 @@ function HomeClient() { title={movie.title} poster={movie.poster} rate={movie.rate} + type='movie' />
))} @@ -242,6 +243,7 @@ function HomeClient() { title={show.title} poster={show.poster} rate={show.rate} + type='tv' /> ))} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index a051005..a819cc5 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -494,7 +494,12 @@ function PlayPageClient() { result.title.toLowerCase() === videoTitle.toLowerCase() && (videoYear ? result.year.toLowerCase() === videoYear.toLowerCase() - : true) + : true) && + detailRef.current?.episodes.length && + ((detailRef.current?.episodes.length === 1 && + result.episodes.length === 1) || + (detailRef.current?.episodes.length > 1 && + result.episodes.length > 1)) ); if (exactMatch) { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index f8f8427..e2bc107 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -44,7 +44,9 @@ function SearchPageClient() { const map = new Map(); searchResults.forEach((item) => { // 使用 title + year 作为键,若 year 不存在则使用 'unknown' - const key = `${item.title}-${item.year || 'unknown'}`; + const key = `${item.title}-${item.year || 'unknown'}-${ + item.episodes.length === 1 ? 'movie' : 'tv' + }`; const arr = map.get(key) || []; arr.push(item); map.set(key, arr); @@ -196,6 +198,7 @@ function SearchPageClient() { episodes={item.episodes.length} source={item.source} source_name={item.source_name} + douban_id={item.douban_id} from='search' /> diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index f4b4192..e2d6f0f 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -1,7 +1,8 @@ +import { LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; // 聚合卡需要的基本字段,与搜索接口保持一致 interface SearchResult { @@ -10,6 +11,7 @@ interface SearchResult { poster: string; source: string; source_name: string; + douban_id?: number; episodes: string[]; } @@ -64,13 +66,60 @@ const AggregateCard: React.FC = ({ const [playHover, setPlayHover] = useState(false); const router = useRouter(); + // 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面 + const mostFrequentDoubanId = useMemo(() => { + const countMap = new Map(); + + items.forEach((item) => { + if (item.douban_id && item.douban_id !== 0) { + countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1); + } + }); + + let selectedId: number | undefined; + let maxCount = 0; + + countMap.forEach((cnt, id) => { + if (cnt > maxCount) { + maxCount = cnt; + selectedId = id; + } + }); + + return selectedId; + }, [items]); + + // 统计出现次数最多的集数(episodes.length),主要用于显示剧集数徽标 + const mostFrequentEpisodes = useMemo(() => { + const countMap = new Map(); + + items.forEach((item) => { + const len = item.episodes?.length || 0; + if (len > 0) { + countMap.set(len, (countMap.get(len) || 0) + 1); + } + }); + + let selectedLen = 0; + let maxCount = 0; + + countMap.forEach((cnt, len) => { + if (cnt > maxCount) { + maxCount = cnt; + selectedLen = len; + } + }); + + return selectedLen; + }, [items]); + return ( 1 ? 'tv' : 'movie'}`} >
{/* 封面图片 2:3 */} @@ -83,6 +132,15 @@ const AggregateCard: React.FC = ({ unoptimized /> + {/* 集数指示器 - 绿色小圆球 */} + {mostFrequentEpisodes && mostFrequentEpisodes > 1 && ( +
+ + {mostFrequentEpisodes} + +
+ )} + {/* Hover 层 & 播放按钮 */}
@@ -109,6 +167,20 @@ const AggregateCard: React.FC = ({
+ + {mostFrequentDoubanId && ( + e.stopPropagation()} + className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200' + > +
+ +
+
+ )} {/* 标题 */} diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index 2d4abf5..0af6db6 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -8,6 +8,7 @@ interface DemoCardProps { title: string; poster: string; rate?: string; + type?: string; } function SearchCircle({ @@ -54,12 +55,12 @@ function SearchCircle({ ); } -const DemoCard = ({ id, title, poster, rate }: DemoCardProps) => { +const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { const [hover, setHover] = useState(false); const router = useRouter(); const handleClick = () => { - router.push(`/aggregate?q=${encodeURIComponent(title)}`); + router.push(`/aggregate?q=${encodeURIComponent(title)}&type=${type}`); }; return ( diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index b7d2856..4453a62 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,4 +1,4 @@ -import { Heart } from 'lucide-react'; +import { Heart, LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; @@ -17,6 +17,7 @@ interface VideoCardProps { year?: string; from?: string; currentEpisode?: number; + douban_id?: number; onDelete?: () => void; } @@ -83,6 +84,7 @@ export default function VideoCard({ year, from, currentEpisode, + douban_id, onDelete, }: VideoCardProps) { const [playHover, setPlayHover] = useState(false); @@ -243,6 +245,20 @@ export default function VideoCard({ )} + + {douban_id && from === 'search' && ( + e.stopPropagation()} + className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200' + > +
+ +
+
+ )} {/* 信息层 */} diff --git a/src/lib/fetchVideoDetail.ts b/src/lib/fetchVideoDetail.ts index a5d6fab..a34e940 100644 --- a/src/lib/fetchVideoDetail.ts +++ b/src/lib/fetchVideoDetail.ts @@ -9,6 +9,7 @@ export interface VideoDetail { year: string; desc?: string; type_name?: string; + douban_id?: number; } interface FetchVideoDetailOptions { diff --git a/src/lib/types.ts b/src/lib/types.ts index d9abe6a..8a2a3a8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -29,6 +29,7 @@ export interface SearchResult { year: string; desc?: string; type_name?: string; + douban_id?: number; } export interface DoubanItem {