From 5022fc401572232a949c18854c130877683cebdd Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 24 Jun 2025 13:01:12 +0800 Subject: [PATCH] feat: add douban rate, aggregate page --- src/app/aggregate/page.tsx | 259 ++++++++++++++++++++++++++++++++++++ src/app/api/douban/route.ts | 7 +- src/app/douban/page.tsx | 7 +- src/app/page.tsx | 13 +- src/components/DemoCard.tsx | 13 +- 5 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 src/app/aggregate/page.tsx diff --git a/src/app/aggregate/page.tsx b/src/app/aggregate/page.tsx new file mode 100644 index 0000000..3d46707 --- /dev/null +++ b/src/app/aggregate/page.tsx @@ -0,0 +1,259 @@ +/* eslint-disable react-hooks/exhaustive-deps, no-console */ + +'use client'; + +import Image from 'next/image'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; + +import type { VideoDetail } from '@/lib/types'; + +import PageLayout from '@/components/PageLayout'; + +interface SearchResult { + id: string; + title: string; + poster: string; + episodes?: number; + source: string; + source_name: string; +} + +function AggregatePageClient() { + const searchParams = useSearchParams(); + const query = searchParams.get('q')?.trim() || ''; + + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [details, setDetails] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + useEffect(() => { + if (!query) { + setError('缺少搜索关键词'); + setLoading(false); + return; + } + + const fetchData = async () => { + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + if (!res.ok) { + throw new Error('搜索失败'); + } + const data = await res.json(); + const all: SearchResult[] = data.results || []; + const exact = all.filter((r) => r.title === query); + setResults(exact); + } catch (e) { + setError(e instanceof Error ? e.message : '搜索失败'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [query]); + + useEffect(() => { + if (results.length === 0) return; + + const fetchDetails = async () => { + setDetailLoading(true); + try { + const promises = results.map(async (r) => { + try { + const res = await fetch( + `/api/detail?source=${r.source}&id=${r.id}` + ); + if (!res.ok) throw new Error(''); + const data: VideoDetail = await res.json(); + return data; + } catch { + return null; + } + }); + const dts = (await Promise.all(promises)).filter( + (d): d is VideoDetail => d !== null + ); + setDetails(dts); + } finally { + setDetailLoading(false); + } + }; + + fetchDetails(); + }, [results]); + + // 选出信息最完整的字段 + 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); + }; + + const aggregatedInfo = { + title: query, + cover: chooseString(details.map((d) => d.videoInfo.cover)), + desc: chooseString(details.map((d) => d.videoInfo.desc)), + type: chooseString(details.map((d) => d.videoInfo.type)), + year: chooseString(details.map((d) => d.videoInfo.year)), + remarks: chooseString(details.map((d) => d.videoInfo.remarks)), + }; + + 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(details.map((d) => [d.videoInfo.source, d])); + + return ( + +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+
+
加载失败
+
{error}
+
+
+ ) : !infoReady && detailLoading ? ( +
+
+
+ ) : !infoReady ? ( +
+
+
未找到匹配结果
+
+
+ ) : ( +
+ {/* 主信息区:左图右文 */} +
+ {/* 返回按钮 */} + + {/* 封面 */} +
+ {aggregatedInfo.title} +
+ {/* 右侧信息 */} +
+

+ {aggregatedInfo.title} +

+
+ {aggregatedInfo.remarks && ( + + {aggregatedInfo.remarks} + + )} + {aggregatedInfo.year && {aggregatedInfo.year}} + {aggregatedInfo.type && {aggregatedInfo.type}} +
+
+ {aggregatedInfo.desc} +
+
+
+ {/* 选播放源 */} + {uniqueSources.length > 0 && ( +
+
+
选择播放源
+
+ 共 {uniqueSources.length} 个 +
+
+
+ {uniqueSources.map((src) => { + const d = sourceDetailMap.get(src.source); + const epCount = d ? d.episodes.length : src.episodes; + return ( + + {/* 名称 */} + + {src.source_name} + + {/* 集数徽标 */} + {epCount && epCount > 1 ? ( + + {epCount}集 + + ) : null} + + ); + })} +
+
+ )} +
+ )} +
+
+ ); +} + +export default function AggregatePage() { + return ( + + + + ); +} diff --git a/src/app/api/douban/route.ts b/src/app/api/douban/route.ts index 75b45a6..6d537cd 100644 --- a/src/app/api/douban/route.ts +++ b/src/app/api/douban/route.ts @@ -5,6 +5,7 @@ import { getCacheTime } from '@/lib/config'; interface DoubanItem { title: string; poster: string; + rate: string; } interface DoubanResponse { @@ -17,6 +18,7 @@ interface DoubanApiResponse { subjects: Array<{ title: string; cover: string; + rate: string; }>; } @@ -104,6 +106,7 @@ export async function GET(request: Request) { const list: DoubanItem[] = doubanData.subjects.map((item) => ({ title: item.title, poster: item.cover, + rate: item.rate, })); const response: DoubanResponse = { @@ -157,13 +160,14 @@ function handleTop250(pageStart: number) { // 使用正则表达式提取电影信息 const moviePattern = - /
[\s\S]*?/g; + /
[\s\S]*?]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?]*>([^<]+)<\/span>[\s\S]*?<\/div>/g; const movies: DoubanItem[] = []; let match; while ((match = moviePattern.exec(html)) !== null) { const title = match[1]; const cover = match[2]; + const rate = match[3] || ''; // 处理图片 URL,确保使用 HTTPS const processedCover = cover.replace(/^http:/, 'https:'); @@ -171,6 +175,7 @@ function handleTop250(pageStart: number) { movies.push({ title: title, poster: processedCover, + rate: rate, }); } diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index 0c56d9b..4ad8dae 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -12,6 +12,7 @@ import PageLayout from '@/components/PageLayout'; interface DoubanItem { title: string; poster: string; + rate?: string; } // 定义豆瓣响应类型 @@ -209,7 +210,11 @@ function DoubanPageClient() { : // 显示实际数据 doubanData.map((item, index) => (
- +
))}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 00e5bff..d7e41c9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,6 +15,7 @@ import VideoCard from '@/components/VideoCard'; interface DoubanItem { title: string; poster: string; + rate?: string; } interface DoubanResponse { @@ -161,7 +162,11 @@ function HomeClient() { key={index} className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' > - +
))} @@ -192,7 +197,11 @@ function HomeClient() { key={index} className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' > - + ))} diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index 22bddc7..56fbc82 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; interface DemoCardProps { title: string; poster: string; + rate?: string; } function SearchCircle({ @@ -52,12 +53,12 @@ function SearchCircle({ ); } -const DemoCard = ({ title, poster }: DemoCardProps) => { +const DemoCard = ({ title, poster, rate }: DemoCardProps) => { const [hover, setHover] = useState(false); const router = useRouter(); const handleClick = () => { - router.push(`/search?q=${encodeURIComponent(title)}`); + router.push(`/aggregate?q=${encodeURIComponent(title)}`); }; return ( @@ -74,6 +75,14 @@ const DemoCard = ({ title, poster }: DemoCardProps) => { className='object-cover' referrerPolicy='no-referrer' /> + {/* 评分徽章 */} + {rate && ( +
+ + {rate} + +
+ )} {/* Hover 效果 */}