1073 lines
37 KiB
TypeScript
1073 lines
37 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
|
||
|
||
'use client';
|
||
|
||
import {
|
||
BookMarked,
|
||
BookOpen,
|
||
Bot,
|
||
ChevronRight,
|
||
Link as LinkIcon,
|
||
ListVideo,
|
||
Music,
|
||
} from 'lucide-react';
|
||
import Link from 'next/link';
|
||
import { Suspense, useEffect, useState } from 'react';
|
||
|
||
import {
|
||
BangumiCalendarData,
|
||
GetBangumiCalendarData,
|
||
} from '@/lib/bangumi.client';
|
||
import { getDoubanCategories } from '@/lib/douban.client';
|
||
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
|
||
import { DoubanItem } from '@/lib/types';
|
||
import { base58Encode, processImageUrl } from '@/lib/utils';
|
||
|
||
import AIChatPanel from '@/components/AIChatPanel';
|
||
import BannerCarousel from '@/components/BannerCarousel';
|
||
import ContinueWatching from '@/components/ContinueWatching';
|
||
import FireworksCanvas from '@/components/FireworksCanvas';
|
||
import HttpWarningDialog from '@/components/HttpWarningDialog';
|
||
import PageLayout from '@/components/PageLayout';
|
||
import ScrollableRow from '@/components/ScrollableRow';
|
||
import { useSite } from '@/components/SiteProvider';
|
||
import Toast, { ToastProps } from '@/components/Toast';
|
||
import VideoCard from '@/components/VideoCard';
|
||
|
||
// 首页模块配置接口
|
||
interface HomeModule {
|
||
id: string;
|
||
name: string;
|
||
enabled: boolean;
|
||
order: number;
|
||
}
|
||
|
||
function HomeClient() {
|
||
// 移除了 activeTab 状态,收藏夹功能已移到 UserMenu
|
||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||
const [hotDuanju, setHotDuanju] = useState<any[]>([]);
|
||
const [upcomingContent, setUpcomingContent] = useState<TMDBItem[]>([]);
|
||
const [bangumiCalendarData, setBangumiCalendarData] = useState<
|
||
BangumiCalendarData[]
|
||
>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const { announcement } = useSite();
|
||
// 首页模块配置状态
|
||
const [homeModules, setHomeModules] = useState<HomeModule[]>([
|
||
{ id: 'hotMovies', name: '热门电影', enabled: true, order: 0 },
|
||
{ id: 'hotDuanju', name: '热播短剧', enabled: true, order: 1 },
|
||
{ id: 'bangumiCalendar', name: '新番放送', enabled: true, order: 2 },
|
||
{ id: 'hotTvShows', name: '热门剧集', enabled: true, order: 3 },
|
||
{ id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 },
|
||
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
|
||
]);
|
||
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] =
|
||
useState(true);
|
||
|
||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
||
const [showAIChat, setShowAIChat] = useState(false);
|
||
const [aiEnabled, setAiEnabled] = useState(false);
|
||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState(
|
||
'你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'
|
||
);
|
||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||
const [musicEnabled, setMusicEnabled] = useState(false);
|
||
const [mangaEnabled, setMangaEnabled] = useState(false);
|
||
const [booksEnabled, setBooksEnabled] = useState(false);
|
||
const [netdiskTempPlayEnabled, setNetdiskTempPlayEnabled] = useState(false);
|
||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
||
const [directPlaySubmitting, setDirectPlaySubmitting] = useState(false);
|
||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||
|
||
const detectNetdiskLink = (
|
||
url: string
|
||
): {
|
||
provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc' | '115';
|
||
shareUrl: string;
|
||
passcode?: string;
|
||
} | null => {
|
||
const trimmed = url.trim();
|
||
|
||
const pickPasscode = (...values: Array<string | undefined>) =>
|
||
values.map((item) => item?.trim()).find(Boolean);
|
||
|
||
const inlinePasscode = (text: string) =>
|
||
pickPasscode(
|
||
text.match(
|
||
/(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i
|
||
)?.[1],
|
||
text.match(/[?&](?:pwd|passcode|accessCode)=([^&\s]+)/i)?.[1]
|
||
);
|
||
|
||
if (
|
||
/https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test(
|
||
trimmed
|
||
)
|
||
) {
|
||
return {
|
||
provider: '123',
|
||
shareUrl: trimmed,
|
||
passcode: pickPasscode(
|
||
trimmed.match(/[?&]pwd=([^&]+)/i)?.[1],
|
||
inlinePasscode(trimmed)
|
||
),
|
||
};
|
||
}
|
||
|
||
if (
|
||
/https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) ||
|
||
/https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(trimmed)
|
||
) {
|
||
return {
|
||
provider: 'tianyi',
|
||
shareUrl: trimmed,
|
||
passcode: pickPasscode(
|
||
trimmed.match(/[?&]pwd=([^&]+)/i)?.[1],
|
||
inlinePasscode(trimmed)
|
||
),
|
||
};
|
||
}
|
||
|
||
if (/pan\.baidu\.com\/(s\/|wap\/init\?surl=)/i.test(trimmed)) {
|
||
return {
|
||
provider: 'baidu',
|
||
shareUrl: trimmed,
|
||
passcode: pickPasscode(
|
||
trimmed.match(/[?&](?:pwd|accessCode)=([^&]+)/i)?.[1],
|
||
inlinePasscode(trimmed)
|
||
),
|
||
};
|
||
}
|
||
|
||
if (/https:\/\/pan\.quark\.cn\/s\//i.test(trimmed)) {
|
||
return {
|
||
provider: 'quark',
|
||
shareUrl: trimmed,
|
||
passcode: pickPasscode(
|
||
trimmed.match(/[?&](?:pwd|passcode)=([^&]+)/i)?.[1],
|
||
inlinePasscode(trimmed)
|
||
),
|
||
};
|
||
}
|
||
|
||
if (/https:\/\/drive\.uc\.cn\/s\//i.test(trimmed)) {
|
||
return {
|
||
provider: 'uc',
|
||
shareUrl: trimmed,
|
||
passcode: pickPasscode(
|
||
trimmed.match(/[?&](?:pwd|passcode)=([^&]+)/i)?.[1],
|
||
inlinePasscode(trimmed)
|
||
),
|
||
};
|
||
}
|
||
|
||
if (/https:\/\/(?:yun|caiyun)\.139\.com\//i.test(trimmed)) {
|
||
return { provider: 'mobile', shareUrl: trimmed };
|
||
}
|
||
|
||
if (/https:\/\/(?:115|anxia|115cdn)\.com\/s\//i.test(trimmed)) {
|
||
return {
|
||
provider: '115',
|
||
shareUrl: trimmed,
|
||
passcode: pickPasscode(
|
||
trimmed.match(/[?&](?:password|pwd|passcode)=([^&]+)/i)?.[1],
|
||
inlinePasscode(trimmed)
|
||
),
|
||
};
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const handleDirectPlay = () => {
|
||
setDirectPlayUrl('');
|
||
setShowDirectPlayDialog(true);
|
||
};
|
||
|
||
const submitDirectPlay = async () => {
|
||
const trimmed = directPlayUrl.trim();
|
||
if (!trimmed) return;
|
||
setDirectPlaySubmitting(true);
|
||
try {
|
||
const netdisk = detectNetdiskLink(trimmed);
|
||
if (netdisk && !netdiskTempPlayEnabled) {
|
||
throw new Error('无权限使用临时播放');
|
||
}
|
||
|
||
if (netdisk) {
|
||
const source =
|
||
netdisk.provider === 'mobile'
|
||
? 'netdisk-mobile'
|
||
: netdisk.provider === 'baidu'
|
||
? 'netdisk-baidu'
|
||
: netdisk.provider === 'tianyi'
|
||
? 'netdisk-tianyi'
|
||
: netdisk.provider === '115'
|
||
? 'netdisk-115'
|
||
: netdisk.provider === 'uc'
|
||
? 'netdisk-uc'
|
||
: netdisk.provider === '123'
|
||
? 'netdisk-123'
|
||
: 'netdisk-quark';
|
||
const id = base58Encode(
|
||
JSON.stringify({
|
||
shareUrl: netdisk.shareUrl,
|
||
passcode: netdisk.passcode || '',
|
||
})
|
||
);
|
||
if (!id) {
|
||
throw new Error('网盘链接编码失败');
|
||
}
|
||
const targetUrl = `/play?source=${encodeURIComponent(
|
||
source
|
||
)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent(
|
||
'网盘直链播放'
|
||
)}`;
|
||
setShowDirectPlayDialog(false);
|
||
setDirectPlayUrl('');
|
||
window.location.assign(targetUrl);
|
||
return;
|
||
}
|
||
|
||
const encoded = base58Encode(trimmed);
|
||
if (!encoded) return;
|
||
const targetUrl = `/play?source=directplay&id=${encodeURIComponent(
|
||
encoded
|
||
)}`;
|
||
setShowDirectPlayDialog(false);
|
||
setDirectPlayUrl('');
|
||
window.location.assign(targetUrl);
|
||
} catch (error) {
|
||
setToast({
|
||
message: error instanceof Error ? error.message : '播放失败',
|
||
type: 'error',
|
||
onClose: () => setToast(null),
|
||
});
|
||
} finally {
|
||
setDirectPlaySubmitting(false);
|
||
}
|
||
};
|
||
|
||
const loadHomeLayoutSettings = () => {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
const savedHomeModules = localStorage.getItem('homeModules');
|
||
if (savedHomeModules) {
|
||
try {
|
||
setHomeModules(JSON.parse(savedHomeModules));
|
||
} catch (error) {
|
||
console.error('解析首页模块配置失败:', error);
|
||
}
|
||
}
|
||
|
||
const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled');
|
||
if (savedHomeBannerEnabled !== null) {
|
||
setHomeBannerEnabled(savedHomeBannerEnabled === 'true');
|
||
}
|
||
|
||
const savedHomeContinueWatchingEnabled = localStorage.getItem(
|
||
'homeContinueWatchingEnabled'
|
||
);
|
||
if (savedHomeContinueWatchingEnabled !== null) {
|
||
setHomeContinueWatchingEnabled(
|
||
savedHomeContinueWatchingEnabled === 'true'
|
||
);
|
||
}
|
||
};
|
||
|
||
// 加载首页模块配置
|
||
useEffect(() => {
|
||
loadHomeLayoutSettings();
|
||
}, []);
|
||
|
||
// 监听首页模块配置更新事件
|
||
useEffect(() => {
|
||
const handleHomeModulesUpdated = () => {
|
||
loadHomeLayoutSettings();
|
||
};
|
||
|
||
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
||
return () => {
|
||
window.removeEventListener(
|
||
'homeModulesUpdated',
|
||
handleHomeModulesUpdated
|
||
);
|
||
};
|
||
}, []);
|
||
|
||
// 检查AI功能是否启用
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const enabled =
|
||
(window as any).RUNTIME_CONFIG?.AI_ENABLED &&
|
||
(window as any).RUNTIME_CONFIG?.AI_ENABLE_HOMEPAGE_ENTRY;
|
||
setAiEnabled(enabled);
|
||
|
||
// 加载AI默认消息配置
|
||
const defaultMsg = (window as any).RUNTIME_CONFIG
|
||
?.AI_DEFAULT_MESSAGE_NO_VIDEO;
|
||
if (defaultMsg) {
|
||
setAiDefaultMessageNoVideo(defaultMsg);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
// 检查源站寻片功能是否启用
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const enabled =
|
||
(window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false;
|
||
setSourceSearchEnabled(enabled);
|
||
}
|
||
}, []);
|
||
|
||
// 检查音乐功能是否启用
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const enabled = !!(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED;
|
||
setMusicEnabled(enabled);
|
||
}
|
||
}, []);
|
||
|
||
// 检查漫画功能是否启用
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const enabled = !!(window as any).RUNTIME_CONFIG?.SUWAYOMI_ENABLED;
|
||
setMangaEnabled(enabled);
|
||
}
|
||
}, []);
|
||
|
||
// 检查电子书功能是否启用
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const enabled = !!(window as any).RUNTIME_CONFIG?.BOOKS_ENABLED;
|
||
setBooksEnabled(enabled);
|
||
}
|
||
}, []);
|
||
|
||
// 检查网盘临时播放权限,仅有权限时在直链播放弹窗展示网盘在线播放提示
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const enabled = !!(window as any).RUNTIME_CONFIG
|
||
?.NETDISK_TEMP_PLAY_ENABLED;
|
||
setNetdiskTempPlayEnabled(enabled);
|
||
}
|
||
}, []);
|
||
|
||
// 检查公告弹窗状态
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined' && announcement) {
|
||
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
|
||
if (hasSeenAnnouncement !== announcement) {
|
||
setShowAnnouncement(true);
|
||
} else {
|
||
setShowAnnouncement(Boolean(!hasSeenAnnouncement && announcement));
|
||
}
|
||
}
|
||
}, [announcement]);
|
||
|
||
useEffect(() => {
|
||
const CACHE_DURATION = 60 * 60 * 1000; // 1小时
|
||
|
||
const getCache = (key: string) => {
|
||
try {
|
||
const cached = localStorage.getItem(key);
|
||
if (!cached) return null;
|
||
const { data, timestamp } = JSON.parse(cached);
|
||
return { data, expired: Date.now() - timestamp > CACHE_DURATION };
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const setCache = (key: string, data: any) => {
|
||
try {
|
||
localStorage.setItem(
|
||
key,
|
||
JSON.stringify({ data, timestamp: Date.now() })
|
||
);
|
||
} catch {
|
||
// Ignore localStorage errors
|
||
}
|
||
};
|
||
|
||
const moviesCache = getCache('homepage_movies');
|
||
const tvShowsCache = getCache('homepage_tvshows');
|
||
const varietyCache = getCache('homepage_variety');
|
||
const bangumiCache = getCache('homepage_bangumi');
|
||
const duanjuCache = getCache('homepage_duanju');
|
||
const upcomingCache = getCache('homepage_upcoming');
|
||
|
||
if (moviesCache?.data) setHotMovies(moviesCache.data);
|
||
if (tvShowsCache?.data) setHotTvShows(tvShowsCache.data);
|
||
if (varietyCache?.data) setHotVarietyShows(varietyCache.data);
|
||
if (bangumiCache?.data) setBangumiCalendarData(bangumiCache.data);
|
||
if (duanjuCache?.data) setHotDuanju(duanjuCache.data);
|
||
if (upcomingCache?.data) setUpcomingContent(upcomingCache.data);
|
||
|
||
const hasCache =
|
||
moviesCache ||
|
||
tvShowsCache ||
|
||
varietyCache ||
|
||
bangumiCache ||
|
||
duanjuCache ||
|
||
upcomingCache;
|
||
if (hasCache) setLoading(false);
|
||
|
||
const needsRefresh =
|
||
!moviesCache ||
|
||
moviesCache.expired ||
|
||
!tvShowsCache ||
|
||
tvShowsCache.expired ||
|
||
!varietyCache ||
|
||
varietyCache.expired ||
|
||
!bangumiCache ||
|
||
bangumiCache.expired ||
|
||
!duanjuCache ||
|
||
duanjuCache.expired ||
|
||
!upcomingCache ||
|
||
upcomingCache.expired;
|
||
|
||
if (needsRefresh) {
|
||
(async () => {
|
||
try {
|
||
const [
|
||
moviesData,
|
||
tvShowsData,
|
||
varietyShowsData,
|
||
bangumiCalendarData,
|
||
] = await Promise.all([
|
||
getDoubanCategories({
|
||
kind: 'movie',
|
||
category: '热门',
|
||
type: '全部',
|
||
}).catch((error) => {
|
||
console.error('获取热门电影数据失败:', error);
|
||
return null;
|
||
}),
|
||
getDoubanCategories({
|
||
kind: 'tv',
|
||
category: 'tv',
|
||
type: 'tv',
|
||
}).catch((error) => {
|
||
console.error('获取热门剧集数据失败:', error);
|
||
return null;
|
||
}),
|
||
getDoubanCategories({
|
||
kind: 'tv',
|
||
category: 'show',
|
||
type: 'show',
|
||
}).catch((error) => {
|
||
console.error('获取热门综艺数据失败:', error);
|
||
return null;
|
||
}),
|
||
GetBangumiCalendarData().catch((error) => {
|
||
console.error('获取新番放送数据失败:', error);
|
||
return [];
|
||
}),
|
||
]);
|
||
|
||
if (moviesData?.code === 200) {
|
||
setHotMovies(moviesData.list);
|
||
if (moviesData.list && moviesData.list.length > 0) {
|
||
setCache('homepage_movies', moviesData.list);
|
||
}
|
||
}
|
||
if (tvShowsData?.code === 200) {
|
||
setHotTvShows(tvShowsData.list);
|
||
if (tvShowsData.list && tvShowsData.list.length > 0) {
|
||
setCache('homepage_tvshows', tvShowsData.list);
|
||
}
|
||
}
|
||
if (varietyShowsData?.code === 200) {
|
||
setHotVarietyShows(varietyShowsData.list);
|
||
if (varietyShowsData.list && varietyShowsData.list.length > 0) {
|
||
setCache('homepage_variety', varietyShowsData.list);
|
||
}
|
||
}
|
||
setBangumiCalendarData(bangumiCalendarData);
|
||
if (bangumiCalendarData && bangumiCalendarData.length > 0) {
|
||
setCache('homepage_bangumi', bangumiCalendarData);
|
||
}
|
||
|
||
try {
|
||
const duanjuResponse = await fetch('/api/duanju/recommends');
|
||
if (duanjuResponse.ok) {
|
||
const duanjuResult = await duanjuResponse.json();
|
||
if (
|
||
duanjuResult.code === 200 &&
|
||
duanjuResult.data &&
|
||
duanjuResult.data.length > 0
|
||
) {
|
||
setHotDuanju(duanjuResult.data);
|
||
setCache('homepage_duanju', duanjuResult.data);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取热播短剧数据失败:', error);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/tmdb/upcoming');
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (
|
||
result.code === 200 &&
|
||
result.data &&
|
||
result.data.length > 0
|
||
) {
|
||
const sorted = [...result.data].sort((a, b) => {
|
||
const dateA = new Date(
|
||
a.release_date || '9999-12-31'
|
||
).getTime();
|
||
const dateB = new Date(
|
||
b.release_date || '9999-12-31'
|
||
).getTime();
|
||
return dateA - dateB;
|
||
});
|
||
setUpcomingContent(sorted);
|
||
setCache('homepage_upcoming', sorted);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取TMDB即将上映数据失败:', error);
|
||
}
|
||
|
||
setLoading(false);
|
||
} catch (error) {
|
||
console.error('获取推荐数据失败:', error);
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}
|
||
}, []);
|
||
|
||
const handleCloseAnnouncement = (announcement: string) => {
|
||
setShowAnnouncement(false);
|
||
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
|
||
};
|
||
|
||
// 渲染模块的函数
|
||
const renderModule = (moduleId: string) => {
|
||
switch (moduleId) {
|
||
case 'hotMovies':
|
||
return (
|
||
<section key='hotMovies' className='mb-8'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||
热门电影
|
||
</h2>
|
||
<Link
|
||
href='/douban?type=movie'
|
||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||
>
|
||
查看更多
|
||
<ChevronRight className='w-4 h-4 ml-1' />
|
||
</Link>
|
||
</div>
|
||
<ScrollableRow>
|
||
{loading
|
||
? Array.from({ length: 8 }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||
</div>
|
||
))
|
||
: hotMovies.map((movie) => (
|
||
<div
|
||
key={movie.id}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<VideoCard
|
||
id={movie.id}
|
||
poster={movie.poster}
|
||
title={movie.title}
|
||
year={movie.year}
|
||
rate={movie.rate}
|
||
type='movie'
|
||
from='douban'
|
||
douban_id={movie.id ? parseInt(movie.id) : undefined}
|
||
/>
|
||
</div>
|
||
))}
|
||
</ScrollableRow>
|
||
</section>
|
||
);
|
||
|
||
case 'hotDuanju':
|
||
if (hotDuanju.length === 0) return null;
|
||
return (
|
||
<section key='hotDuanju' className='mb-8'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||
热播短剧
|
||
</h2>
|
||
<Link
|
||
href='/duanju'
|
||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||
>
|
||
查看更多
|
||
<ChevronRight className='w-4 h-4 ml-1' />
|
||
</Link>
|
||
</div>
|
||
<ScrollableRow>
|
||
{loading
|
||
? Array.from({ length: 8 }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||
</div>
|
||
))
|
||
: hotDuanju.map((duanju) => (
|
||
<div
|
||
key={duanju.id + duanju.source}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<VideoCard
|
||
id={duanju.id}
|
||
source={duanju.source}
|
||
poster={duanju.poster}
|
||
title={duanju.title}
|
||
year={duanju.year}
|
||
type='tv'
|
||
from='search'
|
||
source_name={duanju.source_name}
|
||
episodes={duanju.episodes?.length}
|
||
douban_id={duanju.douban_id}
|
||
cmsData={{
|
||
desc: duanju.desc,
|
||
episodes: duanju.episodes,
|
||
episodes_titles: duanju.episodes_titles,
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</ScrollableRow>
|
||
</section>
|
||
);
|
||
|
||
case 'bangumiCalendar':
|
||
return (
|
||
<section key='bangumiCalendar' className='mb-8'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||
新番放送
|
||
</h2>
|
||
<Link
|
||
href='/douban?type=anime'
|
||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||
>
|
||
查看更多
|
||
<ChevronRight className='w-4 h-4 ml-1' />
|
||
</Link>
|
||
</div>
|
||
<ScrollableRow>
|
||
{loading
|
||
? Array.from({ length: 8 }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||
</div>
|
||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||
</div>
|
||
))
|
||
: (() => {
|
||
const today = new Date();
|
||
const weekdays = [
|
||
'Sun',
|
||
'Mon',
|
||
'Tue',
|
||
'Wed',
|
||
'Thu',
|
||
'Fri',
|
||
'Sat',
|
||
];
|
||
const currentWeekday = weekdays[today.getDay()];
|
||
const todayAnimes =
|
||
bangumiCalendarData
|
||
.find((item) => item.weekday.en === currentWeekday)
|
||
?.items.filter((anime) => anime.images) || [];
|
||
|
||
return todayAnimes.map((anime, index) => (
|
||
<div
|
||
key={`${anime.id}-${index}`}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<VideoCard
|
||
from='douban'
|
||
title={anime.name_cn || anime.name}
|
||
poster={
|
||
anime.images?.large ||
|
||
anime.images?.common ||
|
||
anime.images?.medium ||
|
||
anime.images?.small ||
|
||
anime.images?.grid ||
|
||
''
|
||
}
|
||
douban_id={anime.id}
|
||
rate={anime.rating?.score?.toFixed(1) || ''}
|
||
year={anime.air_date?.split('-')?.[0] || ''}
|
||
isBangumi={true}
|
||
/>
|
||
</div>
|
||
));
|
||
})()}
|
||
</ScrollableRow>
|
||
</section>
|
||
);
|
||
|
||
case 'hotTvShows':
|
||
return (
|
||
<section key='hotTvShows' className='mb-8'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||
热门剧集
|
||
</h2>
|
||
<Link
|
||
href='/douban?type=tv'
|
||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||
>
|
||
查看更多
|
||
<ChevronRight className='w-4 h-4 ml-1' />
|
||
</Link>
|
||
</div>
|
||
<ScrollableRow>
|
||
{loading
|
||
? Array.from({ length: 8 }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||
</div>
|
||
))
|
||
: hotTvShows.map((tvShow) => (
|
||
<div
|
||
key={tvShow.id}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<VideoCard
|
||
id={tvShow.id}
|
||
poster={tvShow.poster}
|
||
title={tvShow.title}
|
||
year={tvShow.year}
|
||
rate={tvShow.rate}
|
||
type='tv'
|
||
from='douban'
|
||
douban_id={tvShow.id ? parseInt(tvShow.id) : undefined}
|
||
/>
|
||
</div>
|
||
))}
|
||
</ScrollableRow>
|
||
</section>
|
||
);
|
||
|
||
case 'hotVarietyShows':
|
||
return (
|
||
<section key='hotVarietyShows' className='mb-8'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||
热门综艺
|
||
</h2>
|
||
<Link
|
||
href='/douban?type=tv&category=show'
|
||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||
>
|
||
查看更多
|
||
<ChevronRight className='w-4 h-4 ml-1' />
|
||
</Link>
|
||
</div>
|
||
<ScrollableRow>
|
||
{loading
|
||
? Array.from({ length: 8 }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<div className='aspect-[2/3] bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse mb-2' />
|
||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4' />
|
||
</div>
|
||
))
|
||
: hotVarietyShows.map((varietyShow) => (
|
||
<div
|
||
key={varietyShow.id}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<VideoCard
|
||
id={varietyShow.id}
|
||
poster={varietyShow.poster}
|
||
title={varietyShow.title}
|
||
year={varietyShow.year}
|
||
rate={varietyShow.rate}
|
||
type='tv'
|
||
from='douban'
|
||
douban_id={
|
||
varietyShow.id ? parseInt(varietyShow.id) : undefined
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</ScrollableRow>
|
||
</section>
|
||
);
|
||
|
||
case 'upcomingContent':
|
||
if (upcomingContent.length === 0) return null;
|
||
return (
|
||
<section key='upcomingContent' className='mb-8'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||
即将上映
|
||
</h2>
|
||
</div>
|
||
<ScrollableRow>
|
||
{upcomingContent.map((item) => (
|
||
<div
|
||
key={`${item.media_type}-${item.id}`}
|
||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||
>
|
||
<VideoCard
|
||
title={item.title}
|
||
poster={processImageUrl(getTMDBImageUrl(item.poster_path))}
|
||
year={item.release_date?.split('-')?.[0] || ''}
|
||
rate={
|
||
item.vote_average && item.vote_average > 0
|
||
? item.vote_average.toFixed(1)
|
||
: ''
|
||
}
|
||
type={item.media_type === 'tv' ? 'tv' : 'movie'}
|
||
from='douban'
|
||
releaseDate={item.release_date}
|
||
isUpcoming={true}
|
||
/>
|
||
</div>
|
||
))}
|
||
</ScrollableRow>
|
||
</section>
|
||
);
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<PageLayout>
|
||
<FireworksCanvas />
|
||
{/* TMDB 热门轮播图 */}
|
||
{homeBannerEnabled && (
|
||
<div className='w-full mb-4'>
|
||
<BannerCarousel delayLoad={true} />
|
||
</div>
|
||
)}
|
||
|
||
<div className='px-2 sm:px-10 pb-4 sm:pb-8 overflow-visible'>
|
||
<div className='max-w-[95%] mx-auto'>
|
||
{/* 首页内容 */}
|
||
<>
|
||
{/* 源站寻片和AI问片入口 */}
|
||
<div
|
||
className={`flex items-center justify-end gap-2 mb-4 ${
|
||
homeBannerEnabled ? '' : 'mt-[30px]'
|
||
}`}
|
||
>
|
||
<button
|
||
onClick={handleDirectPlay}
|
||
className='p-1.5 rounded-lg text-blue-500 hover:text-blue-600 transition-colors'
|
||
title='直链播放'
|
||
>
|
||
<LinkIcon size={18} />
|
||
</button>
|
||
|
||
{musicEnabled && (
|
||
<Link href='/music'>
|
||
<button
|
||
className='p-1.5 rounded-lg text-green-500 hover:text-green-600 transition-colors'
|
||
title='音乐视听'
|
||
>
|
||
<Music size={18} />
|
||
</button>
|
||
</Link>
|
||
)}
|
||
|
||
{mangaEnabled && (
|
||
<Link href='/manga'>
|
||
<button
|
||
className='p-1.5 rounded-lg text-emerald-500 hover:text-emerald-600 transition-colors'
|
||
title='漫画展馆'
|
||
>
|
||
<BookOpen size={18} />
|
||
</button>
|
||
</Link>
|
||
)}
|
||
|
||
{booksEnabled && (
|
||
<Link href='/books'>
|
||
<button
|
||
className='p-1.5 rounded-lg text-amber-500 hover:text-amber-600 transition-colors'
|
||
title='电子书馆'
|
||
>
|
||
<BookMarked size={18} />
|
||
</button>
|
||
</Link>
|
||
)}
|
||
|
||
{/* 源站寻片入口 */}
|
||
{sourceSearchEnabled && (
|
||
<Link href='/source-search'>
|
||
<button
|
||
className='p-2 rounded-lg text-blue-500 hover:text-blue-600 transition-colors'
|
||
title='源站寻片'
|
||
>
|
||
<ListVideo size={20} />
|
||
</button>
|
||
</Link>
|
||
)}
|
||
|
||
{/* AI问片入口 */}
|
||
{aiEnabled && (
|
||
<button
|
||
onClick={() => setShowAIChat(true)}
|
||
className='p-2 rounded-lg text-purple-500 hover:text-purple-600 transition-colors'
|
||
title='AI问片'
|
||
>
|
||
<Bot size={20} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 继续观看 */}
|
||
{homeContinueWatchingEnabled && <ContinueWatching />}
|
||
|
||
{/* 根据配置动态渲染首页模块 */}
|
||
{homeModules
|
||
.filter((module) => module.enabled)
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((module) => renderModule(module.id))}
|
||
</>
|
||
</div>
|
||
</div>
|
||
|
||
{/* HTTP 环境警告弹窗 */}
|
||
{showHttpWarning && (
|
||
<HttpWarningDialog onClose={() => setShowHttpWarning(false)} />
|
||
)}
|
||
|
||
{/* AI问片面板 */}
|
||
{aiEnabled && (
|
||
<AIChatPanel
|
||
isOpen={showAIChat}
|
||
onClose={() => setShowAIChat(false)}
|
||
welcomeMessage={aiDefaultMessageNoVideo}
|
||
/>
|
||
)}
|
||
|
||
{/* 公告弹窗 */}
|
||
{showAnnouncement && (
|
||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'>
|
||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6'>
|
||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||
公告
|
||
</h3>
|
||
<div className='text-gray-700 dark:text-gray-300 mb-4 whitespace-pre-wrap'>
|
||
{announcement}
|
||
</div>
|
||
<button
|
||
onClick={() => handleCloseAnnouncement(announcement || '')}
|
||
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
|
||
>
|
||
知道了
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showDirectPlayDialog && (
|
||
<div
|
||
className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
|
||
onClick={() => setShowDirectPlayDialog(false)}
|
||
>
|
||
<div
|
||
className='bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-lg'
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||
直链播放
|
||
</h3>
|
||
<button
|
||
onClick={() => setShowDirectPlayDialog(false)}
|
||
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
|
||
aria-label='关闭'
|
||
>
|
||
<span className='text-gray-600 dark:text-gray-400'>×</span>
|
||
</button>
|
||
</div>
|
||
<div className='p-4 space-y-4'>
|
||
<div className='text-sm text-gray-600 dark:text-gray-300'>
|
||
请输入可直接播放的视频链接。
|
||
</div>
|
||
{netdiskTempPlayEnabled && (
|
||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||
支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。
|
||
</div>
|
||
)}
|
||
<input
|
||
value={directPlayUrl}
|
||
onChange={(event) => setDirectPlayUrl(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
submitDirectPlay();
|
||
}
|
||
}}
|
||
placeholder='https://example.com/video.m3u8'
|
||
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||
/>
|
||
<div className='flex justify-end gap-2'>
|
||
<button
|
||
onClick={() => setShowDirectPlayDialog(false)}
|
||
className='px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={submitDirectPlay}
|
||
disabled={!directPlayUrl.trim() || directPlaySubmitting}
|
||
className='px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||
>
|
||
{directPlaySubmitting ? '处理中...' : '开始播放'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{toast && <Toast {...toast} />}
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
export default function Home() {
|
||
return (
|
||
<Suspense>
|
||
<HomeClient />
|
||
</Suspense>
|
||
);
|
||
}
|