豆瓣数据源增加备用源
This commit is contained in:
@@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer';
|
|||||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import PansouSearch from '@/components/PansouSearch';
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
import { useSite } from '@/components/SiteProvider';
|
import { useSite } from '@/components/SiteProvider';
|
||||||
import SmartRecommendations from '@/components/SmartRecommendations';
|
import SmartRecommendations from '@/components/SmartRecommendations';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
@@ -9023,8 +9024,8 @@ function PlayPageClient() {
|
|||||||
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
||||||
{videoCover ? (
|
{videoCover ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<ProxyImage
|
||||||
src={processImageUrl(videoCover)}
|
originalSrc={videoCover}
|
||||||
alt={videoTitle}
|
alt={videoTitle}
|
||||||
className='w-full h-full object-cover'
|
className='w-full h-full object-cover'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
|
|||||||
import ImageViewer from '@/components/ImageViewer';
|
import ImageViewer from '@/components/ImageViewer';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import PansouSearch from '@/components/PansouSearch';
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
import SearchResultFilter, {
|
import SearchResultFilter, {
|
||||||
SearchFilterCategory,
|
SearchFilterCategory,
|
||||||
} from '@/components/SearchResultFilter';
|
} from '@/components/SearchResultFilter';
|
||||||
@@ -829,9 +830,8 @@ function SearchPageClient() {
|
|||||||
>
|
>
|
||||||
<div className='flex items-start gap-4'>
|
<div className='flex items-start gap-4'>
|
||||||
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
|
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<ProxyImage
|
||||||
<img
|
originalSrc={item.poster}
|
||||||
src={processImageUrl(item.poster)}
|
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useRef,useState } from 'react';
|
|||||||
|
|
||||||
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
|
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||||
import { getDoubanDetail } from '@/lib/douban.client';
|
import { getDoubanDetail } from '@/lib/douban.client';
|
||||||
import { processImageUrl } from '@/lib/utils';
|
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
interface BannerCarouselProps {
|
interface BannerCarouselProps {
|
||||||
autoPlayInterval?: number; // 自动播放间隔(毫秒)
|
autoPlayInterval?: number; // 自动播放间隔(毫秒)
|
||||||
@@ -67,15 +68,15 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取图片URL(处理TX完整URL和TMDB路径)
|
// 获取图片原始URL(处理TX完整URL和TMDB路径)
|
||||||
const getImageUrl = (path: string | null) => {
|
const getImageUrl = (path: string | null) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
// 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理
|
// 如果是完整URL(TX数据源或豆瓣),直接返回原始地址
|
||||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
return processImageUrl(path);
|
return path;
|
||||||
}
|
}
|
||||||
// 否则使用TMDB的URL拼接,并通过processImageUrl处理
|
// 否则使用TMDB的URL拼接原始地址
|
||||||
return processImageUrl(getTMDBImageUrl(path, 'original'));
|
return getTMDBImageUrl(path, 'original');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取视频URL(处理豆瓣视频代理)
|
// 获取视频URL(处理豆瓣视频代理)
|
||||||
@@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* 显示图片 */
|
/* 显示图片 */
|
||||||
<Image
|
<ProxyImage
|
||||||
src={getImageUrl(item.backdrop_path || item.poster_path)}
|
originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
loading={index === 0 ? 'eager' : 'lazy'}
|
||||||
priority={index === 0}
|
|
||||||
sizes="100vw"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 渐变遮罩 */}
|
{/* 渐变遮罩 */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
|||||||
import { processImageUrl } from '@/lib/utils';
|
import { processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
import ImageViewer from '@/components/ImageViewer';
|
import ImageViewer from '@/components/ImageViewer';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -373,7 +374,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: title,
|
title: title,
|
||||||
intro: cmsData.desc,
|
intro: cmsData.desc,
|
||||||
episodesCount: cmsData.episodes?.length,
|
episodesCount: cmsData.episodes?.length,
|
||||||
poster: poster ? processImageUrl(poster) : poster,
|
poster: poster,
|
||||||
};
|
};
|
||||||
setDetailData(data);
|
setDetailData(data);
|
||||||
setOriginalDetailData(data);
|
setOriginalDetailData(data);
|
||||||
@@ -393,7 +394,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: data.title || title,
|
title: data.title || title,
|
||||||
intro: data.desc || '',
|
intro: data.desc || '',
|
||||||
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
||||||
poster: data.poster ? processImageUrl(data.poster) : poster,
|
poster: data.poster || poster,
|
||||||
year: data.year,
|
year: data.year,
|
||||||
};
|
};
|
||||||
setDetailData(detailData);
|
setDetailData(detailData);
|
||||||
@@ -423,7 +424,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: data.name_cn || data.name,
|
title: data.name_cn || data.name,
|
||||||
originalTitle: data.name,
|
originalTitle: data.name,
|
||||||
year: data.date ? data.date.substring(0, 4) : undefined,
|
year: data.date ? data.date.substring(0, 4) : undefined,
|
||||||
poster: data.images?.large ? processImageUrl(data.images.large) : poster,
|
poster: data.images?.large || poster,
|
||||||
rating: data.rating
|
rating: data.rating
|
||||||
? {
|
? {
|
||||||
value: data.rating.score,
|
value: data.rating.score,
|
||||||
@@ -454,7 +455,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
originalTitle: data.original_title,
|
originalTitle: data.original_title,
|
||||||
year: data.year,
|
year: data.year,
|
||||||
poster: (data.pic?.large || data.pic?.normal) ? processImageUrl(data.pic?.large || data.pic?.normal) : poster,
|
poster: data.pic?.large || data.pic?.normal || poster,
|
||||||
rating: data.rating
|
rating: data.rating
|
||||||
? {
|
? {
|
||||||
value: data.rating.value,
|
value: data.rating.value,
|
||||||
@@ -873,7 +874,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
...prev,
|
...prev,
|
||||||
title: episodesData.name || season?.name || prev.title,
|
title: episodesData.name || season?.name || prev.title,
|
||||||
intro: episodesData.overview || season?.overview || prev.overview,
|
intro: episodesData.overview || season?.overview || prev.overview,
|
||||||
poster: season?.poster_path ? processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')) : prev.poster,
|
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
|
||||||
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
|
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
|
||||||
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
||||||
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
|
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
|
||||||
@@ -1090,11 +1091,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
|
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
|
||||||
>
|
>
|
||||||
{virtualGalleryLayout.visibleItems.map((image) => {
|
{virtualGalleryLayout.visibleItems.map((image) => {
|
||||||
const imageUrl = processImageUrl(
|
const imageUrl = getTMDBImageUrl(
|
||||||
getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original')
|
image.file_path,
|
||||||
|
image.imageType === 'poster' ? 'w500' : 'original'
|
||||||
);
|
);
|
||||||
const thumbUrl = processImageUrl(
|
const thumbUrl = getTMDBImageUrl(
|
||||||
getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780')
|
image.file_path,
|
||||||
|
image.imageType === 'poster' ? 'w342' : 'w780'
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1112,12 +1115,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => handleImageClick(imageUrl)}
|
onClick={() => handleImageClick(imageUrl)}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={thumbUrl}
|
originalSrc={thumbUrl}
|
||||||
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
|
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
|
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
|
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
|
||||||
@@ -1231,7 +1232,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => handleImageClick(detailData.poster!)}
|
onClick={() => handleImageClick(detailData.poster!)}
|
||||||
>
|
>
|
||||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
<ProxyImage
|
||||||
|
originalSrc={detailData.poster}
|
||||||
|
alt={detailData.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{galleryEntryButton}
|
{galleryEntryButton}
|
||||||
</div>
|
</div>
|
||||||
@@ -1356,13 +1362,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{actor.profile_path ? (
|
{actor.profile_path ? (
|
||||||
<div
|
<div
|
||||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
|
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||||
alt={actor.name}
|
alt={actor.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1474,14 +1479,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1536,13 +1540,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{episode.still_path && (
|
{episode.still_path && (
|
||||||
<div
|
<div
|
||||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
|
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1742,7 +1745,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => handleImageClick(detailData.poster!)}
|
onClick={() => handleImageClick(detailData.poster!)}
|
||||||
>
|
>
|
||||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
<ProxyImage
|
||||||
|
originalSrc={detailData.poster}
|
||||||
|
alt={detailData.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{galleryEntryButton}
|
{galleryEntryButton}
|
||||||
</div>
|
</div>
|
||||||
@@ -1867,13 +1875,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{actor.profile_path ? (
|
{actor.profile_path ? (
|
||||||
<div
|
<div
|
||||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
|
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||||
alt={actor.name}
|
alt={actor.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1985,14 +1992,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2047,13 +2053,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{episode.still_path && (
|
{episode.still_path && (
|
||||||
<div
|
<div
|
||||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
|
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import React, {
|
|||||||
|
|
||||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||||
|
|
||||||
import DanmakuPanel from '@/components/DanmakuPanel';
|
import DanmakuPanel from '@/components/DanmakuPanel';
|
||||||
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
// 定义视频信息类型
|
// 定义视频信息类型
|
||||||
interface VideoInfo {
|
interface VideoInfo {
|
||||||
@@ -870,10 +871,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
{source.source === 'directplay' ? (
|
{source.source === 'directplay' ? (
|
||||||
<LinkIcon className='w-6 h-6 text-blue-500' />
|
<LinkIcon className='w-6 h-6 text-blue-500' />
|
||||||
) : source.poster ? (
|
) : source.poster ? (
|
||||||
<img
|
<ProxyImage
|
||||||
src={processImageUrl(source.poster)}
|
originalSrc={source.poster}
|
||||||
alt={source.title}
|
alt={source.title}
|
||||||
className='w-full h-full object-cover'
|
className='w-full h-full object-cover'
|
||||||
|
retryOnError={false}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.style.display = 'none';
|
target.style.display = 'none';
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
interface ImageViewerProps {
|
interface ImageViewerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -151,18 +152,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<ProxyImage
|
||||||
src={imageUrl}
|
originalSrc={imageUrl}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
width={1200}
|
|
||||||
height={1800}
|
|
||||||
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
|
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100vw',
|
maxWidth: '100vw',
|
||||||
maxHeight: '100vh',
|
maxHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
priority
|
loading="eager"
|
||||||
quality={100}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ProxyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
|
originalSrc: string;
|
||||||
|
displaySrc?: string;
|
||||||
|
retryDelay?: number;
|
||||||
|
retryOnError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProxyImage: React.FC<ProxyImageProps> = ({
|
||||||
|
originalSrc,
|
||||||
|
displaySrc,
|
||||||
|
retryDelay = 2000,
|
||||||
|
retryOnError = true,
|
||||||
|
onError,
|
||||||
|
src: _src,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
|
||||||
|
if (tryApplyDoubanImageFallback(img, originalSrc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryOnError && !img.dataset.retried) {
|
||||||
|
img.dataset.retried = 'true';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
img.src = displaySrc || processImageUrl(originalSrc);
|
||||||
|
}, retryDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
onError?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
src={displaySrc || processImageUrl(originalSrc)}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProxyImage;
|
||||||
@@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
||||||
const [enableTrailers, setEnableTrailers] = useState(false);
|
const [enableTrailers, setEnableTrailers] = useState(false);
|
||||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||||
|
const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
|
||||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||||
|
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
|
||||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||||
|
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
|
||||||
|
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
|
||||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||||
|
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
|
||||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
|
||||||
|
useState(false);
|
||||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||||
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
||||||
@@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => {
|
|||||||
setDoubanProxyUrl(defaultDoubanProxy);
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDoubanDataSourceBackup = localStorage.getItem(
|
||||||
|
'doubanDataSourceBackup'
|
||||||
|
);
|
||||||
|
setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
|
||||||
|
|
||||||
|
const savedDoubanProxyUrlBackup = localStorage.getItem(
|
||||||
|
'doubanProxyUrlBackup'
|
||||||
|
);
|
||||||
|
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
|
||||||
|
|
||||||
const savedDoubanImageProxyType = localStorage.getItem(
|
const savedDoubanImageProxyType = localStorage.getItem(
|
||||||
'doubanImageProxyType'
|
'doubanImageProxyType'
|
||||||
);
|
);
|
||||||
@@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => {
|
|||||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
|
||||||
|
'doubanImageProxyTypeBackup'
|
||||||
|
);
|
||||||
|
setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
|
||||||
|
|
||||||
|
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
|
||||||
|
'doubanImageProxyUrlBackup'
|
||||||
|
);
|
||||||
|
setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
|
||||||
|
|
||||||
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
|
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
|
||||||
if (savedTmdbImageBaseUrl !== null) {
|
if (savedTmdbImageBaseUrl !== null) {
|
||||||
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
|
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
|
||||||
@@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isDoubanDropdownOpen]);
|
}, [isDoubanDropdownOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (isDoubanBackupDropdownOpen) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('[data-dropdown="douban-datasource-backup"]')) {
|
||||||
|
setIsDoubanBackupDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDoubanBackupDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isDoubanBackupDropdownOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (isDoubanImageProxyDropdownOpen) {
|
if (isDoubanImageProxyDropdownOpen) {
|
||||||
@@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isDoubanImageProxyDropdownOpen]);
|
}, [isDoubanImageProxyDropdownOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (isDoubanImageProxyBackupDropdownOpen) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('[data-dropdown="douban-image-proxy-backup"]')) {
|
||||||
|
setIsDoubanImageProxyBackupDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDoubanImageProxyBackupDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isDoubanImageProxyBackupDropdownOpen]);
|
||||||
|
|
||||||
const handleMenuClick = () => {
|
const handleMenuClick = () => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
};
|
};
|
||||||
@@ -1049,6 +1110,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDoubanDataSourceBackupChange = (value: string) => {
|
||||||
|
setDoubanDataSourceBackup(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('doubanDataSourceBackup', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDoubanImageProxyTypeChange = (value: string) => {
|
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||||
setDoubanImageProxyType(value);
|
setDoubanImageProxyType(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1056,6 +1124,20 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDoubanImageProxyTypeBackupChange = (value: string) => {
|
||||||
|
setDoubanImageProxyTypeBackup(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('doubanImageProxyTypeBackup', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubanProxyUrlBackupChange = (value: string) => {
|
||||||
|
setDoubanProxyUrlBackup(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('doubanProxyUrlBackup', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDoubanImageProxyUrlChange = (value: string) => {
|
const handleDoubanImageProxyUrlChange = (value: string) => {
|
||||||
setDoubanImageProxyUrl(value);
|
setDoubanImageProxyUrl(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1063,6 +1145,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDoubanImageProxyUrlBackupChange = (value: string) => {
|
||||||
|
setDoubanImageProxyUrlBackup(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('doubanImageProxyUrlBackup', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTmdbImageBaseUrlChange = (value: string) => {
|
const handleTmdbImageBaseUrlChange = (value: string) => {
|
||||||
setTmdbImageBaseUrl(value);
|
setTmdbImageBaseUrl(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
setEnableTrailers(false);
|
setEnableTrailers(false);
|
||||||
setDoubanProxyUrl(defaultDoubanProxy);
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
setDoubanDataSource(defaultDoubanProxyType);
|
setDoubanDataSource(defaultDoubanProxyType);
|
||||||
|
setDoubanDataSourceBackup('direct');
|
||||||
|
setDoubanProxyUrlBackup('');
|
||||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||||
|
setDoubanImageProxyTypeBackup('server');
|
||||||
|
setDoubanImageProxyUrlBackup('');
|
||||||
setTmdbImageBaseUrl('https://image.tmdb.org');
|
setTmdbImageBaseUrl('https://image.tmdb.org');
|
||||||
setBufferStrategy('medium');
|
setBufferStrategy('medium');
|
||||||
setNextEpisodePreCache(true);
|
setNextEpisodePreCache(true);
|
||||||
@@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('enableTrailers', 'false');
|
localStorage.setItem('enableTrailers', 'false');
|
||||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||||
|
localStorage.setItem('doubanDataSourceBackup', 'direct');
|
||||||
|
localStorage.setItem('doubanProxyUrlBackup', '');
|
||||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||||
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
||||||
|
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
|
||||||
|
localStorage.setItem('doubanImageProxyUrlBackup', '');
|
||||||
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
|
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
|
||||||
localStorage.setItem('bufferStrategy', 'medium');
|
localStorage.setItem('bufferStrategy', 'medium');
|
||||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||||
@@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => {
|
|||||||
value={doubanProxyUrl}
|
value={doubanProxyUrl}
|
||||||
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{!doubanProxyUrl.trim() && (
|
||||||
|
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||||
|
未填写地址时将自动按直连处理
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
豆瓣数据备用渠道
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
主渠道失败后自动切换,默认直连
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='relative'
|
||||||
|
data-dropdown='douban-datasource-backup'
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setIsDoubanBackupDropdownOpen(!isDoubanBackupDropdownOpen)
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||||
|
>
|
||||||
|
{
|
||||||
|
doubanDataSourceOptions.find(
|
||||||
|
(option) => option.value === doubanDataSourceBackup
|
||||||
|
)?.label
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanBackupDropdownOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isDoubanBackupDropdownOpen && (
|
||||||
|
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||||
|
{doubanDataSourceOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
handleDoubanDataSourceBackupChange(option.value);
|
||||||
|
setIsDoubanBackupDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSourceBackup === option.value
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||||
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className='truncate'>{option.label}</span>
|
||||||
|
{doubanDataSourceBackup === option.value && (
|
||||||
|
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doubanDataSourceBackup === 'custom' && (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
豆瓣备用代理地址
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
备用渠道为自定义代理时生效
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
|
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||||
|
value={doubanProxyUrlBackup}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleDoubanProxyUrlBackupChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!doubanProxyUrlBackup.trim() && (
|
||||||
|
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||||
|
未填写地址时备用渠道将自动按直连处理
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
|
|||||||
handleDoubanImageProxyUrlChange(e.target.value)
|
handleDoubanImageProxyUrlChange(e.target.value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!doubanImageProxyUrl.trim() && (
|
||||||
|
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||||
|
未填写地址时将自动按服务器代理处理
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
豆瓣图片备用渠道
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
主图片渠道失败后自动切换,默认服务器代理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='relative'
|
||||||
|
data-dropdown='douban-image-proxy-backup'
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setIsDoubanImageProxyBackupDropdownOpen(
|
||||||
|
!isDoubanImageProxyBackupDropdownOpen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||||
|
>
|
||||||
|
{
|
||||||
|
doubanImageProxyTypeOptions.find(
|
||||||
|
(option) => option.value === doubanImageProxyTypeBackup
|
||||||
|
)?.label
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyBackupDropdownOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isDoubanImageProxyBackupDropdownOpen && (
|
||||||
|
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||||
|
{doubanImageProxyTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
handleDoubanImageProxyTypeBackupChange(option.value);
|
||||||
|
setIsDoubanImageProxyBackupDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyTypeBackup === option.value
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||||
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className='truncate'>{option.label}</span>
|
||||||
|
{doubanImageProxyTypeBackup === option.value && (
|
||||||
|
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doubanImageProxyTypeBackup === 'custom' && (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
豆瓣图片备用代理地址
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
备用图片渠道为自定义代理时生效
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
|
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||||
|
value={doubanImageProxyUrlBackup}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleDoubanImageProxyUrlBackupChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!doubanImageProxyUrlBackup.trim() && (
|
||||||
|
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||||
|
未填写地址时备用图片渠道将自动按服务器代理处理
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
saveFavorite,
|
saveFavorite,
|
||||||
subscribeToDataUpdates,
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { processImageUrl, base58Decode } from '@/lib/utils';
|
import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
|
|
||||||
import AIChatPanel from '@/components/AIChatPanel';
|
import AIChatPanel from '@/components/AIChatPanel';
|
||||||
@@ -750,8 +750,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
setShowImageViewer(true);
|
setShowImageViewer(true);
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
if (tryApplyDoubanImageFallback(img, actualPoster)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 图片加载失败时的重试机制
|
// 图片加载失败时的重试机制
|
||||||
const img = e.target as HTMLImageElement;
|
|
||||||
if (!img.dataset.retried) {
|
if (!img.dataset.retried) {
|
||||||
img.dataset.retried = 'true';
|
img.dataset.retried = 'true';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1598,7 +1602,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
<ImageViewer
|
<ImageViewer
|
||||||
isOpen={showImageViewer}
|
isOpen={showImageViewer}
|
||||||
onClose={() => setShowImageViewer(false)}
|
onClose={() => setShowImageViewer(false)}
|
||||||
imageUrl={processImageUrl(actualPoster)}
|
imageUrl={actualPoster}
|
||||||
alt={actualTitle}
|
alt={actualTitle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+244
-98
@@ -88,6 +88,36 @@ interface DoubanDetailApiResponse {
|
|||||||
[key: string]: any; // 允许其他字段
|
[key: string]: any; // 允许其他字段
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DoubanProxyType =
|
||||||
|
| 'direct'
|
||||||
|
| 'cors-proxy-zwei'
|
||||||
|
| 'cmliussss-cdn-tencent'
|
||||||
|
| 'cmliussss-cdn-ali'
|
||||||
|
| 'cors-anywhere'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
function normalizeDoubanProxyConfig(
|
||||||
|
proxyType: DoubanProxyType,
|
||||||
|
proxyUrl: string
|
||||||
|
): {
|
||||||
|
proxyType: DoubanProxyType;
|
||||||
|
proxyUrl: string;
|
||||||
|
} {
|
||||||
|
const normalizedProxyUrl = proxyUrl.trim();
|
||||||
|
|
||||||
|
if (proxyType === 'custom' && !normalizedProxyUrl) {
|
||||||
|
return {
|
||||||
|
proxyType: 'direct',
|
||||||
|
proxyUrl: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxyType,
|
||||||
|
proxyUrl: normalizedProxyUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 带超时的 fetch 请求
|
* 带超时的 fetch 请求
|
||||||
*/
|
*/
|
||||||
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
|
|||||||
| 'cors-anywhere'
|
| 'cors-anywhere'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
|
backupProxyType:
|
||||||
|
| 'direct'
|
||||||
|
| 'cors-proxy-zwei'
|
||||||
|
| 'cmliussss-cdn-tencent'
|
||||||
|
| 'cmliussss-cdn-ali'
|
||||||
|
| 'cors-anywhere'
|
||||||
|
| 'custom';
|
||||||
|
backupProxyUrl: string;
|
||||||
} {
|
} {
|
||||||
const doubanProxyType =
|
const doubanProxyType =
|
||||||
localStorage.getItem('doubanDataSource') ||
|
localStorage.getItem('doubanDataSource') ||
|
||||||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
|
|||||||
localStorage.getItem('doubanProxyUrl') ||
|
localStorage.getItem('doubanProxyUrl') ||
|
||||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
||||||
'';
|
'';
|
||||||
|
const doubanProxyBackupType =
|
||||||
|
(localStorage.getItem('doubanDataSourceBackup') as DoubanProxyType | null) ||
|
||||||
|
'direct';
|
||||||
|
const doubanProxyBackupUrl =
|
||||||
|
localStorage.getItem('doubanProxyUrlBackup') || '';
|
||||||
|
const primaryConfig = normalizeDoubanProxyConfig(doubanProxyType, doubanProxy);
|
||||||
|
const backupConfig = normalizeDoubanProxyConfig(
|
||||||
|
doubanProxyBackupType,
|
||||||
|
doubanProxyBackupUrl
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
proxyType: doubanProxyType,
|
proxyType: primaryConfig.proxyType,
|
||||||
proxyUrl: doubanProxy,
|
proxyUrl: primaryConfig.proxyUrl,
|
||||||
|
backupProxyType: backupConfig.proxyType,
|
||||||
|
backupProxyUrl: backupConfig.proxyUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDoubanRequester(
|
||||||
|
proxyType: DoubanProxyType,
|
||||||
|
proxyUrl: string
|
||||||
|
): {
|
||||||
|
useDirectApi: boolean;
|
||||||
|
requestProxyUrl: string;
|
||||||
|
useTencentCDN: boolean;
|
||||||
|
useAliCDN: boolean;
|
||||||
|
} {
|
||||||
|
switch (proxyType) {
|
||||||
|
case 'cors-proxy-zwei':
|
||||||
|
return {
|
||||||
|
useDirectApi: false,
|
||||||
|
requestProxyUrl: 'https://ciao-cors.is-an.org/',
|
||||||
|
useTencentCDN: false,
|
||||||
|
useAliCDN: false,
|
||||||
|
};
|
||||||
|
case 'cmliussss-cdn-tencent':
|
||||||
|
return {
|
||||||
|
useDirectApi: false,
|
||||||
|
requestProxyUrl: '',
|
||||||
|
useTencentCDN: true,
|
||||||
|
useAliCDN: false,
|
||||||
|
};
|
||||||
|
case 'cmliussss-cdn-ali':
|
||||||
|
return {
|
||||||
|
useDirectApi: false,
|
||||||
|
requestProxyUrl: '',
|
||||||
|
useTencentCDN: false,
|
||||||
|
useAliCDN: true,
|
||||||
|
};
|
||||||
|
case 'cors-anywhere':
|
||||||
|
return {
|
||||||
|
useDirectApi: false,
|
||||||
|
requestProxyUrl: 'https://cors-anywhere.com/',
|
||||||
|
useTencentCDN: false,
|
||||||
|
useAliCDN: false,
|
||||||
|
};
|
||||||
|
case 'custom':
|
||||||
|
return {
|
||||||
|
useDirectApi: false,
|
||||||
|
requestProxyUrl: proxyUrl,
|
||||||
|
useTencentCDN: false,
|
||||||
|
useAliCDN: false,
|
||||||
|
};
|
||||||
|
case 'direct':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
useDirectApi: true,
|
||||||
|
requestProxyUrl: '',
|
||||||
|
useTencentCDN: false,
|
||||||
|
useAliCDN: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDoubanWithFallback<T>(
|
||||||
|
primary: { proxyType: DoubanProxyType; proxyUrl: string },
|
||||||
|
backup: { proxyType: DoubanProxyType; proxyUrl: string },
|
||||||
|
runner: (requester: ReturnType<typeof buildDoubanRequester>) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const primaryRequester = buildDoubanRequester(primary.proxyType, primary.proxyUrl);
|
||||||
|
const backupRequester = buildDoubanRequester(backup.proxyType, backup.proxyUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await runner(primaryRequester);
|
||||||
|
} catch (primaryError) {
|
||||||
|
const sameStrategy =
|
||||||
|
primary.proxyType === backup.proxyType && primary.proxyUrl === backup.proxyUrl;
|
||||||
|
if (sameStrategy) {
|
||||||
|
throw primaryError;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[Douban] 主渠道失败,切换备用渠道: ${primary.proxyType} -> ${backup.proxyType}`,
|
||||||
|
primaryError
|
||||||
|
);
|
||||||
|
return runner(backupRequester);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchDoubanGlobalError(message: string) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('globalError', {
|
||||||
|
detail: { message },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浏览器端豆瓣分类数据获取函数
|
* 浏览器端豆瓣分类数据获取函数
|
||||||
*/
|
*/
|
||||||
@@ -211,14 +352,6 @@ export async function fetchDoubanCategories(
|
|||||||
list: list,
|
list: list,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 触发全局错误提示
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('globalError', {
|
|
||||||
detail: { message: '获取豆瓣分类数据失败' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,25 +363,34 @@ export async function getDoubanCategories(
|
|||||||
params: DoubanCategoriesParams
|
params: DoubanCategoriesParams
|
||||||
): Promise<DoubanResult> {
|
): Promise<DoubanResult> {
|
||||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanCategories(params, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanCategories(params, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(
|
||||||
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
|
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||||
case 'custom':
|
);
|
||||||
return fetchDoubanCategories(params, proxyUrl);
|
if (!response.ok) {
|
||||||
case 'direct':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
default:
|
}
|
||||||
const response = await fetch(
|
return response.json();
|
||||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return response.json();
|
return fetchDoubanCategories(
|
||||||
|
params,
|
||||||
|
requestProxyUrl,
|
||||||
|
useTencentCDN,
|
||||||
|
useAliCDN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dispatchDoubanGlobalError('获取豆瓣分类数据失败');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,25 +405,34 @@ export async function getDoubanList(
|
|||||||
params: DoubanListParams
|
params: DoubanListParams
|
||||||
): Promise<DoubanResult> {
|
): Promise<DoubanResult> {
|
||||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanList(params, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanList(params, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(
|
||||||
return fetchDoubanList(params, 'https://cors-anywhere.com/');
|
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||||
case 'custom':
|
);
|
||||||
return fetchDoubanList(params, proxyUrl);
|
if (!response.ok) {
|
||||||
case 'direct':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
default:
|
}
|
||||||
const response = await fetch(
|
return response.json();
|
||||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return response.json();
|
return fetchDoubanList(
|
||||||
|
params,
|
||||||
|
requestProxyUrl,
|
||||||
|
useTencentCDN,
|
||||||
|
useAliCDN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dispatchDoubanGlobalError('获取豆瓣列表数据失败');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
|
|||||||
list: list,
|
list: list,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 触发全局错误提示
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('globalError', {
|
|
||||||
detail: { message: '获取豆瓣列表数据失败' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
|
|||||||
platform,
|
platform,
|
||||||
sort,
|
sort,
|
||||||
} = params;
|
} = params;
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanRecommends(params, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanRecommends(params, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(
|
||||||
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
|
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||||
case 'custom':
|
);
|
||||||
return fetchDoubanRecommends(params, proxyUrl);
|
if (!response.ok) {
|
||||||
case 'direct':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
default:
|
}
|
||||||
const response = await fetch(
|
return response.json();
|
||||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return response.json();
|
return fetchDoubanRecommends(
|
||||||
|
params,
|
||||||
|
requestProxyUrl,
|
||||||
|
useTencentCDN,
|
||||||
|
useAliCDN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dispatchDoubanGlobalError('获取豆瓣推荐数据失败');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,14 +696,6 @@ export async function fetchDoubanDetail(
|
|||||||
const doubanData: DoubanDetailApiResponse = await response.json();
|
const doubanData: DoubanDetailApiResponse = await response.json();
|
||||||
return doubanData;
|
return doubanData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 触发全局错误提示
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('globalError', {
|
|
||||||
detail: { message: '获取豆瓣详情数据失败' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
|
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
|
|||||||
export async function getDoubanDetail(
|
export async function getDoubanDetail(
|
||||||
id: string
|
id: string
|
||||||
): Promise<DoubanDetailApiResponse> {
|
): Promise<DoubanDetailApiResponse> {
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanDetail(id, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanDetail(id, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(`/api/douban/detail?id=${id}`);
|
||||||
return fetchDoubanDetail(id, 'https://cors-anywhere.com/');
|
if (!response.ok) {
|
||||||
case 'custom':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
return fetchDoubanDetail(id, proxyUrl);
|
}
|
||||||
case 'direct':
|
return response.json();
|
||||||
default:
|
}
|
||||||
const response = await fetch(`/api/douban/detail?id=${id}`);
|
|
||||||
if (!response.ok) {
|
return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
return response.json();
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dispatchDoubanGlobalError('获取豆瓣详情数据失败');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-28
@@ -3,8 +3,7 @@ import bs58 from 'bs58';
|
|||||||
import he from 'he';
|
import he from 'he';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
|
|
||||||
function getDoubanImageProxyConfig(): {
|
export type DoubanImageProxyType =
|
||||||
proxyType:
|
|
||||||
| 'direct'
|
| 'direct'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'img3'
|
| 'img3'
|
||||||
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
|
|||||||
| 'cmliussss-cdn-ali'
|
| 'cmliussss-cdn-ali'
|
||||||
| 'baidu'
|
| 'baidu'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
|
function normalizeDoubanImageProxyConfig(
|
||||||
|
proxyType: DoubanImageProxyType,
|
||||||
|
proxyUrl: string
|
||||||
|
): {
|
||||||
|
proxyType: DoubanImageProxyType;
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
|
} {
|
||||||
|
const normalizedProxyUrl = proxyUrl.trim();
|
||||||
|
|
||||||
|
if (proxyType === 'custom' && !normalizedProxyUrl) {
|
||||||
|
return {
|
||||||
|
proxyType: 'server',
|
||||||
|
proxyUrl: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxyType,
|
||||||
|
proxyUrl: normalizedProxyUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDoubanImageUrl(
|
||||||
|
originalUrl: string,
|
||||||
|
proxyType: DoubanImageProxyType,
|
||||||
|
proxyUrl: string
|
||||||
|
): string {
|
||||||
|
switch (proxyType) {
|
||||||
|
case 'server':
|
||||||
|
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
||||||
|
case 'img3':
|
||||||
|
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
||||||
|
case 'cmliussss-cdn-tencent':
|
||||||
|
return originalUrl.replace(
|
||||||
|
/img\d+\.doubanio\.com/g,
|
||||||
|
'img.doubanio.cmliussss.net'
|
||||||
|
);
|
||||||
|
case 'cmliussss-cdn-ali':
|
||||||
|
return originalUrl.replace(
|
||||||
|
/img\d+\.doubanio\.com/g,
|
||||||
|
'img.doubanio.cmliussss.com'
|
||||||
|
);
|
||||||
|
case 'baidu':
|
||||||
|
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
|
||||||
|
case 'custom':
|
||||||
|
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
|
||||||
|
case 'direct':
|
||||||
|
default:
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDoubanImageProxyConfig(): {
|
||||||
|
proxyType: DoubanImageProxyType;
|
||||||
|
proxyUrl: string;
|
||||||
|
backupProxyType: DoubanImageProxyType;
|
||||||
|
backupProxyUrl: string;
|
||||||
} {
|
} {
|
||||||
// 确保在浏览器环境中执行
|
// 确保在浏览器环境中执行
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return {
|
return {
|
||||||
proxyType: 'cmliussss-cdn-tencent',
|
proxyType: 'cmliussss-cdn-tencent',
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
|
backupProxyType: 'server',
|
||||||
|
backupProxyUrl: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
|
|||||||
localStorage.getItem('doubanImageProxyUrl') ||
|
localStorage.getItem('doubanImageProxyUrl') ||
|
||||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
||||||
'';
|
'';
|
||||||
|
const doubanImageProxyBackupType =
|
||||||
|
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
|
||||||
|
'server';
|
||||||
|
const doubanImageProxyBackupUrl =
|
||||||
|
localStorage.getItem('doubanImageProxyUrlBackup') || '';
|
||||||
|
const primaryConfig = normalizeDoubanImageProxyConfig(
|
||||||
|
doubanImageProxyType,
|
||||||
|
doubanImageProxy
|
||||||
|
);
|
||||||
|
const backupConfig = normalizeDoubanImageProxyConfig(
|
||||||
|
doubanImageProxyBackupType,
|
||||||
|
doubanImageProxyBackupUrl
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
proxyType: doubanImageProxyType,
|
proxyType: primaryConfig.proxyType,
|
||||||
proxyUrl: doubanImageProxy,
|
proxyUrl: primaryConfig.proxyUrl,
|
||||||
|
backupProxyType: backupConfig.proxyType,
|
||||||
|
backupProxyUrl: backupConfig.proxyUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
|
||||||
|
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
|
getDoubanImageProxyConfig();
|
||||||
|
const primaryUrl = buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||||
|
const backupUrl = buildDoubanImageUrl(
|
||||||
|
originalUrl,
|
||||||
|
backupProxyType,
|
||||||
|
backupProxyUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backupUrl === primaryUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return backupUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryApplyDoubanImageFallback(
|
||||||
|
target: HTMLImageElement,
|
||||||
|
originalUrl: string
|
||||||
|
): boolean {
|
||||||
|
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.dataset.doubanBackupTried === 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
|
||||||
|
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.dataset.doubanBackupTried = 'true';
|
||||||
|
target.src = fallbackUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理图片 URL,根据用户设置使用相应的代理
|
* 处理图片 URL,根据用户设置使用相应的代理
|
||||||
*/
|
*/
|
||||||
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||||
switch (proxyType) {
|
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||||
case 'server':
|
|
||||||
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
|
||||||
case 'img3':
|
|
||||||
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
|
||||||
case 'cmliussss-cdn-tencent':
|
|
||||||
return originalUrl.replace(
|
|
||||||
/img\d+\.doubanio\.com/g,
|
|
||||||
'img.doubanio.cmliussss.net'
|
|
||||||
);
|
|
||||||
case 'cmliussss-cdn-ali':
|
|
||||||
return originalUrl.replace(
|
|
||||||
/img\d+\.doubanio\.com/g,
|
|
||||||
'img.doubanio.cmliussss.com'
|
|
||||||
);
|
|
||||||
case 'baidu':
|
|
||||||
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
|
|
||||||
case 'custom':
|
|
||||||
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
|
||||||
case 'direct':
|
|
||||||
default:
|
|
||||||
return originalUrl;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
|
|||||||
// 在 Node.js 环境中使用 Buffer
|
// 在 Node.js 环境中使用 Buffer
|
||||||
return Buffer.from(bytes).toString('utf-8');
|
return Buffer.from(bytes).toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user