增加动漫数据源配置
This commit is contained in:
+571
-90
@@ -367,6 +367,10 @@ interface SiteConfig {
|
|||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
TMDBReverseProxy?: string;
|
TMDBReverseProxy?: string;
|
||||||
|
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl';
|
||||||
|
BangumiApiBaseUrl?: string;
|
||||||
|
BangumiImageBaseUrl?: string;
|
||||||
|
BangumiProxy?: string;
|
||||||
BannerDataSource?: string;
|
BannerDataSource?: string;
|
||||||
RecommendationDataSource?: string;
|
RecommendationDataSource?: string;
|
||||||
PansouApiUrl?: string;
|
PansouApiUrl?: string;
|
||||||
@@ -894,12 +898,14 @@ const UserConfig = ({
|
|||||||
if (checked) {
|
if (checked) {
|
||||||
// 只选择自己有权限操作的用户
|
// 只选择自己有权限操作的用户
|
||||||
const selectableUsernames =
|
const selectableUsernames =
|
||||||
displayUsers?.filter(
|
displayUsers
|
||||||
|
?.filter(
|
||||||
(user) =>
|
(user) =>
|
||||||
role === 'owner' ||
|
role === 'owner' ||
|
||||||
(role === 'admin' &&
|
(role === 'admin' &&
|
||||||
(user.role === 'user' || user.username === currentUsername))
|
(user.role === 'user' || user.username === currentUsername))
|
||||||
).map((u) => u.username) || [];
|
)
|
||||||
|
.map((u) => u.username) || [];
|
||||||
setSelectedUsers(new Set(selectableUsernames));
|
setSelectedUsers(new Set(selectableUsernames));
|
||||||
} else {
|
} else {
|
||||||
setSelectedUsers(new Set());
|
setSelectedUsers(new Set());
|
||||||
@@ -1261,9 +1267,7 @@ const UserConfig = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
showAddUserForm
|
showAddUserForm ? buttonStyles.secondary : buttonStyles.success
|
||||||
? buttonStyles.secondary
|
|
||||||
: buttonStyles.success
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{showAddUserForm ? '取消' : '添加用户'}
|
{showAddUserForm ? '取消' : '添加用户'}
|
||||||
@@ -1900,7 +1904,9 @@ const UserConfig = ({
|
|||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsersV2(userTotalPages, trimmedUserSearch)}
|
onClick={() =>
|
||||||
|
fetchUsersV2(userTotalPages, trimmedUserSearch)
|
||||||
|
}
|
||||||
disabled={userPage === userTotalPages}
|
disabled={userPage === userTotalPages}
|
||||||
className={`px-3 py-1 text-sm rounded ${
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
userPage === userTotalPages
|
userPage === userTotalPages
|
||||||
@@ -3618,7 +3624,8 @@ const OpenListConfigComponent = ({
|
|||||||
离线下载使用独立 OpenList 源
|
离线下载使用独立 OpenList 源
|
||||||
</h3>
|
</h3>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
开启后,存到私人影库和追番订阅会把任务提交到下方 OpenList,扫描和播放仍使用上方主 OpenList
|
开启后,存到私人影库和追番订阅会把任务提交到下方
|
||||||
|
OpenList,扫描和播放仍使用上方主 OpenList
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -3669,9 +3676,7 @@ const OpenListConfigComponent = ({
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
value={offlineDownloadUsername}
|
value={offlineDownloadUsername}
|
||||||
onChange={(e) =>
|
onChange={(e) => setOfflineDownloadUsername(e.target.value)}
|
||||||
setOfflineDownloadUsername(e.target.value)
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
placeholder='admin'
|
placeholder='admin'
|
||||||
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
@@ -3684,9 +3689,7 @@ const OpenListConfigComponent = ({
|
|||||||
<input
|
<input
|
||||||
type='password'
|
type='password'
|
||||||
value={offlineDownloadPassword}
|
value={offlineDownloadPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) => setOfflineDownloadPassword(e.target.value)}
|
||||||
setOfflineDownloadPassword(e.target.value)
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
placeholder='password'
|
placeholder='password'
|
||||||
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
@@ -4041,8 +4044,11 @@ const NetDiskConfigComponent = ({
|
|||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [cookie, setCookie] = useState('');
|
const [cookie, setCookie] = useState('');
|
||||||
const [savePath, setSavePath] = useState('/');
|
const [savePath, setSavePath] = useState('/');
|
||||||
const [quarkPlayMode, setQuarkPlayMode] = useState<'direct_first' | 'transcode_first'>('transcode_first');
|
const [quarkPlayMode, setQuarkPlayMode] = useState<
|
||||||
const [quarkMultiThreadPlayback, setQuarkMultiThreadPlayback] = useState(false);
|
'direct_first' | 'transcode_first'
|
||||||
|
>('transcode_first');
|
||||||
|
const [quarkMultiThreadPlayback, setQuarkMultiThreadPlayback] =
|
||||||
|
useState(false);
|
||||||
const [mobileEnabled, setMobileEnabled] = useState(false);
|
const [mobileEnabled, setMobileEnabled] = useState(false);
|
||||||
const [mobileAuthorization, setMobileAuthorization] = useState('');
|
const [mobileAuthorization, setMobileAuthorization] = useState('');
|
||||||
const [baiduEnabled, setBaiduEnabled] = useState(false);
|
const [baiduEnabled, setBaiduEnabled] = useState(false);
|
||||||
@@ -4066,7 +4072,9 @@ const NetDiskConfigComponent = ({
|
|||||||
setEnabled(quark?.Enabled || false);
|
setEnabled(quark?.Enabled || false);
|
||||||
setCookie(quark?.Cookie || '');
|
setCookie(quark?.Cookie || '');
|
||||||
setSavePath(quark?.SavePath || '/');
|
setSavePath(quark?.SavePath || '/');
|
||||||
setQuarkPlayMode(quark?.PlayMode === 'direct_first' ? 'direct_first' : 'transcode_first');
|
setQuarkPlayMode(
|
||||||
|
quark?.PlayMode === 'direct_first' ? 'direct_first' : 'transcode_first'
|
||||||
|
);
|
||||||
setQuarkMultiThreadPlayback(Boolean(quark?.MultiThreadPlayback));
|
setQuarkMultiThreadPlayback(Boolean(quark?.MultiThreadPlayback));
|
||||||
setMobileEnabled(mobile?.Enabled || false);
|
setMobileEnabled(mobile?.Enabled || false);
|
||||||
setMobileAuthorization(mobile?.Authorization || '');
|
setMobileAuthorization(mobile?.Authorization || '');
|
||||||
@@ -4423,7 +4431,13 @@ const NetDiskConfigComponent = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={quarkPlayMode}
|
value={quarkPlayMode}
|
||||||
onChange={(e) => setQuarkPlayMode(e.target.value === 'transcode_first' ? 'transcode_first' : 'direct_first')}
|
onChange={(e) =>
|
||||||
|
setQuarkPlayMode(
|
||||||
|
e.target.value === 'transcode_first'
|
||||||
|
? 'transcode_first'
|
||||||
|
: 'direct_first'
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
>
|
>
|
||||||
@@ -9621,6 +9635,10 @@ const SiteConfigComponent = ({
|
|||||||
TMDBApiKey: '',
|
TMDBApiKey: '',
|
||||||
TMDBProxy: '',
|
TMDBProxy: '',
|
||||||
TMDBReverseProxy: '',
|
TMDBReverseProxy: '',
|
||||||
|
BangumiDataSource: 'direct',
|
||||||
|
BangumiApiBaseUrl: 'https://api.bgm.tv',
|
||||||
|
BangumiImageBaseUrl: '',
|
||||||
|
BangumiProxy: '',
|
||||||
BannerDataSource: 'Douban',
|
BannerDataSource: 'Douban',
|
||||||
RecommendationDataSource: 'Mixed',
|
RecommendationDataSource: 'Mixed',
|
||||||
PansouApiUrl: '',
|
PansouApiUrl: '',
|
||||||
@@ -9726,6 +9744,11 @@ const SiteConfigComponent = ({
|
|||||||
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
|
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
|
||||||
TMDBProxy: config.SiteConfig.TMDBProxy || '',
|
TMDBProxy: config.SiteConfig.TMDBProxy || '',
|
||||||
TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
|
TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
|
||||||
|
BangumiDataSource: config.SiteConfig.BangumiDataSource || 'direct',
|
||||||
|
BangumiApiBaseUrl:
|
||||||
|
config.SiteConfig.BangumiApiBaseUrl || 'https://api.bgm.tv',
|
||||||
|
BangumiImageBaseUrl: config.SiteConfig.BangumiImageBaseUrl || '',
|
||||||
|
BangumiProxy: config.SiteConfig.BangumiProxy || '',
|
||||||
BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban',
|
BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban',
|
||||||
RecommendationDataSource:
|
RecommendationDataSource:
|
||||||
config.SiteConfig.RecommendationDataSource || 'Mixed',
|
config.SiteConfig.RecommendationDataSource || 'Mixed',
|
||||||
@@ -10487,6 +10510,122 @@ const SiteConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
{/* 动漫/Bangumi 配置 */}
|
||||||
|
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||||
|
动漫数据源配置
|
||||||
|
</summary>
|
||||||
|
<div className='mt-4 space-y-4'>
|
||||||
|
<p className='text-xs text-amber-600 dark:text-amber-400'>
|
||||||
|
Bangumi
|
||||||
|
在部分国内网络环境下可能无法直连,可按部署环境选择合适的数据源。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
默认动漫数据源
|
||||||
|
</label>
|
||||||
|
<div className='inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800'>
|
||||||
|
{[
|
||||||
|
{ value: 'direct', label: '直连' },
|
||||||
|
{ value: 'server-proxy', label: '服务器代理' },
|
||||||
|
{ value: 'custom-baseurl', label: '自定义 Base URL' },
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
BangumiDataSource:
|
||||||
|
option.value as SiteConfig['BangumiDataSource'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
(siteSettings.BangumiDataSource || 'direct') ===
|
||||||
|
option.value
|
||||||
|
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
作为新用户本地设置的默认动漫数据源;用户仍可在本地网络配置中覆盖。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Bangumi Base URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='https://api.bgm.tv'
|
||||||
|
value={siteSettings.BangumiApiBaseUrl || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
BangumiApiBaseUrl: 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'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
Bangumi 官方或自建反代地址,不要带末尾路径,例如
|
||||||
|
https://api.bgm.tv。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Bangumi 图片 Base URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='例如: https://proxy.example.com'
|
||||||
|
value={siteSettings.BangumiImageBaseUrl || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
BangumiImageBaseUrl: 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'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
用于替换 Bangumi
|
||||||
|
图片域名。只需填写基础部分,不需要填写完整图片路径,例如
|
||||||
|
https://lain.bgm.tv。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Bangumi 系统代理
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='例如: http://127.0.0.1:7890'
|
||||||
|
value={siteSettings.BangumiProxy || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
BangumiProxy: 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'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
用于服务器代理访问 Bangumi API。Cloudflare
|
||||||
|
部署环境下不会使用该代理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||||
磁链配置
|
磁链配置
|
||||||
@@ -12324,13 +12463,16 @@ const OPDSConfigComponent = ({
|
|||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [legadoSubscriptionName, setLegadoSubscriptionName] = useState('');
|
const [legadoSubscriptionName, setLegadoSubscriptionName] = useState('');
|
||||||
const [legadoSubscriptionUrl, setLegadoSubscriptionUrl] = useState('');
|
const [legadoSubscriptionUrl, setLegadoSubscriptionUrl] = useState('');
|
||||||
const [legadoSubscriptions, setLegadoSubscriptions] = useState<NonNullable<AdminConfig['OPDSConfig']>['LegadoSubscriptions']>([]);
|
const [legadoSubscriptions, setLegadoSubscriptions] = useState<
|
||||||
|
NonNullable<AdminConfig['OPDSConfig']>['LegadoSubscriptions']
|
||||||
|
>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config?.OPDSConfig) return;
|
if (!config?.OPDSConfig) return;
|
||||||
setEnabled(config.OPDSConfig.Enabled || false);
|
setEnabled(config.OPDSConfig.Enabled || false);
|
||||||
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
|
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
|
||||||
setSources((config.OPDSConfig.Sources || []).map((item, index) => ({
|
setSources(
|
||||||
|
(config.OPDSConfig.Sources || []).map((item, index) => ({
|
||||||
id: item.id || `source_${index + 1}`,
|
id: item.id || `source_${index + 1}`,
|
||||||
name: item.name || `书源 ${index + 1}`,
|
name: item.name || `书源 ${index + 1}`,
|
||||||
type: 'opds' as const,
|
type: 'opds' as const,
|
||||||
@@ -12344,20 +12486,25 @@ const OPDSConfigComponent = ({
|
|||||||
searchTemplate: item.searchTemplate || '',
|
searchTemplate: item.searchTemplate || '',
|
||||||
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
||||||
language: item.language || '',
|
language: item.language || '',
|
||||||
})));
|
}))
|
||||||
|
);
|
||||||
setLegadoSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
|
setLegadoSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const updateSource = (index: number, patch: Partial<BookSource>) => {
|
const updateSource = (index: number, patch: Partial<BookSource>) => {
|
||||||
setSources((prev) => prev.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
setSources((prev) =>
|
||||||
|
prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSource = () => {
|
const addSource = () => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const nextIndex = prev.length;
|
const nextIndex = prev.length;
|
||||||
setEditingIndex(nextIndex);
|
setEditingIndex(nextIndex);
|
||||||
return [...prev, {
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
id: `source_${nextIndex + 1}`,
|
id: `source_${nextIndex + 1}`,
|
||||||
name: `书源 ${nextIndex + 1}`,
|
name: `书源 ${nextIndex + 1}`,
|
||||||
type: 'opds' as const,
|
type: 'opds' as const,
|
||||||
@@ -12371,13 +12518,16 @@ const OPDSConfigComponent = ({
|
|||||||
searchTemplate: '',
|
searchTemplate: '',
|
||||||
preferFormat: ['epub' as const, 'pdf' as const],
|
preferFormat: ['epub' as const, 'pdf' as const],
|
||||||
language: '',
|
language: '',
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSource = (index: number) => {
|
const removeSource = (index: number) => {
|
||||||
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
||||||
setEditingIndex((prev) => prev === index ? null : prev !== null && prev > index ? prev - 1 : prev);
|
setEditingIndex((prev) =>
|
||||||
|
prev === index ? null : prev !== null && prev > index ? prev - 1 : prev
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeSource = (source: BookSource, index: number) => ({
|
const normalizeSource = (source: BookSource, index: number) => ({
|
||||||
@@ -12389,10 +12539,13 @@ const OPDSConfigComponent = ({
|
|||||||
authMode: source.authMode || 'none',
|
authMode: source.authMode || 'none',
|
||||||
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
||||||
password: source.authMode === 'none' ? '' : source.password || '',
|
password: source.authMode === 'none' ? '' : source.password || '',
|
||||||
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
headerName:
|
||||||
|
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
||||||
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
||||||
searchTemplate: source.searchTemplate?.trim() || '',
|
searchTemplate: source.searchTemplate?.trim() || '',
|
||||||
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
|
preferFormat: source.preferFormat?.length
|
||||||
|
? source.preferFormat
|
||||||
|
: ['epub', 'pdf'],
|
||||||
language: source.language?.trim() || '',
|
language: source.language?.trim() || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -12417,7 +12570,10 @@ const OPDSConfigComponent = ({
|
|||||||
showSuccess('电子书源配置已保存', showAlert);
|
showSuccess('电子书源配置已保存', showAlert);
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
showError(
|
||||||
|
error instanceof Error ? error.message : '保存失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12431,14 +12587,29 @@ const OPDSConfigComponent = ({
|
|||||||
const response = await fetch('/api/admin/opds', {
|
const response = await fetch('/api/admin/opds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ Enabled: true, CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), Sources: [source] }),
|
body: JSON.stringify({
|
||||||
|
Enabled: true,
|
||||||
|
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
||||||
|
Sources: [source],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) throw new Error(data.message || data.error || '测试连接失败');
|
if (!response.ok || !data.success)
|
||||||
|
throw new Error(data.message || data.error || '测试连接失败');
|
||||||
const result = Array.isArray(data.results) ? data.results[0] : null;
|
const result = Array.isArray(data.results) ? data.results[0] : null;
|
||||||
showSuccess(result ? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}` : '测试成功', showAlert);
|
showSuccess(
|
||||||
|
result
|
||||||
|
? `${result.name}: 分类${
|
||||||
|
result.capability.catalogSupported ? '√' : '×'
|
||||||
|
} / 搜索${result.capability.searchSupported ? '√' : '×'}`
|
||||||
|
: '测试成功',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
|
showError(
|
||||||
|
error instanceof Error ? error.message : '测试连接失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12450,16 +12621,26 @@ const OPDSConfigComponent = ({
|
|||||||
const response = await fetch('/api/admin/legado-subscriptions/import', {
|
const response = await fetch('/api/admin/legado-subscriptions/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: legadoSubscriptionName, url: legadoSubscriptionUrl }),
|
body: JSON.stringify({
|
||||||
|
name: legadoSubscriptionName,
|
||||||
|
url: legadoSubscriptionUrl,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || '导入 Legado 订阅失败');
|
if (!response.ok || !data.success)
|
||||||
|
throw new Error(data.error || '导入 Legado 订阅失败');
|
||||||
setLegadoSubscriptionName('');
|
setLegadoSubscriptionName('');
|
||||||
setLegadoSubscriptionUrl('');
|
setLegadoSubscriptionUrl('');
|
||||||
showSuccess(`已导入 ${data.subscription?.sourceCount || 0} 个 Legado 书源`, showAlert);
|
showSuccess(
|
||||||
|
`已导入 ${data.subscription?.sourceCount || 0} 个 Legado 书源`,
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : '导入 Legado 订阅失败', showAlert);
|
showError(
|
||||||
|
error instanceof Error ? error.message : '导入 Legado 订阅失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12468,13 +12649,23 @@ const OPDSConfigComponent = ({
|
|||||||
const refreshLegadoSubscription = async (id: string) => {
|
const refreshLegadoSubscription = async (id: string) => {
|
||||||
await withLoading(`refreshLegadoSubscription-${id}`, async () => {
|
await withLoading(`refreshLegadoSubscription-${id}`, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/legado-subscriptions/${encodeURIComponent(id)}/refresh`, { method: 'POST' });
|
const response = await fetch(
|
||||||
|
`/api/admin/legado-subscriptions/${encodeURIComponent(id)}/refresh`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || '刷新 Legado 订阅失败');
|
if (!response.ok || !data.success)
|
||||||
showSuccess(`已同步 ${data.subscription?.sourceCount || 0} 个 Legado 书源`, showAlert);
|
throw new Error(data.error || '刷新 Legado 订阅失败');
|
||||||
|
showSuccess(
|
||||||
|
`已同步 ${data.subscription?.sourceCount || 0} 个 Legado 书源`,
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : '刷新 Legado 订阅失败', showAlert);
|
showError(
|
||||||
|
error instanceof Error ? error.message : '刷新 Legado 订阅失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12483,13 +12674,20 @@ const OPDSConfigComponent = ({
|
|||||||
const deleteLegadoSubscription = async (id: string) => {
|
const deleteLegadoSubscription = async (id: string) => {
|
||||||
await withLoading(`deleteLegadoSubscription-${id}`, async () => {
|
await withLoading(`deleteLegadoSubscription-${id}`, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/legado-subscriptions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
const response = await fetch(
|
||||||
|
`/api/admin/legado-subscriptions/${encodeURIComponent(id)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || '删除 Legado 订阅失败');
|
if (!response.ok || !data.success)
|
||||||
|
throw new Error(data.error || '删除 Legado 订阅失败');
|
||||||
showSuccess('Legado 订阅已删除', showAlert);
|
showSuccess('Legado 订阅已删除', showAlert);
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : '删除 Legado 订阅失败', showAlert);
|
showError(
|
||||||
|
error instanceof Error ? error.message : '删除 Legado 订阅失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -12498,7 +12696,9 @@ const OPDSConfigComponent = ({
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
<div className='rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||||
<h3 className='mb-2 text-sm font-medium text-amber-900 dark:text-amber-100'>关于电子书馆 / OPDS / Legado</h3>
|
<h3 className='mb-2 text-sm font-medium text-amber-900 dark:text-amber-100'>
|
||||||
|
关于电子书馆 / OPDS / Legado
|
||||||
|
</h3>
|
||||||
<div className='space-y-1 text-sm text-amber-800 dark:text-amber-200'>
|
<div className='space-y-1 text-sm text-amber-800 dark:text-amber-200'>
|
||||||
<p>• OPDS 源手动配置。</p>
|
<p>• OPDS 源手动配置。</p>
|
||||||
<p>• Legado 通过订阅 URL 导入。</p>
|
<p>• Legado 通过订阅 URL 导入。</p>
|
||||||
@@ -12507,87 +12707,353 @@ const OPDSConfigComponent = ({
|
|||||||
|
|
||||||
<div className='flex items-center justify-between border-b border-gray-200 py-3 dark:border-gray-700'>
|
<div className='flex items-center justify-between border-b border-gray-200 py-3 dark:border-gray-700'>
|
||||||
<div>
|
<div>
|
||||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>启用电子书馆</h3>
|
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>关闭后不会展示电子书入口。</p>
|
启用电子书馆
|
||||||
|
</h3>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
关闭后不会展示电子书入口。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setEnabled(!enabled)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
|
<button
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
onClick={() => setEnabled(!enabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Feed 缓存时长(毫秒)</label>
|
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
<input type='number' min='60000' value={cacheTTL} onChange={(e) => setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
Feed 缓存时长(毫秒)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='60000'
|
||||||
|
value={cacheTTL}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)
|
||||||
|
}
|
||||||
|
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>Legado 订阅</h4>
|
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>
|
||||||
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>目前处于实验性阶段,仅支持部分简单订阅。</p>
|
Legado 订阅
|
||||||
|
</h4>
|
||||||
|
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>
|
||||||
|
目前处于实验性阶段,仅支持部分简单订阅。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type='button' onClick={importLegadoSubscription} disabled={!legadoSubscriptionUrl.trim() || isLoading('importLegadoSubscription')} className={buttonStyles.primarySmall}>{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}</button>
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={importLegadoSubscription}
|
||||||
|
disabled={
|
||||||
|
!legadoSubscriptionUrl.trim() ||
|
||||||
|
isLoading('importLegadoSubscription')
|
||||||
|
}
|
||||||
|
className={buttonStyles.primarySmall}
|
||||||
|
>
|
||||||
|
{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||||
<input type='text' value={legadoSubscriptionName} onChange={(e) => setLegadoSubscriptionName(e.target.value)} placeholder='订阅名称(可选)' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' />
|
<input
|
||||||
<input type='text' value={legadoSubscriptionUrl} onChange={(e) => setLegadoSubscriptionUrl(e.target.value)} placeholder='https://example.com/bookSource.json' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' />
|
type='text'
|
||||||
|
value={legadoSubscriptionName}
|
||||||
|
onChange={(e) => setLegadoSubscriptionName(e.target.value)}
|
||||||
|
placeholder='订阅名称(可选)'
|
||||||
|
className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={legadoSubscriptionUrl}
|
||||||
|
onChange={(e) => setLegadoSubscriptionUrl(e.target.value)}
|
||||||
|
placeholder='https://example.com/bookSource.json'
|
||||||
|
className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-4 space-y-2'>
|
<div className='mt-4 space-y-2'>
|
||||||
{(legadoSubscriptions || []).length === 0 ? <div className='text-xs text-amber-800 dark:text-amber-200'>暂无 Legado 订阅。</div> : (legadoSubscriptions || []).map((sub) => (
|
{(legadoSubscriptions || []).length === 0 ? (
|
||||||
<div key={sub.id} className='rounded-lg border border-amber-200 bg-white p-3 text-sm dark:border-amber-800 dark:bg-gray-900'>
|
<div className='text-xs text-amber-800 dark:text-amber-200'>
|
||||||
|
暂无 Legado 订阅。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(legadoSubscriptions || []).map((sub) => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
className='rounded-lg border border-amber-200 bg-white p-3 text-sm dark:border-amber-800 dark:bg-gray-900'
|
||||||
|
>
|
||||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='font-medium text-gray-900 dark:text-gray-100'>{sub.name}</div>
|
<div className='font-medium text-gray-900 dark:text-gray-100'>
|
||||||
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{sub.url}</div>
|
{sub.name}
|
||||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>源数量:{sub.sourceCount || 0} · 上次同步:{sub.lastSuccessAt ? new Date(sub.lastSuccessAt).toLocaleString() : '-'}</div>
|
</div>
|
||||||
{sub.lastError ? <div className='mt-1 text-xs text-red-500'>{sub.lastError}</div> : null}
|
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{sub.url}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
源数量:{sub.sourceCount || 0} · 上次同步:
|
||||||
|
{sub.lastSuccessAt
|
||||||
|
? new Date(sub.lastSuccessAt).toLocaleString()
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
{sub.lastError ? (
|
||||||
|
<div className='mt-1 text-xs text-red-500'>
|
||||||
|
{sub.lastError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<button type='button' onClick={() => setLegadoSubscriptions((prev) => (prev || []).map((item) => item.id === sub.id ? { ...item, enabled: item.enabled === false } : item))} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${sub.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}><span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${sub.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /></button>
|
<button
|
||||||
<button type='button' onClick={() => refreshLegadoSubscription(sub.id)} disabled={isLoading(`refreshLegadoSubscription-${sub.id}`)} className={buttonStyles.secondarySmall}>{isLoading(`refreshLegadoSubscription-${sub.id}`) ? '同步中...' : '同步'}</button>
|
type='button'
|
||||||
<button type='button' onClick={() => deleteLegadoSubscription(sub.id)} disabled={isLoading(`deleteLegadoSubscription-${sub.id}`)} className={buttonStyles.dangerSmall}>删除</button>
|
onClick={() =>
|
||||||
|
setLegadoSubscriptions((prev) =>
|
||||||
|
(prev || []).map((item) =>
|
||||||
|
item.id === sub.id
|
||||||
|
? { ...item, enabled: item.enabled === false }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
sub.enabled !== false
|
||||||
|
? 'bg-green-600'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
sub.enabled !== false
|
||||||
|
? 'translate-x-6'
|
||||||
|
: 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => refreshLegadoSubscription(sub.id)}
|
||||||
|
disabled={isLoading(
|
||||||
|
`refreshLegadoSubscription-${sub.id}`
|
||||||
|
)}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
{isLoading(`refreshLegadoSubscription-${sub.id}`)
|
||||||
|
? '同步中...'
|
||||||
|
: '同步'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => deleteLegadoSubscription(sub.id)}
|
||||||
|
disabled={isLoading(`deleteLegadoSubscription-${sub.id}`)}
|
||||||
|
className={buttonStyles.dangerSmall}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>OPDS 书源列表</h3>
|
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||||
<button type='button' onClick={addSource} className={buttonStyles.primary}><Plus size={16} className='mr-1 inline' />添加 OPDS</button>
|
OPDS 书源列表
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={addSource}
|
||||||
|
className={buttonStyles.primary}
|
||||||
|
>
|
||||||
|
<Plus size={16} className='mr-1 inline' />
|
||||||
|
添加 OPDS
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sources.length === 0 ? <div className='rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400'>暂无 OPDS 书源。</div> : null}
|
{sources.length === 0 ? (
|
||||||
|
<div className='rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400'>
|
||||||
|
暂无 OPDS 书源。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{sources.map((source, index) => {
|
{sources.map((source, index) => {
|
||||||
const isEditing = editingIndex === index;
|
const isEditing = editingIndex === index;
|
||||||
return (
|
return (
|
||||||
<div key={`opds-source-${index}`} className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900'>
|
<div
|
||||||
|
key={`opds-source-${index}`}
|
||||||
|
className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900'
|
||||||
|
>
|
||||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='font-medium text-gray-900 dark:text-gray-100'>{source.name || `书源 ${index + 1}`}</div>
|
<div className='font-medium text-gray-900 dark:text-gray-100'>
|
||||||
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{source.url || '-'}</div>
|
{source.name || `书源 ${index + 1}`}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{source.url || '-'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<button type='button' onClick={() => updateSource(index, { enabled: source.enabled === false })} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}><span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /></button>
|
<button
|
||||||
<button type='button' onClick={() => handleTest(index)} disabled={isLoading(`testOPDSConfig-${index}`)} className={buttonStyles.primarySmall}>{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'}</button>
|
type='button'
|
||||||
<button type='button' onClick={() => setEditingIndex(isEditing ? null : index)} className={buttonStyles.secondarySmall}>{isEditing ? '收起' : '编辑'}</button>
|
onClick={() =>
|
||||||
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>删除</button>
|
updateSource(index, {
|
||||||
|
enabled: source.enabled === false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
source.enabled !== false
|
||||||
|
? 'bg-green-600'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
source.enabled !== false
|
||||||
|
? 'translate-x-6'
|
||||||
|
: 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleTest(index)}
|
||||||
|
disabled={isLoading(`testOPDSConfig-${index}`)}
|
||||||
|
className={buttonStyles.primarySmall}
|
||||||
|
>
|
||||||
|
{isLoading(`testOPDSConfig-${index}`)
|
||||||
|
? '测试中...'
|
||||||
|
: '测试'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setEditingIndex(isEditing ? null : index)}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
{isEditing ? '收起' : '编辑'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => removeSource(index)}
|
||||||
|
className={buttonStyles.dangerSmall}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className='mt-4 grid grid-cols-1 gap-4 border-t border-gray-200 pt-4 dark:border-gray-700 md:grid-cols-2'>
|
<div className='mt-4 grid grid-cols-1 gap-4 border-t border-gray-200 pt-4 dark:border-gray-700 md:grid-cols-2'>
|
||||||
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} placeholder='书源 ID' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
<input
|
||||||
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} placeholder='书源名称' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
type='text'
|
||||||
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' />
|
value={source.id}
|
||||||
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'>
|
onChange={(e) =>
|
||||||
<option value='none'>无认证</option><option value='basic'>Basic Auth</option><option value='header'>自定义 Header</option>
|
updateSource(index, { id: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='书源 ID'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={source.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='书源名称'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={source.url}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { url: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='https://example.com/opds'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2'
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={source.authMode || 'none'}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, {
|
||||||
|
authMode: e.target.value as BookSource['authMode'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
>
|
||||||
|
<option value='none'>无认证</option>
|
||||||
|
<option value='basic'>Basic Auth</option>
|
||||||
|
<option value='header'>自定义 Header</option>
|
||||||
</select>
|
</select>
|
||||||
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='语言 zh / en' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
|
<input
|
||||||
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='搜索模板 https://...{searchTerms}' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' />
|
type='text'
|
||||||
{source.authMode === 'basic' ? <><input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} placeholder='用户名' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /><input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} placeholder='密码' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /></> : null}
|
value={source.language || ''}
|
||||||
{source.authMode === 'header' ? <><input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} placeholder='Header 名称' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /><input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} placeholder='Header 值' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /></> : null}
|
onChange={(e) =>
|
||||||
|
updateSource(index, { language: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='语言 zh / en'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={source.searchTemplate || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { searchTemplate: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='搜索模板 https://...{searchTerms}'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2'
|
||||||
|
/>
|
||||||
|
{source.authMode === 'basic' ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={source.username || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { username: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='用户名'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
value={source.password || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { password: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='密码'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{source.authMode === 'header' ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={source.headerName || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { headerName: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Header 名称'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
value={source.headerValue || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSource(index, { headerValue: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Header 值'
|
||||||
|
className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -12597,10 +13063,24 @@ const OPDSConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex gap-3'>
|
<div className='flex gap-3'>
|
||||||
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}</button>
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading('saveOPDSConfig')}
|
||||||
|
className={buttonStyles.success}
|
||||||
|
>
|
||||||
|
{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertModal isOpen={alertModal.isOpen} onClose={hideAlert} type={alertModal.type} title={alertModal.title} message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} />
|
<AlertModal
|
||||||
|
isOpen={alertModal.isOpen}
|
||||||
|
onClose={hideAlert}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
timer={alertModal.timer}
|
||||||
|
showConfirm={alertModal.showConfirm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -15717,7 +16197,8 @@ function AdminPageClient() {
|
|||||||
const userLimit = 10;
|
const userLimit = 10;
|
||||||
|
|
||||||
// 获取新版本用户列表
|
// 获取新版本用户列表
|
||||||
const fetchUsersV2 = useCallback(async (page = 1, search = userSearch) => {
|
const fetchUsersV2 = useCallback(
|
||||||
|
async (page = 1, search = userSearch) => {
|
||||||
try {
|
try {
|
||||||
setUserListLoading(true);
|
setUserListLoading(true);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -15728,9 +16209,7 @@ function AdminPageClient() {
|
|||||||
if (trimmedSearch) {
|
if (trimmedSearch) {
|
||||||
params.set('search', trimmedSearch);
|
params.set('search', trimmedSearch);
|
||||||
}
|
}
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/admin/users?${params.toString()}`);
|
||||||
`/api/admin/users?${params.toString()}`
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUsersV2(data.users);
|
setUsersV2(data.users);
|
||||||
@@ -15743,7 +16222,9 @@ function AdminPageClient() {
|
|||||||
} finally {
|
} finally {
|
||||||
setUserListLoading(false);
|
setUserListLoading(false);
|
||||||
}
|
}
|
||||||
}, [userSearch]);
|
},
|
||||||
|
[userSearch]
|
||||||
|
);
|
||||||
|
|
||||||
// 刷新配置和用户列表
|
// 刷新配置和用户列表
|
||||||
const refreshConfigAndUsers = useCallback(async () => {
|
const refreshConfigAndUsers = useCallback(async () => {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export async function POST(request: NextRequest) {
|
|||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
TMDBProxy,
|
TMDBProxy,
|
||||||
TMDBReverseProxy,
|
TMDBReverseProxy,
|
||||||
|
BangumiDataSource,
|
||||||
|
BangumiApiBaseUrl,
|
||||||
|
BangumiImageBaseUrl,
|
||||||
|
BangumiProxy,
|
||||||
BannerDataSource,
|
BannerDataSource,
|
||||||
RecommendationDataSource,
|
RecommendationDataSource,
|
||||||
PansouApiUrl,
|
PansouApiUrl,
|
||||||
@@ -95,6 +99,10 @@ export async function POST(request: NextRequest) {
|
|||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
TMDBReverseProxy?: string;
|
TMDBReverseProxy?: string;
|
||||||
|
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl';
|
||||||
|
BangumiApiBaseUrl?: string;
|
||||||
|
BangumiImageBaseUrl?: string;
|
||||||
|
BangumiProxy?: string;
|
||||||
BannerDataSource?: string;
|
BannerDataSource?: string;
|
||||||
RecommendationDataSource?: string;
|
RecommendationDataSource?: string;
|
||||||
PansouApiUrl?: string;
|
PansouApiUrl?: string;
|
||||||
@@ -149,33 +157,63 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof DanmakuAutoLoadDefault !== 'boolean') ||
|
typeof DanmakuAutoLoadDefault !== 'boolean') ||
|
||||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||||
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
||||||
(TMDBReverseProxy !== undefined && typeof TMDBReverseProxy !== 'string') ||
|
(TMDBReverseProxy !== undefined &&
|
||||||
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
|
typeof TMDBReverseProxy !== 'string') ||
|
||||||
(RecommendationDataSource !== undefined && typeof RecommendationDataSource !== 'string') ||
|
(BangumiDataSource !== undefined &&
|
||||||
(PansouKeywordBlocklist !== undefined && typeof PansouKeywordBlocklist !== 'string') ||
|
BangumiDataSource !== 'direct' &&
|
||||||
|
BangumiDataSource !== 'server-proxy' &&
|
||||||
|
BangumiDataSource !== 'custom-baseurl') ||
|
||||||
|
(BangumiApiBaseUrl !== undefined &&
|
||||||
|
typeof BangumiApiBaseUrl !== 'string') ||
|
||||||
|
(BangumiImageBaseUrl !== undefined &&
|
||||||
|
typeof BangumiImageBaseUrl !== 'string') ||
|
||||||
|
(BangumiProxy !== undefined && typeof BangumiProxy !== 'string') ||
|
||||||
|
(BannerDataSource !== undefined &&
|
||||||
|
typeof BannerDataSource !== 'string') ||
|
||||||
|
(RecommendationDataSource !== undefined &&
|
||||||
|
typeof RecommendationDataSource !== 'string') ||
|
||||||
|
(PansouKeywordBlocklist !== undefined &&
|
||||||
|
typeof PansouKeywordBlocklist !== 'string') ||
|
||||||
(MagnetProxy !== undefined && typeof MagnetProxy !== 'string') ||
|
(MagnetProxy !== undefined && typeof MagnetProxy !== 'string') ||
|
||||||
(MagnetMikanReverseProxy !== undefined && typeof MagnetMikanReverseProxy !== 'string') ||
|
(MagnetMikanReverseProxy !== undefined &&
|
||||||
(MagnetDmhyReverseProxy !== undefined && typeof MagnetDmhyReverseProxy !== 'string') ||
|
typeof MagnetMikanReverseProxy !== 'string') ||
|
||||||
(MagnetAcgripReverseProxy !== undefined && typeof MagnetAcgripReverseProxy !== 'string') ||
|
(MagnetDmhyReverseProxy !== undefined &&
|
||||||
|
typeof MagnetDmhyReverseProxy !== 'string') ||
|
||||||
|
(MagnetAcgripReverseProxy !== undefined &&
|
||||||
|
typeof MagnetAcgripReverseProxy !== 'string') ||
|
||||||
typeof EnableComments !== 'boolean' ||
|
typeof EnableComments !== 'boolean' ||
|
||||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
(CustomAdFilterCode !== undefined &&
|
||||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
typeof CustomAdFilterCode !== 'string') ||
|
||||||
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
|
(CustomAdFilterVersion !== undefined &&
|
||||||
(RequireRegistrationInviteCode !== undefined && typeof RequireRegistrationInviteCode !== 'boolean') ||
|
typeof CustomAdFilterVersion !== 'number') ||
|
||||||
(RegistrationInviteCode !== undefined && typeof RegistrationInviteCode !== 'string') ||
|
(EnableRegistration !== undefined &&
|
||||||
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
|
typeof EnableRegistration !== 'boolean') ||
|
||||||
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
|
(RequireRegistrationInviteCode !== undefined &&
|
||||||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
|
typeof RequireRegistrationInviteCode !== 'boolean') ||
|
||||||
(TurnstileSecretKey !== undefined && typeof TurnstileSecretKey !== 'string') ||
|
(RegistrationInviteCode !== undefined &&
|
||||||
|
typeof RegistrationInviteCode !== 'string') ||
|
||||||
|
(RegistrationRequireTurnstile !== undefined &&
|
||||||
|
typeof RegistrationRequireTurnstile !== 'boolean') ||
|
||||||
|
(LoginRequireTurnstile !== undefined &&
|
||||||
|
typeof LoginRequireTurnstile !== 'boolean') ||
|
||||||
|
(TurnstileSiteKey !== undefined &&
|
||||||
|
typeof TurnstileSiteKey !== 'string') ||
|
||||||
|
(TurnstileSecretKey !== undefined &&
|
||||||
|
typeof TurnstileSecretKey !== 'string') ||
|
||||||
(DefaultUserTags !== undefined && !Array.isArray(DefaultUserTags)) ||
|
(DefaultUserTags !== undefined && !Array.isArray(DefaultUserTags)) ||
|
||||||
(EnableOIDCLogin !== undefined && typeof EnableOIDCLogin !== 'boolean') ||
|
(EnableOIDCLogin !== undefined && typeof EnableOIDCLogin !== 'boolean') ||
|
||||||
(EnableOIDCRegistration !== undefined && typeof EnableOIDCRegistration !== 'boolean') ||
|
(EnableOIDCRegistration !== undefined &&
|
||||||
|
typeof EnableOIDCRegistration !== 'boolean') ||
|
||||||
(OIDCIssuer !== undefined && typeof OIDCIssuer !== 'string') ||
|
(OIDCIssuer !== undefined && typeof OIDCIssuer !== 'string') ||
|
||||||
(OIDCAuthorizationEndpoint !== undefined && typeof OIDCAuthorizationEndpoint !== 'string') ||
|
(OIDCAuthorizationEndpoint !== undefined &&
|
||||||
(OIDCTokenEndpoint !== undefined && typeof OIDCTokenEndpoint !== 'string') ||
|
typeof OIDCAuthorizationEndpoint !== 'string') ||
|
||||||
(OIDCUserInfoEndpoint !== undefined && typeof OIDCUserInfoEndpoint !== 'string') ||
|
(OIDCTokenEndpoint !== undefined &&
|
||||||
|
typeof OIDCTokenEndpoint !== 'string') ||
|
||||||
|
(OIDCUserInfoEndpoint !== undefined &&
|
||||||
|
typeof OIDCUserInfoEndpoint !== 'string') ||
|
||||||
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
|
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
|
||||||
(OIDCClientSecret !== undefined && typeof OIDCClientSecret !== 'string') ||
|
(OIDCClientSecret !== undefined &&
|
||||||
|
typeof OIDCClientSecret !== 'string') ||
|
||||||
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
|
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
|
||||||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
|
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
|
||||||
) {
|
) {
|
||||||
@@ -211,6 +249,10 @@ export async function POST(request: NextRequest) {
|
|||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
TMDBProxy,
|
TMDBProxy,
|
||||||
TMDBReverseProxy,
|
TMDBReverseProxy,
|
||||||
|
BangumiDataSource,
|
||||||
|
BangumiApiBaseUrl,
|
||||||
|
BangumiImageBaseUrl,
|
||||||
|
BangumiProxy,
|
||||||
BannerDataSource,
|
BannerDataSource,
|
||||||
RecommendationDataSource,
|
RecommendationDataSource,
|
||||||
PansouApiUrl,
|
PansouApiUrl,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { fetchBangumiFromServer } from '@/lib/bangumi.server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const response = await fetchBangumiFromServer('/calendar', {
|
||||||
|
baseUrl: config.SiteConfig.BangumiApiBaseUrl,
|
||||||
|
proxy: config.SiteConfig.BangumiProxy,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Bangumi calendar 请求失败: ${response.status}` },
|
||||||
|
{ status: response.status || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=21600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 Bangumi calendar 失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取 Bangumi calendar 失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { fetchBangumiFromServer } from '@/lib/bangumi.server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const id = request.nextUrl.searchParams.get('id') || '';
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Bangumi ID 格式错误' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const response = await fetchBangumiFromServer(`/v0/subjects/${id}`, {
|
||||||
|
baseUrl: config.SiteConfig.BangumiApiBaseUrl,
|
||||||
|
proxy: config.SiteConfig.BangumiProxy,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Bangumi subject 请求失败: ${response.status}` },
|
||||||
|
{ status: response.status || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 Bangumi subject 失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取 Bangumi subject 失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { fetchBangumiFromServer } from '@/lib/bangumi.server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Bangumi ID 格式错误' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const response = await fetchBangumiFromServer(`/v0/subjects/${id}`, {
|
||||||
|
baseUrl: config.SiteConfig.BangumiApiBaseUrl,
|
||||||
|
proxy: config.SiteConfig.BangumiProxy,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Bangumi subject 请求失败: ${response.status}` },
|
||||||
|
{ status: response.status || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 Bangumi subject 失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取 Bangumi subject 失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,103 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function isCloudflareEnvironment(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return baseUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBangumiImageBaseUrl(
|
||||||
|
imageUrl: string,
|
||||||
|
imageBaseUrl?: string
|
||||||
|
): string {
|
||||||
|
const normalizedBaseUrl = normalizeBaseUrl(imageBaseUrl || '');
|
||||||
|
if (!normalizedBaseUrl) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl.startsWith(`${normalizedBaseUrl}/`)) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedBaseUrl}/${imageUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBangumiImageUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase();
|
||||||
|
return (
|
||||||
|
hostname === 'lain.bgm.tv' ||
|
||||||
|
hostname === 'r.bgm.tv' ||
|
||||||
|
hostname.endsWith('.bgm.tv') ||
|
||||||
|
hostname.endsWith('.bangumi.tv')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImage(
|
||||||
|
imageUrl: string,
|
||||||
|
options?: { source?: string }
|
||||||
|
): Promise<Response> {
|
||||||
|
const isBangumiImage =
|
||||||
|
options?.source === 'bangumi' || isBangumiImageUrl(imageUrl);
|
||||||
|
const headers = {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
Referer: isBangumiImage ? 'https://bgm.tv/' : 'https://movie.douban.com/',
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = isBangumiImage ? await getConfig() : null;
|
||||||
|
const targetUrl = isBangumiImage
|
||||||
|
? applyBangumiImageBaseUrl(imageUrl, config?.SiteConfig.BangumiImageBaseUrl)
|
||||||
|
: imageUrl;
|
||||||
|
|
||||||
|
if (!isBangumiImage || isCloudflareEnvironment()) {
|
||||||
|
return fetch(targetUrl, { headers, signal: AbortSignal.timeout(15000) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = config?.SiteConfig.BangumiProxy?.trim();
|
||||||
|
const fetchOptions: any = {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(proxy ? 30000 : 15000),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxy) {
|
||||||
|
fetchOptions.agent = new HttpsProxyAgent(proxy, {
|
||||||
|
timeout: 30000,
|
||||||
|
keepAlive: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeFetch(targetUrl, fetchOptions) as unknown as Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
// OrionTV 兼容接口
|
// OrionTV 兼容接口
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const imageUrl = searchParams.get('url');
|
const imageUrl = searchParams.get('url');
|
||||||
|
const source = searchParams.get('source') || undefined;
|
||||||
|
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imageResponse = await fetch(imageUrl, {
|
const imageResponse = await fetchImage(imageUrl, { source });
|
||||||
headers: {
|
|
||||||
'User-Agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
|
||||||
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
|
||||||
Referer: 'https://movie.douban.com/',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -55,6 +133,7 @@ export async function GET(request: Request) {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('图片代理请求失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Error fetching image' },
|
{ error: 'Error fetching image' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
+53
-18
@@ -59,7 +59,8 @@ export default async function RootLayout({
|
|||||||
process.env.ANNOUNCEMENT ||
|
process.env.ANNOUNCEMENT ||
|
||||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||||
|
|
||||||
let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
let doubanProxyType =
|
||||||
|
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||||
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||||
let doubanImageProxyType =
|
let doubanImageProxyType =
|
||||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||||
@@ -71,6 +72,16 @@ export default async function RootLayout({
|
|||||||
let danmakuAutoLoadDefault = true;
|
let danmakuAutoLoadDefault = true;
|
||||||
let recommendationDataSource = 'Mixed';
|
let recommendationDataSource = 'Mixed';
|
||||||
let tmdbApiKey = '';
|
let tmdbApiKey = '';
|
||||||
|
let bangumiDataSource =
|
||||||
|
(process.env.NEXT_PUBLIC_BANGUMI_DATA_SOURCE as any) || 'direct';
|
||||||
|
let bangumiApiBaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_BANGUMI_API_BASE_URL ||
|
||||||
|
process.env.BANGUMI_API_BASE_URL ||
|
||||||
|
'https://api.bgm.tv';
|
||||||
|
let bangumiImageBaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_BANGUMI_IMAGE_BASE_URL ||
|
||||||
|
process.env.BANGUMI_IMAGE_BASE_URL ||
|
||||||
|
'';
|
||||||
let openListEnabled = false;
|
let openListEnabled = false;
|
||||||
let embyEnabled = false;
|
let embyEnabled = false;
|
||||||
let xiaoyaEnabled = false;
|
let xiaoyaEnabled = false;
|
||||||
@@ -101,7 +112,13 @@ export default async function RootLayout({
|
|||||||
let customAdFilterVersion = 0;
|
let customAdFilterVersion = 0;
|
||||||
let musicFeatureEnabled = false;
|
let musicFeatureEnabled = false;
|
||||||
let suwayomiEnabled = false;
|
let suwayomiEnabled = false;
|
||||||
let booksEnabled = process.env.OPDS_ENABLED === 'true' && !!(process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL || process.env.OPDS_SOURCES_JSON);
|
let booksEnabled =
|
||||||
|
process.env.OPDS_ENABLED === 'true' &&
|
||||||
|
!!(
|
||||||
|
process.env.OPDS_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_OPDS_URL ||
|
||||||
|
process.env.OPDS_SOURCES_JSON
|
||||||
|
);
|
||||||
let musicProxyEnabled = true;
|
let musicProxyEnabled = true;
|
||||||
let advancedRecommendationEnabled = false;
|
let advancedRecommendationEnabled = false;
|
||||||
let userFeatureAccess =
|
let userFeatureAccess =
|
||||||
@@ -137,8 +154,13 @@ export default async function RootLayout({
|
|||||||
fluidSearch = config.SiteConfig.FluidSearch;
|
fluidSearch = config.SiteConfig.FluidSearch;
|
||||||
enableComments = config.SiteConfig.EnableComments;
|
enableComments = config.SiteConfig.EnableComments;
|
||||||
danmakuAutoLoadDefault = config.SiteConfig.DanmakuAutoLoadDefault !== false;
|
danmakuAutoLoadDefault = config.SiteConfig.DanmakuAutoLoadDefault !== false;
|
||||||
recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed';
|
recommendationDataSource =
|
||||||
|
config.SiteConfig.RecommendationDataSource || 'Mixed';
|
||||||
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
||||||
|
bangumiDataSource = config.SiteConfig.BangumiDataSource || 'direct';
|
||||||
|
bangumiApiBaseUrl =
|
||||||
|
config.SiteConfig.BangumiApiBaseUrl || 'https://api.bgm.tv';
|
||||||
|
bangumiImageBaseUrl = config.SiteConfig.BangumiImageBaseUrl || '';
|
||||||
loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || '';
|
loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || '';
|
||||||
registerBackgroundImage = config.ThemeConfig?.registerBackgroundImage || '';
|
registerBackgroundImage = config.ThemeConfig?.registerBackgroundImage || '';
|
||||||
homeBackgroundImage = config.ThemeConfig?.homeBackgroundImage || '';
|
homeBackgroundImage = config.ThemeConfig?.homeBackgroundImage || '';
|
||||||
@@ -146,9 +168,11 @@ export default async function RootLayout({
|
|||||||
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
|
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
|
||||||
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
|
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
|
||||||
enableRegistration = config.SiteConfig.EnableRegistration || false;
|
enableRegistration = config.SiteConfig.EnableRegistration || false;
|
||||||
requireRegistrationInviteCode = config.SiteConfig.RequireRegistrationInviteCode || false;
|
requireRegistrationInviteCode =
|
||||||
|
config.SiteConfig.RequireRegistrationInviteCode || false;
|
||||||
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
|
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
|
||||||
registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false;
|
registrationRequireTurnstile =
|
||||||
|
config.SiteConfig.RegistrationRequireTurnstile || false;
|
||||||
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
|
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
|
||||||
enableOIDCLogin = config.SiteConfig.EnableOIDCLogin || false;
|
enableOIDCLogin = config.SiteConfig.EnableOIDCLogin || false;
|
||||||
enableOIDCRegistration = config.SiteConfig.EnableOIDCRegistration || false;
|
enableOIDCRegistration = config.SiteConfig.EnableOIDCRegistration || false;
|
||||||
@@ -173,8 +197,7 @@ export default async function RootLayout({
|
|||||||
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
|
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
|
||||||
// 漫画功能配置
|
// 漫画功能配置
|
||||||
suwayomiEnabled = !!(
|
suwayomiEnabled = !!(
|
||||||
config.SuwayomiConfig?.Enabled &&
|
config.SuwayomiConfig?.Enabled && config.SuwayomiConfig?.ServerURL
|
||||||
config.SuwayomiConfig?.ServerURL
|
|
||||||
);
|
);
|
||||||
// 电子书功能配置
|
// 电子书功能配置
|
||||||
const opdsConfig = config.OPDSConfig;
|
const opdsConfig = config.OPDSConfig;
|
||||||
@@ -198,19 +221,23 @@ export default async function RootLayout({
|
|||||||
embyEnabled = !!(
|
embyEnabled = !!(
|
||||||
config.EmbyConfig?.Sources &&
|
config.EmbyConfig?.Sources &&
|
||||||
config.EmbyConfig.Sources.length > 0 &&
|
config.EmbyConfig.Sources.length > 0 &&
|
||||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
config.EmbyConfig.Sources.some((s) => s.enabled && s.ServerURL)
|
||||||
);
|
);
|
||||||
// 检查是否启用了小雅功能
|
// 检查是否启用了小雅功能
|
||||||
xiaoyaEnabled = !!(
|
xiaoyaEnabled = !!(
|
||||||
config.XiaoyaConfig?.Enabled &&
|
config.XiaoyaConfig?.Enabled && config.XiaoyaConfig?.ServerURL
|
||||||
config.XiaoyaConfig?.ServerURL
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
const runtimeStorageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const runtimeStorageType =
|
||||||
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
const displayStorageType = runtimeStorageType === 'd1' && !isCloudflare ? 'sqlite' : runtimeStorageType;
|
const isCloudflare =
|
||||||
|
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||||
|
const displayStorageType =
|
||||||
|
runtimeStorageType === 'd1' && !isCloudflare
|
||||||
|
? 'sqlite'
|
||||||
|
: runtimeStorageType;
|
||||||
|
|
||||||
const runtimeConfig = {
|
const runtimeConfig = {
|
||||||
STORAGE_TYPE: runtimeStorageType,
|
STORAGE_TYPE: runtimeStorageType,
|
||||||
@@ -225,9 +252,14 @@ export default async function RootLayout({
|
|||||||
EnableComments: enableComments,
|
EnableComments: enableComments,
|
||||||
DANMAKU_AUTO_LOAD_DEFAULT: danmakuAutoLoadDefault,
|
DANMAKU_AUTO_LOAD_DEFAULT: danmakuAutoLoadDefault,
|
||||||
RecommendationDataSource: recommendationDataSource,
|
RecommendationDataSource: recommendationDataSource,
|
||||||
|
BANGUMI_DATA_SOURCE: bangumiDataSource,
|
||||||
|
BANGUMI_API_BASE_URL: bangumiApiBaseUrl,
|
||||||
|
BANGUMI_IMAGE_BASE_URL: bangumiImageBaseUrl,
|
||||||
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
||||||
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
ENABLE_OFFLINE_DOWNLOAD:
|
||||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
|
VOICE_CHAT_STRATEGY:
|
||||||
|
process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
||||||
OPENLIST_ENABLED: openListEnabled && userFeatureAccess.private_library,
|
OPENLIST_ENABLED: openListEnabled && userFeatureAccess.private_library,
|
||||||
EMBY_ENABLED: embyEnabled && userFeatureAccess.emby,
|
EMBY_ENABLED: embyEnabled && userFeatureAccess.emby,
|
||||||
XIAOYA_ENABLED: xiaoyaEnabled && userFeatureAccess.xiaoya,
|
XIAOYA_ENABLED: xiaoyaEnabled && userFeatureAccess.xiaoya,
|
||||||
@@ -273,8 +305,7 @@ export default async function RootLayout({
|
|||||||
userFeatureAccess.magnet_save_private_library,
|
userFeatureAccess.magnet_save_private_library,
|
||||||
NETDISK_TRANSFER_ENABLED: userFeatureAccess.netdisk_transfer,
|
NETDISK_TRANSFER_ENABLED: userFeatureAccess.netdisk_transfer,
|
||||||
NETDISK_TEMP_PLAY_ENABLED: userFeatureAccess.netdisk_temp_play,
|
NETDISK_TEMP_PLAY_ENABLED: userFeatureAccess.netdisk_temp_play,
|
||||||
FESTIVE_EFFECT_ENABLED:
|
FESTIVE_EFFECT_ENABLED: process.env.FESTIVE_EFFECT_ENABLED === 'true',
|
||||||
process.env.FESTIVE_EFFECT_ENABLED === 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -307,7 +338,11 @@ export default async function RootLayout({
|
|||||||
<TopProgressBar />
|
<TopProgressBar />
|
||||||
<RouteScrollReset />
|
<RouteScrollReset />
|
||||||
<TokenRefreshManager />
|
<TokenRefreshManager />
|
||||||
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
<SiteProvider
|
||||||
|
siteName={siteName}
|
||||||
|
announcement={announcement}
|
||||||
|
tmdbApiKey={tmdbApiKey}
|
||||||
|
>
|
||||||
<WatchRoomProvider>
|
<WatchRoomProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<StartupCacheCleanup />
|
<StartupCacheCleanup />
|
||||||
|
|||||||
+686
-339
@@ -1,10 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react';
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
|
Film,
|
||||||
|
Globe,
|
||||||
|
Images,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
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 { getBangumiSubject } from '@/lib/bangumi.client';
|
||||||
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||||
import { processImageUrl } from '@/lib/utils';
|
import { processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -105,9 +117,14 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const [detailData, setDetailData] = useState<DetailData | null>(null);
|
const [detailData, setDetailData] = useState<DetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [seasonData, setSeasonData] = useState<{ seasons: any[]; episodes: Episode[] } | null>(null);
|
const [seasonData, setSeasonData] = useState<{
|
||||||
|
seasons: any[];
|
||||||
|
episodes: Episode[];
|
||||||
|
} | null>(null);
|
||||||
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
||||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
|
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||||
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
||||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
@@ -122,12 +139,16 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
|
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
|
||||||
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
|
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
||||||
// 数据源状态管理
|
// 数据源状态管理
|
||||||
const [currentSource, setCurrentSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
|
const [currentSource, setCurrentSource] = useState<
|
||||||
const [originalSource, setOriginalSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
|
'douban' | 'bangumi' | 'cms' | 'tmdb'
|
||||||
|
>('tmdb');
|
||||||
|
const [originalSource, setOriginalSource] = useState<
|
||||||
|
'douban' | 'bangumi' | 'cms' | 'tmdb'
|
||||||
|
>('tmdb');
|
||||||
const [isUsingTmdb, setIsUsingTmdb] = useState(false);
|
const [isUsingTmdb, setIsUsingTmdb] = useState(false);
|
||||||
const [originalDetailData, setOriginalDetailData] = useState<DetailData | null>(null);
|
const [originalDetailData, setOriginalDetailData] =
|
||||||
|
useState<DetailData | null>(null);
|
||||||
|
|
||||||
const getExternalUrl = () => {
|
const getExternalUrl = () => {
|
||||||
if (currentSource === 'douban' && doubanId) {
|
if (currentSource === 'douban' && doubanId) {
|
||||||
@@ -387,14 +408,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
if (sourceId && source) {
|
if (sourceId && source) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/source-detail?id=${encodeURIComponent(sourceId)}&source=${encodeURIComponent(source)}&title=${encodeURIComponent(title)}`
|
`/api/source-detail?id=${encodeURIComponent(
|
||||||
|
sourceId
|
||||||
|
)}&source=${encodeURIComponent(
|
||||||
|
source
|
||||||
|
)}&title=${encodeURIComponent(title)}`
|
||||||
);
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const detailData = {
|
const detailData = {
|
||||||
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 || poster,
|
poster: data.poster || poster,
|
||||||
year: data.year,
|
year: data.year,
|
||||||
};
|
};
|
||||||
@@ -415,11 +441,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
setCurrentSource('bangumi');
|
setCurrentSource('bangumi');
|
||||||
setOriginalSource('bangumi');
|
setOriginalSource('bangumi');
|
||||||
const actualBangumiId = bangumiId || doubanId;
|
const actualBangumiId = bangumiId || doubanId;
|
||||||
const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`);
|
if (!actualBangumiId) {
|
||||||
if (!response.ok) {
|
throw new Error('Bangumi ID 缺失');
|
||||||
throw new Error('获取Bangumi详情失败');
|
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await getBangumiSubject(actualBangumiId);
|
||||||
|
|
||||||
const detailData = {
|
const detailData = {
|
||||||
title: data.name_cn || data.name,
|
title: data.name_cn || data.name,
|
||||||
@@ -517,10 +542,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const seasonStr = match[1];
|
const seasonStr = match[1];
|
||||||
// 中文数字转数字
|
// 中文数字转数字
|
||||||
const chineseNumbers: Record<string, number> = {
|
const chineseNumbers: Record<string, number> = {
|
||||||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
一: 1,
|
||||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
二: 2,
|
||||||
|
三: 3,
|
||||||
|
四: 4,
|
||||||
|
五: 5,
|
||||||
|
六: 6,
|
||||||
|
七: 7,
|
||||||
|
八: 8,
|
||||||
|
九: 9,
|
||||||
|
十: 10,
|
||||||
};
|
};
|
||||||
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
extractedSeasonNumber =
|
||||||
|
chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -540,7 +574,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const mediaType = result.media_type || type;
|
const mediaType = result.media_type || type;
|
||||||
|
|
||||||
// 获取详情
|
// 获取详情
|
||||||
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
|
const detailResponse = await fetch(
|
||||||
|
`/api/tmdb/detail?id=${detailId}&type=${mediaType}`
|
||||||
|
);
|
||||||
if (!detailResponse.ok) {
|
if (!detailResponse.ok) {
|
||||||
throw new Error('获取TMDB详情失败');
|
throw new Error('获取TMDB详情失败');
|
||||||
}
|
}
|
||||||
@@ -569,13 +605,22 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
? `${detailResult.name} ${seasonData.name}`
|
? `${detailResult.name} ${seasonData.name}`
|
||||||
: detailResult.name,
|
: detailResult.name,
|
||||||
originalTitle:
|
originalTitle:
|
||||||
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
|
mediaType === 'movie'
|
||||||
|
? detailResult.original_title
|
||||||
|
: detailResult.original_name,
|
||||||
year:
|
year:
|
||||||
mediaType === 'movie'
|
mediaType === 'movie'
|
||||||
? detailResult.release_date?.substring(0, 4)
|
? detailResult.release_date?.substring(0, 4)
|
||||||
: seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4),
|
: seasonData?.air_date?.substring(0, 4) ||
|
||||||
poster: (seasonData?.poster_path || detailResult.poster_path)
|
detailResult.first_air_date?.substring(0, 4),
|
||||||
? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500'))
|
poster:
|
||||||
|
seasonData?.poster_path || detailResult.poster_path
|
||||||
|
? processImageUrl(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
seasonData?.poster_path || detailResult.poster_path,
|
||||||
|
'w500'
|
||||||
|
)
|
||||||
|
)
|
||||||
: poster,
|
: poster,
|
||||||
rating: detailResult.vote_average
|
rating: detailResult.vote_average
|
||||||
? {
|
? {
|
||||||
@@ -587,8 +632,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
genres: detailResult.genres?.map((g: any) => g.name),
|
genres: detailResult.genres?.map((g: any) => g.name),
|
||||||
countries: detailResult.production_countries?.map((c: any) => c.name),
|
countries: detailResult.production_countries?.map((c: any) => c.name),
|
||||||
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
||||||
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
|
duration: detailResult.runtime
|
||||||
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
|
? `${detailResult.runtime}分钟`
|
||||||
|
: undefined,
|
||||||
|
episodesCount:
|
||||||
|
seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||||
releaseDate:
|
releaseDate:
|
||||||
mediaType === 'movie'
|
mediaType === 'movie'
|
||||||
? detailResult.release_date
|
? detailResult.release_date
|
||||||
@@ -609,7 +657,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchDetail();
|
fetchDetail();
|
||||||
}, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source, isUsingTmdb]);
|
}, [
|
||||||
|
isOpen,
|
||||||
|
doubanId,
|
||||||
|
bangumiId,
|
||||||
|
isBangumi,
|
||||||
|
tmdbId,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
seasonNumber,
|
||||||
|
poster,
|
||||||
|
cmsData,
|
||||||
|
sourceId,
|
||||||
|
source,
|
||||||
|
isUsingTmdb,
|
||||||
|
]);
|
||||||
|
|
||||||
// 切换数据源的函数
|
// 切换数据源的函数
|
||||||
const handleToggleSource = async () => {
|
const handleToggleSource = async () => {
|
||||||
@@ -664,10 +726,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const seasonStr = match[1];
|
const seasonStr = match[1];
|
||||||
// 中文数字转数字
|
// 中文数字转数字
|
||||||
const chineseNumbers: Record<string, number> = {
|
const chineseNumbers: Record<string, number> = {
|
||||||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
一: 1,
|
||||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
二: 2,
|
||||||
|
三: 3,
|
||||||
|
四: 4,
|
||||||
|
五: 5,
|
||||||
|
六: 6,
|
||||||
|
七: 7,
|
||||||
|
八: 8,
|
||||||
|
九: 9,
|
||||||
|
十: 10,
|
||||||
};
|
};
|
||||||
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
extractedSeasonNumber =
|
||||||
|
chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -687,7 +758,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const mediaType = result.media_type || type;
|
const mediaType = result.media_type || type;
|
||||||
|
|
||||||
// 获取详情
|
// 获取详情
|
||||||
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
|
const detailResponse = await fetch(
|
||||||
|
`/api/tmdb/detail?id=${detailId}&type=${mediaType}`
|
||||||
|
);
|
||||||
if (!detailResponse.ok) {
|
if (!detailResponse.ok) {
|
||||||
throw new Error('获取TMDB详情失败');
|
throw new Error('获取TMDB详情失败');
|
||||||
}
|
}
|
||||||
@@ -716,13 +789,22 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
? `${detailResult.name} ${seasonData.name}`
|
? `${detailResult.name} ${seasonData.name}`
|
||||||
: detailResult.name,
|
: detailResult.name,
|
||||||
originalTitle:
|
originalTitle:
|
||||||
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
|
mediaType === 'movie'
|
||||||
|
? detailResult.original_title
|
||||||
|
: detailResult.original_name,
|
||||||
year:
|
year:
|
||||||
mediaType === 'movie'
|
mediaType === 'movie'
|
||||||
? detailResult.release_date?.substring(0, 4)
|
? detailResult.release_date?.substring(0, 4)
|
||||||
: seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4),
|
: seasonData?.air_date?.substring(0, 4) ||
|
||||||
poster: (seasonData?.poster_path || detailResult.poster_path)
|
detailResult.first_air_date?.substring(0, 4),
|
||||||
? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500'))
|
poster:
|
||||||
|
seasonData?.poster_path || detailResult.poster_path
|
||||||
|
? processImageUrl(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
seasonData?.poster_path || detailResult.poster_path,
|
||||||
|
'w500'
|
||||||
|
)
|
||||||
|
)
|
||||||
: poster,
|
: poster,
|
||||||
rating: detailResult.vote_average
|
rating: detailResult.vote_average
|
||||||
? {
|
? {
|
||||||
@@ -734,8 +816,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
genres: detailResult.genres?.map((g: any) => g.name),
|
genres: detailResult.genres?.map((g: any) => g.name),
|
||||||
countries: detailResult.production_countries?.map((c: any) => c.name),
|
countries: detailResult.production_countries?.map((c: any) => c.name),
|
||||||
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
||||||
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
|
duration: detailResult.runtime
|
||||||
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
|
? `${detailResult.runtime}分钟`
|
||||||
|
: undefined,
|
||||||
|
episodesCount:
|
||||||
|
seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||||
releaseDate:
|
releaseDate:
|
||||||
mediaType === 'movie'
|
mediaType === 'movie'
|
||||||
? detailResult.release_date
|
? detailResult.release_date
|
||||||
@@ -758,7 +843,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
// 异步获取季度和集数详情(仅TMDB)
|
// 异步获取季度和集数详情(仅TMDB)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailData?.tmdbId || !detailData?.mediaType || detailData.mediaType !== 'tv' || seasonsLoaded) {
|
if (
|
||||||
|
!detailData?.tmdbId ||
|
||||||
|
!detailData?.mediaType ||
|
||||||
|
detailData.mediaType !== 'tv' ||
|
||||||
|
seasonsLoaded
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +856,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
setLoadingSeasons(true);
|
setLoadingSeasons(true);
|
||||||
try {
|
try {
|
||||||
// 获取所有季度
|
// 获取所有季度
|
||||||
const seasonsResponse = await fetch(`/api/tmdb/seasons?tvId=${detailData.tmdbId}`);
|
const seasonsResponse = await fetch(
|
||||||
|
`/api/tmdb/seasons?tvId=${detailData.tmdbId}`
|
||||||
|
);
|
||||||
if (!seasonsResponse.ok) return;
|
if (!seasonsResponse.ok) return;
|
||||||
const seasonsData = await seasonsResponse.json();
|
const seasonsData = await seasonsResponse.json();
|
||||||
|
|
||||||
@@ -794,24 +886,36 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchSeasonData();
|
fetchSeasonData();
|
||||||
}, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]);
|
}, [
|
||||||
|
detailData?.tmdbId,
|
||||||
|
detailData?.mediaType,
|
||||||
|
detailData?.seasonNumber,
|
||||||
|
seasonsLoaded,
|
||||||
|
]);
|
||||||
|
|
||||||
// 自动滚动到当前集数
|
// 自动滚动到当前集数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentEpisode || !seasonData?.episodes || !episodesScrollRef.current || currentSource !== 'tmdb') {
|
if (
|
||||||
|
!currentEpisode ||
|
||||||
|
!seasonData?.episodes ||
|
||||||
|
!episodesScrollRef.current ||
|
||||||
|
currentSource !== 'tmdb'
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待 DOM 更新后再滚动
|
// 等待 DOM 更新后再滚动
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const episodeElement = document.getElementById(`episode-${currentEpisode}`);
|
const episodeElement = document.getElementById(
|
||||||
|
`episode-${currentEpisode}`
|
||||||
|
);
|
||||||
if (episodeElement && episodesScrollRef.current) {
|
if (episodeElement && episodesScrollRef.current) {
|
||||||
// 计算滚动位置,使当前集数居中显示
|
// 计算滚动位置,使当前集数居中显示
|
||||||
const container = episodesScrollRef.current;
|
const container = episodesScrollRef.current;
|
||||||
const elementLeft = episodeElement.offsetLeft;
|
const elementLeft = episodeElement.offsetLeft;
|
||||||
const elementWidth = episodeElement.offsetWidth;
|
const elementWidth = episodeElement.offsetWidth;
|
||||||
const containerWidth = container.offsetWidth;
|
const containerWidth = container.offsetWidth;
|
||||||
const scrollLeft = elementLeft - (containerWidth / 2) + (elementWidth / 2);
|
const scrollLeft = elementLeft - containerWidth / 2 + elementWidth / 2;
|
||||||
|
|
||||||
container.scrollLeft = scrollLeft;
|
container.scrollLeft = scrollLeft;
|
||||||
}
|
}
|
||||||
@@ -822,7 +926,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
// 异步获取演职人员信息(仅TMDB)
|
// 异步获取演职人员信息(仅TMDB)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailData?.tmdbId || !detailData?.mediaType || currentSource !== 'tmdb') {
|
if (
|
||||||
|
!detailData?.tmdbId ||
|
||||||
|
!detailData?.mediaType ||
|
||||||
|
currentSource !== 'tmdb'
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,30 +948,39 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const creditsData = await creditsResponse.json();
|
const creditsData = await creditsResponse.json();
|
||||||
|
|
||||||
// 更新演员和导演信息
|
// 更新演员和导演信息
|
||||||
setDetailData(prev => prev ? {
|
setDetailData((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
directors: creditsData.crew
|
directors:
|
||||||
|
creditsData.crew
|
||||||
?.filter((person: any) => person.job === 'Director')
|
?.filter((person: any) => person.job === 'Director')
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((person: any) => ({
|
.map((person: any) => ({
|
||||||
name: person.name,
|
name: person.name,
|
||||||
profile_path: person.profile_path,
|
profile_path: person.profile_path,
|
||||||
})) || prev.directors,
|
})) || prev.directors,
|
||||||
actors: creditsData.cast
|
actors:
|
||||||
?.slice(0, 15)
|
creditsData.cast?.slice(0, 15).map((person: any) => ({
|
||||||
.map((person: any) => ({
|
|
||||||
name: person.name,
|
name: person.name,
|
||||||
character: person.character,
|
character: person.character,
|
||||||
profile_path: person.profile_path,
|
profile_path: person.profile_path,
|
||||||
})) || prev.actors,
|
})) || prev.actors,
|
||||||
} : null);
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取演职人员信息失败:', err);
|
console.error('获取演职人员信息失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchCredits();
|
fetchCredits();
|
||||||
}, [detailData?.tmdbId, detailData?.mediaType, currentSource, detailData?.actors]);
|
}, [
|
||||||
|
detailData?.tmdbId,
|
||||||
|
detailData?.mediaType,
|
||||||
|
currentSource,
|
||||||
|
detailData?.actors,
|
||||||
|
]);
|
||||||
|
|
||||||
// 切换季度时获取集数
|
// 切换季度时获取集数
|
||||||
const handleSeasonChange = async (seasonNumber: number) => {
|
const handleSeasonChange = async (seasonNumber: number) => {
|
||||||
@@ -879,25 +996,43 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const episodesData = await episodesResponse.json();
|
const episodesData = await episodesResponse.json();
|
||||||
|
|
||||||
// 从当前 seasonData 中查找季度信息
|
// 从当前 seasonData 中查找季度信息
|
||||||
const season = seasonData?.seasons.find((s: any) => s.season_number === seasonNumber);
|
const season = seasonData?.seasons.find(
|
||||||
|
(s: any) => s.season_number === seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
setSeasonData(prev => ({
|
setSeasonData((prev) => ({
|
||||||
seasons: prev?.seasons || [],
|
seasons: prev?.seasons || [],
|
||||||
episodes: episodesData.episodes || [],
|
episodes: episodesData.episodes || [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 更新季度元信息
|
// 更新季度元信息
|
||||||
setDetailData(prev => prev ? {
|
setDetailData((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
title: (episodesData.name || season?.name)
|
title:
|
||||||
? `${prev.seriesTitle || prev.title} ${episodesData.name || season?.name}`
|
episodesData.name || season?.name
|
||||||
|
? `${prev.seriesTitle || prev.title} ${
|
||||||
|
episodesData.name || season?.name
|
||||||
|
}`
|
||||||
: prev.title,
|
: prev.title,
|
||||||
intro: episodesData.overview || season?.overview || prev.overview,
|
intro: episodesData.overview || season?.overview || prev.overview,
|
||||||
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
|
poster: season?.poster_path
|
||||||
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
|
? getTMDBImageUrl(season.poster_path, 'w500')
|
||||||
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
: prev.poster,
|
||||||
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
|
releaseDate:
|
||||||
} : null);
|
episodesData.air_date || season?.air_date || prev.releaseDate,
|
||||||
|
year:
|
||||||
|
episodesData.air_date?.substring(0, 4) ||
|
||||||
|
season?.air_date?.substring(0, 4) ||
|
||||||
|
prev.year,
|
||||||
|
episodesCount:
|
||||||
|
episodesData.episodes?.length ||
|
||||||
|
season?.episode_count ||
|
||||||
|
prev.episodesCount,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
setExpandedEpisodes(new Set());
|
setExpandedEpisodes(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1006,7 +1141,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const galleryEntryButton = canShowGalleryEntry ? (
|
const galleryEntryButton = canShowGalleryEntry ? (
|
||||||
<button
|
<button
|
||||||
onClick={openGallery}
|
onClick={openGallery}
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors"
|
className='inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors'
|
||||||
>
|
>
|
||||||
<Images size={16} />
|
<Images size={16} />
|
||||||
照片墙
|
照片墙
|
||||||
@@ -1016,7 +1151,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const virtualGalleryLayout = React.useMemo(() => {
|
const virtualGalleryLayout = React.useMemo(() => {
|
||||||
if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
|
if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
|
||||||
return {
|
return {
|
||||||
visibleItems: [] as Array<GalleryImage & { top: number; left: number; renderWidth: number; renderHeight: number; index: number }>,
|
visibleItems: [] as Array<
|
||||||
|
GalleryImage & {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
renderWidth: number;
|
||||||
|
renderHeight: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
>,
|
||||||
totalHeight: 0,
|
totalHeight: 0,
|
||||||
usedWidth: 0,
|
usedWidth: 0,
|
||||||
};
|
};
|
||||||
@@ -1026,8 +1169,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const overscan = 800;
|
const overscan = 800;
|
||||||
const horizontalPadding = 32;
|
const horizontalPadding = 32;
|
||||||
const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
|
const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
|
||||||
const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
|
const columnCount =
|
||||||
const columnWidth = Math.floor((width - gap * (columnCount - 1)) / columnCount);
|
width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
|
||||||
|
const columnWidth = Math.floor(
|
||||||
|
(width - gap * (columnCount - 1)) / columnCount
|
||||||
|
);
|
||||||
const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||||
const columnHeights = new Array(columnCount).fill(0);
|
const columnHeights = new Array(columnCount).fill(0);
|
||||||
|
|
||||||
@@ -1039,7 +1185,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratio = image.width && image.height ? image.height / image.width : (image.imageType === 'poster' ? 1.5 : 0.5625);
|
const ratio =
|
||||||
|
image.width && image.height
|
||||||
|
? image.height / image.width
|
||||||
|
: image.imageType === 'poster'
|
||||||
|
? 1.5
|
||||||
|
: 0.5625;
|
||||||
const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
|
const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
|
||||||
const top = columnHeights[targetColumn];
|
const top = columnHeights[targetColumn];
|
||||||
const left = targetColumn * (columnWidth + gap);
|
const left = targetColumn * (columnWidth + gap);
|
||||||
@@ -1058,32 +1209,52 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
const totalHeight = Math.max(...columnHeights, 0);
|
const totalHeight = Math.max(...columnHeights, 0);
|
||||||
const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
|
const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
|
||||||
const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan;
|
const maxVisibleBottom =
|
||||||
const visibleItems = items.filter(item => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom);
|
galleryScrollTop + galleryViewportHeight + overscan;
|
||||||
|
const visibleItems = items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.top + item.renderHeight >= minVisibleTop &&
|
||||||
|
item.top <= maxVisibleBottom
|
||||||
|
);
|
||||||
|
|
||||||
return { visibleItems, totalHeight, usedWidth };
|
return { visibleItems, totalHeight, usedWidth };
|
||||||
}, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
|
}, [
|
||||||
|
galleryImages,
|
||||||
|
galleryScrollTop,
|
||||||
|
galleryViewportHeight,
|
||||||
|
galleryViewportWidth,
|
||||||
|
]);
|
||||||
|
|
||||||
const galleryBody = (
|
const galleryBody = (
|
||||||
<div ref={galleryScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden p-4">
|
<div
|
||||||
|
ref={galleryScrollRef}
|
||||||
|
className='flex-1 overflow-y-auto overflow-x-hidden p-4'
|
||||||
|
>
|
||||||
{galleryLoading && (
|
{galleryLoading && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className='flex items-center justify-center py-20'>
|
||||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
|
<div className='animate-spin rounded-full h-10 w-10 border-b-2 border-green-500'></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!galleryLoading && galleryError && (
|
{!galleryLoading && galleryError && (
|
||||||
<div className="text-center py-12 text-red-500 dark:text-red-400">{galleryError}</div>
|
<div className='text-center py-12 text-red-500 dark:text-red-400'>
|
||||||
|
{galleryError}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
|
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">暂无图片</div>
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无图片
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
|
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto"
|
className='relative mx-auto'
|
||||||
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 = getTMDBImageUrl(
|
const imageUrl = getTMDBImageUrl(
|
||||||
@@ -1098,7 +1269,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${image.imageType}-${image.file_path}-${image.index}`}
|
key={`${image.imageType}-${image.file_path}-${image.index}`}
|
||||||
className="group absolute"
|
className='group absolute'
|
||||||
style={{
|
style={{
|
||||||
top: image.top,
|
top: image.top,
|
||||||
left: image.left,
|
left: image.left,
|
||||||
@@ -1107,16 +1278,18 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
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)}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={thumbUrl}
|
originalSrc={thumbUrl}
|
||||||
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
|
alt={`${detailData?.title || title}-gallery-${
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
image.index + 1
|
||||||
|
}`}
|
||||||
|
className='absolute inset-0 w-full h-full 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'>
|
||||||
{image.imageType === 'poster' ? '海报' : '剧照'}
|
{image.imageType === 'poster' ? '海报' : '剧照'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1129,49 +1302,55 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const galleryHeader = (
|
const galleryHeader = (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
|
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800'>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">照片墙</h3>
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
照片墙
|
||||||
|
</h3>
|
||||||
{!galleryLoading && (
|
{!galleryLoading && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
共 {galleryTotal} 张
|
共 {galleryTotal} 张
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGallery(false)}
|
onClick={() => setShowGallery(false)}
|
||||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||||
aria-label="关闭照片墙"
|
aria-label='关闭照片墙'
|
||||||
>
|
>
|
||||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
<X size={20} className='text-gray-500 dark:text-gray-400' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const galleryModal = showGallery ? (useDrawer ? (
|
const galleryModal = showGallery ? (
|
||||||
<div className="fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none">
|
useDrawer ? (
|
||||||
<div className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}>
|
<div className='fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none'>
|
||||||
|
<div
|
||||||
|
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}
|
||||||
|
>
|
||||||
{galleryHeader}
|
{galleryHeader}
|
||||||
{galleryBody}
|
{galleryBody}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
|
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60"
|
className='absolute inset-0 bg-black/60'
|
||||||
onClick={() => setShowGallery(false)}
|
onClick={() => setShowGallery(false)}
|
||||||
/>
|
/>
|
||||||
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
<div className='relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col'>
|
||||||
{galleryHeader}
|
{galleryHeader}
|
||||||
{galleryBody}
|
{galleryBody}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : null;
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (!isVisible || !mounted) return null;
|
if (!isVisible || !mounted) return null;
|
||||||
|
|
||||||
const content = useDrawer ? (
|
const content = useDrawer ? (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none">
|
<div className='fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none'>
|
||||||
{/* 详情面板 - 抽屉模式 */}
|
{/* 详情面板 - 抽屉模式 */}
|
||||||
<div
|
<div
|
||||||
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
|
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
|
||||||
@@ -1179,74 +1358,90 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10'>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">详情</h2>
|
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
<div className="flex items-center gap-2">
|
详情
|
||||||
|
</h2>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
{externalUrl && (
|
{externalUrl && (
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open(externalUrl, '_blank', 'noopener,noreferrer')}
|
onClick={() =>
|
||||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
window.open(externalUrl, '_blank', 'noopener,noreferrer')
|
||||||
title="打开外部页面"
|
}
|
||||||
aria-label="打开外部页面"
|
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||||
|
title='打开外部页面'
|
||||||
|
aria-label='打开外部页面'
|
||||||
>
|
>
|
||||||
<ExternalLink size={18} className="text-gray-500 dark:text-gray-400" />
|
<ExternalLink
|
||||||
|
size={18}
|
||||||
|
className='text-gray-500 dark:text-gray-400'
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||||
title="关闭"
|
title='关闭'
|
||||||
aria-label="关闭"
|
aria-label='关闭'
|
||||||
>
|
>
|
||||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
<X size={20} className='text-gray-500 dark:text-gray-400' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
|
<div className='overflow-y-auto max-h-[calc(90vh-4rem)]'>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className='flex items-center justify-center py-20'>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-green-500'></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-6">
|
<div className='p-6'>
|
||||||
<div className="text-center mb-6">
|
<div className='text-center mb-6'>
|
||||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
<p className='text-red-500 dark:text-red-400'>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 数据源显示和切换 - 错误时也显示 */}
|
{/* 数据源显示和切换 - 错误时也显示 */}
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className='flex items-center justify-between gap-3 flex-wrap'>
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
数据来源:
|
||||||
|
</span>
|
||||||
|
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||||
{currentSource === 'douban' && 'Douban'}
|
{currentSource === 'douban' && 'Douban'}
|
||||||
{currentSource === 'bangumi' && 'Bangumi'}
|
{currentSource === 'bangumi' && 'Bangumi'}
|
||||||
{currentSource === 'cms' && 'CMS'}
|
{currentSource === 'cms' && 'CMS'}
|
||||||
{currentSource === 'tmdb' && 'TMDB'}
|
{currentSource === 'tmdb' && 'TMDB'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className='flex items-center gap-2 flex-wrap'>
|
||||||
{galleryEntryButton}
|
{galleryEntryButton}
|
||||||
{currentSource !== 'tmdb' && (
|
{currentSource !== 'tmdb' && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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
|
切换到 TMDB
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
{currentSource === 'tmdb' &&
|
||||||
|
originalSource !== 'tmdb' &&
|
||||||
|
originalDetailData && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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'}
|
切换回{' '}
|
||||||
|
{originalSource === 'douban'
|
||||||
|
? 'Douban'
|
||||||
|
: originalSource === 'bangumi'
|
||||||
|
? 'Bangumi'
|
||||||
|
: 'CMS'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1256,47 +1451,48 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && detailData && (
|
{!loading && !error && detailData && (
|
||||||
<div className="p-6">
|
<div className='p-6'>
|
||||||
{/* 海报和基本信息 */}
|
{/* 海报和基本信息 */}
|
||||||
<div className="flex gap-6 mb-6">
|
<div className='flex gap-6 mb-6'>
|
||||||
{detailData.poster && (
|
{detailData.poster && (
|
||||||
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
<div className='flex flex-col items-start gap-3 flex-shrink-0'>
|
||||||
<div
|
<div
|
||||||
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!)}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={detailData.poster}
|
originalSrc={detailData.poster}
|
||||||
alt={detailData.title}
|
alt={detailData.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{galleryEntryButton}
|
{galleryEntryButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className='flex-1 min-w-0'>
|
||||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
{detailData.title}
|
{detailData.title}
|
||||||
</h3>
|
</h3>
|
||||||
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
|
{detailData.originalTitle &&
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
detailData.originalTitle !== detailData.title && (
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
|
||||||
{detailData.originalTitle}
|
{detailData.originalTitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 评分 */}
|
{/* 评分 */}
|
||||||
{detailData.rating && (
|
{detailData.rating && (
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className='flex items-center gap-2 mb-3'>
|
||||||
<Star
|
<Star
|
||||||
size={20}
|
size={20}
|
||||||
className="text-yellow-500 fill-yellow-500"
|
className='text-yellow-500 fill-yellow-500'
|
||||||
/>
|
/>
|
||||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<span className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
{detailData.rating.value.toFixed(1)}
|
{detailData.rating.value.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
{detailData.rating.count > 0 && (
|
{detailData.rating.count > 0 && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
({detailData.rating.count} 评价)
|
({detailData.rating.count} 评价)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1305,11 +1501,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 类型标签 */}
|
{/* 类型标签 */}
|
||||||
{detailData.genres && detailData.genres.length > 0 && (
|
{detailData.genres && detailData.genres.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className='flex flex-wrap gap-2 mb-3'>
|
||||||
{detailData.genres.map((genre, index) => (
|
{detailData.genres.map((genre, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
className='px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
>
|
>
|
||||||
{genre}
|
{genre}
|
||||||
</span>
|
</span>
|
||||||
@@ -1318,21 +1514,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 年份和时长 */}
|
{/* 年份和时长 */}
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
<div className='flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
{detailData.year && (
|
{detailData.year && (
|
||||||
<div className="flex items-center gap-1">
|
<div className='flex items-center gap-1'>
|
||||||
<Calendar size={16} />
|
<Calendar size={16} />
|
||||||
<span>{detailData.year}</span>
|
<span>{detailData.year}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detailData.duration && (
|
{detailData.duration && (
|
||||||
<div className="flex items-center gap-1">
|
<div className='flex items-center gap-1'>
|
||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
<span>{detailData.duration}</span>
|
<span>{detailData.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detailData.episodesCount && (
|
{detailData.episodesCount && (
|
||||||
<div className="flex items-center gap-1">
|
<div className='flex items-center gap-1'>
|
||||||
<Film size={16} />
|
<Film size={16} />
|
||||||
<span>{detailData.episodesCount} 集</span>
|
<span>{detailData.episodesCount} 集</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1343,11 +1539,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 简介 */}
|
{/* 简介 */}
|
||||||
{(detailData.intro || detailData.overview) && (
|
{(detailData.intro || detailData.overview) && (
|
||||||
<div className="mb-6">
|
<div className='mb-6'>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
简介
|
简介
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
<p className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
|
||||||
{detailData.intro || detailData.overview}
|
{detailData.intro || detailData.overview}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1355,20 +1551,20 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 导演和演员 */}
|
{/* 导演和演员 */}
|
||||||
{detailData.directors && detailData.directors.length > 0 && (
|
{detailData.directors && detailData.directors.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className='mb-4'>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
导演
|
导演
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.directors.map((d) => d.name).join(', ')}
|
{detailData.directors.map((d) => d.name).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailData.actors && detailData.actors.length > 0 && (
|
{detailData.actors && detailData.actors.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className='mb-4'>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
演员
|
演员
|
||||||
</h4>
|
</h4>
|
||||||
@@ -1379,47 +1575,61 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
onMouseMove={handleActorsMouseMove}
|
onMouseMove={handleActorsMouseMove}
|
||||||
onMouseUp={handleActorsMouseUp}
|
onMouseUp={handleActorsMouseUp}
|
||||||
onMouseLeave={handleActorsMouseLeave}
|
onMouseLeave={handleActorsMouseLeave}
|
||||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||||
style={{
|
style={{
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
|
scrollBehavior: isActorsDragging ? 'auto' : 'smooth',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-4 pb-2">
|
<div className='flex gap-4 pb-2'>
|
||||||
{detailData.actors.map((actor, index) => (
|
{detailData.actors.map((actor, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex flex-col items-center flex-shrink-0"
|
className='flex flex-col items-center flex-shrink-0'
|
||||||
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
|
style={{
|
||||||
|
pointerEvents: isActorsDragging ? 'none' : 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{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(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
onClick={() =>
|
||||||
|
handleImageClick(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
actor.profile_path || null,
|
||||||
|
'w185'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
originalSrc={getTMDBImageUrl(
|
||||||
|
actor.profile_path || null,
|
||||||
|
'w185'
|
||||||
|
)}
|
||||||
alt={actor.name}
|
alt={actor.name}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
|
<div className='w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center'>
|
||||||
<Users size={28} className="text-gray-400" />
|
<Users size={28} className='text-gray-400' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
|
href={`https://baike.baidu.com/item/${encodeURIComponent(
|
||||||
target="_blank"
|
actor.name
|
||||||
rel="noopener noreferrer"
|
)}`}
|
||||||
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer'
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{actor.name}
|
{actor.name}
|
||||||
</a>
|
</a>
|
||||||
{actor.character && (
|
{actor.character && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
|
<p className='text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2'>
|
||||||
{actor.character}
|
{actor.character}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1428,22 +1638,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
{detailData.actors
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((a) => a.name)
|
||||||
|
.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 制作信息 */}
|
{/* 制作信息 */}
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||||||
{detailData.countries && detailData.countries.length > 0 && (
|
{detailData.countries && detailData.countries.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||||
<Globe size={14} />
|
<Globe size={14} />
|
||||||
国家/地区
|
国家/地区
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.countries.join(', ')}
|
{detailData.countries.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1451,11 +1664,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{detailData.languages && detailData.languages.length > 0 && (
|
{detailData.languages && detailData.languages.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||||
<Tag size={14} />
|
<Tag size={14} />
|
||||||
语言
|
语言
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.languages.join(', ')}
|
{detailData.languages.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1463,28 +1676,34 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{detailData.releaseDate && (
|
{detailData.releaseDate && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||||
<Calendar size={14} />
|
<Calendar size={14} />
|
||||||
上映日期
|
上映日期
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
|
{detailData.releaseDate}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailData.status && (
|
{detailData.status && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">状态</h4>
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1'>
|
||||||
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
|
状态
|
||||||
|
</h4>
|
||||||
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
|
{detailData.status}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 季度和集数信息(仅TMDB电视剧) */}
|
{/* 季度和集数信息(仅TMDB电视剧) */}
|
||||||
{detailData.mediaType === 'tv' && (
|
{detailData.mediaType === 'tv' && (
|
||||||
<div className="mt-6">
|
<div className='mt-6'>
|
||||||
{loadingSeasons && (
|
{loadingSeasons && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className='flex items-center justify-center py-4'>
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1492,15 +1711,17 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* 季度列表 */}
|
{/* 季度列表 */}
|
||||||
{seasonData.seasons.length > 0 && (
|
{seasonData.seasons.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className='mb-6'>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||||
季度
|
季度
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className='grid grid-cols-2 sm:grid-cols-3 gap-3'>
|
||||||
{seasonData.seasons.map((season: any) => (
|
{seasonData.seasons.map((season: any) => (
|
||||||
<div
|
<div
|
||||||
key={season.id}
|
key={season.id}
|
||||||
onClick={() => handleSeasonChange(season.season_number)}
|
onClick={() =>
|
||||||
|
handleSeasonChange(season.season_number)
|
||||||
|
}
|
||||||
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
||||||
selectedSeason === season.season_number
|
selectedSeason === season.season_number
|
||||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||||
@@ -1509,25 +1730,33 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{season.poster_path && (
|
{season.poster_path && (
|
||||||
<div
|
<div
|
||||||
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(getTMDBImageUrl(season.poster_path, 'w500'));
|
handleImageClick(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
season.poster_path,
|
||||||
|
'w500'
|
||||||
|
)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
originalSrc={getTMDBImageUrl(
|
||||||
|
season.poster_path,
|
||||||
|
'w92'
|
||||||
|
)}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className='flex-1 min-w-0'>
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||||
{season.name}
|
{season.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
{season.episode_count} 集
|
{season.episode_count} 集
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1540,8 +1769,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{/* 集数列表 */}
|
{/* 集数列表 */}
|
||||||
{seasonData.episodes.length > 0 && (
|
{seasonData.episodes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||||
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`}
|
{seasonData.seasons.find(
|
||||||
|
(s: any) => s.season_number === selectedSeason
|
||||||
|
)?.name || `第${selectedSeason}季`}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
ref={episodesScrollRef}
|
ref={episodesScrollRef}
|
||||||
@@ -1549,16 +1780,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||||
style={{
|
style={{
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
scrollBehavior: isDragging ? 'auto' : 'smooth'
|
scrollBehavior: isDragging ? 'auto' : 'smooth',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 py-2">
|
<div className='flex gap-3 py-2'>
|
||||||
{seasonData.episodes.map((episode: Episode) => {
|
{seasonData.episodes.map((episode: Episode) => {
|
||||||
const isExpanded = expandedEpisodes.has(episode.id);
|
const isExpanded = expandedEpisodes.has(
|
||||||
const isCurrentEpisode = currentEpisode === episode.episode_number;
|
episode.id
|
||||||
|
);
|
||||||
|
const isCurrentEpisode =
|
||||||
|
currentEpisode === episode.episode_number;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
@@ -1568,28 +1802,45 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||||
: 'bg-gray-50 dark:bg-gray-800'
|
: 'bg-gray-50 dark:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
style={{
|
||||||
|
pointerEvents: isDragging
|
||||||
|
? 'none'
|
||||||
|
: 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{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(getTMDBImageUrl(episode.still_path, 'w500'))}
|
onClick={() =>
|
||||||
|
handleImageClick(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
episode.still_path,
|
||||||
|
'w500'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
originalSrc={getTMDBImageUrl(
|
||||||
|
episode.still_path,
|
||||||
|
'w300'
|
||||||
|
)}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 mb-1'>
|
||||||
第{episode.episode_number}集: {episode.name}
|
第{episode.episode_number}集:{' '}
|
||||||
|
{episode.name}
|
||||||
</p>
|
</p>
|
||||||
{episode.overview && (
|
{episode.overview && (
|
||||||
<p
|
<p
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newExpanded = new Set(expandedEpisodes);
|
const newExpanded = new Set(
|
||||||
|
expandedEpisodes
|
||||||
|
);
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
newExpanded.delete(episode.id);
|
newExpanded.delete(episode.id);
|
||||||
} else {
|
} else {
|
||||||
@@ -1597,13 +1848,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}
|
}
|
||||||
setExpandedEpisodes(newExpanded);
|
setExpandedEpisodes(newExpanded);
|
||||||
}}
|
}}
|
||||||
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
|
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${
|
||||||
|
isExpanded ? '' : 'line-clamp-3'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{episode.overview}
|
{episode.overview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{episode.air_date && (
|
{episode.air_date && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
|
||||||
{episode.air_date}
|
{episode.air_date}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1620,35 +1873,44 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 数据源显示和切换 */}
|
{/* 数据源显示和切换 */}
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className='flex items-center justify-between gap-3 flex-wrap'>
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
数据来源:
|
||||||
|
</span>
|
||||||
|
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||||
{currentSource === 'douban' && 'Douban'}
|
{currentSource === 'douban' && 'Douban'}
|
||||||
{currentSource === 'bangumi' && 'Bangumi'}
|
{currentSource === 'bangumi' && 'Bangumi'}
|
||||||
{currentSource === 'cms' && 'CMS'}
|
{currentSource === 'cms' && 'CMS'}
|
||||||
{currentSource === 'tmdb' && 'TMDB'}
|
{currentSource === 'tmdb' && 'TMDB'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className='flex items-center gap-2 flex-wrap'>
|
||||||
{galleryEntryButton}
|
{galleryEntryButton}
|
||||||
{currentSource !== 'tmdb' && (
|
{currentSource !== 'tmdb' && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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
|
切换到 TMDB
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
{currentSource === 'tmdb' &&
|
||||||
|
originalSource !== 'tmdb' &&
|
||||||
|
originalDetailData && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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'}
|
切换回{' '}
|
||||||
|
{originalSource === 'douban'
|
||||||
|
? 'Douban'
|
||||||
|
: originalSource === 'bangumi'
|
||||||
|
? 'Bangumi'
|
||||||
|
: 'CMS'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1671,7 +1933,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
<div className='fixed inset-0 z-[9999] flex items-center justify-center p-4'>
|
||||||
{/* 背景遮罩 */}
|
{/* 背景遮罩 */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${
|
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${
|
||||||
@@ -1686,59 +1948,70 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 详情面板 - 居中模式 */}
|
{/* 详情面板 - 居中模式 */}
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out"
|
className='relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out'
|
||||||
style={{
|
style={{
|
||||||
willChange: 'transform, opacity',
|
willChange: 'transform, opacity',
|
||||||
backfaceVisibility: 'hidden',
|
backfaceVisibility: 'hidden',
|
||||||
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
|
transform: isAnimating
|
||||||
|
? 'scale(1) translateZ(0)'
|
||||||
|
: 'scale(0.95) translateZ(0)',
|
||||||
opacity: isAnimating ? 1 : 0,
|
opacity: isAnimating ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10'>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">详情</h2>
|
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
<div className="flex items-center gap-2">
|
详情
|
||||||
|
</h2>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
{externalUrl && (
|
{externalUrl && (
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open(externalUrl, '_blank', 'noopener,noreferrer')}
|
onClick={() =>
|
||||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
window.open(externalUrl, '_blank', 'noopener,noreferrer')
|
||||||
title="打开外部页面"
|
}
|
||||||
aria-label="打开外部页面"
|
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||||
|
title='打开外部页面'
|
||||||
|
aria-label='打开外部页面'
|
||||||
>
|
>
|
||||||
<ExternalLink size={18} className="text-gray-500 dark:text-gray-400" />
|
<ExternalLink
|
||||||
|
size={18}
|
||||||
|
className='text-gray-500 dark:text-gray-400'
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||||
title="关闭"
|
title='关闭'
|
||||||
aria-label="关闭"
|
aria-label='关闭'
|
||||||
>
|
>
|
||||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
<X size={20} className='text-gray-500 dark:text-gray-400' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
|
<div className='overflow-y-auto max-h-[calc(90vh-4rem)]'>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className='flex items-center justify-center py-20'>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-green-500'></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-6">
|
<div className='p-6'>
|
||||||
<div className="text-center mb-6">
|
<div className='text-center mb-6'>
|
||||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
<p className='text-red-500 dark:text-red-400'>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 数据源显示和切换 - 错误时也显示 */}
|
{/* 数据源显示和切换 - 错误时也显示 */}
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<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 justify-between'>
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
数据来源:
|
||||||
|
</span>
|
||||||
|
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||||
{currentSource === 'douban' && 'Douban'}
|
{currentSource === 'douban' && 'Douban'}
|
||||||
{currentSource === 'bangumi' && 'Bangumi'}
|
{currentSource === 'bangumi' && 'Bangumi'}
|
||||||
{currentSource === 'cms' && 'CMS'}
|
{currentSource === 'cms' && 'CMS'}
|
||||||
@@ -1749,18 +2022,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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
|
切换到 TMDB
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
{currentSource === 'tmdb' &&
|
||||||
|
originalSource !== 'tmdb' &&
|
||||||
|
originalDetailData && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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'}
|
切换回{' '}
|
||||||
|
{originalSource === 'douban'
|
||||||
|
? 'Douban'
|
||||||
|
: originalSource === 'bangumi'
|
||||||
|
? 'Bangumi'
|
||||||
|
: 'CMS'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1769,47 +2049,48 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && detailData && (
|
{!loading && !error && detailData && (
|
||||||
<div className="p-6">
|
<div className='p-6'>
|
||||||
{/* 海报和基本信息 */}
|
{/* 海报和基本信息 */}
|
||||||
<div className="flex gap-6 mb-6">
|
<div className='flex gap-6 mb-6'>
|
||||||
{detailData.poster && (
|
{detailData.poster && (
|
||||||
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
<div className='flex flex-col items-start gap-3 flex-shrink-0'>
|
||||||
<div
|
<div
|
||||||
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!)}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={detailData.poster}
|
originalSrc={detailData.poster}
|
||||||
alt={detailData.title}
|
alt={detailData.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{galleryEntryButton}
|
{galleryEntryButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className='flex-1 min-w-0'>
|
||||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
{detailData.title}
|
{detailData.title}
|
||||||
</h3>
|
</h3>
|
||||||
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
|
{detailData.originalTitle &&
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
detailData.originalTitle !== detailData.title && (
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
|
||||||
{detailData.originalTitle}
|
{detailData.originalTitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 评分 */}
|
{/* 评分 */}
|
||||||
{detailData.rating && (
|
{detailData.rating && (
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className='flex items-center gap-2 mb-3'>
|
||||||
<Star
|
<Star
|
||||||
size={20}
|
size={20}
|
||||||
className="text-yellow-500 fill-yellow-500"
|
className='text-yellow-500 fill-yellow-500'
|
||||||
/>
|
/>
|
||||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<span className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
{detailData.rating.value.toFixed(1)}
|
{detailData.rating.value.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
{detailData.rating.count > 0 && (
|
{detailData.rating.count > 0 && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
({detailData.rating.count} 评价)
|
({detailData.rating.count} 评价)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1818,11 +2099,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 类型标签 */}
|
{/* 类型标签 */}
|
||||||
{detailData.genres && detailData.genres.length > 0 && (
|
{detailData.genres && detailData.genres.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className='flex flex-wrap gap-2 mb-3'>
|
||||||
{detailData.genres.map((genre, index) => (
|
{detailData.genres.map((genre, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
className='px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
>
|
>
|
||||||
{genre}
|
{genre}
|
||||||
</span>
|
</span>
|
||||||
@@ -1831,21 +2112,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 年份和时长 */}
|
{/* 年份和时长 */}
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
<div className='flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
{detailData.year && (
|
{detailData.year && (
|
||||||
<div className="flex items-center gap-1">
|
<div className='flex items-center gap-1'>
|
||||||
<Calendar size={16} />
|
<Calendar size={16} />
|
||||||
<span>{detailData.year}</span>
|
<span>{detailData.year}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detailData.duration && (
|
{detailData.duration && (
|
||||||
<div className="flex items-center gap-1">
|
<div className='flex items-center gap-1'>
|
||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
<span>{detailData.duration}</span>
|
<span>{detailData.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detailData.episodesCount && (
|
{detailData.episodesCount && (
|
||||||
<div className="flex items-center gap-1">
|
<div className='flex items-center gap-1'>
|
||||||
<Film size={16} />
|
<Film size={16} />
|
||||||
<span>{detailData.episodesCount} 集</span>
|
<span>{detailData.episodesCount} 集</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1856,11 +2137,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 简介 */}
|
{/* 简介 */}
|
||||||
{(detailData.intro || detailData.overview) && (
|
{(detailData.intro || detailData.overview) && (
|
||||||
<div className="mb-6">
|
<div className='mb-6'>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
简介
|
简介
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
<p className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
|
||||||
{detailData.intro || detailData.overview}
|
{detailData.intro || detailData.overview}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1868,20 +2149,20 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{/* 导演和演员 */}
|
{/* 导演和演员 */}
|
||||||
{detailData.directors && detailData.directors.length > 0 && (
|
{detailData.directors && detailData.directors.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className='mb-4'>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
导演
|
导演
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.directors.map((d) => d.name).join(', ')}
|
{detailData.directors.map((d) => d.name).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailData.actors && detailData.actors.length > 0 && (
|
{detailData.actors && detailData.actors.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className='mb-4'>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
演员
|
演员
|
||||||
</h4>
|
</h4>
|
||||||
@@ -1892,47 +2173,61 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
onMouseMove={handleActorsMouseMove}
|
onMouseMove={handleActorsMouseMove}
|
||||||
onMouseUp={handleActorsMouseUp}
|
onMouseUp={handleActorsMouseUp}
|
||||||
onMouseLeave={handleActorsMouseLeave}
|
onMouseLeave={handleActorsMouseLeave}
|
||||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||||
style={{
|
style={{
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
|
scrollBehavior: isActorsDragging ? 'auto' : 'smooth',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-4 pb-2">
|
<div className='flex gap-4 pb-2'>
|
||||||
{detailData.actors.map((actor, index) => (
|
{detailData.actors.map((actor, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex flex-col items-center flex-shrink-0"
|
className='flex flex-col items-center flex-shrink-0'
|
||||||
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
|
style={{
|
||||||
|
pointerEvents: isActorsDragging ? 'none' : 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{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(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
onClick={() =>
|
||||||
|
handleImageClick(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
actor.profile_path || null,
|
||||||
|
'w185'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
originalSrc={getTMDBImageUrl(
|
||||||
|
actor.profile_path || null,
|
||||||
|
'w185'
|
||||||
|
)}
|
||||||
alt={actor.name}
|
alt={actor.name}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
|
<div className='w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center'>
|
||||||
<Users size={28} className="text-gray-400" />
|
<Users size={28} className='text-gray-400' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
|
href={`https://baike.baidu.com/item/${encodeURIComponent(
|
||||||
target="_blank"
|
actor.name
|
||||||
rel="noopener noreferrer"
|
)}`}
|
||||||
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer'
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{actor.name}
|
{actor.name}
|
||||||
</a>
|
</a>
|
||||||
{actor.character && (
|
{actor.character && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
|
<p className='text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2'>
|
||||||
{actor.character}
|
{actor.character}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1941,22 +2236,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
{detailData.actors
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((a) => a.name)
|
||||||
|
.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 制作信息 */}
|
{/* 制作信息 */}
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||||||
{detailData.countries && detailData.countries.length > 0 && (
|
{detailData.countries && detailData.countries.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||||
<Globe size={14} />
|
<Globe size={14} />
|
||||||
国家/地区
|
国家/地区
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.countries.join(', ')}
|
{detailData.countries.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1964,11 +2262,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{detailData.languages && detailData.languages.length > 0 && (
|
{detailData.languages && detailData.languages.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||||
<Tag size={14} />
|
<Tag size={14} />
|
||||||
语言
|
语言
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
{detailData.languages.join(', ')}
|
{detailData.languages.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1976,28 +2274,34 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
|
|
||||||
{detailData.releaseDate && (
|
{detailData.releaseDate && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||||
<Calendar size={14} />
|
<Calendar size={14} />
|
||||||
上映日期
|
上映日期
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
|
{detailData.releaseDate}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailData.status && (
|
{detailData.status && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">状态</h4>
|
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1'>
|
||||||
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
|
状态
|
||||||
|
</h4>
|
||||||
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
|
{detailData.status}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 季度和集数信息(仅TMDB电视剧) */}
|
{/* 季度和集数信息(仅TMDB电视剧) */}
|
||||||
{detailData.mediaType === 'tv' && (
|
{detailData.mediaType === 'tv' && (
|
||||||
<div className="mt-6">
|
<div className='mt-6'>
|
||||||
{loadingSeasons && (
|
{loadingSeasons && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className='flex items-center justify-center py-4'>
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2005,15 +2309,17 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* 季度列表 */}
|
{/* 季度列表 */}
|
||||||
{seasonData.seasons.length > 0 && (
|
{seasonData.seasons.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className='mb-6'>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||||
季度
|
季度
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className='grid grid-cols-2 sm:grid-cols-3 gap-3'>
|
||||||
{seasonData.seasons.map((season: any) => (
|
{seasonData.seasons.map((season: any) => (
|
||||||
<div
|
<div
|
||||||
key={season.id}
|
key={season.id}
|
||||||
onClick={() => handleSeasonChange(season.season_number)}
|
onClick={() =>
|
||||||
|
handleSeasonChange(season.season_number)
|
||||||
|
}
|
||||||
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
||||||
selectedSeason === season.season_number
|
selectedSeason === season.season_number
|
||||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||||
@@ -2022,25 +2328,33 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{season.poster_path && (
|
{season.poster_path && (
|
||||||
<div
|
<div
|
||||||
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(getTMDBImageUrl(season.poster_path, 'w500'));
|
handleImageClick(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
season.poster_path,
|
||||||
|
'w500'
|
||||||
|
)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
originalSrc={getTMDBImageUrl(
|
||||||
|
season.poster_path,
|
||||||
|
'w92'
|
||||||
|
)}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className='flex-1 min-w-0'>
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||||
{season.name}
|
{season.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
{season.episode_count} 集
|
{season.episode_count} 集
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2053,8 +2367,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{/* 集数列表 */}
|
{/* 集数列表 */}
|
||||||
{seasonData.episodes.length > 0 && (
|
{seasonData.episodes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||||
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`}
|
{seasonData.seasons.find(
|
||||||
|
(s: any) => s.season_number === selectedSeason
|
||||||
|
)?.name || `第${selectedSeason}季`}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
ref={episodesScrollRef}
|
ref={episodesScrollRef}
|
||||||
@@ -2062,16 +2378,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||||
style={{
|
style={{
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
scrollBehavior: isDragging ? 'auto' : 'smooth'
|
scrollBehavior: isDragging ? 'auto' : 'smooth',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 py-2">
|
<div className='flex gap-3 py-2'>
|
||||||
{seasonData.episodes.map((episode: Episode) => {
|
{seasonData.episodes.map((episode: Episode) => {
|
||||||
const isExpanded = expandedEpisodes.has(episode.id);
|
const isExpanded = expandedEpisodes.has(
|
||||||
const isCurrentEpisode = currentEpisode === episode.episode_number;
|
episode.id
|
||||||
|
);
|
||||||
|
const isCurrentEpisode =
|
||||||
|
currentEpisode === episode.episode_number;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
@@ -2081,28 +2400,45 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||||
: 'bg-gray-50 dark:bg-gray-800'
|
: 'bg-gray-50 dark:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
style={{
|
||||||
|
pointerEvents: isDragging
|
||||||
|
? 'none'
|
||||||
|
: 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{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(getTMDBImageUrl(episode.still_path, 'w500'))}
|
onClick={() =>
|
||||||
|
handleImageClick(
|
||||||
|
getTMDBImageUrl(
|
||||||
|
episode.still_path,
|
||||||
|
'w500'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
originalSrc={getTMDBImageUrl(
|
||||||
|
episode.still_path,
|
||||||
|
'w300'
|
||||||
|
)}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 mb-1'>
|
||||||
第{episode.episode_number}集: {episode.name}
|
第{episode.episode_number}集:{' '}
|
||||||
|
{episode.name}
|
||||||
</p>
|
</p>
|
||||||
{episode.overview && (
|
{episode.overview && (
|
||||||
<p
|
<p
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newExpanded = new Set(expandedEpisodes);
|
const newExpanded = new Set(
|
||||||
|
expandedEpisodes
|
||||||
|
);
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
newExpanded.delete(episode.id);
|
newExpanded.delete(episode.id);
|
||||||
} else {
|
} else {
|
||||||
@@ -2110,13 +2446,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}
|
}
|
||||||
setExpandedEpisodes(newExpanded);
|
setExpandedEpisodes(newExpanded);
|
||||||
}}
|
}}
|
||||||
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
|
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${
|
||||||
|
isExpanded ? '' : 'line-clamp-3'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{episode.overview}
|
{episode.overview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{episode.air_date && (
|
{episode.air_date && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
|
||||||
{episode.air_date}
|
{episode.air_date}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -2133,11 +2471,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 数据源显示和切换 */}
|
{/* 数据源显示和切换 */}
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<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 justify-between'>
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
数据来源:
|
||||||
|
</span>
|
||||||
|
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||||
{currentSource === 'douban' && 'Douban'}
|
{currentSource === 'douban' && 'Douban'}
|
||||||
{currentSource === 'bangumi' && 'Bangumi'}
|
{currentSource === 'bangumi' && 'Bangumi'}
|
||||||
{currentSource === 'cms' && 'CMS'}
|
{currentSource === 'cms' && 'CMS'}
|
||||||
@@ -2148,18 +2488,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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
|
切换到 TMDB
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
{currentSource === 'tmdb' &&
|
||||||
|
originalSource !== 'tmdb' &&
|
||||||
|
originalDetailData && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleSource}
|
onClick={handleToggleSource}
|
||||||
disabled={loading}
|
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"
|
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'}
|
切换回{' '}
|
||||||
|
{originalSource === 'douban'
|
||||||
|
? 'Douban'
|
||||||
|
: originalSource === 'bangumi'
|
||||||
|
? 'Bangumi'
|
||||||
|
: 'CMS'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
|
import {
|
||||||
|
processImageUrl,
|
||||||
|
tryApplyBangumiImageFallback,
|
||||||
|
tryApplyDoubanImageFallback,
|
||||||
|
} from '@/lib/utils';
|
||||||
|
|
||||||
interface ProxyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface ProxyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
originalSrc: string;
|
originalSrc: string;
|
||||||
@@ -22,17 +26,49 @@ const ProxyImage: React.FC<ProxyImageProps> = ({
|
|||||||
src: _src,
|
src: _src,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const initialSrc = useMemo(
|
||||||
|
() => displaySrc || processImageUrl(originalSrc),
|
||||||
|
[displaySrc, originalSrc]
|
||||||
|
);
|
||||||
|
const [currentSrc, setCurrentSrc] = useState(initialSrc);
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSrc(initialSrc);
|
||||||
|
}, [initialSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (displaySrc) return;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img || img.complete || img.dataset.bangumiBackupTried === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tryApplyBangumiImageFallback(img, originalSrc)) {
|
||||||
|
setCurrentSrc(img.src);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [currentSrc, displaySrc, originalSrc]);
|
||||||
|
|
||||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
|
|
||||||
if (tryApplyDoubanImageFallback(img, originalSrc)) {
|
if (
|
||||||
|
tryApplyDoubanImageFallback(img, originalSrc) ||
|
||||||
|
tryApplyBangumiImageFallback(img, originalSrc)
|
||||||
|
) {
|
||||||
|
setCurrentSrc(img.src);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (retryOnError && !img.dataset.retried) {
|
if (retryOnError && !img.dataset.retried) {
|
||||||
img.dataset.retried = 'true';
|
img.dataset.retried = 'true';
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
img.src = displaySrc || processImageUrl(originalSrc);
|
setCurrentSrc(initialSrc);
|
||||||
}, retryDelay);
|
}, retryDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +78,8 @@ const ProxyImage: React.FC<ProxyImageProps> = ({
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
src={displaySrc || processImageUrl(originalSrc)}
|
ref={imgRef}
|
||||||
|
src={currentSrc}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decoding={decoding}
|
decoding={decoding}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { createPortal } from 'react-dom';
|
|||||||
|
|
||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
import { clearAllDanmakuCache, getDanmakuCacheStats } from '@/lib/danmaku/api';
|
import { clearAllDanmakuCache, getDanmakuCacheStats } from '@/lib/danmaku/api';
|
||||||
|
import { clearBangumiImageFallbackCache } from '@/lib/utils';
|
||||||
import { CURRENT_VERSION } from '@/lib/version';
|
import { CURRENT_VERSION } from '@/lib/version';
|
||||||
import { UpdateStatus } from '@/lib/version_check';
|
import { UpdateStatus } from '@/lib/version_check';
|
||||||
|
|
||||||
@@ -152,6 +153,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const [doubanDataSourceBackup, setDoubanDataSourceBackup] =
|
const [doubanDataSourceBackup, setDoubanDataSourceBackup] =
|
||||||
useState('direct');
|
useState('direct');
|
||||||
|
const [animeDataSource, setAnimeDataSource] = useState('direct');
|
||||||
|
const [animeDataSourceBackup, setAnimeDataSourceBackup] =
|
||||||
|
useState('server-proxy');
|
||||||
|
const [animeCustomBaseUrl, setAnimeCustomBaseUrl] = useState('');
|
||||||
|
const [animeImageBaseUrl, setAnimeImageBaseUrl] = useState('');
|
||||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
|
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
|
||||||
'cmliussss-cdn-tencent'
|
'cmliussss-cdn-tencent'
|
||||||
);
|
);
|
||||||
@@ -164,6 +170,9 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||||
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] =
|
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [isAnimeDropdownOpen, setIsAnimeDropdownOpen] = useState(false);
|
||||||
|
const [isAnimeBackupDropdownOpen, setIsAnimeBackupDropdownOpen] =
|
||||||
|
useState(false);
|
||||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [
|
const [
|
||||||
@@ -277,6 +286,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
{ value: 'custom', label: '自定义代理' },
|
{ value: 'custom', label: '自定义代理' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const animeDataSourceOptions = [
|
||||||
|
{ value: 'direct', label: '直连(浏览器直连 Bangumi)' },
|
||||||
|
{ value: 'server-proxy', label: '服务器代理(由服务器访问 Bangumi)' },
|
||||||
|
{ value: 'custom-baseurl', label: '自定义 Base URL' },
|
||||||
|
];
|
||||||
|
|
||||||
// 豆瓣图片代理选项
|
// 豆瓣图片代理选项
|
||||||
const doubanImageProxyTypeOptions = [
|
const doubanImageProxyTypeOptions = [
|
||||||
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
||||||
@@ -584,6 +599,23 @@ export const UserMenu: React.FC = () => {
|
|||||||
);
|
);
|
||||||
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
|
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
|
||||||
|
|
||||||
|
const savedAnimeDataSource = localStorage.getItem('animeDataSource');
|
||||||
|
const defaultAnimeDataSource =
|
||||||
|
(window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct';
|
||||||
|
setAnimeDataSource(savedAnimeDataSource || defaultAnimeDataSource);
|
||||||
|
|
||||||
|
const savedAnimeDataSourceBackup = localStorage.getItem(
|
||||||
|
'animeDataSourceBackup'
|
||||||
|
);
|
||||||
|
setAnimeDataSourceBackup(savedAnimeDataSourceBackup || 'server-proxy');
|
||||||
|
|
||||||
|
const savedAnimeCustomBaseUrl =
|
||||||
|
localStorage.getItem('animeCustomBaseUrl');
|
||||||
|
setAnimeCustomBaseUrl(savedAnimeCustomBaseUrl || '');
|
||||||
|
|
||||||
|
const savedAnimeImageBaseUrl = localStorage.getItem('animeImageBaseUrl');
|
||||||
|
setAnimeImageBaseUrl(savedAnimeImageBaseUrl || '');
|
||||||
|
|
||||||
const savedDoubanImageProxyType = localStorage.getItem(
|
const savedDoubanImageProxyType = localStorage.getItem(
|
||||||
'doubanImageProxyType'
|
'doubanImageProxyType'
|
||||||
);
|
);
|
||||||
@@ -973,6 +1005,40 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isDoubanBackupDropdownOpen]);
|
}, [isDoubanBackupDropdownOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (isAnimeDropdownOpen) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('[data-dropdown="anime-datasource"]')) {
|
||||||
|
setIsAnimeDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAnimeDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isAnimeDropdownOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (isAnimeBackupDropdownOpen) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('[data-dropdown="anime-datasource-backup"]')) {
|
||||||
|
setIsAnimeBackupDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAnimeBackupDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isAnimeBackupDropdownOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (isDoubanImageProxyDropdownOpen) {
|
if (isDoubanImageProxyDropdownOpen) {
|
||||||
@@ -1325,6 +1391,38 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAnimeDataSourceChange = (value: string) => {
|
||||||
|
clearBangumiImageFallbackCache();
|
||||||
|
setAnimeDataSource(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('animeDataSource', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimeDataSourceBackupChange = (value: string) => {
|
||||||
|
clearBangumiImageFallbackCache();
|
||||||
|
setAnimeDataSourceBackup(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('animeDataSourceBackup', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimeCustomBaseUrlChange = (value: string) => {
|
||||||
|
clearBangumiImageFallbackCache();
|
||||||
|
setAnimeCustomBaseUrl(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('animeCustomBaseUrl', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimeImageBaseUrlChange = (value: string) => {
|
||||||
|
clearBangumiImageFallbackCache();
|
||||||
|
setAnimeImageBaseUrl(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('animeImageBaseUrl', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDoubanImageProxyTypeChange = (value: string) => {
|
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||||
setDoubanImageProxyType(value);
|
setDoubanImageProxyType(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1539,6 +1637,10 @@ export const UserMenu: React.FC = () => {
|
|||||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||||
const defaultFluidSearch =
|
const defaultFluidSearch =
|
||||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||||
|
const defaultAnimeDataSource =
|
||||||
|
(window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct';
|
||||||
|
const defaultAnimeBaseUrl = '';
|
||||||
|
const defaultAnimeImageBaseUrl = '';
|
||||||
|
|
||||||
setDefaultAggregateSearch(true);
|
setDefaultAggregateSearch(true);
|
||||||
setEnableOptimization(true);
|
setEnableOptimization(true);
|
||||||
@@ -1550,6 +1652,10 @@ export const UserMenu: React.FC = () => {
|
|||||||
setDoubanDataSource(defaultDoubanProxyType);
|
setDoubanDataSource(defaultDoubanProxyType);
|
||||||
setDoubanDataSourceBackup('direct');
|
setDoubanDataSourceBackup('direct');
|
||||||
setDoubanProxyUrlBackup('');
|
setDoubanProxyUrlBackup('');
|
||||||
|
setAnimeDataSource(defaultAnimeDataSource);
|
||||||
|
setAnimeDataSourceBackup('server-proxy');
|
||||||
|
setAnimeCustomBaseUrl(defaultAnimeBaseUrl);
|
||||||
|
setAnimeImageBaseUrl(defaultAnimeImageBaseUrl);
|
||||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||||
setDoubanImageProxyTypeBackup('server');
|
setDoubanImageProxyTypeBackup('server');
|
||||||
@@ -1581,6 +1687,10 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||||
localStorage.setItem('doubanDataSourceBackup', 'direct');
|
localStorage.setItem('doubanDataSourceBackup', 'direct');
|
||||||
localStorage.setItem('doubanProxyUrlBackup', '');
|
localStorage.setItem('doubanProxyUrlBackup', '');
|
||||||
|
localStorage.setItem('animeDataSource', defaultAnimeDataSource);
|
||||||
|
localStorage.setItem('animeDataSourceBackup', 'server-proxy');
|
||||||
|
localStorage.setItem('animeCustomBaseUrl', defaultAnimeBaseUrl);
|
||||||
|
localStorage.setItem('animeImageBaseUrl', defaultAnimeImageBaseUrl);
|
||||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||||
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
||||||
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
|
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
|
||||||
@@ -2142,6 +2252,9 @@ export const UserMenu: React.FC = () => {
|
|||||||
{/* 分割线 */}
|
{/* 分割线 */}
|
||||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
{/* 豆瓣图片代理设置 */}
|
{/* 豆瓣图片代理设置 */}
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div>
|
<div>
|
||||||
@@ -2376,6 +2489,185 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
{/* 动漫数据源设置 */}
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<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'>
|
||||||
|
用于 Bangumi
|
||||||
|
新番放送和番剧详情;默认主源直连,备用源服务器代理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
主数据源
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className='relative'
|
||||||
|
data-dropdown='anime-datasource'
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setIsAnimeDropdownOpen(!isAnimeDropdownOpen)
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
>
|
||||||
|
{
|
||||||
|
animeDataSourceOptions.find(
|
||||||
|
(option) => option.value === animeDataSource
|
||||||
|
)?.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 ${
|
||||||
|
isAnimeDropdownOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isAnimeDropdownOpen && (
|
||||||
|
<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'>
|
||||||
|
{animeDataSourceOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
handleAnimeDataSourceChange(option.value);
|
||||||
|
setIsAnimeDropdownOpen(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 ${
|
||||||
|
animeDataSource === 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>
|
||||||
|
{animeDataSource === option.value && (
|
||||||
|
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
备用数据源
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className='relative'
|
||||||
|
data-dropdown='anime-datasource-backup'
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setIsAnimeBackupDropdownOpen(
|
||||||
|
!isAnimeBackupDropdownOpen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
>
|
||||||
|
{
|
||||||
|
animeDataSourceOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.value === animeDataSourceBackup
|
||||||
|
)?.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 ${
|
||||||
|
isAnimeBackupDropdownOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isAnimeBackupDropdownOpen && (
|
||||||
|
<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'>
|
||||||
|
{animeDataSourceOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
handleAnimeDataSourceBackupChange(
|
||||||
|
option.value
|
||||||
|
);
|
||||||
|
setIsAnimeBackupDropdownOpen(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 ${
|
||||||
|
animeDataSourceBackup === 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>
|
||||||
|
{animeDataSourceBackup === option.value && (
|
||||||
|
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(animeDataSource === 'custom-baseurl' ||
|
||||||
|
animeDataSourceBackup === 'custom-baseurl') && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
动漫自定义 Base URL
|
||||||
|
</label>
|
||||||
|
<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://api.bgm.tv 或 https://bangumi-proxy.example.com'
|
||||||
|
value={animeCustomBaseUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAnimeCustomBaseUrlChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!animeCustomBaseUrl.trim() && (
|
||||||
|
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||||
|
未填写时自定义 Base URL 会自动按 Bangumi
|
||||||
|
官方直连处理。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
动漫图片 Base URL
|
||||||
|
</label>
|
||||||
|
<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'
|
||||||
|
value={animeImageBaseUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAnimeImageBaseUrlChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
用于替换 Bangumi
|
||||||
|
图片域名。只需填写基础部分,不需要填写完整图片路径。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+541
-182
@@ -1,6 +1,16 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
||||||
|
|
||||||
import { Cloud, ExternalLink, Heart, Info, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
Cloud,
|
||||||
|
ExternalLink,
|
||||||
|
Heart,
|
||||||
|
Info,
|
||||||
|
Link,
|
||||||
|
PlayCircleIcon,
|
||||||
|
Radio,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, {
|
import React, {
|
||||||
@@ -10,6 +20,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
@@ -24,8 +35,11 @@ import {
|
|||||||
import { isNetdiskSource } from '@/lib/netdisk/source';
|
import { isNetdiskSource } from '@/lib/netdisk/source';
|
||||||
import {
|
import {
|
||||||
base58Decode,
|
base58Decode,
|
||||||
|
getBangumiImageFallbackUrl,
|
||||||
getDoubanImageFallbackUrl,
|
getDoubanImageFallbackUrl,
|
||||||
|
markBangumiImageFallbackActive,
|
||||||
processImageUrl,
|
processImageUrl,
|
||||||
|
tryApplyBangumiImageFallback,
|
||||||
tryApplyDoubanImageFallback,
|
tryApplyDoubanImageFallback,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
@@ -47,7 +61,13 @@ export interface VideoCardProps {
|
|||||||
source_names?: string[];
|
source_names?: string[];
|
||||||
progress?: number;
|
progress?: number;
|
||||||
year?: string;
|
year?: string;
|
||||||
from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb' | 'source-search';
|
from:
|
||||||
|
| 'playrecord'
|
||||||
|
| 'favorite'
|
||||||
|
| 'search'
|
||||||
|
| 'douban'
|
||||||
|
| 'tmdb'
|
||||||
|
| 'source-search';
|
||||||
currentEpisode?: number;
|
currentEpisode?: number;
|
||||||
douban_id?: number;
|
douban_id?: number;
|
||||||
tmdb_id?: number;
|
tmdb_id?: number;
|
||||||
@@ -78,7 +98,8 @@ export type VideoCardHandle = {
|
|||||||
setDoubanId: (id?: number) => void;
|
setDoubanId: (id?: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard(
|
const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
|
function VideoCard(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
title = '',
|
title = '',
|
||||||
@@ -137,11 +158,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
const [favorited, setFavorited] = useState(false);
|
const [favorited, setFavorited] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||||
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(null); // 搜索结果的收藏状态
|
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(
|
||||||
|
null
|
||||||
|
); // 搜索结果的收藏状态
|
||||||
const [showAIChat, setShowAIChat] = useState(false);
|
const [showAIChat, setShowAIChat] = useState(false);
|
||||||
const [isAIStreaming, setIsAIStreaming] = useState(false);
|
const [isAIStreaming, setIsAIStreaming] = useState(false);
|
||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] =
|
||||||
|
useState('');
|
||||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示
|
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示
|
||||||
@@ -156,7 +180,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
setAiEnabled(enabled);
|
setAiEnabled(enabled);
|
||||||
|
|
||||||
// 加载AI默认消息配置
|
// 加载AI默认消息配置
|
||||||
const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_WITH_VIDEO;
|
const defaultMsg = (window as any).RUNTIME_CONFIG
|
||||||
|
?.AI_DEFAULT_MESSAGE_WITH_VIDEO;
|
||||||
if (defaultMsg) {
|
if (defaultMsg) {
|
||||||
setAiDefaultMessageWithVideo(defaultMsg);
|
setAiDefaultMessageWithVideo(defaultMsg);
|
||||||
}
|
}
|
||||||
@@ -167,9 +192,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
|
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
|
||||||
episodes
|
episodes
|
||||||
);
|
);
|
||||||
const [dynamicSourceNames, setDynamicSourceNames] = useState<string[] | undefined>(
|
const [dynamicSourceNames, setDynamicSourceNames] = useState<
|
||||||
source_names
|
string[] | undefined
|
||||||
);
|
>(source_names);
|
||||||
const [dynamicDoubanId, setDynamicDoubanId] = useState<number | undefined>(
|
const [dynamicDoubanId, setDynamicDoubanId] = useState<number | undefined>(
|
||||||
douban_id
|
douban_id
|
||||||
);
|
);
|
||||||
@@ -190,6 +215,45 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
setDisplayPoster(processedPoster);
|
setDisplayPoster(processedPoster);
|
||||||
}, [processedPoster]);
|
}, [processedPoster]);
|
||||||
|
|
||||||
|
const bangumiImageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bangumiImageTimeoutRef.current) {
|
||||||
|
clearTimeout(bangumiImageTimeoutRef.current);
|
||||||
|
bangumiImageTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actualPoster) return;
|
||||||
|
|
||||||
|
const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster);
|
||||||
|
if (!bangumiFallbackPoster || displayPoster === bangumiFallbackPoster) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bangumiImageTimeoutRef.current = setTimeout(() => {
|
||||||
|
markBangumiImageFallbackActive();
|
||||||
|
setDisplayPoster((current) =>
|
||||||
|
current === bangumiFallbackPoster ? current : bangumiFallbackPoster
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (bangumiImageTimeoutRef.current) {
|
||||||
|
clearTimeout(bangumiImageTimeoutRef.current);
|
||||||
|
bangumiImageTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [actualPoster, displayPoster]);
|
||||||
|
|
||||||
|
const clearBangumiImageTimeout = useCallback(() => {
|
||||||
|
if (bangumiImageTimeoutRef.current) {
|
||||||
|
clearTimeout(bangumiImageTimeoutRef.current);
|
||||||
|
bangumiImageTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
|
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
|
||||||
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
|
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
|
||||||
@@ -223,7 +287,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 获取收藏状态(搜索结果页面不检查)
|
// 获取收藏状态(搜索结果页面不检查)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (from === 'douban' || from === 'search' || !actualSource || !actualId) return;
|
if (from === 'douban' || from === 'search' || !actualSource || !actualId)
|
||||||
|
return;
|
||||||
|
|
||||||
const fetchFavoriteStatus = async () => {
|
const fetchFavoriteStatus = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -258,7 +323,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 确定当前收藏状态
|
// 确定当前收藏状态
|
||||||
const currentFavorited = from === 'search' ? searchFavorited : favorited;
|
const currentFavorited =
|
||||||
|
from === 'search' ? searchFavorited : favorited;
|
||||||
|
|
||||||
if (currentFavorited) {
|
if (currentFavorited) {
|
||||||
// 如果已收藏,删除收藏
|
// 如果已收藏,删除收藏
|
||||||
@@ -332,14 +398,27 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
if (origin === 'live' && actualSource && actualId) {
|
if (origin === 'live' && actualSource && actualId) {
|
||||||
// 直播内容跳转到直播页面
|
// 直播内容跳转到直播页面
|
||||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
const url = `/live?source=${actualSource.replace(
|
||||||
|
'live_',
|
||||||
|
''
|
||||||
|
)}&id=${actualId.replace('live_', '')}`;
|
||||||
router.push(url);
|
router.push(url);
|
||||||
} else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) {
|
} else if (
|
||||||
|
from === 'douban' ||
|
||||||
|
from === 'tmdb' ||
|
||||||
|
(isAggregate && !actualSource && !actualId)
|
||||||
|
) {
|
||||||
// 检测当前是否在 play 页面
|
// 检测当前是否在 play 页面
|
||||||
const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play';
|
const isCurrentlyOnPlayPage =
|
||||||
|
typeof window !== 'undefined' && window.location.pathname === '/play';
|
||||||
|
|
||||||
let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
|
let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${
|
||||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
actualYear ? `&year=${actualYear}` : ''
|
||||||
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${
|
||||||
|
isAggregate ? '&prefer=true' : ''
|
||||||
|
}${
|
||||||
|
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
if (isCurrentlyOnPlayPage) {
|
if (isCurrentlyOnPlayPage) {
|
||||||
// 在 play 页面内,添加 _reload 参数强制刷新
|
// 在 play 页面内,添加 _reload 参数强制刷新
|
||||||
@@ -351,12 +430,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
}
|
}
|
||||||
} else if (actualSource && actualId) {
|
} else if (actualSource && actualId) {
|
||||||
// 检测当前是否在 play 页面
|
// 检测当前是否在 play 页面
|
||||||
const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play';
|
const isCurrentlyOnPlayPage =
|
||||||
|
typeof window !== 'undefined' && window.location.pathname === '/play';
|
||||||
|
|
||||||
let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||||
actualTitle
|
actualTitle
|
||||||
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : ''
|
)}${actualYear ? `&year=${actualYear}` : ''}${
|
||||||
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
isAggregate ? '&prefer=true' : ''
|
||||||
|
}${
|
||||||
|
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||||
|
|
||||||
if (isCurrentlyOnPlayPage) {
|
if (isCurrentlyOnPlayPage) {
|
||||||
@@ -394,16 +476,31 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
if (origin === 'live' && actualSource && actualId) {
|
if (origin === 'live' && actualSource && actualId) {
|
||||||
// 直播内容跳转到直播页面
|
// 直播内容跳转到直播页面
|
||||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
const url = `/live?source=${actualSource.replace(
|
||||||
|
'live_',
|
||||||
|
''
|
||||||
|
)}&id=${actualId.replace('live_', '')}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) {
|
} else if (
|
||||||
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
from === 'douban' ||
|
||||||
|
from === 'tmdb' ||
|
||||||
|
(isAggregate && !actualSource && !actualId)
|
||||||
|
) {
|
||||||
|
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${
|
||||||
|
actualYear ? `&year=${actualYear}` : ''
|
||||||
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${
|
||||||
|
isAggregate ? '&prefer=true' : ''
|
||||||
|
}${
|
||||||
|
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||||
|
}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else if (actualSource && actualId) {
|
} else if (actualSource && actualId) {
|
||||||
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||||
actualTitle
|
actualTitle
|
||||||
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : ''
|
)}${actualYear ? `&year=${actualYear}` : ''}${
|
||||||
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
isAggregate ? '&prefer=true' : ''
|
||||||
|
}${
|
||||||
|
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
@@ -423,7 +520,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 检查搜索结果的收藏状态
|
// 检查搜索结果的收藏状态
|
||||||
const checkSearchFavoriteStatus = useCallback(async () => {
|
const checkSearchFavoriteStatus = useCallback(async () => {
|
||||||
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
|
if (
|
||||||
|
from === 'search' &&
|
||||||
|
!isAggregate &&
|
||||||
|
actualSource &&
|
||||||
|
actualId &&
|
||||||
|
searchFavorited === null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const fav = await isFavorited(actualSource, actualId);
|
const fav = await isFavorited(actualSource, actualId);
|
||||||
setSearchFavorited(fav);
|
setSearchFavorited(fav);
|
||||||
@@ -435,16 +538,31 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 长按操作
|
// 长按操作
|
||||||
const handleLongPress = useCallback(() => {
|
const handleLongPress = useCallback(() => {
|
||||||
if (!showMobileActions) { // 防止重复触发
|
if (!showMobileActions) {
|
||||||
|
// 防止重复触发
|
||||||
// 立即显示菜单,避免等待数据加载导致动画卡顿
|
// 立即显示菜单,避免等待数据加载导致动画卡顿
|
||||||
setShowMobileActions(true);
|
setShowMobileActions(true);
|
||||||
|
|
||||||
// 异步检查收藏状态,不阻塞菜单显示
|
// 异步检查收藏状态,不阻塞菜单显示
|
||||||
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
|
if (
|
||||||
|
from === 'search' &&
|
||||||
|
!isAggregate &&
|
||||||
|
actualSource &&
|
||||||
|
actualId &&
|
||||||
|
searchFavorited === null
|
||||||
|
) {
|
||||||
checkSearchFavoriteStatus();
|
checkSearchFavoriteStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
|
}, [
|
||||||
|
showMobileActions,
|
||||||
|
from,
|
||||||
|
isAggregate,
|
||||||
|
actualSource,
|
||||||
|
actualId,
|
||||||
|
searchFavorited,
|
||||||
|
checkSearchFavoriteStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
// 长按手势hook
|
// 长按手势hook
|
||||||
const longPressProps = useLongPress({
|
const longPressProps = useLongPress({
|
||||||
@@ -459,11 +577,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 获取今天的本地日期(午夜)
|
// 获取今天的本地日期(午夜)
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const todayStr = `${today.getFullYear()}-${String(
|
||||||
|
today.getMonth() + 1
|
||||||
|
).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
// 将日期字符串解析为本地时区的日期对象
|
// 将日期字符串解析为本地时区的日期对象
|
||||||
// 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题
|
// 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题
|
||||||
const [releaseYear, releaseMonth, releaseDay] = releaseDate.split('-').map(Number);
|
const [releaseYear, releaseMonth, releaseDay] = releaseDate
|
||||||
|
.split('-')
|
||||||
|
.map(Number);
|
||||||
const release = new Date(releaseYear, releaseMonth - 1, releaseDay);
|
const release = new Date(releaseYear, releaseMonth - 1, releaseDay);
|
||||||
|
|
||||||
const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number);
|
const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number);
|
||||||
@@ -568,8 +690,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
|
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
|
||||||
|
|
||||||
// 收藏/取消收藏操作
|
// 收藏/取消收藏操作
|
||||||
if (config.showHeart && from !== 'douban' && from !== 'tmdb' && actualSource && actualId) {
|
if (
|
||||||
const currentFavorited = from === 'search' ? searchFavorited : favorited;
|
config.showHeart &&
|
||||||
|
from !== 'douban' &&
|
||||||
|
from !== 'tmdb' &&
|
||||||
|
actualSource &&
|
||||||
|
actualId
|
||||||
|
) {
|
||||||
|
const currentFavorited =
|
||||||
|
from === 'search' ? searchFavorited : favorited;
|
||||||
|
|
||||||
if (from === 'search') {
|
if (from === 'search') {
|
||||||
// 搜索结果:根据加载状态显示不同的选项
|
// 搜索结果:根据加载状态显示不同的选项
|
||||||
@@ -579,9 +708,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
id: 'favorite',
|
id: 'favorite',
|
||||||
label: currentFavorited ? '取消收藏' : '添加收藏',
|
label: currentFavorited ? '取消收藏' : '添加收藏',
|
||||||
icon: currentFavorited ? (
|
icon: currentFavorited ? (
|
||||||
<Heart size={20} className="fill-red-600 stroke-red-600" />
|
<Heart size={20} className='fill-red-600 stroke-red-600' />
|
||||||
) : (
|
) : (
|
||||||
<Heart size={20} className="fill-transparent stroke-red-500" />
|
<Heart size={20} className='fill-transparent stroke-red-500' />
|
||||||
),
|
),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
@@ -590,7 +719,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
} as React.MouseEvent;
|
} as React.MouseEvent;
|
||||||
handleToggleFavorite(mockEvent);
|
handleToggleFavorite(mockEvent);
|
||||||
},
|
},
|
||||||
color: currentFavorited ? ('danger' as const) : ('default' as const),
|
color: currentFavorited
|
||||||
|
? ('danger' as const)
|
||||||
|
: ('default' as const),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 正在加载中,显示占位项
|
// 正在加载中,显示占位项
|
||||||
@@ -608,9 +739,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
id: 'favorite',
|
id: 'favorite',
|
||||||
label: currentFavorited ? '取消收藏' : '添加收藏',
|
label: currentFavorited ? '取消收藏' : '添加收藏',
|
||||||
icon: currentFavorited ? (
|
icon: currentFavorited ? (
|
||||||
<Heart size={20} className="fill-red-600 stroke-red-600" />
|
<Heart size={20} className='fill-red-600 stroke-red-600' />
|
||||||
) : (
|
) : (
|
||||||
<Heart size={20} className="fill-transparent stroke-red-500" />
|
<Heart size={20} className='fill-transparent stroke-red-500' />
|
||||||
),
|
),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
@@ -619,13 +750,20 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
} as React.MouseEvent;
|
} as React.MouseEvent;
|
||||||
handleToggleFavorite(mockEvent);
|
handleToggleFavorite(mockEvent);
|
||||||
},
|
},
|
||||||
color: currentFavorited ? ('danger' as const) : ('default' as const),
|
color: currentFavorited
|
||||||
|
? ('danger' as const)
|
||||||
|
: ('default' as const),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除播放记录操作
|
// 删除播放记录操作
|
||||||
if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) {
|
if (
|
||||||
|
config.showCheckCircle &&
|
||||||
|
from === 'playrecord' &&
|
||||||
|
actualSource &&
|
||||||
|
actualId
|
||||||
|
) {
|
||||||
actions.push({
|
actions.push({
|
||||||
id: 'delete',
|
id: 'delete',
|
||||||
label: '删除记录',
|
label: '删除记录',
|
||||||
@@ -714,12 +852,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500] ${isUpcoming ? 'cursor-default' : 'cursor-pointer'} ${
|
className={`group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500] ${
|
||||||
showUpcomingInfo ? 'scale-[1.05] z-[500]' : ''
|
isUpcoming ? 'cursor-default' : 'cursor-pointer'
|
||||||
}`}
|
} ${showUpcomingInfo ? 'scale-[1.05] z-[500]' : ''}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...longPressProps}
|
{...longPressProps}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
// 禁用所有默认的长按和选择效果
|
// 禁用所有默认的长按和选择效果
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -728,7 +867,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
touchAction: 'manipulation',
|
touchAction: 'manipulation',
|
||||||
// 禁用右键菜单和长按菜单
|
// 禁用右键菜单和长按菜单
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
// 阻止默认右键菜单
|
// 阻止默认右键菜单
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -738,13 +878,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
setShowMobileActions(true);
|
setShowMobileActions(true);
|
||||||
|
|
||||||
// 异步检查收藏状态,不阻塞菜单显示
|
// 异步检查收藏状态,不阻塞菜单显示
|
||||||
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
|
if (
|
||||||
|
from === 'search' &&
|
||||||
|
!isAggregate &&
|
||||||
|
actualSource &&
|
||||||
|
actualId &&
|
||||||
|
searchFavorited === null
|
||||||
|
) {
|
||||||
checkSearchFavoriteStatus();
|
checkSearchFavoriteStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
// 阻止拖拽
|
// 阻止拖拽
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -753,28 +898,40 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
{/* 海报容器 */}
|
{/* 海报容器 */}
|
||||||
<div
|
<div
|
||||||
className={`relative overflow-hidden rounded-lg ${origin === 'live' ? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80' : ''} ${
|
className={`relative overflow-hidden rounded-lg ${
|
||||||
orientation === 'horizontal'
|
origin === 'live'
|
||||||
? 'aspect-[3/2]'
|
? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80'
|
||||||
: 'aspect-[2/3]'
|
: ''
|
||||||
|
} ${
|
||||||
|
orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 骨架屏 */}
|
{/* 骨架屏 */}
|
||||||
{!isLoading && !isDirectPlaySource && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />}
|
{!isLoading && !isDirectPlaySource && (
|
||||||
|
<ImagePlaceholder
|
||||||
|
aspectRatio={
|
||||||
|
orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isDirectPlaySource ? (
|
{isDirectPlaySource ? (
|
||||||
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
|
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
|
||||||
<Link className='w-8 h-8 text-blue-500' />
|
<Link className='w-8 h-8 text-blue-500' />
|
||||||
</div>
|
</div>
|
||||||
) : (isNetdiskSource(actualSource) && !actualPoster && displayPoster === netdiskPosterPlaceholder) ? (
|
) : isNetdiskSource(actualSource) &&
|
||||||
|
!actualPoster &&
|
||||||
|
displayPoster === netdiskPosterPlaceholder ? (
|
||||||
<div className='absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'>
|
<div className='absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'>
|
||||||
<Cloud className='w-10 h-10 opacity-80' />
|
<Cloud className='w-10 h-10 opacity-80' />
|
||||||
</div>
|
</div>
|
||||||
@@ -783,19 +940,44 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
src={displayPoster}
|
src={displayPoster}
|
||||||
alt={actualTitle}
|
alt={actualTitle}
|
||||||
fill
|
fill
|
||||||
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
|
className={
|
||||||
|
origin === 'live'
|
||||||
|
? 'object-contain'
|
||||||
|
: orientation === 'horizontal'
|
||||||
|
? 'object-cover object-center'
|
||||||
|
: 'object-cover'
|
||||||
|
}
|
||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
onLoadingComplete={() => setIsLoading(true)}
|
onLoadingComplete={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
clearBangumiImageTimeout();
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowImageViewer(true);
|
setShowImageViewer(true);
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const img = e.currentTarget as HTMLImageElement;
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
const fallbackPoster = getDoubanImageFallbackUrl(actualPoster);
|
const doubanFallbackPoster =
|
||||||
if (fallbackPoster && tryApplyDoubanImageFallback(img, actualPoster)) {
|
getDoubanImageFallbackUrl(actualPoster);
|
||||||
setDisplayPoster(fallbackPoster);
|
if (
|
||||||
|
doubanFallbackPoster &&
|
||||||
|
tryApplyDoubanImageFallback(img, actualPoster)
|
||||||
|
) {
|
||||||
|
clearBangumiImageTimeout();
|
||||||
|
setDisplayPoster(doubanFallbackPoster);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bangumiFallbackPoster =
|
||||||
|
getBangumiImageFallbackUrl(actualPoster);
|
||||||
|
if (
|
||||||
|
bangumiFallbackPoster &&
|
||||||
|
tryApplyBangumiImageFallback(img, actualPoster)
|
||||||
|
) {
|
||||||
|
clearBangumiImageTimeout();
|
||||||
|
setDisplayPoster(bangumiFallbackPoster);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,14 +990,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
// 禁用图片的默认长按效果
|
// 禁用图片的默认长按效果
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
pointerEvents: 'auto', // 改为auto以响应点击事件
|
pointerEvents: 'auto', // 改为auto以响应点击事件
|
||||||
cursor: 'pointer', // 添加指针样式
|
cursor: 'pointer', // 添加指针样式
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -830,11 +1014,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 悬浮遮罩 */}
|
{/* 悬浮遮罩 */}
|
||||||
<div
|
<div
|
||||||
className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100'
|
className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -844,15 +1030,19 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 播放按钮或上映倒计时 */}
|
{/* 播放按钮或上映倒计时 */}
|
||||||
{isUpcoming && daysUntilRelease !== null ? (
|
{isUpcoming && daysUntilRelease !== null ? (
|
||||||
<div
|
<div
|
||||||
data-button="true"
|
data-button='true'
|
||||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
|
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
|
||||||
showUpcomingInfo ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
|
showUpcomingInfo
|
||||||
|
? 'opacity-100 scale-100'
|
||||||
|
: 'opacity-0 scale-75'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -860,11 +1050,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-xs md:text-sm font-medium shadow-lg'
|
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-xs md:text-sm font-medium shadow-lg'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{daysUntilRelease > 0
|
{daysUntilRelease > 0
|
||||||
? `${daysUntilRelease}天后上映`
|
? `${daysUntilRelease}天后上映`
|
||||||
@@ -873,15 +1065,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
: '已上映'}
|
: '已上映'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : config.showPlayButton && (
|
) : (
|
||||||
|
config.showPlayButton && (
|
||||||
<div
|
<div
|
||||||
data-button="true"
|
data-button='true'
|
||||||
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'
|
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -891,29 +1086,35 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
size={50}
|
size={50}
|
||||||
strokeWidth={0.8}
|
strokeWidth={0.8}
|
||||||
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
|
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */}
|
{/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */}
|
||||||
{(config.showHeart || config.showCheckCircle) && from !== 'playrecord' && (
|
{(config.showHeart || config.showCheckCircle) &&
|
||||||
|
from !== 'playrecord' && (
|
||||||
<div
|
<div
|
||||||
data-button="true"
|
data-button='true'
|
||||||
className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out sm:group-hover:opacity-100 sm:group-hover:translate-y-0'
|
className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out sm:group-hover:opacity-100 sm:group-hover:translate-y-0'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -924,11 +1125,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
onClick={handleDeleteRecord}
|
onClick={handleDeleteRecord}
|
||||||
size={20}
|
size={20}
|
||||||
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -939,15 +1142,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
<Heart
|
<Heart
|
||||||
onClick={handleToggleFavorite}
|
onClick={handleToggleFavorite}
|
||||||
size={20}
|
size={20}
|
||||||
className={`transition-all duration-300 ease-out ${favorited
|
className={`transition-all duration-300 ease-out ${
|
||||||
|
favorited
|
||||||
? 'fill-red-600 stroke-red-600'
|
? 'fill-red-600 stroke-red-600'
|
||||||
: 'fill-transparent stroke-white hover:stroke-red-400'
|
: 'fill-transparent stroke-white hover:stroke-red-400'
|
||||||
} hover:scale-[1.1]`}
|
} hover:scale-[1.1]`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -957,16 +1163,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* 季度徽章 */}
|
{/* 季度徽章 */}
|
||||||
{seasonNumber && (
|
{seasonNumber && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-2 left-2 bg-blue-500/80 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90"
|
className='absolute top-2 left-2 bg-blue-500/80 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -981,11 +1188,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{config.showRating && rate && (
|
{config.showRating && rate && (
|
||||||
<div
|
<div
|
||||||
className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'
|
className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -996,14 +1205,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 竖向模式:顶部直链地址显示 */}
|
{/* 竖向模式:顶部直链地址显示 */}
|
||||||
{orientation === 'vertical' && isDirectPlaySource && directLinkUrl && (
|
{orientation === 'vertical' &&
|
||||||
|
isDirectPlaySource &&
|
||||||
|
directLinkUrl && (
|
||||||
<div
|
<div
|
||||||
className='absolute top-1 left-1 right-1 sm:top-2 sm:left-2 sm:right-2 pt-1 px-1 sm:pt-2 sm:px-2'
|
className='absolute top-1 left-1 right-1 sm:top-2 sm:left-2 sm:right-2 pt-1 px-1 sm:pt-2 sm:px-2'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1011,11 +1224,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='text-[9px] sm:text-[10px] text-yellow-400 line-clamp-2 break-all'
|
className='text-[9px] sm:text-[10px] text-yellow-400 line-clamp-2 break-all'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1027,14 +1242,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actualEpisodes && actualEpisodes > 1 && orientation === 'vertical' && (
|
{actualEpisodes &&
|
||||||
|
actualEpisodes > 1 &&
|
||||||
|
orientation === 'vertical' && (
|
||||||
<div
|
<div
|
||||||
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
|
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1043,11 +1262,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 集数显示 */}
|
{/* 集数显示 */}
|
||||||
<div
|
<div
|
||||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1062,11 +1283,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{displayYear && (
|
{displayYear && (
|
||||||
<div
|
<div
|
||||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1079,14 +1302,19 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 竖向模式:来源名称显示在海报右下角 */}
|
{/* 竖向模式:来源名称显示在海报右下角 */}
|
||||||
{orientation === 'vertical' && config.showSourceName && source_name && !cmsData && (
|
{orientation === 'vertical' &&
|
||||||
|
config.showSourceName &&
|
||||||
|
source_name &&
|
||||||
|
!cmsData && (
|
||||||
<div
|
<div
|
||||||
className='absolute bottom-1 right-1 sm:bottom-2 sm:right-2'
|
className='absolute bottom-1 right-1 sm:bottom-2 sm:right-2'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1094,20 +1322,35 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
|
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
|
||||||
actualSource === 'xiaoya' ? 'border-blue-500' : isNetdiskSource(actualSource) ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : origin === 'live' ? 'border-red-500' : 'border-white/60'
|
actualSource === 'xiaoya'
|
||||||
|
? 'border-blue-500'
|
||||||
|
: isNetdiskSource(actualSource)
|
||||||
|
? 'border-purple-500'
|
||||||
|
: actualSource === 'openlist' ||
|
||||||
|
actualSource === 'emby' ||
|
||||||
|
actualSource?.startsWith('emby_')
|
||||||
|
? 'border-yellow-500'
|
||||||
|
: origin === 'live'
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-white/60'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{origin === 'live' && (
|
{origin === 'live' && (
|
||||||
<Radio size={8} className="inline-block text-white/90 mr-0.5" />
|
<Radio
|
||||||
|
size={8}
|
||||||
|
className='inline-block text-white/90 mr-0.5'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{source_name}
|
{source_name}
|
||||||
</span>
|
</span>
|
||||||
@@ -1115,7 +1358,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 豆瓣链接 */}
|
{/* 豆瓣链接 */}
|
||||||
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
|
{config.showDoubanLink &&
|
||||||
|
actualDoubanId &&
|
||||||
|
actualDoubanId !== 0 && (
|
||||||
<a
|
<a
|
||||||
href={
|
href={
|
||||||
isBangumi
|
isBangumi
|
||||||
@@ -1126,11 +1371,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0'
|
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1138,11 +1385,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'
|
className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1150,32 +1399,41 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
size={16}
|
size={16}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 聚合播放源指示器 */}
|
{/* 聚合播放源指示器 */}
|
||||||
{isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => {
|
{isAggregate &&
|
||||||
|
dynamicSourceNames &&
|
||||||
|
dynamicSourceNames.length > 0 &&
|
||||||
|
(() => {
|
||||||
const uniqueSources = Array.from(new Set(dynamicSourceNames));
|
const uniqueSources = Array.from(new Set(dynamicSourceNames));
|
||||||
const sourceCount = uniqueSources.length;
|
const sourceCount = uniqueSources.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-1 right-1 sm:bottom-2 sm:right-2 transition-all duration-300 ease-in-out delay-75 ${
|
className={`absolute bottom-1 right-1 sm:bottom-2 sm:right-2 transition-all duration-300 ease-in-out delay-75 ${
|
||||||
from === 'search' ? 'opacity-100' : 'opacity-0 sm:group-hover:opacity-100'
|
from === 'search'
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 sm:group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1183,19 +1441,23 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='relative group/sources'
|
className='relative group/sources'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer'
|
className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1207,31 +1469,46 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 播放源详情悬浮框 */}
|
{/* 播放源详情悬浮框 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// 优先显示的播放源(常见的主流平台)
|
// 优先显示的播放源(常见的主流平台)
|
||||||
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+'];
|
const prioritySources = [
|
||||||
|
'爱奇艺',
|
||||||
|
'腾讯视频',
|
||||||
|
'优酷',
|
||||||
|
'芒果TV',
|
||||||
|
'哔哩哔哩',
|
||||||
|
'Netflix',
|
||||||
|
'Disney+',
|
||||||
|
];
|
||||||
|
|
||||||
// 按优先级排序播放源
|
// 按优先级排序播放源
|
||||||
const sortedSources = uniqueSources.sort((a, b) => {
|
const sortedSources = uniqueSources.sort((a, b) => {
|
||||||
const aIndex = prioritySources.indexOf(a);
|
const aIndex = prioritySources.indexOf(a);
|
||||||
const bIndex = prioritySources.indexOf(b);
|
const bIndex = prioritySources.indexOf(b);
|
||||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
if (aIndex !== -1 && bIndex !== -1)
|
||||||
|
return aIndex - bIndex;
|
||||||
if (aIndex !== -1) return -1;
|
if (aIndex !== -1) return -1;
|
||||||
if (bIndex !== -1) return 1;
|
if (bIndex !== -1) return 1;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxDisplayCount = 6; // 最多显示6个
|
const maxDisplayCount = 6; // 最多显示6个
|
||||||
const displaySources = sortedSources.slice(0, maxDisplayCount);
|
const displaySources = sortedSources.slice(
|
||||||
|
0,
|
||||||
|
maxDisplayCount
|
||||||
|
);
|
||||||
const hasMore = sortedSources.length > maxDisplayCount;
|
const hasMore = sortedSources.length > maxDisplayCount;
|
||||||
const remainingCount = sortedSources.length - maxDisplayCount;
|
const remainingCount =
|
||||||
|
sortedSources.length - maxDisplayCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'
|
className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1239,11 +1516,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden'
|
className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1252,9 +1531,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 单列布局 */}
|
{/* 单列布局 */}
|
||||||
<div className='space-y-0.5 sm:space-y-1'>
|
<div className='space-y-0.5 sm:space-y-1'>
|
||||||
{displaySources.map((sourceName, index) => (
|
{displaySources.map((sourceName, index) => (
|
||||||
<div key={index} className='flex items-center gap-1 sm:gap-1.5'>
|
<div
|
||||||
|
key={index}
|
||||||
|
className='flex items-center gap-1 sm:gap-1.5'
|
||||||
|
>
|
||||||
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div>
|
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div>
|
||||||
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}>
|
<span
|
||||||
|
className='truncate text-[10px] sm:text-xs leading-tight'
|
||||||
|
title={sourceName}
|
||||||
|
>
|
||||||
{sourceName}
|
{sourceName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1265,7 +1550,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
|
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
|
||||||
<div className='flex items-center justify-center text-gray-400'>
|
<div className='flex items-center justify-center text-gray-400'>
|
||||||
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} 播放源</span>
|
<span className='text-[10px] sm:text-xs font-medium'>
|
||||||
|
+{remainingCount} 播放源
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1287,11 +1574,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 顶部渐变遮罩 - 用于标题背景 */}
|
{/* 顶部渐变遮罩 - 用于标题背景 */}
|
||||||
<div
|
<div
|
||||||
className='absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 via-black/40 to-transparent pt-2 pb-8 px-2'
|
className='absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 via-black/40 to-transparent pt-2 pb-8 px-2'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1300,19 +1589,23 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<div
|
<div
|
||||||
className='mb-1'
|
className='mb-1'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className='block text-sm font-bold truncate text-white'
|
className='block text-sm font-bold truncate text-white'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1327,11 +1620,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{currentEpisode && actualEpisodes && actualEpisodes > 1 && (
|
{currentEpisode && actualEpisodes && actualEpisodes > 1 && (
|
||||||
<div
|
<div
|
||||||
className='text-xs text-white/90'
|
className='text-xs text-white/90'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1345,11 +1640,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{isDirectPlaySource && directLinkUrl && (
|
{isDirectPlaySource && directLinkUrl && (
|
||||||
<div
|
<div
|
||||||
className='text-[10px] text-white/75 truncate'
|
className='text-[10px] text-white/75 truncate'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1364,36 +1661,46 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 底部渐变遮罩 - 用于进度条背景 */}
|
{/* 底部渐变遮罩 - 用于进度条背景 */}
|
||||||
<div
|
<div
|
||||||
className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent pt-8 pb-2 px-2'
|
className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent pt-8 pb-2 px-2'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
{config.showProgress && progress !== undefined && origin !== 'live' && (
|
{config.showProgress &&
|
||||||
|
progress !== undefined &&
|
||||||
|
origin !== 'live' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* 来源和时长显示 - 在进度条上方 */}
|
{/* 来源和时长显示 - 在进度条上方 */}
|
||||||
<div className='flex items-center justify-between mb-1'>
|
<div className='flex items-center justify-between mb-1'>
|
||||||
{/* 时长显示 - 左侧 */}
|
{/* 时长显示 - 左侧 */}
|
||||||
{from === 'playrecord' && playTime !== undefined && totalTime !== undefined && (
|
{from === 'playrecord' &&
|
||||||
|
playTime !== undefined &&
|
||||||
|
totalTime !== undefined && (
|
||||||
<div
|
<div
|
||||||
className='text-[10px] text-white/80'
|
className='text-[10px] text-white/80'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1418,13 +1725,23 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{config.showSourceName && source_name && !cmsData && (
|
{config.showSourceName && source_name && !cmsData && (
|
||||||
<span
|
<span
|
||||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||||
actualSource === 'xiaoya' ? 'border-blue-500' : isNetdiskSource(actualSource) ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
actualSource === 'xiaoya'
|
||||||
|
? 'border-blue-500'
|
||||||
|
: isNetdiskSource(actualSource)
|
||||||
|
? 'border-purple-500'
|
||||||
|
: actualSource === 'openlist' ||
|
||||||
|
actualSource === 'emby' ||
|
||||||
|
actualSource?.startsWith('emby_')
|
||||||
|
? 'border-yellow-500'
|
||||||
|
: 'border-white/60'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1436,11 +1753,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className='h-1 w-full bg-white/20 rounded-full overflow-hidden'
|
className='h-1 w-full bg-white/20 rounded-full overflow-hidden'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1448,12 +1767,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='h-full bg-white transition-all duration-500 ease-out'
|
className='h-full bg-white transition-all duration-500 ease-out'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1464,23 +1785,37 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 直播时只显示来源 */}
|
{/* 直播时只显示来源 */}
|
||||||
{origin === 'live' && config.showSourceName && source_name && !cmsData && (
|
{origin === 'live' &&
|
||||||
|
config.showSourceName &&
|
||||||
|
source_name &&
|
||||||
|
!cmsData && (
|
||||||
<div className='flex items-center justify-end'>
|
<div className='flex items-center justify-end'>
|
||||||
<span
|
<span
|
||||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||||
origin === 'live' ? 'border-red-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
origin === 'live'
|
||||||
|
? 'border-red-500'
|
||||||
|
: actualSource === 'openlist' ||
|
||||||
|
actualSource === 'emby' ||
|
||||||
|
actualSource?.startsWith('emby_')
|
||||||
|
? 'border-yellow-500'
|
||||||
|
: 'border-white/60'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Radio size={8} className="inline-block text-white/90 mr-0.5" />
|
<Radio
|
||||||
|
size={8}
|
||||||
|
className='inline-block text-white/90 mr-0.5'
|
||||||
|
/>
|
||||||
{source_name}
|
{source_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1497,11 +1832,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{config.showProgress && progress !== undefined && (
|
{config.showProgress && progress !== undefined && (
|
||||||
<div
|
<div
|
||||||
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'
|
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1509,12 +1846,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='h-full bg-green-500 transition-all duration-500 ease-out'
|
className='h-full bg-green-500 transition-all duration-500 ease-out'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1526,11 +1865,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<div
|
<div
|
||||||
className='mt-2 text-center'
|
className='mt-2 text-center'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1538,19 +1879,23 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='relative'
|
className='relative'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'
|
className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1561,11 +1906,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{/* 自定义 tooltip */}
|
{/* 自定义 tooltip */}
|
||||||
<div
|
<div
|
||||||
className='absolute bottom-full left-1/2 z-10 mb-2 w-max max-w-[min(20rem,calc(100vw-2rem))] -translate-x-1/2 rounded-md bg-gray-800 px-3 py-1 text-center text-xs text-white shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-normal break-words pointer-events-none'
|
className='absolute bottom-full left-1/2 z-10 mb-2 w-max max-w-[min(20rem,calc(100vw-2rem))] -translate-x-1/2 rounded-md bg-gray-800 px-3 py-1 text-center text-xs text-white shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-normal break-words pointer-events-none'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
@@ -1574,11 +1921,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{actualTitle}
|
{actualTitle}
|
||||||
<div
|
<div
|
||||||
className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'
|
className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'
|
||||||
style={{
|
style={
|
||||||
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1594,7 +1943,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
title={actualTitle}
|
title={actualTitle}
|
||||||
poster={displayPoster}
|
poster={displayPoster}
|
||||||
actions={mobileActions}
|
actions={mobileActions}
|
||||||
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined}
|
sources={
|
||||||
|
isAggregate && dynamicSourceNames
|
||||||
|
? Array.from(new Set(dynamicSourceNames))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
isAggregate={isAggregate}
|
isAggregate={isAggregate}
|
||||||
sourceName={cmsData ? undefined : source_name}
|
sourceName={cmsData ? undefined : source_name}
|
||||||
directLinkUrl={directLinkUrl || undefined}
|
directLinkUrl={directLinkUrl || undefined}
|
||||||
@@ -1620,7 +1973,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
type: actualSearchType as 'movie' | 'tv',
|
type: actualSearchType as 'movie' | 'tv',
|
||||||
currentEpisode,
|
currentEpisode,
|
||||||
}}
|
}}
|
||||||
welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', actualTitle || '') : `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
|
welcomeMessage={
|
||||||
|
aiDefaultMessageWithVideo
|
||||||
|
? aiDefaultMessageWithVideo.replace(
|
||||||
|
'{title}',
|
||||||
|
actualTitle || ''
|
||||||
|
)
|
||||||
|
: `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1656,7 +2016,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default memo(VideoCard);
|
export default memo(VideoCard);
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export interface AdminConfig {
|
|||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
TMDBReverseProxy?: string;
|
TMDBReverseProxy?: string;
|
||||||
|
// 动漫/Bangumi配置
|
||||||
|
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl';
|
||||||
|
BangumiApiBaseUrl?: string;
|
||||||
|
BangumiImageBaseUrl?: string;
|
||||||
|
BangumiProxy?: string;
|
||||||
BannerDataSource?: string; // 轮播图数据源:TMDB、TX 或 Douban
|
BannerDataSource?: string; // 轮播图数据源:TMDB、TX 或 Douban
|
||||||
RecommendationDataSource?: string; // 更多推荐数据源:Douban、TMDB、Mixed、MixedSmart
|
RecommendationDataSource?: string; // 更多推荐数据源:Douban、TMDB、Mixed、MixedSmart
|
||||||
// Pansou配置
|
// Pansou配置
|
||||||
|
|||||||
+151
-3
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl';
|
||||||
|
|
||||||
export interface BangumiCalendarData {
|
export interface BangumiCalendarData {
|
||||||
weekday: {
|
weekday: {
|
||||||
en: string;
|
en: string;
|
||||||
@@ -22,8 +24,154 @@ export interface BangumiCalendarData {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BangumiSubjectData {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
name_cn?: string;
|
||||||
|
date?: string;
|
||||||
|
images?: {
|
||||||
|
large?: string;
|
||||||
|
common?: string;
|
||||||
|
medium?: string;
|
||||||
|
small?: string;
|
||||||
|
grid?: string;
|
||||||
|
};
|
||||||
|
rating?: {
|
||||||
|
score: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
summary?: string;
|
||||||
|
tags?: { name: string }[];
|
||||||
|
eps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BANGUMI_OFFICIAL_BASE_URL = 'https://api.bgm.tv';
|
||||||
|
const SERVER_PROXY_BASE_URL = '/api/bangumi';
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return baseUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeConfig() {
|
||||||
|
if (typeof window === 'undefined') return {} as any;
|
||||||
|
return (window as any).RUNTIME_CONFIG || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryAnimeDataSource(): AnimeDataSource {
|
||||||
|
if (typeof window === 'undefined') return 'direct';
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(
|
||||||
|
'animeDataSource'
|
||||||
|
) as AnimeDataSource | null;
|
||||||
|
if (
|
||||||
|
saved === 'direct' ||
|
||||||
|
saved === 'server-proxy' ||
|
||||||
|
saved === 'custom-baseurl'
|
||||||
|
) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeValue = getRuntimeConfig().BANGUMI_DATA_SOURCE as
|
||||||
|
| AnimeDataSource
|
||||||
|
| undefined;
|
||||||
|
if (
|
||||||
|
runtimeValue === 'direct' ||
|
||||||
|
runtimeValue === 'server-proxy' ||
|
||||||
|
runtimeValue === 'custom-baseurl'
|
||||||
|
) {
|
||||||
|
return runtimeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupAnimeDataSource(
|
||||||
|
primary: AnimeDataSource
|
||||||
|
): AnimeDataSource | null {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return primary === 'server-proxy' ? null : 'server-proxy';
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(
|
||||||
|
'animeDataSourceBackup'
|
||||||
|
) as AnimeDataSource | null;
|
||||||
|
const backup =
|
||||||
|
saved === 'direct' || saved === 'server-proxy' || saved === 'custom-baseurl'
|
||||||
|
? saved
|
||||||
|
: 'server-proxy';
|
||||||
|
|
||||||
|
return backup === primary ? null : backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomAnimeBaseUrl(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return localStorage.getItem('animeCustomBaseUrl') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBangumiUrl(source: AnimeDataSource, path: string): string {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
|
||||||
|
switch (source) {
|
||||||
|
case 'server-proxy':
|
||||||
|
return `${SERVER_PROXY_BASE_URL}${normalizedPath}`;
|
||||||
|
case 'custom-baseurl': {
|
||||||
|
const customBaseUrl = normalizeBaseUrl(getCustomAnimeBaseUrl());
|
||||||
|
if (!customBaseUrl) {
|
||||||
|
return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
return `${customBaseUrl}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
case 'direct':
|
||||||
|
default:
|
||||||
|
return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBangumiJson<T>(
|
||||||
|
source: AnimeDataSource,
|
||||||
|
path: string
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(buildBangumiUrl(source, path), {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Bangumi 请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithFallback<T>(path: string): Promise<T> {
|
||||||
|
const primary = getPrimaryAnimeDataSource();
|
||||||
|
const backup = getBackupAnimeDataSource(primary);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetchBangumiJson<T>(primary, path);
|
||||||
|
} catch (primaryError) {
|
||||||
|
if (!backup) throw primaryError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetchBangumiJson<T>(backup, path);
|
||||||
|
} catch (backupError) {
|
||||||
|
console.error('Bangumi 主源与备用源均请求失败:', {
|
||||||
|
primary,
|
||||||
|
backup,
|
||||||
|
primaryError,
|
||||||
|
backupError,
|
||||||
|
});
|
||||||
|
throw backupError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
|
export async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
|
||||||
const response = await fetch('https://api.bgm.tv/calendar');
|
return requestWithFallback<BangumiCalendarData[]>('/calendar');
|
||||||
const data = await response.json();
|
}
|
||||||
return data;
|
|
||||||
|
export async function getBangumiSubject(
|
||||||
|
id: number | string
|
||||||
|
): Promise<BangumiSubjectData> {
|
||||||
|
return requestWithFallback<BangumiSubjectData>(
|
||||||
|
`/v0/subjects/${encodeURIComponent(String(id))}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
|
export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl';
|
||||||
|
|
||||||
|
export const DEFAULT_BANGUMI_BASE_URL = 'https://api.bgm.tv';
|
||||||
|
|
||||||
|
function isCloudflareEnvironment(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBangumiBaseUrl(baseUrl?: string): string {
|
||||||
|
const normalized = (baseUrl || DEFAULT_BANGUMI_BASE_URL)
|
||||||
|
.trim()
|
||||||
|
.replace(/\/+$/, '');
|
||||||
|
return normalized || DEFAULT_BANGUMI_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBangumiFromServer(
|
||||||
|
path: string,
|
||||||
|
options?: { baseUrl?: string; proxy?: string }
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const url = `${normalizeBangumiBaseUrl(options?.baseUrl)}${normalizedPath}`;
|
||||||
|
const proxy = options?.proxy?.trim();
|
||||||
|
|
||||||
|
if (isCloudflareEnvironment()) {
|
||||||
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'MoonTVPlus/1.0 (https://github.com)',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
}) as Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions: any = {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'MoonTVPlus/1.0 (https://github.com)',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(proxy ? 30000 : 15000),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxy) {
|
||||||
|
fetchOptions.agent = new HttpsProxyAgent(proxy, {
|
||||||
|
timeout: 30000,
|
||||||
|
keepAlive: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeFetch(url, fetchOptions) as unknown as Promise<Response>;
|
||||||
|
}
|
||||||
+113
-44
@@ -7,7 +7,9 @@ import { AdminConfig } from './admin.types';
|
|||||||
const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
|
const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
|
||||||
const DEFAULT_LIVE_REFRESH_INTERVAL_HOURS = 12;
|
const DEFAULT_LIVE_REFRESH_INTERVAL_HOURS = 12;
|
||||||
|
|
||||||
function normalizeLiveRefreshIntervalHours(refreshIntervalHours?: number): number {
|
function normalizeLiveRefreshIntervalHours(
|
||||||
|
refreshIntervalHours?: number
|
||||||
|
): number {
|
||||||
const normalizedInterval = Number(refreshIntervalHours);
|
const normalizedInterval = Number(refreshIntervalHours);
|
||||||
|
|
||||||
if (!Number.isFinite(normalizedInterval) || normalizedInterval <= 0) {
|
if (!Number.isFinite(normalizedInterval) || normalizedInterval <= 0) {
|
||||||
@@ -44,7 +46,7 @@ interface ConfigFileStruct {
|
|||||||
}[];
|
}[];
|
||||||
lives?: {
|
lives?: {
|
||||||
[key: string]: LiveCfg;
|
[key: string]: LiveCfg;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
@@ -71,7 +73,6 @@ export const API_CONFIG = {
|
|||||||
let cachedConfig: AdminConfig;
|
let cachedConfig: AdminConfig;
|
||||||
let configInitPromise: Promise<AdminConfig> | null = null;
|
let configInitPromise: Promise<AdminConfig> | null = null;
|
||||||
|
|
||||||
|
|
||||||
// 从配置文件补充管理员配置
|
// 从配置文件补充管理员配置
|
||||||
export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||||
let fileConfig: ConfigFileStruct;
|
let fileConfig: ConfigFileStruct;
|
||||||
@@ -197,19 +198,22 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
|||||||
return adminConfig;
|
return adminConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getInitConfig(configFile: string, subConfig: {
|
async function getInitConfig(
|
||||||
|
configFile: string,
|
||||||
|
subConfig: {
|
||||||
URL: string;
|
URL: string;
|
||||||
AutoUpdate: boolean;
|
AutoUpdate: boolean;
|
||||||
LastCheck: string;
|
LastCheck: string;
|
||||||
} = {
|
} = {
|
||||||
URL: "",
|
URL: '',
|
||||||
AutoUpdate: false,
|
AutoUpdate: false,
|
||||||
LastCheck: "",
|
LastCheck: '',
|
||||||
}): Promise<AdminConfig> {
|
}
|
||||||
|
): Promise<AdminConfig> {
|
||||||
let cfgFile: ConfigFileStruct;
|
let cfgFile: ConfigFileStruct;
|
||||||
|
|
||||||
// 优先从环境变量读取订阅 URL
|
// 优先从环境变量读取订阅 URL
|
||||||
const envSubUrl = process.env.CONFIG_SUBSCRIPTION_URL || "";
|
const envSubUrl = process.env.CONFIG_SUBSCRIPTION_URL || '';
|
||||||
|
|
||||||
if (envSubUrl) {
|
if (envSubUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -228,7 +232,7 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 优先从环境变量读取配置
|
// 优先从环境变量读取配置
|
||||||
const envConfig = process.env.INIT_CONFIG || "";
|
const envConfig = process.env.INIT_CONFIG || '';
|
||||||
const configSource = envConfig || configFile;
|
const configSource = envConfig || configFile;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -254,23 +258,37 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||||
DoubanImageProxyType:
|
DoubanImageProxyType:
|
||||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE ||
|
||||||
|
'cmliussss-cdn-tencent',
|
||||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||||
DisableYellowFilter:
|
DisableYellowFilter:
|
||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||||
FluidSearch:
|
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
|
||||||
// 弹幕配置
|
// 弹幕配置
|
||||||
DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin',
|
DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin',
|
||||||
DanmakuApiBase:
|
DanmakuApiBase:
|
||||||
process.env.DANMAKU_API_BASE ||
|
process.env.DANMAKU_API_BASE ||
|
||||||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
|
(hasCustomDanmakuEnv
|
||||||
|
? 'http://localhost:9321'
|
||||||
|
: BUILTIN_DANMAKU_API_BASE),
|
||||||
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||||
DanmakuAutoLoadDefault: true,
|
DanmakuAutoLoadDefault: true,
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
||||||
TMDBProxy: process.env.TMDB_PROXY || '',
|
TMDBProxy: process.env.TMDB_PROXY || '',
|
||||||
TMDBReverseProxy: process.env.TMDB_REVERSE_PROXY || '',
|
TMDBReverseProxy: process.env.TMDB_REVERSE_PROXY || '',
|
||||||
|
// 动漫/Bangumi配置
|
||||||
|
BangumiDataSource:
|
||||||
|
(process.env.NEXT_PUBLIC_BANGUMI_DATA_SOURCE as any) || 'direct',
|
||||||
|
BangumiApiBaseUrl:
|
||||||
|
process.env.BANGUMI_API_BASE_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_BANGUMI_API_BASE_URL ||
|
||||||
|
'https://api.bgm.tv',
|
||||||
|
BangumiImageBaseUrl:
|
||||||
|
process.env.BANGUMI_IMAGE_BASE_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_BANGUMI_IMAGE_BASE_URL ||
|
||||||
|
'',
|
||||||
|
BangumiProxy: process.env.BANGUMI_PROXY || '',
|
||||||
// Pansou配置
|
// Pansou配置
|
||||||
PansouApiUrl: '',
|
PansouApiUrl: '',
|
||||||
PansouUsername: '',
|
PansouUsername: '',
|
||||||
@@ -365,7 +383,7 @@ export async function getConfig(): Promise<AdminConfig> {
|
|||||||
// localStorage 模式下直接从环境变量初始化
|
// localStorage 模式下直接从环境变量初始化
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
console.log('localStorage 模式:从环境变量初始化配置');
|
console.log('localStorage 模式:从环境变量初始化配置');
|
||||||
const adminConfig = await getInitConfig("");
|
const adminConfig = await getInitConfig('');
|
||||||
cachedConfig = configSelfCheck(adminConfig);
|
cachedConfig = configSelfCheck(adminConfig);
|
||||||
configInitPromise = null;
|
configInitPromise = null;
|
||||||
return cachedConfig;
|
return cachedConfig;
|
||||||
@@ -386,17 +404,18 @@ export async function getConfig(): Promise<AdminConfig> {
|
|||||||
if (dbReadFailed) {
|
if (dbReadFailed) {
|
||||||
// 数据库读取失败,使用默认配置但不保存,避免覆盖数据库
|
// 数据库读取失败,使用默认配置但不保存,避免覆盖数据库
|
||||||
console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)');
|
console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)');
|
||||||
adminConfig = await getInitConfig("");
|
adminConfig = await getInitConfig('');
|
||||||
} else {
|
} else {
|
||||||
// 数据库中确实没有配置,首次初始化并保存
|
// 数据库中确实没有配置,首次初始化并保存
|
||||||
console.log('首次初始化配置');
|
console.log('首次初始化配置');
|
||||||
adminConfig = await getInitConfig("");
|
adminConfig = await getInitConfig('');
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有旧格式Emby配置需要迁移
|
// 检查是否有旧格式Emby配置需要迁移
|
||||||
const needsEmbyMigration = adminConfig.EmbyConfig &&
|
const needsEmbyMigration =
|
||||||
|
adminConfig.EmbyConfig &&
|
||||||
adminConfig.EmbyConfig.ServerURL &&
|
adminConfig.EmbyConfig.ServerURL &&
|
||||||
!adminConfig.EmbyConfig.Sources;
|
!adminConfig.EmbyConfig.Sources;
|
||||||
|
|
||||||
@@ -544,19 +563,27 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (!adminConfig.UserConfig) {
|
if (!adminConfig.UserConfig) {
|
||||||
adminConfig.UserConfig = { Users: [] };
|
adminConfig.UserConfig = { Users: [] };
|
||||||
}
|
}
|
||||||
if (!adminConfig.UserConfig.Users || !Array.isArray(adminConfig.UserConfig.Users)) {
|
if (
|
||||||
|
!adminConfig.UserConfig.Users ||
|
||||||
|
!Array.isArray(adminConfig.UserConfig.Users)
|
||||||
|
) {
|
||||||
adminConfig.UserConfig.Users = [];
|
adminConfig.UserConfig.Users = [];
|
||||||
}
|
}
|
||||||
if (!adminConfig.SourceConfig || !Array.isArray(adminConfig.SourceConfig)) {
|
if (!adminConfig.SourceConfig || !Array.isArray(adminConfig.SourceConfig)) {
|
||||||
adminConfig.SourceConfig = [];
|
adminConfig.SourceConfig = [];
|
||||||
}
|
}
|
||||||
if (!adminConfig.CustomCategories || !Array.isArray(adminConfig.CustomCategories)) {
|
if (
|
||||||
|
!adminConfig.CustomCategories ||
|
||||||
|
!Array.isArray(adminConfig.CustomCategories)
|
||||||
|
) {
|
||||||
adminConfig.CustomCategories = [];
|
adminConfig.CustomCategories = [];
|
||||||
}
|
}
|
||||||
if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) {
|
if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) {
|
||||||
adminConfig.LiveConfig = [];
|
adminConfig.LiveConfig = [];
|
||||||
}
|
}
|
||||||
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(adminConfig.LiveRefreshIntervalHours);
|
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(
|
||||||
|
adminConfig.LiveRefreshIntervalHours
|
||||||
|
);
|
||||||
|
|
||||||
if (adminConfig.OpenListConfig) {
|
if (adminConfig.OpenListConfig) {
|
||||||
if (!adminConfig.OpenListConfig.RootPaths) {
|
if (!adminConfig.OpenListConfig.RootPaths) {
|
||||||
@@ -567,7 +594,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (!adminConfig.OpenListConfig.OfflineDownloadPath) {
|
if (!adminConfig.OpenListConfig.OfflineDownloadPath) {
|
||||||
adminConfig.OpenListConfig.OfflineDownloadPath = '/';
|
adminConfig.OpenListConfig.OfflineDownloadPath = '/';
|
||||||
}
|
}
|
||||||
if (adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined) {
|
if (
|
||||||
|
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined
|
||||||
|
) {
|
||||||
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource = false;
|
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource = false;
|
||||||
}
|
}
|
||||||
if (adminConfig.OpenListConfig.OfflineDownloadURL === undefined) {
|
if (adminConfig.OpenListConfig.OfflineDownloadURL === undefined) {
|
||||||
@@ -584,11 +613,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
// 用户信息已迁移到新版数据库
|
// 用户信息已迁移到新版数据库
|
||||||
// 这里只保留站长用户用于兼容性,其他用户从数据库读取
|
// 这里只保留站长用户用于兼容性,其他用户从数据库读取
|
||||||
const ownerUser = process.env.USERNAME;
|
const ownerUser = process.env.USERNAME;
|
||||||
adminConfig.UserConfig.Users = [{
|
adminConfig.UserConfig.Users = [
|
||||||
|
{
|
||||||
username: ownerUser!,
|
username: ownerUser!,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
banned: false,
|
banned: false,
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 采集源去重
|
// 采集源去重
|
||||||
const seenSourceKeys = new Set<string>();
|
const seenSourceKeys = new Set<string>();
|
||||||
@@ -602,13 +633,15 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
|
|
||||||
// 自定义分类去重
|
// 自定义分类去重
|
||||||
const seenCustomCategoryKeys = new Set<string>();
|
const seenCustomCategoryKeys = new Set<string>();
|
||||||
adminConfig.CustomCategories = adminConfig.CustomCategories.filter((category) => {
|
adminConfig.CustomCategories = adminConfig.CustomCategories.filter(
|
||||||
|
(category) => {
|
||||||
if (seenCustomCategoryKeys.has(category.query + category.type)) {
|
if (seenCustomCategoryKeys.has(category.query + category.type)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
seenCustomCategoryKeys.add(category.query + category.type);
|
seenCustomCategoryKeys.add(category.query + category.type);
|
||||||
return true;
|
return true;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 直播源去重
|
// 直播源去重
|
||||||
const seenLiveKeys = new Set<string>();
|
const seenLiveKeys = new Set<string>();
|
||||||
@@ -627,7 +660,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
console.log('[Config] 检测到旧格式Emby配置,自动迁移到新格式');
|
console.log('[Config] 检测到旧格式Emby配置,自动迁移到新格式');
|
||||||
const oldConfig = adminConfig.EmbyConfig;
|
const oldConfig = adminConfig.EmbyConfig;
|
||||||
adminConfig.EmbyConfig = {
|
adminConfig.EmbyConfig = {
|
||||||
Sources: [{
|
Sources: [
|
||||||
|
{
|
||||||
key: 'default',
|
key: 'default',
|
||||||
name: 'Emby',
|
name: 'Emby',
|
||||||
enabled: oldConfig.Enabled ?? false,
|
enabled: oldConfig.Enabled ?? false,
|
||||||
@@ -641,28 +675,37 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
LastSyncTime: oldConfig.LastSyncTime,
|
LastSyncTime: oldConfig.LastSyncTime,
|
||||||
ItemCount: oldConfig.ItemCount,
|
ItemCount: oldConfig.ItemCount,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emby源去重
|
// Emby源去重
|
||||||
if (adminConfig.EmbyConfig?.Sources) {
|
if (adminConfig.EmbyConfig?.Sources) {
|
||||||
const seenEmbyKeys = new Set<string>();
|
const seenEmbyKeys = new Set<string>();
|
||||||
adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter((source) => {
|
adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter(
|
||||||
|
(source) => {
|
||||||
if (seenEmbyKeys.has(source.key)) {
|
if (seenEmbyKeys.has(source.key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
seenEmbyKeys.add(source.key);
|
seenEmbyKeys.add(source.key);
|
||||||
return true;
|
return true;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminConfig.SuwayomiConfig) {
|
if (!adminConfig.SuwayomiConfig) {
|
||||||
adminConfig.SuwayomiConfig = {
|
adminConfig.SuwayomiConfig = {
|
||||||
Enabled: process.env.SUWAYOMI_ENABLED === 'true',
|
Enabled: process.env.SUWAYOMI_ENABLED === 'true',
|
||||||
ServerURL: process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
|
ServerURL:
|
||||||
AuthMode: (process.env.SUWAYOMI_AUTH_MODE as 'none' | 'basic_auth' | 'simple_login' | undefined) || 'none',
|
process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
|
||||||
|
AuthMode:
|
||||||
|
(process.env.SUWAYOMI_AUTH_MODE as
|
||||||
|
| 'none'
|
||||||
|
| 'basic_auth'
|
||||||
|
| 'simple_login'
|
||||||
|
| undefined) || 'none',
|
||||||
Username: process.env.SUWAYOMI_USERNAME || '',
|
Username: process.env.SUWAYOMI_USERNAME || '',
|
||||||
Password: process.env.SUWAYOMI_PASSWORD || '',
|
Password: process.env.SUWAYOMI_PASSWORD || '',
|
||||||
DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh',
|
DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh',
|
||||||
@@ -694,7 +737,10 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (!Array.isArray(adminConfig.SuwayomiConfig.SourceIds)) {
|
if (!Array.isArray(adminConfig.SuwayomiConfig.SourceIds)) {
|
||||||
adminConfig.SuwayomiConfig.SourceIds = [];
|
adminConfig.SuwayomiConfig.SourceIds = [];
|
||||||
}
|
}
|
||||||
if (adminConfig.SuwayomiConfig.MaxSources === undefined || Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)) {
|
if (
|
||||||
|
adminConfig.SuwayomiConfig.MaxSources === undefined ||
|
||||||
|
Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)
|
||||||
|
) {
|
||||||
adminConfig.SuwayomiConfig.MaxSources = 10;
|
adminConfig.SuwayomiConfig.MaxSources = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,19 +761,26 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
const envUrl = process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL;
|
const envUrl = process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL;
|
||||||
if (!envUrl) return [];
|
if (!envUrl) return [];
|
||||||
|
|
||||||
return [{
|
return [
|
||||||
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: process.env.OPDS_NAME || '默认书源',
|
name: process.env.OPDS_NAME || '默认书源',
|
||||||
type: 'opds',
|
type: 'opds',
|
||||||
url: envUrl,
|
url: envUrl,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
|
authMode:
|
||||||
|
(process.env.OPDS_AUTH_MODE as
|
||||||
|
| 'none'
|
||||||
|
| 'basic'
|
||||||
|
| 'header'
|
||||||
|
| undefined) || 'none',
|
||||||
username: process.env.OPDS_USERNAME || '',
|
username: process.env.OPDS_USERNAME || '',
|
||||||
password: process.env.OPDS_PASSWORD || '',
|
password: process.env.OPDS_PASSWORD || '',
|
||||||
headerName: process.env.OPDS_HEADER_NAME || '',
|
headerName: process.env.OPDS_HEADER_NAME || '',
|
||||||
headerValue: process.env.OPDS_HEADER_VALUE || '',
|
headerValue: process.env.OPDS_HEADER_VALUE || '',
|
||||||
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
|
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
})(),
|
})(),
|
||||||
CacheTTL: Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000),
|
CacheTTL: Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000),
|
||||||
};
|
};
|
||||||
@@ -738,15 +791,22 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
|
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
|
||||||
adminConfig.OPDSConfig.Sources = [];
|
adminConfig.OPDSConfig.Sources = [];
|
||||||
}
|
}
|
||||||
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter((source: any) => (source?.type || 'opds') === 'opds').map((source: any) => {
|
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter(
|
||||||
|
(source: any) => (source?.type || 'opds') === 'opds'
|
||||||
|
).map((source: any) => {
|
||||||
const { legado: _legado, ...rest } = source || {};
|
const { legado: _legado, ...rest } = source || {};
|
||||||
return { ...rest, type: 'opds' };
|
return { ...rest, type: 'opds' };
|
||||||
});
|
});
|
||||||
if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) {
|
if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) {
|
||||||
adminConfig.OPDSConfig.LegadoSubscriptions = [];
|
adminConfig.OPDSConfig.LegadoSubscriptions = [];
|
||||||
}
|
}
|
||||||
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
|
if (
|
||||||
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
adminConfig.OPDSConfig.CacheTTL === undefined ||
|
||||||
|
Number.isNaN(adminConfig.OPDSConfig.CacheTTL)
|
||||||
|
) {
|
||||||
|
adminConfig.OPDSConfig.CacheTTL = Number(
|
||||||
|
process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminConfig.NetDiskConfig) {
|
if (!adminConfig.NetDiskConfig) {
|
||||||
@@ -888,7 +948,10 @@ export async function resetConfig() {
|
|||||||
if (!originConfig) {
|
if (!originConfig) {
|
||||||
originConfig = {} as AdminConfig;
|
originConfig = {} as AdminConfig;
|
||||||
}
|
}
|
||||||
const adminConfig = await getInitConfig(originConfig.ConfigFile, originConfig.ConfigSubscribtion);
|
const adminConfig = await getInitConfig(
|
||||||
|
originConfig.ConfigFile,
|
||||||
|
originConfig.ConfigSubscribtion
|
||||||
|
);
|
||||||
cachedConfig = adminConfig;
|
cachedConfig = adminConfig;
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
@@ -923,7 +986,9 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
|||||||
// 优先根据用户自己的 enabledApis 配置查找
|
// 优先根据用户自己的 enabledApis 配置查找
|
||||||
if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) {
|
if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) {
|
||||||
const userApiSitesSet = new Set(userInfoV2.enabledApis);
|
const userApiSitesSet = new Set(userInfoV2.enabledApis);
|
||||||
return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({
|
return allApiSites
|
||||||
|
.filter((s) => userApiSitesSet.has(s.key))
|
||||||
|
.map((s) => ({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
api: s.api,
|
api: s.api,
|
||||||
@@ -937,15 +1002,19 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
|||||||
const enabledApisFromTags = new Set<string>();
|
const enabledApisFromTags = new Set<string>();
|
||||||
|
|
||||||
// 遍历用户的所有 tags,收集对应的 enabledApis
|
// 遍历用户的所有 tags,收集对应的 enabledApis
|
||||||
userInfoV2.tags.forEach(tagName => {
|
userInfoV2.tags.forEach((tagName) => {
|
||||||
const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName);
|
const tagConfig = config.UserConfig.Tags?.find((t) => t.name === tagName);
|
||||||
if (tagConfig && tagConfig.enabledApis) {
|
if (tagConfig && tagConfig.enabledApis) {
|
||||||
tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey));
|
tagConfig.enabledApis.forEach((apiKey) =>
|
||||||
|
enabledApisFromTags.add(apiKey)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabledApisFromTags.size > 0) {
|
if (enabledApisFromTags.size > 0) {
|
||||||
return allApiSites.filter((s) => enabledApisFromTags.has(s.key)).map((s) => ({
|
return allApiSites
|
||||||
|
.filter((s) => enabledApisFromTags.has(s.key))
|
||||||
|
.map((s) => ({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
api: s.api,
|
api: s.api,
|
||||||
|
|||||||
+236
-13
@@ -55,9 +55,13 @@ function buildDoubanImageUrl(
|
|||||||
'img.doubanio.cmliussss.com'
|
'img.doubanio.cmliussss.com'
|
||||||
);
|
);
|
||||||
case 'baidu':
|
case 'baidu':
|
||||||
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
|
return `https://image.baidu.com/search/down?url=${encodeURIComponent(
|
||||||
|
originalUrl
|
||||||
|
)}`;
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
|
return proxyUrl
|
||||||
|
? `${proxyUrl}${encodeURIComponent(originalUrl)}`
|
||||||
|
: originalUrl;
|
||||||
case 'direct':
|
case 'direct':
|
||||||
default:
|
default:
|
||||||
return originalUrl;
|
return originalUrl;
|
||||||
@@ -89,8 +93,9 @@ function getDoubanImageProxyConfig(): {
|
|||||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
||||||
'';
|
'';
|
||||||
const doubanImageProxyBackupType =
|
const doubanImageProxyBackupType =
|
||||||
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
|
(localStorage.getItem(
|
||||||
'server';
|
'doubanImageProxyTypeBackup'
|
||||||
|
) as DoubanImageProxyType | null) || 'server';
|
||||||
const doubanImageProxyBackupUrl =
|
const doubanImageProxyBackupUrl =
|
||||||
localStorage.getItem('doubanImageProxyUrlBackup') || '';
|
localStorage.getItem('doubanImageProxyUrlBackup') || '';
|
||||||
const primaryConfig = normalizeDoubanImageProxyConfig(
|
const primaryConfig = normalizeDoubanImageProxyConfig(
|
||||||
@@ -130,6 +135,162 @@ export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
|
|||||||
return backupUrl;
|
return backupUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBangumiImageUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase();
|
||||||
|
return (
|
||||||
|
hostname === 'lain.bgm.tv' ||
|
||||||
|
hostname === 'r.bgm.tv' ||
|
||||||
|
hostname.endsWith('.bgm.tv') ||
|
||||||
|
hostname.endsWith('.bangumi.tv')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnimeImageSource = 'direct' | 'server-proxy' | 'custom-baseurl';
|
||||||
|
|
||||||
|
const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil';
|
||||||
|
const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature';
|
||||||
|
const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return baseUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnimeImageSource(
|
||||||
|
value: string | null | undefined
|
||||||
|
): AnimeImageSource {
|
||||||
|
return value === 'server-proxy' ||
|
||||||
|
value === 'custom-baseurl' ||
|
||||||
|
value === 'direct'
|
||||||
|
? value
|
||||||
|
: 'direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryBangumiImageSource(): AnimeImageSource {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeAnimeImageSource(
|
||||||
|
localStorage.getItem('animeDataSource') ||
|
||||||
|
(window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE ||
|
||||||
|
'direct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupBangumiImageSource(
|
||||||
|
primary: AnimeImageSource
|
||||||
|
): AnimeImageSource | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return primary === 'server-proxy' ? null : 'server-proxy';
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = normalizeAnimeImageSource(
|
||||||
|
localStorage.getItem('animeDataSourceBackup') || 'server-proxy'
|
||||||
|
);
|
||||||
|
|
||||||
|
return backup === primary ? null : backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBangumiImageBaseUrl(): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeBaseUrl(localStorage.getItem('animeImageBaseUrl') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBangumiImageFallbackSignature(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
primary: getPrimaryBangumiImageSource(),
|
||||||
|
backup: normalizeAnimeImageSource(
|
||||||
|
localStorage.getItem('animeDataSourceBackup') || 'server-proxy'
|
||||||
|
),
|
||||||
|
imageBaseUrl: getBangumiImageBaseUrl(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBangumiImageFallbackCache(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY);
|
||||||
|
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markBangumiImageFallbackActive(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(
|
||||||
|
BANGUMI_IMAGE_FALLBACK_UNTIL_KEY,
|
||||||
|
String(Date.now() + BANGUMI_IMAGE_FALLBACK_DURATION)
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY,
|
||||||
|
getBangumiImageFallbackSignature()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBangumiImageFallbackActive(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const until = Number(localStorage.getItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY));
|
||||||
|
if (!until || Date.now() >= until) {
|
||||||
|
clearBangumiImageFallbackCache();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = localStorage.getItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
|
||||||
|
if (signature !== getBangumiImageFallbackSignature()) {
|
||||||
|
clearBangumiImageFallbackCache();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBangumiImageUrl(
|
||||||
|
originalUrl: string,
|
||||||
|
source: AnimeImageSource
|
||||||
|
): string {
|
||||||
|
switch (source) {
|
||||||
|
case 'server-proxy':
|
||||||
|
return `/api/image-proxy?url=${encodeURIComponent(
|
||||||
|
originalUrl
|
||||||
|
)}&source=bangumi`;
|
||||||
|
case 'custom-baseurl': {
|
||||||
|
const imageBaseUrl = getBangumiImageBaseUrl();
|
||||||
|
return imageBaseUrl ? `${imageBaseUrl}/${originalUrl}` : originalUrl;
|
||||||
|
}
|
||||||
|
case 'direct':
|
||||||
|
default:
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBangumiImageFallbackUrl(originalUrl: string): string | null {
|
||||||
|
if (!originalUrl || !isBangumiImageUrl(originalUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = getPrimaryBangumiImageSource();
|
||||||
|
const backup = getBackupBangumiImageSource(primary);
|
||||||
|
if (!backup) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryUrl = buildBangumiImageUrl(originalUrl, primary);
|
||||||
|
const backupUrl = buildBangumiImageUrl(originalUrl, backup);
|
||||||
|
|
||||||
|
if (backupUrl === primaryUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return backupUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export function tryApplyDoubanImageFallback(
|
export function tryApplyDoubanImageFallback(
|
||||||
target: HTMLImageElement,
|
target: HTMLImageElement,
|
||||||
originalUrl: string
|
originalUrl: string
|
||||||
@@ -143,7 +304,11 @@ export function tryApplyDoubanImageFallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
|
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
|
||||||
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
|
if (
|
||||||
|
!fallbackUrl ||
|
||||||
|
fallbackUrl === target.currentSrc ||
|
||||||
|
fallbackUrl === target.src
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +317,33 @@ export function tryApplyDoubanImageFallback(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function tryApplyBangumiImageFallback(
|
||||||
|
target: HTMLImageElement,
|
||||||
|
originalUrl: string
|
||||||
|
): boolean {
|
||||||
|
if (!originalUrl || !isBangumiImageUrl(originalUrl)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.dataset.bangumiBackupTried === 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackUrl = getBangumiImageFallbackUrl(originalUrl);
|
||||||
|
if (
|
||||||
|
!fallbackUrl ||
|
||||||
|
fallbackUrl === target.currentSrc ||
|
||||||
|
fallbackUrl === target.src
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.dataset.bangumiBackupTried = 'true';
|
||||||
|
markBangumiImageFallbackActive();
|
||||||
|
target.src = fallbackUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理图片 URL,根据用户设置使用相应的代理
|
* 处理图片 URL,根据用户设置使用相应的代理
|
||||||
*/
|
*/
|
||||||
@@ -166,7 +358,8 @@ export function processImageUrl(originalUrl: string): string {
|
|||||||
// 处理 TMDB 图片 URL 替换
|
// 处理 TMDB 图片 URL 替换
|
||||||
if (originalUrl.includes('image.tmdb.org')) {
|
if (originalUrl.includes('image.tmdb.org')) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const tmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org';
|
const tmdbImageBaseUrl =
|
||||||
|
localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org';
|
||||||
// 只有当用户设置了不同的 baseUrl 时才进行替换
|
// 只有当用户设置了不同的 baseUrl 时才进行替换
|
||||||
if (tmdbImageBaseUrl !== 'https://image.tmdb.org') {
|
if (tmdbImageBaseUrl !== 'https://image.tmdb.org') {
|
||||||
return originalUrl.replace('https://image.tmdb.org', tmdbImageBaseUrl);
|
return originalUrl.replace('https://image.tmdb.org', tmdbImageBaseUrl);
|
||||||
@@ -175,6 +368,17 @@ export function processImageUrl(originalUrl: string): string {
|
|||||||
return originalUrl;
|
return originalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片;
|
||||||
|
// 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。
|
||||||
|
if (isBangumiImageUrl(originalUrl)) {
|
||||||
|
const primary = getPrimaryBangumiImageSource();
|
||||||
|
const backup = getBackupBangumiImageSource(primary);
|
||||||
|
if (backup && isBangumiImageFallbackActive()) {
|
||||||
|
return buildBangumiImageUrl(originalUrl, backup);
|
||||||
|
}
|
||||||
|
return buildBangumiImageUrl(originalUrl, primary);
|
||||||
|
}
|
||||||
|
|
||||||
// 处理豆瓣图片代理
|
// 处理豆瓣图片代理
|
||||||
if (!originalUrl.includes('doubanio.com')) {
|
if (!originalUrl.includes('doubanio.com')) {
|
||||||
return originalUrl;
|
return originalUrl;
|
||||||
@@ -229,7 +433,10 @@ export function processVideoUrl(originalUrl: string): string {
|
|||||||
case 'custom':
|
case 'custom':
|
||||||
// 使用自定义代理
|
// 使用自定义代理
|
||||||
if (proxyUrl) {
|
if (proxyUrl) {
|
||||||
return originalUrl.replace(/https?:\/\/img\d\.doubanio\.com/g, proxyUrl);
|
return originalUrl.replace(
|
||||||
|
/https?:\/\/img\d\.doubanio\.com/g,
|
||||||
|
proxyUrl
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return originalUrl;
|
return originalUrl;
|
||||||
|
|
||||||
@@ -302,7 +509,8 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
? 'SD'
|
? 'SD'
|
||||||
: '未知';
|
: '未知';
|
||||||
|
|
||||||
const bitrateStr = estimatedBitrate > 0
|
const bitrateStr =
|
||||||
|
estimatedBitrate > 0
|
||||||
? estimatedBitrate >= 1000000
|
? estimatedBitrate >= 1000000
|
||||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||||
@@ -385,9 +593,17 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
const fragmentSize = size; // 分片大小(字节)
|
const fragmentSize = size; // 分片大小(字节)
|
||||||
|
|
||||||
// 码率 = (分片大小 × 8 bits) / 分片时长
|
// 码率 = (分片大小 × 8 bits) / 分片时长
|
||||||
estimatedBitrate = Math.round((fragmentSize * 8) / fragmentDuration);
|
estimatedBitrate = Math.round(
|
||||||
|
(fragmentSize * 8) / fragmentDuration
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(2)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(2)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`);
|
console.log(
|
||||||
|
`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(
|
||||||
|
2
|
||||||
|
)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(
|
||||||
|
2
|
||||||
|
)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAndResolve(); // 尝试返回结果
|
checkAndResolve(); // 尝试返回结果
|
||||||
@@ -412,8 +628,14 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
const statusCode = data.response?.code || data.response?.status;
|
const statusCode = data.response?.code || data.response?.status;
|
||||||
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
|
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
|
||||||
if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) {
|
if (
|
||||||
console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过');
|
statusCode === 415 &&
|
||||||
|
(m3u8Url.includes('/api/proxy-m3u8') ||
|
||||||
|
m3u8Url.includes('/api/proxy/vod/m3u8'))
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
'[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过'
|
||||||
|
);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
video.remove();
|
video.remove();
|
||||||
@@ -441,7 +663,8 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error getting video resolution: ${error instanceof Error ? error.message : String(error)
|
`Error getting video resolution: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user