mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-26 14:14:44 +08:00
feat: play page && other refactor
This commit is contained in:
@@ -4,6 +4,17 @@ import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
|
||||
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
// 清理 HTML 标签的工具函数
|
||||
function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||
.trim(); // 去掉首尾空格
|
||||
}
|
||||
|
||||
export interface VideoDetail {
|
||||
code: number;
|
||||
episodes: string[];
|
||||
@@ -71,9 +82,7 @@ async function handleSpecialSourceDetail(
|
||||
const descMatch = html.match(
|
||||
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
|
||||
);
|
||||
const descText = descMatch
|
||||
? descMatch[1].replace(/<[^>]+>/g, ' ').trim()
|
||||
: '';
|
||||
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
|
||||
|
||||
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
|
||||
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
|
||||
@@ -158,7 +167,7 @@ async function getDetailFromApi(
|
||||
videoInfo: {
|
||||
title: videoDetail.vod_name,
|
||||
cover: videoDetail.vod_pic,
|
||||
desc: videoDetail.vod_content,
|
||||
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||
type: videoDetail.type_name,
|
||||
year: videoDetail.vod_year,
|
||||
area: videoDetail.vod_area,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
|
||||
|
||||
import { getVideoDetail } from '../detail/route';
|
||||
|
||||
interface SearchResult {
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function DetailPage() {
|
||||
return (
|
||||
<PageLayout activePath='/detail'>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
{/* 顶部返回按钮已移入右侧信息容器 */}
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||||
@@ -65,7 +66,34 @@ export default function DetailPage() {
|
||||
) : (
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
|
||||
<div className='relative flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6 md:items-start'>
|
||||
{/* 返回按钮放置在主信息区左上角 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const from = searchParams.get('from');
|
||||
if (from === 'search') {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}}
|
||||
className='absolute top-0 left-0 -translate-x-[180%] -translate-y-1/2 p-2 rounded transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 transition-colors'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M15 19l-7-7 7-7'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-full md:w-64'>
|
||||
<Image
|
||||
@@ -79,55 +107,64 @@ export default function DetailPage() {
|
||||
/>
|
||||
</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
|
||||
className='flex-1 flex flex-col min-h-0'
|
||||
style={{ height: '384px' }}
|
||||
>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0'>
|
||||
{detail.videoInfo.title}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{detail.videoInfo.remarks && (
|
||||
<span className='text-green-600 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 flex-shrink-0'>
|
||||
<a
|
||||
href={`/play?source=${searchParams.get(
|
||||
'source'
|
||||
)}&id=${searchParams.get('id')}`}
|
||||
className='flex items-center justify-center gap-2 px-6 py-2 bg-green-500 hover:bg-green-600 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>
|
||||
</a>
|
||||
<button className='flex items-center justify-center w-10 h-10 bg-pink-400 hover:bg-pink-500 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 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{detail.videoInfo.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 选集按钮区 */}
|
||||
@@ -139,14 +176,14 @@ export default function DetailPage() {
|
||||
共 {detail.episodes.length} 集
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
{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'
|
||||
href={`/play?source=${searchParams.get(
|
||||
'source'
|
||||
)}&id=${searchParams.get('id')}&index=${idx + 1}`}
|
||||
className='bg-gray-500/80 hover:bg-green-500 text-white px-5 py-2 rounded-lg transition-colors text-base font-medium w-24 text-center'
|
||||
>
|
||||
第{idx + 1}集
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Film,
|
||||
MessageCircleHeart,
|
||||
MountainSnow,
|
||||
Star,
|
||||
Swords,
|
||||
Tv,
|
||||
VenetianMask,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import CollectionCard from '@/components/CollectionCard';
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
@@ -92,6 +102,29 @@ const mockData = {
|
||||
],
|
||||
};
|
||||
|
||||
// 合集数据
|
||||
const collections = [
|
||||
{
|
||||
icon: Film,
|
||||
title: '热门电影',
|
||||
href: '/douban?type=movie&tag=热门&title=热门电影',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
title: '热门剧集',
|
||||
href: '/douban?type=tv&tag=热门&title=热门剧集',
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
title: '豆瓣 Top250',
|
||||
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
|
||||
},
|
||||
{ icon: Swords, title: '美剧', href: '/douban?type=tv&tag=美剧' },
|
||||
{ icon: MessageCircleHeart, title: '韩剧', href: '/douban?type=tv&tag=韩剧' },
|
||||
{ icon: MountainSnow, title: '日剧', href: '/douban?type=tv&tag=日剧' },
|
||||
{ icon: VenetianMask, title: '日漫', href: '/douban?type=tv&tag=日本动画' },
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||
@@ -142,6 +175,24 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 推荐 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
推荐
|
||||
</h2>
|
||||
<ScrollableRow scrollDistance={800}>
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.title} className='min-w-[280px] w-72'>
|
||||
<CollectionCard
|
||||
title={collection.title}
|
||||
icon={collection.icon}
|
||||
href={collection.href}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
|
||||
1094
src/app/play/page.tsx
Normal file
1094
src/app/play/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,7 @@ export default function SearchPage() {
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-44'>
|
||||
<VideoCard {...item} />
|
||||
<VideoCard {...item} from='search' />
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user