mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-12 08:47:28 +08:00
feat: search api/page && detail api/page
This commit is contained in:
219
src/app/api/detail/route.ts
Normal file
219
src/app/api/detail/route.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
|
||||
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
export interface VideoDetail {
|
||||
code: number;
|
||||
episodes: string[];
|
||||
detailUrl: string;
|
||||
videoInfo: {
|
||||
title: string;
|
||||
cover?: string;
|
||||
desc?: string;
|
||||
type?: string;
|
||||
year?: string;
|
||||
area?: string;
|
||||
director?: string;
|
||||
actor?: string;
|
||||
remarks?: string;
|
||||
source_name: string;
|
||||
source: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSpecialSourceDetail(
|
||||
id: string,
|
||||
apiSite: ApiSite
|
||||
): Promise<VideoDetail> {
|
||||
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情页请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
let matches: string[] = [];
|
||||
|
||||
if (apiSite.key === 'ffzy') {
|
||||
const ffzyPattern =
|
||||
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
|
||||
matches = html.match(ffzyPattern) || [];
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
matches = html.match(generalPattern) || [];
|
||||
}
|
||||
|
||||
matches = Array.from(new Set(matches));
|
||||
matches = matches.map((link: string) => {
|
||||
link = link.substring(1, link.length);
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
|
||||
const titleText = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
const descMatch = html.match(
|
||||
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
|
||||
);
|
||||
const descText = descMatch
|
||||
? descMatch[1].replace(/<[^>]+>/g, ' ').trim()
|
||||
: '';
|
||||
|
||||
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
|
||||
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
episodes: matches,
|
||||
detailUrl: detailUrl,
|
||||
videoInfo: {
|
||||
title: titleText,
|
||||
cover: coverUrl,
|
||||
desc: descText,
|
||||
source_name: apiSite.name,
|
||||
source: apiSite.key,
|
||||
id: id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getDetailFromApi(
|
||||
apiSite: ApiSite,
|
||||
id: string
|
||||
): Promise<VideoDetail> {
|
||||
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
throw new Error('获取到的详情内容无效');
|
||||
}
|
||||
|
||||
const videoDetail = data.list[0];
|
||||
let episodes: string[] = [];
|
||||
|
||||
if (videoDetail.vod_play_url) {
|
||||
const playSources = videoDetail.vod_play_url.split('$$$');
|
||||
|
||||
if (playSources.length > 0) {
|
||||
const mainSource = playSources[0];
|
||||
const episodeList = mainSource.split('#');
|
||||
|
||||
episodes = episodeList
|
||||
.map((ep: string) => {
|
||||
const parts = ep.split('$');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
})
|
||||
.filter(
|
||||
(url: string) =>
|
||||
url && (url.startsWith('http://') || url.startsWith('https://'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (episodes.length === 0 && videoDetail.vod_content) {
|
||||
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
|
||||
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
episodes: episodes,
|
||||
detailUrl: detailUrl,
|
||||
videoInfo: {
|
||||
title: videoDetail.vod_name,
|
||||
cover: videoDetail.vod_pic,
|
||||
desc: videoDetail.vod_content,
|
||||
type: videoDetail.type_name,
|
||||
year: videoDetail.vod_year,
|
||||
area: videoDetail.vod_area,
|
||||
director: videoDetail.vod_director,
|
||||
actor: videoDetail.vod_actor,
|
||||
remarks: videoDetail.vod_remarks,
|
||||
source_name: apiSite.name,
|
||||
source: apiSite.key,
|
||||
id: id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVideoDetail(
|
||||
id: string,
|
||||
sourceCode: string
|
||||
): Promise<VideoDetail> {
|
||||
if (!id) {
|
||||
throw new Error('缺少视频ID参数');
|
||||
}
|
||||
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw new Error('无效的视频ID格式');
|
||||
}
|
||||
|
||||
const apiSites = getApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
|
||||
if (!apiSite) {
|
||||
throw new Error('无效的API来源');
|
||||
}
|
||||
|
||||
if (apiSite.detail) {
|
||||
return await handleSpecialSourceDetail(id, apiSite);
|
||||
} else {
|
||||
return await getDetailFromApi(apiSite, id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getVideoDetail(id, sourceCode);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ hello: 'Next.js' });
|
||||
}
|
||||
177
src/app/api/search/route.ts
Normal file
177
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
|
||||
|
||||
import { getVideoDetail } from '../detail/route';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes?: number;
|
||||
source: string;
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
interface ApiSearchItem {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic: string;
|
||||
vod_remarks?: string;
|
||||
}
|
||||
|
||||
async function searchFromApi(
|
||||
apiSite: ApiSite,
|
||||
query: string
|
||||
): Promise<SearchResult[]> {
|
||||
try {
|
||||
const apiBaseUrl = apiSite.api;
|
||||
const apiUrl =
|
||||
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
|
||||
const apiName = apiSite.name;
|
||||
|
||||
// 添加超时处理
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 处理第一页结果
|
||||
const results = data.list.map((item: ApiSearchItem) => ({
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
poster: item.vod_pic,
|
||||
episodes: item.vod_remarks ? parseInt(item.vod_remarks) : undefined,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
}));
|
||||
|
||||
// 获取总页数
|
||||
const pageCount = data.pagecount || 1;
|
||||
// 确定需要获取的额外页数
|
||||
const pagesToFetch = Math.min(
|
||||
pageCount - 1,
|
||||
API_CONFIG.search.maxPages - 1
|
||||
);
|
||||
|
||||
// 如果有额外页数,获取更多页的结果
|
||||
if (pagesToFetch > 0) {
|
||||
const additionalPagePromises = [];
|
||||
|
||||
for (let page = 2; page <= pagesToFetch + 1; page++) {
|
||||
const pageUrl =
|
||||
apiBaseUrl +
|
||||
API_CONFIG.search.pagePath
|
||||
.replace('{query}', encodeURIComponent(query))
|
||||
.replace('{page}', page.toString());
|
||||
|
||||
const pagePromise = (async () => {
|
||||
try {
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(
|
||||
() => pageController.abort(),
|
||||
8000
|
||||
);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: pageController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (!pageResponse.ok) return [];
|
||||
|
||||
const pageData = await pageResponse.json();
|
||||
|
||||
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
|
||||
return [];
|
||||
|
||||
return pageData.list.map((item: ApiSearchItem) => ({
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
poster: item.vod_pic,
|
||||
episodes: item.vod_remarks
|
||||
? parseInt(item.vod_remarks)
|
||||
: undefined,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
}));
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
additionalPagePromises.push(pagePromise);
|
||||
}
|
||||
|
||||
// 等待所有额外页的结果
|
||||
const additionalResults = await Promise.all(additionalPagePromises);
|
||||
|
||||
// 合并所有页的结果
|
||||
additionalResults.forEach((pageResults) => {
|
||||
if (pageResults.length > 0) {
|
||||
results.push(...pageResults);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取每个结果的详情
|
||||
const detailPromises = results.map(async (result: SearchResult) => {
|
||||
try {
|
||||
const detail = await getVideoDetail(result.id, result.source);
|
||||
if (detail.episodes.length > 1) {
|
||||
return { ...result, episodes: detail.episodes.length };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
return await Promise.all(detailPromises);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
const apiSites = getApiSites();
|
||||
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
|
||||
|
||||
try {
|
||||
const results = await Promise.all(searchPromises);
|
||||
const flattenedResults = results.flat();
|
||||
|
||||
return NextResponse.json({ results: flattenedResults });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
158
src/app/detail/page.tsx
Normal file
158
src/app/detail/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
|
||||
import { VideoDetail } from '../api/detail/route';
|
||||
|
||||
export default function DetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!source || !id) {
|
||||
setError('缺少必要参数');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
setDetail(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar activePath='/detail' />
|
||||
<main className='flex-1 p-8 flex flex-col items-center'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<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-screen'>
|
||||
<div className='text-red-500'>{error}</div>
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-gray-500'>未找到视频详情</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full max-w-[90%]'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-full md:w-64'>
|
||||
<Image
|
||||
src={detail.videoInfo.cover || '/images/placeholder.png'}
|
||||
alt={detail.videoInfo.title}
|
||||
width={256}
|
||||
height={384}
|
||||
className='w-full rounded-xl object-cover'
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧信息 */}
|
||||
<div className='flex-1 flex flex-col justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center'>
|
||||
{detail.videoInfo.title}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80'>
|
||||
{detail.videoInfo.remarks && (
|
||||
<span className='text-red-500 font-semibold'>
|
||||
{detail.videoInfo.remarks}
|
||||
</span>
|
||||
)}
|
||||
{detail.videoInfo.year && (
|
||||
<span>{detail.videoInfo.year}</span>
|
||||
)}
|
||||
{detail.videoInfo.source_name && (
|
||||
<span>{detail.videoInfo.source_name}</span>
|
||||
)}
|
||||
{detail.videoInfo.type && (
|
||||
<span>{detail.videoInfo.type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-4 mb-4'>
|
||||
<button className='flex items-center justify-center gap-2 px-6 py-2 bg-gray-500/40 hover:bg-[#22c55e] rounded-lg transition-colors text-white'>
|
||||
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
|
||||
<span>播放</span>
|
||||
</button>
|
||||
<button className='flex items-center justify-center w-10 h-10 bg-gray-500/40 hover:bg-[#22c55e] rounded-full transition-colors'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-5 w-5 text-white'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{detail.videoInfo.desc && (
|
||||
<div className='mt-4 text-base leading-relaxed opacity-90 max-h-40 overflow-y-auto pr-2'>
|
||||
{detail.videoInfo.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 选集按钮区 */}
|
||||
{detail.episodes.length > 0 && (
|
||||
<div className='mt-8 bg-transparent rounded-xl 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'>
|
||||
共 {detail.episodes.length} 集
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{detail.episodes.map((episode, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={episode}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='bg-gray-500/40 hover:bg-[#22c55e] text-white px-5 py-2 rounded-lg shadow transition-colors text-base font-medium w-24 text-center'
|
||||
>
|
||||
第{idx + 1}集
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/page.tsx
137
src/app/page.tsx
@@ -3,9 +3,9 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import DemoCard from '@/components/card/DemoCard';
|
||||
import VideoCard from '@/components/card/VideoCard';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
const defaultPoster =
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg';
|
||||
@@ -14,57 +14,46 @@ const defaultPoster =
|
||||
const mockData = {
|
||||
recentMovies: [
|
||||
{
|
||||
id: 1,
|
||||
id: '1',
|
||||
title: '流浪地球2',
|
||||
poster: defaultPoster,
|
||||
rating: 8.3,
|
||||
type: 'movie' as const,
|
||||
source: '电影天堂',
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: '2',
|
||||
title: '满江红',
|
||||
poster: defaultPoster,
|
||||
rating: 7.5,
|
||||
type: 'movie' as const,
|
||||
source: '电影天堂',
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
recentTvShows: [
|
||||
{
|
||||
id: 3,
|
||||
id: '3',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
rating: 8.7,
|
||||
type: 'tv' as const,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
source: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
id: '4',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
rating: 8.5,
|
||||
type: 'tv' as const,
|
||||
episodes: 39,
|
||||
source: '电影天堂',
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('home'); // 'home' 或 'favorites'
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar onToggle={setSidebarCollapsed} />
|
||||
|
||||
<main
|
||||
className={`flex-1 px-10 py-8 transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
}`}
|
||||
>
|
||||
<PageLayout>
|
||||
<div className='px-10 py-8'>
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
@@ -77,54 +66,52 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
继续观看
|
||||
</h2>
|
||||
<div className='flex space-x-8 overflow-x-auto pb-2'>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows]
|
||||
.slice(0, 4)
|
||||
.map((item) => (
|
||||
<div key={item.id} className='min-w-[192px] w-48'>
|
||||
<VideoCard
|
||||
{...item}
|
||||
showProgress={true}
|
||||
progress={Math.random() * 100}
|
||||
/>
|
||||
<div className='max-w-[90%] mx-auto'>
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
继续观看
|
||||
</h2>
|
||||
<div className='flex space-x-8 overflow-x-auto pb-2'>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows]
|
||||
.slice(0, 4)
|
||||
.map((item) => (
|
||||
<div key={item.id} className='min-w-[192px] w-48'>
|
||||
<VideoCard {...item} progress={Math.random() * 100} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最新电影 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电影
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentMovies.map((movie) => (
|
||||
<div key={movie.id} className='w-48'>
|
||||
<DemoCard {...movie} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最新电影 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电影
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentMovies.map((movie) => (
|
||||
<div key={movie.id} className='w-48'>
|
||||
<DemoCard {...movie} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最新电视剧 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电视剧
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentTvShows.map((show) => (
|
||||
<div key={show.id} className='w-48'>
|
||||
<DemoCard {...show} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{/* 最新电视剧 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电视剧
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentTvShows.map((show) => (
|
||||
<div key={show.id} className='w-48'>
|
||||
<DemoCard {...show} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,42 +4,29 @@ import { Search } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import VideoCard from '@/components/card/VideoCard';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
// 模拟搜索历史数据
|
||||
const mockSearchHistory = ['流浪地球', '三体', '狂飙', '满江红'];
|
||||
|
||||
// 模拟搜索结果数据
|
||||
const mockSearchResults = [
|
||||
{
|
||||
id: 1,
|
||||
title: '流浪地球2',
|
||||
poster:
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg',
|
||||
type: 'movie' as const,
|
||||
source: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '三体',
|
||||
poster:
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg',
|
||||
type: 'tv' as const,
|
||||
episodes: 30,
|
||||
source: '电影天堂',
|
||||
},
|
||||
];
|
||||
// 定义搜索结果类型
|
||||
type SearchResult = {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
source: string;
|
||||
source_name: string;
|
||||
episodes?: number;
|
||||
};
|
||||
|
||||
export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||
const [searchResults, setSearchResults] = useState<typeof mockSearchResults>(
|
||||
[]
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,30 +39,43 @@ export default function SearchPage() {
|
||||
const query = searchParams.get('q');
|
||||
if (query) {
|
||||
setSearchQuery(query);
|
||||
// 这里应该调用实际的搜索 API
|
||||
setSearchResults(mockSearchResults);
|
||||
setShowResults(true);
|
||||
fetchSearchResults(query);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
const fetchSearchResults = async (query: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results);
|
||||
setShowResults(true);
|
||||
} catch (error) {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar onToggle={setSidebarCollapsed} activePath='/search' />
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
<main
|
||||
className={`flex-1 px-10 py-8 transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
}`}
|
||||
>
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
// 模拟搜索延迟
|
||||
setTimeout(() => {
|
||||
fetchSearchResults(searchQuery);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-10 py-8'>
|
||||
{/* 搜索框 */}
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
@@ -94,15 +94,24 @@ export default function SearchPage() {
|
||||
</div>
|
||||
|
||||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-7xl mx-auto mt-12'>
|
||||
{showResults ? (
|
||||
<div className='max-w-[90%] mx-auto mt-12'>
|
||||
{isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
// 搜索结果
|
||||
<div className='grid grid-cols-5 gap-7'>
|
||||
<div className='grid grid-cols-6 gap-7'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-44'>
|
||||
<VideoCard {...item} />
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-5 text-center text-gray-500 py-8'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : mockSearchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
@@ -127,7 +136,7 @@ export default function SearchPage() {
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user