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 ? (
-
- ) : !infoReady ? (
-
- ) : (
-
- {/* 主信息区:左图右文 */}
-
- {/* 封面 */}
-
-
-
- {/* 右侧信息 */}
-
-
-
- {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) && (