feat: search api/page && detail api/page

This commit is contained in:
shinya
2025-06-18 00:59:11 +08:00
parent b38b513df0
commit 479fafcb1c
15 changed files with 1050 additions and 376 deletions

4
.gitignore vendored
View File

@@ -39,4 +39,6 @@ next-env.d.ts
# next-sitemap
robots.txt
sitemap.xml
sitemap-*.xml
sitemap-*.xml
config.json

3
config.example.json Normal file
View File

@@ -0,0 +1,3 @@
{
"api_site": {}
}

View File

@@ -12,7 +12,11 @@ const nextConfig = {
remotePatterns: [
{
protocol: 'https',
hostname: 'vip.dytt-img.com',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},

219
src/app/api/detail/route.ts Normal file
View 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 }
);
}
}

View File

@@ -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
View 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
View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -6,9 +6,6 @@ import React, { useState } from 'react';
interface DemoCardProps {
title: string;
poster: string;
rating?: number;
type: 'movie' | 'tv';
episodes?: number;
}
function SearchCircle({
@@ -55,7 +52,7 @@ function SearchCircle({
);
}
const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
const DemoCard = ({ title, poster }: DemoCardProps) => {
const [hover, setHover] = useState(false);
const router = useRouter();
@@ -65,7 +62,7 @@ const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
return (
<div
className='group relative w-full overflow-hidden rounded-lg bg-white border border-[#e6e6e6] shadow-none flex flex-col cursor-pointer'
className='group relative w-full overflow-hidden rounded-lg bg-transparent shadow-none flex flex-col cursor-pointer'
onClick={handleClick}
>
{/* 海报图片 - 2:3 比例 */}
@@ -85,17 +82,11 @@ const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
</div>
</div>
</div>
{/* 集数指示器 - 绿色小圆球 */}
{episodes && (
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
<span className='text-white text-xs font-bold'>{episodes}</span>
</div>
)}
</div>
{/* 信息层 */}
<div className='p-2'>
<div className='flex items-center justify-between'>
<span className='text-gray-900 font-semibold truncate flex-1 mr-2'>
<div className='p-2 bg-transparent'>
<div className='flex flex-col items-center justify-center'>
<span className='text-gray-900 font-semibold truncate w-full text-center'>
{title}
</span>
</div>

View File

@@ -0,0 +1,140 @@
import { Heart } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
interface VideoCardProps {
id: string;
source: string;
title: string;
poster: string;
episodes?: number;
source_name: string;
progress?: number;
}
function CheckCircleCustom() {
return (
<span className='inline-flex items-center justify-center'>
<svg
width='24'
height='24'
viewBox='0 0 32 32'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<circle cx='16' cy='16' r='13' stroke='white' strokeWidth='2' />
<path
d='M11 16.5L15 20L21 13.5'
stroke='white'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
</span>
);
}
function PlayCircleSolid({
className = '',
fillColor = 'none',
}: {
className?: string;
fillColor?: string;
}) {
return (
<svg
width='44'
height='44'
viewBox='0 0 44 44'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle
cx='22'
cy='22'
r='20'
stroke='white'
strokeWidth='1.5'
fill={fillColor}
/>
<polygon points='19,15 19,29 29,22' fill='white' />
</svg>
);
}
export default function VideoCard({
id,
title,
poster,
episodes,
source,
source_name,
progress,
}: VideoCardProps) {
const [playHover, setPlayHover] = useState(false);
return (
<Link href={`/detail?source=${source}&id=${id}`}>
<div className='group relative w-full overflow-hidden rounded-lg bg-transparent shadow-none flex flex-col'>
{/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden'>
<Image src={poster} alt={title} fill className='object-cover' />
{/* Hover 效果 */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
<div className='absolute inset-0 flex items-center justify-center'>
<div
onMouseEnter={() => setPlayHover(true)}
onMouseLeave={() => setPlayHover(false)}
className={`transition-all duration-200 ${
playHover ? 'scale-110' : ''
}`}
style={{ cursor: 'pointer' }}
>
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
</div>
</div>
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
<CheckCircleCustom />
<Heart className='h-5 w-5 text-white/90 stroke-[2]' />
</div>
</div>
{/* 集数指示器 - 绿色小圆球 */}
{episodes && (
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
<span className='text-white text-xs font-bold'>{episodes}</span>
</div>
)}
{/* 播放进度条 */}
{progress !== undefined && (
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300'>
<div
className='h-full bg-blue-500 transition-all duration-300'
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
{/* 信息层 */}
<div className='p-2 bg-transparent'>
<div className='flex flex-col items-center justify-center'>
<span className='text-gray-900 font-semibold truncate w-full text-center'>
{title}
</span>
{source && (
<span className='text-gray-500 text-xs w-full text-center mt-1'>
{source_name}
</span>
)}
</div>
</div>
</div>
</Link>
);
}

View File

@@ -1,140 +0,0 @@
import { Heart } from 'lucide-react';
import Image from 'next/image';
import React, { useState } from 'react';
interface VideoCardProps {
title: string;
poster: string;
rating?: number;
type: 'movie' | 'tv';
episodes?: number;
source?: string;
showProgress?: boolean;
progress?: number; // 0-100
}
function CheckCircleCustom() {
return (
<span className='inline-flex items-center justify-center'>
<svg
width='24'
height='24'
viewBox='0 0 32 32'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<circle cx='16' cy='16' r='13' stroke='white' strokeWidth='2' />
<path
d='M11 16.5L15 20L21 13.5'
stroke='white'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
</span>
);
}
function PlayCircleSolid({
className = '',
fillColor = 'none',
}: {
className?: string;
fillColor?: string;
}) {
return (
<svg
width='44'
height='44'
viewBox='0 0 44 44'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle
cx='22'
cy='22'
r='20'
stroke='white'
strokeWidth='1.5'
fill={fillColor}
/>
<polygon points='19,15 19,29 29,22' fill='white' />
</svg>
);
}
const VideoCard = ({
title,
poster,
episodes,
source,
showProgress,
progress,
}: VideoCardProps) => {
const [playHover, setPlayHover] = useState(false);
return (
<div className='group relative w-full overflow-hidden rounded-lg bg-white border border-[#e6e6e6] shadow-none flex flex-col'>
{/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden'>
<Image src={poster} alt={title} fill className='object-cover' />
{/* Hover 效果 */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
<div className='absolute inset-0 flex items-center justify-center'>
<div
onMouseEnter={() => setPlayHover(true)}
onMouseLeave={() => setPlayHover(false)}
className={`transition-all duration-200 ${
playHover ? 'scale-110' : ''
}`}
style={{ cursor: 'pointer' }}
>
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
</div>
</div>
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
<CheckCircleCustom />
<Heart className='h-5 w-5 text-white/90 stroke-[2]' />
</div>
</div>
{/* 集数指示器 - 绿色小圆球 */}
{episodes && (
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
<span className='text-white text-xs font-bold'>{episodes}</span>
</div>
)}
{/* 播放进度条 */}
{showProgress && progress !== undefined && (
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300'>
<div
className='h-full bg-blue-500 transition-all duration-300'
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
{/* 信息层 */}
<div className='p-2'>
<div className='flex items-center justify-between'>
<span className='text-gray-900 font-semibold truncate flex-1 mr-2'>
{title}
</span>
{/* 数据源信息 */}
{source && (
<span className='text-gray-400 text-xs flex-shrink-0'>
{source}
</span>
)}
</div>
</div>
</div>
);
};
export default VideoCard;

View File

@@ -0,0 +1,26 @@
import { useSidebar } from './Sidebar';
import Sidebar from './Sidebar';
interface PageLayoutProps {
children: React.ReactNode;
activePath?: string;
}
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
const { isCollapsed } = useSidebar();
return (
<div className='grid grid-cols-[auto_1fr] min-h-screen'>
<Sidebar activePath={activePath} />
<div
className={`transition-all duration-300 ${
isCollapsed ? 'col-start-2' : 'col-start-2'
}`}
>
{children}
</div>
</div>
);
};
export default PageLayout;

View File

@@ -1,7 +1,23 @@
import { Film, Folder, Home, Menu, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
interface SidebarContextType {
isCollapsed: boolean;
}
const SidebarContext = createContext<SidebarContextType>({
isCollapsed: false,
});
export const useSidebar = () => useContext(SidebarContext);
// 可替换为你自己的 logo 图片
const Logo = () => (
@@ -22,17 +38,31 @@ interface SidebarProps {
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const router = useRouter();
const [isCollapsed, setIsCollapsed] = useState(false);
const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window === 'undefined') return false;
const saved = localStorage.getItem('sidebarCollapsed');
return saved !== null ? JSON.parse(saved) : false;
});
const [active, setActive] = useState(activePath);
const handleToggle = () => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
onToggle?.(newCollapsed);
};
useEffect(() => {
setActive(pathname);
}, [pathname]);
const handleSearchClick = () => {
const handleToggle = useCallback(() => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
onToggle?.(newState);
}, [isCollapsed, onToggle]);
const handleSearchClick = useCallback(() => {
router.push('/search');
}, [router]);
const contextValue = {
isCollapsed,
};
const menuItems = [
@@ -45,107 +75,122 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
];
return (
<aside
className={`fixed left-0 top-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg ${
isCollapsed ? 'w-16' : 'w-64'
}`}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.4)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
<div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */}
<div className='relative h-16'>
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<Logo />
</div>
<button
onClick={handleToggle}
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 ${
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
}`}
>
<Menu className='h-4 w-4' />
</button>
</div>
{/* 首页和搜索导航 */}
<nav className='px-2 mt-4 space-y-1'>
<Link
href='/'
onClick={() => setActive('/')}
data-active={active === '/'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
isCollapsed ? 'w-full max-w-none mx-0' : 'max-w-[220px] mx-auto'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
<SidebarContext.Provider value={contextValue}>
<div className='flex'>
<aside
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg ${
isCollapsed ? 'w-16' : 'w-64'
}`}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.3)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
<div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */}
<div className='relative h-16'>
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<div className='w-[calc(100%-4rem)] flex justify-center'>
{!isCollapsed && <Logo />}
</div>
</div>
<button
onClick={handleToggle}
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 ${
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
}`}
>
<Menu className='h-4 w-4' />
</button>
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
<Link
href='/search'
onClick={(e) => {
e.preventDefault();
handleSearchClick();
setActive('/search');
}}
data-active={active === '/search'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
isCollapsed ? 'w-full max-w-none mx-0' : 'max-w-[220px] mx-auto'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
</nav>
{/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-2 pt-4'>
<div className='space-y-1'>
{menuItems.map((item) => (
{/* 首页和搜索导航 */}
<nav className='px-2 mt-4 space-y-1'>
<Link
key={item.href}
href={item.href}
onClick={() => setActive(item.href)}
data-active={active === item.href}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
href='/'
onClick={() => setActive('/')}
data-active={active === '/'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
isCollapsed
? 'w-full max-w-none mx-0'
: 'max-w-[220px] mx-auto'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
{item.label}
</span>
)}
</Link>
))}
<Link
href='/search'
onClick={(e) => {
e.preventDefault();
handleSearchClick();
setActive('/search');
}}
data-active={active === '/search'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
isCollapsed
? 'w-full max-w-none mx-0'
: 'max-w-[220px] mx-auto'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
</nav>
{/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-2 pt-4'>
<div className='space-y-1'>
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setActive(item.href)}
data-active={active === item.href}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
isCollapsed
? 'w-full max-w-none mx-0'
: 'max-w-[220px] mx-auto'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
{item.label}
</span>
)}
</Link>
))}
</div>
</div>
</div>
</div>
</aside>
<div
className={`transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-64'
}`}
></div>
</div>
</aside>
</SidebarContext.Provider>
);
};

58
src/lib/config.ts Normal file
View File

@@ -0,0 +1,58 @@
import fs from 'fs';
import path from 'path';
export interface ApiSite {
key: string;
api: string;
name: string;
detail?: string;
}
export interface Config {
api_site: {
[key: string]: ApiSite;
};
}
export const API_CONFIG = {
search: {
path: '?ac=videolist&wd=',
pagePath: '?ac=videolist&wd={query}&pg={page}',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Accept: 'application/json',
},
maxPages: 50,
},
detail: {
path: '?ac=videolist&ids=',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Accept: 'application/json',
},
},
};
let config: Config | null = null;
export function getConfig(): Config {
if (config) {
return config;
}
const configPath = path.join(process.cwd(), 'config.json');
const configContent = fs.readFileSync(configPath, 'utf-8');
const parsedConfig = JSON.parse(configContent) as Config;
config = parsedConfig;
return parsedConfig;
}
export function getApiSites(): ApiSite[] {
const config = getConfig();
return Object.entries(config.api_site).map(([key, site]) => ({
...site,
key,
}));
}