From ec5271dca0d9f2658adb137b47b392213565243c Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 14 Dec 2025 22:15:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=B3=E5=B0=86=E4=B8=8A?= =?UTF-8?q?=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 40 +++++++ src/app/api/admin/site/route.ts | 4 + src/app/layout.tsx | 4 +- src/app/page.tsx | 60 +++++++++- src/components/SiteProvider.tsx | 11 +- src/components/VideoCard.tsx | 89 +++++++++++++- src/lib/admin.types.ts | 2 + src/lib/config.ts | 2 + src/lib/tmdb.client.ts | 201 ++++++++++++++++++++++++++++++++ 9 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 src/lib/tmdb.client.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3925cc7..a99bd5a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -296,6 +296,7 @@ interface SiteConfig { FluidSearch: boolean; DanmakuApiBase: string; DanmakuApiToken: string; + TMDBApiKey?: string; EnableComments: boolean; EnableRegistration?: boolean; RegistrationRequireTurnstile?: boolean; @@ -4592,6 +4593,7 @@ const SiteConfigComponent = ({ FluidSearch: true, DanmakuApiBase: 'http://localhost:9321', DanmakuApiToken: '87654321', + TMDBApiKey: '', EnableComments: false, EnableRegistration: false, RegistrationRequireTurnstile: false, @@ -4674,6 +4676,7 @@ const SiteConfigComponent = ({ DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321', DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', + TMDBApiKey: config.SiteConfig.TMDBApiKey || '', EnableComments: config.SiteConfig.EnableComments || false, EnableRegistration: config.SiteConfig.EnableRegistration || false, RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false, @@ -5235,6 +5238,43 @@ const SiteConfigComponent = ({ + {/* TMDB 配置 */} +
+

+ TMDB 配置 +

+ + {/* TMDB API Key */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + TMDBApiKey: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 配置后首页将显示 TMDB 即将上映电影。获取 API Key 请访问{' '} + + TMDB API 设置页面 + +

+
+
+ {/* 评论功能配置 */}

diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index ad53b7b..51cc10c 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -41,6 +41,7 @@ export async function POST(request: NextRequest) { FluidSearch, DanmakuApiBase, DanmakuApiToken, + TMDBApiKey, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, @@ -72,6 +73,7 @@ export async function POST(request: NextRequest) { FluidSearch: boolean; DanmakuApiBase: string; DanmakuApiToken: string; + TMDBApiKey?: string; EnableComments: boolean; CustomAdFilterCode?: string; CustomAdFilterVersion?: number; @@ -106,6 +108,7 @@ export async function POST(request: NextRequest) { typeof FluidSearch !== 'boolean' || typeof DanmakuApiBase !== 'string' || typeof DanmakuApiToken !== 'string' || + (TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') || typeof EnableComments !== 'boolean' || (CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') || (CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') || @@ -155,6 +158,7 @@ export async function POST(request: NextRequest) { FluidSearch, DanmakuApiBase, DanmakuApiToken, + TMDBApiKey, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e730b5f..c9d6d5c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -61,6 +61,7 @@ export default async function RootLayout({ process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false'; let enableComments = false; + let tmdbApiKey = ''; let customCategories = [] as { name: string; type: 'movie' | 'tv'; @@ -85,6 +86,7 @@ export default async function RootLayout({ })); fluidSearch = config.SiteConfig.FluidSearch; enableComments = config.SiteConfig.EnableComments; + tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; } // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 @@ -128,7 +130,7 @@ export default async function RootLayout({ enableSystem disableTransitionOnChange > - + diff --git a/src/app/page.tsx b/src/app/page.tsx index 1430e45..608a92c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,6 +18,11 @@ import { subscribeToDataUpdates, } from '@/lib/db.client'; import { getDoubanCategories } from '@/lib/douban.client'; +import { + getTMDBUpcomingContent, + getTMDBImageUrl, + TMDBItem, +} from '@/lib/tmdb.client'; import { DoubanItem } from '@/lib/types'; import CapsuleSwitch from '@/components/CapsuleSwitch'; @@ -32,11 +37,12 @@ function HomeClient() { const [hotMovies, setHotMovies] = useState([]); const [hotTvShows, setHotTvShows] = useState([]); const [hotVarietyShows, setHotVarietyShows] = useState([]); + const [upcomingContent, setUpcomingContent] = useState([]); const [bangumiCalendarData, setBangumiCalendarData] = useState< BangumiCalendarData[] >([]); const [loading, setLoading] = useState(true); - const { announcement } = useSite(); + const { announcement, tmdbApiKey } = useSite(); const [showAnnouncement, setShowAnnouncement] = useState(false); @@ -73,7 +79,7 @@ function HomeClient() { try { setLoading(true); - // 并行获取热门电影、热门剧集和热门综艺 + // 并行获取热门电影、热门剧集、热门综艺和番剧日历 const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] = await Promise.all([ getDoubanCategories({ @@ -99,6 +105,20 @@ function HomeClient() { } setBangumiCalendarData(bangumiCalendarData); + + // 如果配置了 TMDB API Key,则获取即将上映/播出内容 + if (tmdbApiKey) { + const tmdbData = await getTMDBUpcomingContent(tmdbApiKey); + if (tmdbData.code === 200) { + // 按上映/播出日期升序排序(最近的排在前面) + const sortedContent = [...tmdbData.list].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(sortedContent); + } + } } catch (error) { console.error('获取推荐数据失败:', error); } finally { @@ -107,7 +127,7 @@ function HomeClient() { }; fetchRecommendData(); - }, []); + }, [tmdbApiKey]); // 处理收藏数据更新的函数 const updateFavoriteItems = useCallback( @@ -447,6 +467,40 @@ function HomeClient() { ))} + + {/* 即将上映/播出 (TMDB) */} + {tmdbApiKey && upcomingContent.length > 0 && ( +
+
+

+ 即将上映 +

+
+ + {upcomingContent.map((item) => ( +
+ 0 + ? item.vote_average.toFixed(1) + : '' + } + type={item.media_type === 'tv' ? 'tv' : 'movie'} + from='douban' + releaseDate={item.release_date} + isUpcoming={true} + /> +
+ ))} +
+
+ )} )}

diff --git a/src/components/SiteProvider.tsx b/src/components/SiteProvider.tsx index 3223a31..6e76094 100644 --- a/src/components/SiteProvider.tsx +++ b/src/components/SiteProvider.tsx @@ -2,11 +2,16 @@ import { createContext, ReactNode, useContext } from 'react'; -const SiteContext = createContext<{ siteName: string; announcement?: string }>({ +const SiteContext = createContext<{ + siteName: string; + announcement?: string; + tmdbApiKey?: string; +}>({ // 默认值 siteName: 'MoonTV', announcement: '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', + tmdbApiKey: '', }); export const useSite = () => useContext(SiteContext); @@ -15,13 +20,15 @@ export function SiteProvider({ children, siteName, announcement, + tmdbApiKey, }: { children: ReactNode; siteName: string; announcement?: string; + tmdbApiKey?: string; }) { return ( - + {children} ); diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index fcbc77c..8160f6b 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -47,6 +47,8 @@ export interface VideoCardProps { isBangumi?: boolean; isAggregate?: boolean; origin?: 'vod' | 'live'; + releaseDate?: string; // 上映日期,格式:YYYY-MM-DD + isUpcoming?: boolean; // 是否为即将上映 } export type VideoCardHandle = { @@ -76,6 +78,8 @@ const VideoCard = forwardRef(function VideoCard isBangumi = false, isAggregate = false, origin = 'vod', + releaseDate, + isUpcoming = false, }: VideoCardProps, ref ) { @@ -223,6 +227,11 @@ const VideoCard = forwardRef(function VideoCard ); const handleClick = useCallback(() => { + // 即将上映的电影不跳转 + if (isUpcoming) { + return; + } + if (origin === 'live' && actualSource && actualId) { // 直播内容跳转到直播页面 const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; @@ -240,6 +249,7 @@ const VideoCard = forwardRef(function VideoCard router.push(url); } }, [ + isUpcoming, origin, from, actualSource, @@ -254,6 +264,11 @@ const VideoCard = forwardRef(function VideoCard // 新标签页播放处理函数 const handlePlayInNewTab = useCallback(() => { + // 即将上映的电影不跳转 + if (isUpcoming) { + return; + } + if (origin === 'live' && actualSource && actualId) { // 直播内容跳转到直播页面 const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; @@ -270,6 +285,7 @@ const VideoCard = forwardRef(function VideoCard window.open(url, '_blank'); } }, [ + isUpcoming, origin, from, actualSource, @@ -295,6 +311,11 @@ const VideoCard = forwardRef(function VideoCard // 长按操作 const handleLongPress = useCallback(() => { + // 即将上映的电影不显示操作菜单 + if (isUpcoming) { + return; + } + if (!showMobileActions) { // 防止重复触发 // 立即显示菜单,避免等待数据加载导致动画卡顿 setShowMobileActions(true); @@ -304,7 +325,7 @@ const VideoCard = forwardRef(function VideoCard checkSearchFavoriteStatus(); } } - }, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); + }, [isUpcoming, showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); // 长按手势hook const longPressProps = useLongPress({ @@ -313,6 +334,28 @@ const VideoCard = forwardRef(function VideoCard longPressDelay: 500, }); + // 计算距离上映的天数(使用本地时区) + const daysUntilRelease = useMemo(() => { + if (!isUpcoming || !releaseDate) return null; + + // 获取今天的本地日期(午夜) + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // 将日期字符串解析为本地时区的日期对象 + // 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题 + const [releaseYear, releaseMonth, releaseDay] = releaseDate.split('-').map(Number); + const release = new Date(releaseYear, releaseMonth - 1, releaseDay); + + const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number); + const todayDate = new Date(todayYear, todayMonth - 1, todayDay); + + const diffTime = release.getTime() - todayDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + return diffDays; + }, [isUpcoming, releaseDate]); + const config = useMemo(() => { const configs = { playrecord: { @@ -348,7 +391,7 @@ const VideoCard = forwardRef(function VideoCard douban: { showSourceName: false, showProgress: false, - showPlayButton: true, + showPlayButton: !isUpcoming, // 即将上映不显示播放按钮 showHeart: false, showCheckCircle: false, showDoubanLink: true, @@ -357,7 +400,7 @@ const VideoCard = forwardRef(function VideoCard }, }; return configs[from] || configs.search; - }, [from, isAggregate, douban_id, rate]); + }, [from, isAggregate, douban_id, rate, isUpcoming]); // 移动端操作菜单配置 const mobileActions = useMemo(() => { @@ -495,7 +538,7 @@ const VideoCard = forwardRef(function VideoCard return ( <>
(function VideoCard e.preventDefault(); e.stopPropagation(); + // 即将上映的电影不显示操作菜单 + if (isUpcoming) { + return false; + } + // 右键弹出操作菜单 setShowMobileActions(true); @@ -595,8 +643,37 @@ const VideoCard = forwardRef(function VideoCard }} /> - {/* 播放按钮 */} - {config.showPlayButton && ( + {/* 播放按钮或上映倒计时 */} + {isUpcoming && daysUntilRelease !== null ? ( +
{ + e.preventDefault(); + return false; + }} + > +
+ {daysUntilRelease > 0 + ? `${daysUntilRelease}天后上映` + : daysUntilRelease === 0 + ? '今日上映' + : '已上映'} +
+
+ ) : config.showPlayButton && (
{ + try { + if (!apiKey) { + return { code: 400, list: [] }; + } + + const response = await fetch( + `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}` + ); + + if (!response.ok) { + console.error('TMDB API 请求失败:', response.status, response.statusText); + return { code: response.status, list: [] }; + } + + const data: TMDBUpcomingResponse = await response.json(); + + return { + code: 200, + list: data.results, + }; + } catch (error) { + console.error('获取 TMDB 即将上映电影失败:', error); + return { code: 500, list: [] }; + } +} + +/** + * 获取正在播出的电视剧 + * @param apiKey - TMDB API Key + * @param page - 页码 + * @returns 正在播出的电视剧列表 + */ +export async function getTMDBUpcomingTVShows( + apiKey: string, + page: number = 1 +): Promise<{ code: number; list: TMDBTVShow[] }> { + try { + if (!apiKey) { + return { code: 400, list: [] }; + } + + // 使用 on_the_air 接口获取正在播出的电视剧 + const response = await fetch( + `https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}` + ); + + if (!response.ok) { + console.error('TMDB TV API 请求失败:', response.status, response.statusText); + return { code: response.status, list: [] }; + } + + const data: TMDBTVAiringTodayResponse = await response.json(); + + return { + code: 200, + list: data.results, + }; + } catch (error) { + console.error('获取 TMDB 正在播出电视剧失败:', error); + return { code: 500, list: [] }; + } +} + +/** + * 获取即将上映/播出的内容(电影+电视剧) + * @param apiKey - TMDB API Key + * @returns 统一格式的即将上映/播出列表 + */ +export async function getTMDBUpcomingContent( + apiKey: string +): Promise<{ code: number; list: TMDBItem[] }> { + try { + if (!apiKey) { + return { code: 400, list: [] }; + } + + // 并行获取电影和电视剧数据 + const [moviesResult, tvShowsResult] = await Promise.all([ + getTMDBUpcomingMovies(apiKey), + getTMDBUpcomingTVShows(apiKey), + ]); + + // 获取今天的日期(本地时区) + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // 转换电影数据为统一格式,并过滤掉已上映的 + const movies: TMDBItem[] = moviesResult.list + .filter((movie) => { + // 只保留未来上映的电影 + return movie.release_date && movie.release_date >= todayStr; + }) + .map((movie) => ({ + id: movie.id, + title: movie.title, + poster_path: movie.poster_path, + release_date: movie.release_date, + overview: movie.overview, + vote_average: movie.vote_average, + media_type: 'movie' as const, + })); + + // 转换电视剧数据为统一格式,并过滤掉已播出的 + const tvShows: TMDBItem[] = tvShowsResult.list + .filter((tv) => { + // 只保留未来播出的电视剧 + return tv.first_air_date && tv.first_air_date >= todayStr; + }) + .map((tv) => ({ + id: tv.id, + title: tv.name, + poster_path: tv.poster_path, + release_date: tv.first_air_date, + overview: tv.overview, + vote_average: tv.vote_average, + media_type: 'tv' as const, + })); + + // 合并并返回 + const allContent = [...movies, ...tvShows]; + + return { + code: 200, + list: allContent, + }; + } catch (error) { + console.error('获取 TMDB 即将上映内容失败:', error); + return { code: 500, list: [] }; + } +} + +/** + * 获取 TMDB 图片完整 URL + * @param path - 图片路径 + * @param size - 图片尺寸,默认 w500 + * @returns 完整的图片 URL + */ +export function getTMDBImageUrl( + path: string | null, + size: string = 'w500' +): string { + if (!path) return ''; + return `https://image.tmdb.org/t/p/${size}${path}`; +}