详情面板支持切换数据源

This commit is contained in:
mtvpls
2026-02-17 00:46:12 +08:00
parent 6824ad7514
commit 54dd196af4
2 changed files with 355 additions and 112 deletions

View File

@@ -326,6 +326,7 @@ function HomeClient() {
rate={movie.rate}
type='movie'
from='douban'
douban_id={movie.id ? parseInt(movie.id) : undefined}
/>
</div>
))}
@@ -485,6 +486,7 @@ function HomeClient() {
rate={tvShow.rate}
type='tv'
from='douban'
douban_id={tvShow.id ? parseInt(tvShow.id) : undefined}
/>
</div>
))}
@@ -531,6 +533,7 @@ function HomeClient() {
rate={varietyShow.rate}
type='tv'
from='douban'
douban_id={varietyShow.id ? parseInt(varietyShow.id) : undefined}
/>
</div>
))}

View File

@@ -96,6 +96,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState<string>('');
// 数据源状态管理
const [currentSource, setCurrentSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
const [originalSource, setOriginalSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
const [isUsingTmdb, setIsUsingTmdb] = useState(false);
const [originalDetailData, setOriginalDetailData] = useState<DetailData | null>(null);
// 拖动滚动状态
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
@@ -218,17 +224,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
setError(null);
try {
// 如果正在使用 TMDB 数据,强制使用 TMDB
if (isUsingTmdb && title) {
await fetchTmdbData();
return;
}
// 优先使用苹果CMS数据短剧等
// 如果 cmsData 存在但 desc 为空,尝试通过 source-detail API 获取
if (cmsData) {
setCurrentSource('cms');
setOriginalSource('cms');
if (cmsData.desc) {
// 有 desc直接使用
setDetailData({
const data = {
title: title,
intro: cmsData.desc,
episodesCount: cmsData.episodes?.length,
poster: poster ? processImageUrl(poster) : poster,
});
};
setDetailData(data);
setOriginalDetailData(data);
setLoading(false);
return;
}
@@ -241,13 +257,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
);
if (response.ok) {
const data = await response.json();
setDetailData({
const detailData = {
title: data.title || title,
intro: data.desc || '',
episodesCount: data.episodes?.length || cmsData.episodes?.length,
poster: data.poster ? processImageUrl(data.poster) : poster,
year: data.year,
});
};
setDetailData(detailData);
setOriginalDetailData(detailData);
setLoading(false);
return;
}
@@ -260,6 +278,8 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
// 优先使用 Bangumi ID因为 isBangumi 为 true 时doubanId 实际上是 bangumiId
if (bangumiId || (isBangumi && doubanId)) {
setCurrentSource('bangumi');
setOriginalSource('bangumi');
const actualBangumiId = bangumiId || doubanId;
const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`);
if (!response.ok) {
@@ -267,7 +287,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
const data = await response.json();
setDetailData({
const detailData = {
title: data.name_cn || data.name,
originalTitle: data.name,
year: data.date ? data.date.substring(0, 4) : undefined,
@@ -282,19 +302,23 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
genres: data.tags?.map((tag: any) => tag.name).slice(0, 5),
episodesCount: data.eps,
releaseDate: data.date,
});
};
setDetailData(detailData);
setOriginalDetailData(detailData);
return;
}
// 使用豆瓣ID
if (doubanId && !isBangumi) {
setCurrentSource('douban');
setOriginalSource('douban');
const response = await fetch(`/api/douban/detail?id=${doubanId}`);
if (!response.ok) {
throw new Error('获取豆瓣详情失败');
}
const data = await response.json();
setDetailData({
const detailData = {
title: data.title,
originalTitle: data.original_title,
year: data.year,
@@ -313,113 +337,18 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
languages: data.languages,
duration: data.durations?.[0],
episodesCount: data.episodes_count,
});
};
setDetailData(detailData);
setOriginalDetailData(detailData);
return;
}
// 使用 TMDB 搜索
if (title) {
// 移除季度信息进行搜索
let searchTitle = title;
let extractedSeasonNumber = seasonNumber;
// 匹配各种季度格式: 第一季、第1季、第一部、Season 1、S1等
const seasonPatterns = [
/第([一二三四五六七八九十\d]+)[季部]/,
/Season\s*(\d+)/i,
/S(\d+)/i,
];
for (const pattern of seasonPatterns) {
const match = title.match(pattern);
if (match) {
searchTitle = title.replace(pattern, '').trim();
// 如果没有传入seasonNumber,尝试从标题中提取
if (!extractedSeasonNumber) {
const seasonStr = match[1];
// 中文数字转数字
const chineseNumbers: Record<string, number> = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
};
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
}
break;
}
}
const searchResponse = await fetch(
`/api/tmdb/search?query=${encodeURIComponent(searchTitle)}`
);
if (!searchResponse.ok) {
throw new Error('搜索失败');
}
const searchData = await searchResponse.json();
if (searchData.results && searchData.results.length > 0) {
const result = searchData.results[0];
const detailId = result.id;
const mediaType = result.media_type || type;
// 获取详情
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
if (!detailResponse.ok) {
throw new Error('获取TMDB详情失败');
}
const detailResult = await detailResponse.json();
// 如果有季度信息,尝试获取季度详情
let seasonData = null;
if (extractedSeasonNumber && mediaType === 'tv') {
try {
const seasonResponse = await fetch(
`/api/tmdb/seasons?id=${detailId}&season=${extractedSeasonNumber}`
);
if (seasonResponse.ok) {
seasonData = await seasonResponse.json();
}
} catch (err) {
console.error('获取季度信息失败', err);
}
}
setDetailData({
title: mediaType === 'movie' ? detailResult.title : detailResult.name,
originalTitle:
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
year:
mediaType === 'movie'
? detailResult.release_date?.substring(0, 4)
: detailResult.first_air_date?.substring(0, 4),
poster: detailResult.poster_path
? processImageUrl(getTMDBImageUrl(detailResult.poster_path, 'w500'))
: poster,
rating: detailResult.vote_average
? {
value: detailResult.vote_average,
count: detailResult.vote_count,
}
: undefined,
intro: seasonData?.overview || detailResult.overview,
genres: detailResult.genres?.map((g: any) => g.name),
countries: detailResult.production_countries?.map((c: any) => c.name),
languages: detailResult.spoken_languages?.map((l: any) => l.name),
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
releaseDate:
mediaType === 'movie' ? detailResult.release_date : detailResult.first_air_date,
status: detailResult.status,
tagline: detailResult.tagline,
seasons: detailResult.number_of_seasons,
overview: detailResult.overview,
tmdbId: detailId,
mediaType: mediaType,
seasonNumber: extractedSeasonNumber,
});
return;
}
throw new Error('未找到相关内容');
setCurrentSource('tmdb');
setOriginalSource('tmdb');
await fetchTmdbData();
return;
}
throw new Error('缺少必要的查询参数');
@@ -431,8 +360,251 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
};
// 提取 TMDB 数据获取逻辑为独立函数
const fetchTmdbData = async () => {
setCurrentSource('tmdb');
// 移除季度信息进行搜索
let searchTitle = title;
let extractedSeasonNumber = seasonNumber;
// 匹配各种季度格式: 第一季、第1季、第一部、Season 1、S1等
const seasonPatterns = [
/第([一二三四五六七八九十\d]+)[季部]/,
/Season\s*(\d+)/i,
/S(\d+)/i,
];
for (const pattern of seasonPatterns) {
const match = title.match(pattern);
if (match) {
searchTitle = title.replace(pattern, '').trim();
// 如果没有传入seasonNumber,尝试从标题中提取
if (!extractedSeasonNumber) {
const seasonStr = match[1];
// 中文数字转数字
const chineseNumbers: Record<string, number> = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
};
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
}
break;
}
}
const searchResponse = await fetch(
`/api/tmdb/search?query=${encodeURIComponent(searchTitle)}`
);
if (!searchResponse.ok) {
throw new Error('搜索失败');
}
const searchData = await searchResponse.json();
if (searchData.results && searchData.results.length > 0) {
const result = searchData.results[0];
const detailId = result.id;
const mediaType = result.media_type || type;
// 获取详情
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
if (!detailResponse.ok) {
throw new Error('获取TMDB详情失败');
}
const detailResult = await detailResponse.json();
// 如果有季度信息,尝试获取季度详情
let seasonData = null;
if (extractedSeasonNumber && mediaType === 'tv') {
try {
const seasonResponse = await fetch(
`/api/tmdb/seasons?id=${detailId}&season=${extractedSeasonNumber}`
);
if (seasonResponse.ok) {
seasonData = await seasonResponse.json();
}
} catch (err) {
console.error('获取季度信息失败', err);
}
}
setDetailData({
title: mediaType === 'movie' ? detailResult.title : detailResult.name,
originalTitle:
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
year:
mediaType === 'movie'
? detailResult.release_date?.substring(0, 4)
: detailResult.first_air_date?.substring(0, 4),
poster: detailResult.poster_path
? processImageUrl(getTMDBImageUrl(detailResult.poster_path, 'w500'))
: poster,
rating: detailResult.vote_average
? {
value: detailResult.vote_average,
count: detailResult.vote_count,
}
: undefined,
intro: seasonData?.overview || detailResult.overview,
genres: detailResult.genres?.map((g: any) => g.name),
countries: detailResult.production_countries?.map((c: any) => c.name),
languages: detailResult.spoken_languages?.map((l: any) => l.name),
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
releaseDate:
mediaType === 'movie' ? detailResult.release_date : detailResult.first_air_date,
status: detailResult.status,
tagline: detailResult.tagline,
seasons: detailResult.number_of_seasons,
overview: detailResult.overview,
tmdbId: detailId,
mediaType: mediaType,
seasonNumber: extractedSeasonNumber,
});
return;
}
throw new Error('未找到相关内容');
};
fetchDetail();
}, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source]);
}, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source, isUsingTmdb]);
// 切换数据源的函数
const handleToggleSource = async () => {
if (currentSource === 'tmdb') {
// 切换回原始数据源
if (originalDetailData) {
setDetailData(originalDetailData);
setCurrentSource(originalSource);
setError(null);
}
} else {
// 切换到 TMDB
// 保存当前数据
if (detailData && !originalDetailData) {
setOriginalDetailData(detailData);
}
setLoading(true);
setError(null);
try {
await fetchTmdbDataForToggle();
} catch (err) {
console.error('切换到TMDB失败:', err);
setError(err instanceof Error ? err.message : '切换到TMDB失败');
// 切换失败,但保持 currentSource 为 tmdb这样可以显示切换回按钮
setCurrentSource('tmdb');
} finally {
setLoading(false);
}
}
};
// 用于切换时获取 TMDB 数据
const fetchTmdbDataForToggle = async () => {
// 移除季度信息进行搜索
let searchTitle = title;
let extractedSeasonNumber = seasonNumber;
// 匹配各种季度格式: 第一季、第1季、第一部、Season 1、S1等
const seasonPatterns = [
/第([一二三四五六七八九十\d]+)[季部]/,
/Season\s*(\d+)/i,
/S(\d+)/i,
];
for (const pattern of seasonPatterns) {
const match = title.match(pattern);
if (match) {
searchTitle = title.replace(pattern, '').trim();
// 如果没有传入seasonNumber,尝试从标题中提取
if (!extractedSeasonNumber) {
const seasonStr = match[1];
// 中文数字转数字
const chineseNumbers: Record<string, number> = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
};
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
}
break;
}
}
const searchResponse = await fetch(
`/api/tmdb/search?query=${encodeURIComponent(searchTitle)}`
);
if (!searchResponse.ok) {
throw new Error('搜索失败');
}
const searchData = await searchResponse.json();
if (searchData.results && searchData.results.length > 0) {
const result = searchData.results[0];
const detailId = result.id;
const mediaType = result.media_type || type;
// 获取详情
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
if (!detailResponse.ok) {
throw new Error('获取TMDB详情失败');
}
const detailResult = await detailResponse.json();
// 如果有季度信息,尝试获取季度详情
let seasonData = null;
if (extractedSeasonNumber && mediaType === 'tv') {
try {
const seasonResponse = await fetch(
`/api/tmdb/seasons?id=${detailId}&season=${extractedSeasonNumber}`
);
if (seasonResponse.ok) {
seasonData = await seasonResponse.json();
}
} catch (err) {
console.error('获取季度信息失败', err);
}
}
setDetailData({
title: mediaType === 'movie' ? detailResult.title : detailResult.name,
originalTitle:
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
year:
mediaType === 'movie'
? detailResult.release_date?.substring(0, 4)
: detailResult.first_air_date?.substring(0, 4),
poster: detailResult.poster_path
? processImageUrl(getTMDBImageUrl(detailResult.poster_path, 'w500'))
: poster,
rating: detailResult.vote_average
? {
value: detailResult.vote_average,
count: detailResult.vote_count,
}
: undefined,
intro: seasonData?.overview || detailResult.overview,
genres: detailResult.genres?.map((g: any) => g.name),
countries: detailResult.production_countries?.map((c: any) => c.name),
languages: detailResult.spoken_languages?.map((l: any) => l.name),
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
releaseDate:
mediaType === 'movie' ? detailResult.release_date : detailResult.first_air_date,
status: detailResult.status,
tagline: detailResult.tagline,
seasons: detailResult.number_of_seasons,
overview: detailResult.overview,
tmdbId: detailId,
mediaType: mediaType,
seasonNumber: extractedSeasonNumber,
});
setCurrentSource('tmdb');
return;
}
throw new Error('未找到相关内容');
};
// 异步获取季度和集数详情仅TMDB
useEffect(() => {
@@ -608,8 +780,43 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
{error && (
<div className="p-6 text-center">
<p className="text-red-500 dark:text-red-400">{error}</p>
<div className="p-6">
<div className="text-center mb-6">
<p className="text-red-500 dark:text-red-400">{error}</p>
</div>
{/* 数据源显示和切换 - 错误时也显示 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
{currentSource === 'tmdb' && 'TMDB'}
</span>
</div>
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
</div>
</div>
</div>
)}
@@ -907,6 +1114,39 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
</div>
)}
{/* 数据源显示和切换 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
{currentSource === 'tmdb' && 'TMDB'}
</span>
</div>
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
</div>
</div>
</div>
)}
</div>