增加动漫数据源配置

This commit is contained in:
mtvpls
2026-05-28 16:00:07 +08:00
parent dc31a788fc
commit cc98b21d66
16 changed files with 4448 additions and 2153 deletions
+627 -146
View File
@@ -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
(user) => ?.filter(
role === 'owner' || (user) =>
(role === 'admin' && role === 'owner' ||
(user.role === 'user' || user.username === currentUsername)) (role === 'admin' &&
).map((u) => u.username) || []; (user.role === 'user' || user.username === currentUsername))
)
.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 APICloudflare
使
</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,60 +12463,71 @@ 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(
id: item.id || `source_${index + 1}`, (config.OPDSConfig.Sources || []).map((item, index) => ({
name: item.name || `书源 ${index + 1}`, id: item.id || `source_${index + 1}`,
type: 'opds' as const, name: item.name || `书源 ${index + 1}`,
url: item.url || '', type: 'opds' as const,
enabled: item.enabled !== false, url: item.url || '',
authMode: item.authMode || 'none', enabled: item.enabled !== false,
username: item.username || '', authMode: item.authMode || 'none',
password: item.password || '', username: item.username || '',
headerName: item.headerName || '', password: item.password || '',
headerValue: item.headerValue || '', headerName: item.headerName || '',
searchTemplate: item.searchTemplate || '', headerValue: item.headerValue || '',
preferFormat: item.preferFormat || ['epub', 'pdf'], searchTemplate: item.searchTemplate || '',
language: item.language || '', preferFormat: item.preferFormat || ['epub', 'pdf'],
}))); 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 [
id: `source_${nextIndex + 1}`, ...prev,
name: `书源 ${nextIndex + 1}`, {
type: 'opds' as const, id: `source_${nextIndex + 1}`,
url: '', name: `书源 ${nextIndex + 1}`,
enabled: true, type: 'opds' as const,
authMode: 'none' as const, url: '',
username: '', enabled: true,
password: '', authMode: 'none' as const,
headerName: '', username: '',
headerValue: '', password: '',
searchTemplate: '', headerName: '',
preferFormat: ['epub' as const, 'pdf' as const], headerValue: '',
language: '', searchTemplate: '',
}]; preferFormat: ['epub' as const, 'pdf' as const],
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'>
<div className='flex flex-wrap items-start justify-between gap-3'> Legado
<div className='min-w-0 flex-1'> </div>
<div className='font-medium text-gray-900 dark:text-gray-100'>{sub.name}</div> ) : (
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{sub.url}</div> (legadoSubscriptions || []).map((sub) => (
<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} key={sub.id}
</div> className='rounded-lg border border-amber-200 bg-white p-3 text-sm dark:border-amber-800 dark:bg-gray-900'
<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> <div className='flex flex-wrap items-start justify-between gap-3'>
<button type='button' onClick={() => refreshLegadoSubscription(sub.id)} disabled={isLoading(`refreshLegadoSubscription-${sub.id}`)} className={buttonStyles.secondarySmall}>{isLoading(`refreshLegadoSubscription-${sub.id}`) ? '同步中...' : '同步'}</button> <div className='min-w-0 flex-1'>
<button type='button' onClick={() => deleteLegadoSubscription(sub.id)} disabled={isLoading(`deleteLegadoSubscription-${sub.id}`)} className={buttonStyles.dangerSmall}></button> <div className='font-medium text-gray-900 dark:text-gray-100'>
{sub.name}
</div>
<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 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
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,33 +16197,34 @@ function AdminPageClient() {
const userLimit = 10; const userLimit = 10;
// 获取新版本用户列表 // 获取新版本用户列表
const fetchUsersV2 = useCallback(async (page = 1, search = userSearch) => { const fetchUsersV2 = useCallback(
try { async (page = 1, search = userSearch) => {
setUserListLoading(true); try {
const params = new URLSearchParams({ setUserListLoading(true);
page: String(page), const params = new URLSearchParams({
limit: String(userLimit), page: String(page),
}); limit: String(userLimit),
const trimmedSearch = search.trim(); });
if (trimmedSearch) { const trimmedSearch = search.trim();
params.set('search', trimmedSearch); if (trimmedSearch) {
params.set('search', trimmedSearch);
}
const response = await fetch(`/api/admin/users?${params.toString()}`);
if (response.ok) {
const data = await response.json();
setUsersV2(data.users);
setUserTotalPages(data.totalPages || 1);
setUserTotal(data.total || 0);
setUserPage(page);
}
} catch (err) {
console.error('获取新版本用户列表失败:', err);
} finally {
setUserListLoading(false);
} }
const response = await fetch( },
`/api/admin/users?${params.toString()}` [userSearch]
); );
if (response.ok) {
const data = await response.json();
setUsersV2(data.users);
setUserTotalPages(data.totalPages || 1);
setUserTotal(data.total || 0);
setUserPage(page);
}
} catch (err) {
console.error('获取新版本用户列表失败:', err);
} finally {
setUserListLoading(false);
}
}, [userSearch]);
// 刷新配置和用户列表 // 刷新配置和用户列表
const refreshConfigAndUsers = useCallback(async () => { const refreshConfigAndUsers = useCallback(async () => {
+63 -21
View File
@@ -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,
+34
View File
@@ -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 }
);
}
}
+42
View File
@@ -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 }
);
}
}
+87 -8
View File
@@ -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
View File
@@ -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 />
+746 -399
View File
@@ -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详情失败');
} }
@@ -566,17 +602,26 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
mediaType === 'movie' mediaType === 'movie'
? detailResult.title ? detailResult.title
: seasonData?.name : seasonData?.name
? `${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:
: poster, seasonData?.poster_path || detailResult.poster_path
? processImageUrl(
getTMDBImageUrl(
seasonData?.poster_path || detailResult.poster_path,
'w500'
)
)
: poster,
rating: detailResult.vote_average rating: detailResult.vote_average
? { ? {
value: detailResult.vote_average, value: 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详情失败');
} }
@@ -713,17 +786,26 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
mediaType === 'movie' mediaType === 'movie'
? detailResult.title ? detailResult.title
: seasonData?.name : seasonData?.name
? `${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:
: poster, seasonData?.poster_path || detailResult.poster_path
? processImageUrl(
getTMDBImageUrl(
seasonData?.poster_path || detailResult.poster_path,
'w500'
)
)
: poster,
rating: detailResult.vote_average rating: detailResult.vote_average
? { ? {
value: detailResult.vote_average, value: 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
directors: creditsData.crew ? {
?.filter((person: any) => person.job === 'Director') ...prev,
.slice(0, 5) directors:
.map((person: any) => ({ creditsData.crew
name: person.name, ?.filter((person: any) => person.job === 'Director')
profile_path: person.profile_path, .slice(0, 5)
})) || prev.directors, .map((person: any) => ({
actors: creditsData.cast name: person.name,
?.slice(0, 15) profile_path: person.profile_path,
.map((person: any) => ({ })) || prev.directors,
name: person.name, actors:
character: person.character, creditsData.cast?.slice(0, 15).map((person: any) => ({
profile_path: person.profile_path, name: person.name,
})) || prev.actors, character: person.character,
} : null); profile_path: person.profile_path,
})) || prev.actors,
}
: 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
title: (episodesData.name || season?.name) ? {
? `${prev.seriesTitle || prev.title} ${episodesData.name || season?.name}` ...prev,
: prev.title, title:
intro: episodesData.overview || season?.overview || prev.overview, episodesData.name || season?.name
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster, ? `${prev.seriesTitle || prev.title} ${
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate, episodesData.name || season?.name
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year, }`
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount, : prev.title,
} : null); intro: episodesData.overview || season?.overview || prev.overview,
poster: season?.poster_path
? getTMDBImageUrl(season.poster_path, 'w500')
: prev.poster,
releaseDate:
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'>
{galleryHeader} <div
{galleryBody} className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}
>
{galleryHeader}
{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,76 +1358,92 @@ 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' &&
<button originalSource !== 'tmdb' &&
onClick={handleToggleSource} originalDetailData && (
disabled={loading} <button
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" onClick={handleToggleSource}
> disabled={loading}
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'} 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'
</button> >
)} {' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div> </div>
</div> </div>
</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 && (
{detailData.originalTitle} <p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
</p> {detailData.originalTitle}
)} </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,37 +1873,46 @@ 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' &&
<button originalSource !== 'tmdb' &&
onClick={handleToggleSource} originalDetailData && (
disabled={loading} <button
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" onClick={handleToggleSource}
> disabled={loading}
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'} 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'
</button> >
)} {' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div> </div>
</div> </div>
</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,67 +2022,75 @@ 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' &&
<button originalSource !== 'tmdb' &&
onClick={handleToggleSource} originalDetailData && (
disabled={loading} <button
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" onClick={handleToggleSource}
> disabled={loading}
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'} 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'
</button> >
)} {' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
)} )}
{!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 && (
{detailData.originalTitle} <p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
</p> {detailData.originalTitle}
)} </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,20 +2488,27 @@ 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' &&
<button originalSource !== 'tmdb' &&
onClick={handleToggleSource} originalDetailData && (
disabled={loading} <button
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" onClick={handleToggleSource}
> disabled={loading}
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'} 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'
</button> >
)} {' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
+42 -5
View File
@@ -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}
+292
View File
@@ -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>
+1788 -1429
View File
@@ -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,45 +98,46 @@ 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, {
title = '', id,
query = '', title = '',
poster = '', query = '',
episodes, poster = '',
source, episodes,
source_name, source,
source_names, source_name,
progress = 0, source_names,
year, progress = 0,
from, year,
currentEpisode, from,
douban_id, currentEpisode,
tmdb_id, douban_id,
onDelete, tmdb_id,
rate, onDelete,
type = '', rate,
isBangumi = false, type = '',
isAggregate = false, isBangumi = false,
origin = 'vod', isAggregate = false,
releaseDate, origin = 'vod',
isUpcoming = false, releaseDate,
seasonNumber, isUpcoming = false,
seasonName, seasonNumber,
orientation = 'vertical', seasonName,
playTime, orientation = 'vertical',
totalTime, playTime,
cmsData, totalTime,
onBeforeNavigate, cmsData,
}: VideoCardProps, onBeforeNavigate,
ref }: VideoCardProps,
) { ref
const router = useRouter(); ) {
const actualTitle = title; const router = useRouter();
const actualPoster = poster; const actualTitle = title;
const netdiskPosterPlaceholder = useMemo(() => { const actualPoster = poster;
return `data:image/svg+xml;utf8,${encodeURIComponent(` const netdiskPosterPlaceholder = useMemo(() => {
return `data:image/svg+xml;utf8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600">
<rect width="400" height="600" fill="#f3f4f6"/> <rect width="400" height="600" fill="#f3f4f6"/>
<g fill="none" stroke="#9ca3af" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#9ca3af" stroke-width="16" stroke-linecap="round" stroke-linejoin="round">
@@ -124,1232 +145,1092 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</g> </g>
</svg> </svg>
`)}`; `)}`;
}, []); }, []);
const processedPoster = useMemo( const processedPoster = useMemo(
() => () =>
actualPoster actualPoster
? processImageUrl(actualPoster) ? processImageUrl(actualPoster)
: isNetdiskSource(source) : isNetdiskSource(source)
? netdiskPosterPlaceholder ? netdiskPosterPlaceholder
: '', : '',
[actualPoster, source, netdiskPosterPlaceholder] [actualPoster, source, netdiskPosterPlaceholder]
); );
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>(
const [showAIChat, setShowAIChat] = useState(false); null
const [isAIStreaming, setIsAIStreaming] = useState(false); ); // 搜索结果的收藏状态
const [aiEnabled, setAiEnabled] = useState(false); const [showAIChat, setShowAIChat] = useState(false);
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState(''); const [isAIStreaming, setIsAIStreaming] = useState(false);
const [showDetailPanel, setShowDetailPanel] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false); const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] =
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示 useState('');
const [displayPoster, setDisplayPoster] = useState(processedPoster); const [showDetailPanel, setShowDetailPanel] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示
const [displayPoster, setDisplayPoster] = useState(processedPoster);
// 检查AI功能是否启用 // 检查AI功能是否启用
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const enabled = const enabled =
(window as any).RUNTIME_CONFIG?.AI_ENABLED && (window as any).RUNTIME_CONFIG?.AI_ENABLED &&
(window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY; (window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY;
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
if (defaultMsg) { ?.AI_DEFAULT_MESSAGE_WITH_VIDEO;
setAiDefaultMessageWithVideo(defaultMsg); if (defaultMsg) {
setAiDefaultMessageWithVideo(defaultMsg);
}
} }
} }, []);
}, []);
// 可外部修改的可控字段 // 可外部修改的可控字段
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
);
useEffect(() => {
setDynamicEpisodes(episodes);
}, [episodes]);
useEffect(() => {
setDynamicSourceNames(source_names);
}, [source_names]);
useEffect(() => {
setDynamicDoubanId(douban_id);
}, [douban_id]);
useEffect(() => {
setDisplayPoster(processedPoster);
}, [processedPoster]);
useImperativeHandle(ref, () => ({
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
setDoubanId: (id?: number) => setDynamicDoubanId(id),
}));
const actualSource = source;
const actualId = id;
const actualDoubanId = dynamicDoubanId;
const actualEpisodes = dynamicEpisodes;
const actualYear = year;
const actualQuery = query || '';
const actualSearchType = type;
const isDirectPlaySource = actualSource === 'directplay';
const directLinkUrl = useMemo(() => {
if (!isDirectPlaySource || !actualId) return '';
try {
return base58Decode(actualId);
} catch {
return '';
}
}, [isDirectPlaySource, actualId]);
const displayYear = useMemo(() => {
if (!actualYear) return '';
const normalized = actualYear.trim();
if (!normalized || normalized === 'unknown') return '';
const digits = normalized.replace(/\D/g, '');
if (!digits) return normalized;
return digits.slice(-2).padStart(2, '0');
}, [actualYear]);
// 获取收藏状态(搜索结果页面不检查)
useEffect(() => {
if (from === 'douban' || from === 'search' || !actualSource || !actualId) return;
const fetchFavoriteStatus = async () => {
try {
const fav = await isFavorited(actualSource, actualId);
setFavorited(fav);
} catch (err) {
throw new Error('检查收藏状态失败');
}
};
fetchFavoriteStatus();
// 监听收藏状态更新事件
const storageKey = generateStorageKey(actualSource, actualId);
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
// 检查当前项目是否在新的收藏列表中
const isNowFavorited = !!newFavorites[storageKey];
setFavorited(isNowFavorited);
}
); );
return unsubscribe; useEffect(() => {
}, [from, actualSource, actualId]); setDynamicEpisodes(episodes);
}, [episodes]);
const handleToggleFavorite = useCallback( useEffect(() => {
async (e: React.MouseEvent) => { setDynamicSourceNames(source_names);
e.preventDefault(); }, [source_names]);
e.stopPropagation();
if (from === 'douban' || !actualSource || !actualId) return;
try { useEffect(() => {
// 确定当前收藏状态 setDynamicDoubanId(douban_id);
const currentFavorited = from === 'search' ? searchFavorited : favorited; }, [douban_id]);
if (currentFavorited) { useEffect(() => {
// 如果已收藏,删除收藏 setDisplayPoster(processedPoster);
await deleteFavorite(actualSource, actualId); }, [processedPoster]);
if (from === 'search') {
setSearchFavorited(false); const bangumiImageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
} else { null
setFavorited(false); );
}
} else { useEffect(() => {
// 如果未收藏,添加收藏 if (bangumiImageTimeoutRef.current) {
await saveFavorite(actualSource, actualId, { clearTimeout(bangumiImageTimeoutRef.current);
title: actualTitle, bangumiImageTimeoutRef.current = null;
source_name: source_name || '',
year: actualYear || '',
cover: actualPoster,
total_episodes: actualEpisodes ?? 1,
save_time: Date.now(),
});
if (from === 'search') {
setSearchFavorited(true);
} else {
setFavorited(true);
}
}
} catch (err) {
throw new Error('切换收藏状态失败');
} }
},
[ 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, () => ({
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
setDoubanId: (id?: number) => setDynamicDoubanId(id),
}));
const actualSource = source;
const actualId = id;
const actualDoubanId = dynamicDoubanId;
const actualEpisodes = dynamicEpisodes;
const actualYear = year;
const actualQuery = query || '';
const actualSearchType = type;
const isDirectPlaySource = actualSource === 'directplay';
const directLinkUrl = useMemo(() => {
if (!isDirectPlaySource || !actualId) return '';
try {
return base58Decode(actualId);
} catch {
return '';
}
}, [isDirectPlaySource, actualId]);
const displayYear = useMemo(() => {
if (!actualYear) return '';
const normalized = actualYear.trim();
if (!normalized || normalized === 'unknown') return '';
const digits = normalized.replace(/\D/g, '');
if (!digits) return normalized;
return digits.slice(-2).padStart(2, '0');
}, [actualYear]);
// 获取收藏状态(搜索结果页面不检查)
useEffect(() => {
if (from === 'douban' || from === 'search' || !actualSource || !actualId)
return;
const fetchFavoriteStatus = async () => {
try {
const fav = await isFavorited(actualSource, actualId);
setFavorited(fav);
} catch (err) {
throw new Error('检查收藏状态失败');
}
};
fetchFavoriteStatus();
// 监听收藏状态更新事件
const storageKey = generateStorageKey(actualSource, actualId);
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
// 检查当前项目是否在新的收藏列表中
const isNowFavorited = !!newFavorites[storageKey];
setFavorited(isNowFavorited);
}
);
return unsubscribe;
}, [from, actualSource, actualId]);
const handleToggleFavorite = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from === 'douban' || !actualSource || !actualId) return;
try {
// 确定当前收藏状态
const currentFavorited =
from === 'search' ? searchFavorited : favorited;
if (currentFavorited) {
// 如果已收藏,删除收藏
await deleteFavorite(actualSource, actualId);
if (from === 'search') {
setSearchFavorited(false);
} else {
setFavorited(false);
}
} else {
// 如果未收藏,添加收藏
await saveFavorite(actualSource, actualId, {
title: actualTitle,
source_name: source_name || '',
year: actualYear || '',
cover: actualPoster,
total_episodes: actualEpisodes ?? 1,
save_time: Date.now(),
});
if (from === 'search') {
setSearchFavorited(true);
} else {
setFavorited(true);
}
}
} catch (err) {
throw new Error('切换收藏状态失败');
}
},
[
from,
actualSource,
actualId,
actualTitle,
source_name,
actualYear,
actualPoster,
actualEpisodes,
favorited,
searchFavorited,
]
);
const handleDeleteRecord = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from !== 'playrecord' || !actualSource || !actualId) return;
try {
await deletePlayRecord(actualSource, actualId);
onDelete?.();
} catch (err) {
throw new Error('删除播放记录失败');
}
},
[from, actualSource, actualId, onDelete]
);
const handleClick = useCallback(() => {
// 即将上映的电影:单击显示上映倒计时提示,不跳转
if (isUpcoming) {
setShowUpcomingInfo(true);
// 2秒后自动隐藏
setTimeout(() => {
setShowUpcomingInfo(false);
}, 2000);
return;
}
onBeforeNavigate?.();
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace(
'live_',
''
)}&id=${actualId.replace('live_', '')}`;
router.push(url);
} else if (
from === 'douban' ||
from === 'tmdb' ||
(isAggregate && !actualSource && !actualId)
) {
// 检测当前是否在 play 页面
const isCurrentlyOnPlayPage =
typeof window !== 'undefined' && window.location.pathname === '/play';
let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${
actualYear ? `&year=${actualYear}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}`;
if (isCurrentlyOnPlayPage) {
// 在 play 页面内,添加 _reload 参数强制刷新
url += `&_reload=${Date.now()}`;
window.location.href = url;
} else {
// 不在 play 页面,正常跳转
router.push(url);
}
} else if (actualSource && actualId) {
// 检测当前是否在 play 页面
const isCurrentlyOnPlayPage =
typeof window !== 'undefined' && window.location.pathname === '/play';
let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
if (isCurrentlyOnPlayPage) {
// 在 play 页面内,添加 _reload 参数强制刷新
url += `&_reload=${Date.now()}`;
window.location.href = url;
} else {
// 不在 play 页面,正常跳转
router.push(url);
}
}
}, [
isUpcoming,
origin,
from,
actualSource,
actualId,
router,
actualTitle,
actualYear,
isAggregate,
actualQuery,
actualSearchType,
onBeforeNavigate,
]);
// 新标签页播放处理函数
const handlePlayInNewTab = useCallback(() => {
// 即将上映的电影不跳转
if (isUpcoming) {
return;
}
onBeforeNavigate?.();
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace(
'live_',
''
)}&id=${actualId.replace('live_', '')}`;
window.open(url, '_blank');
} else if (
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');
} else if (actualSource && actualId) {
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
window.open(url, '_blank');
}
}, [
isUpcoming,
origin,
from, from,
actualSource, actualSource,
actualId, actualId,
actualTitle, actualTitle,
source_name,
actualYear, actualYear,
actualPoster, isAggregate,
actualEpisodes, actualQuery,
favorited, actualSearchType,
onBeforeNavigate,
]);
// 检查搜索结果的收藏状态
const checkSearchFavoriteStatus = useCallback(async () => {
if (
from === 'search' &&
!isAggregate &&
actualSource &&
actualId &&
searchFavorited === null
) {
try {
const fav = await isFavorited(actualSource, actualId);
setSearchFavorited(fav);
} catch (err) {
setSearchFavorited(false);
}
}
}, [from, isAggregate, actualSource, actualId, searchFavorited]);
// 长按操作
const handleLongPress = useCallback(() => {
if (!showMobileActions) {
// 防止重复触发
// 立即显示菜单,避免等待数据加载导致动画卡顿
setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示
if (
from === 'search' &&
!isAggregate &&
actualSource &&
actualId &&
searchFavorited === null
) {
checkSearchFavoriteStatus();
}
}
}, [
showMobileActions,
from,
isAggregate,
actualSource,
actualId,
searchFavorited, searchFavorited,
] checkSearchFavoriteStatus,
); ]);
const handleDeleteRecord = useCallback( // 长按手势hook
async (e: React.MouseEvent) => { const longPressProps = useLongPress({
e.preventDefault(); onLongPress: handleLongPress,
e.stopPropagation(); onClick: handleClick, // 保持点击播放功能
if (from !== 'playrecord' || !actualSource || !actualId) return; longPressDelay: 500,
try { });
await deletePlayRecord(actualSource, actualId);
onDelete?.(); // 计算距离上映的天数(使用本地时区)
} catch (err) { const daysUntilRelease = useMemo(() => {
throw new Error('删除播放记录失败'); if (!isUpcoming || !releaseDate) return null;
// 获取今天的本地日期(午夜)
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(
today.getMonth() + 1
).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// 将日期字符串解析为本地时区的日期对象
// 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题
const [releaseYear, releaseMonth, releaseDay] = releaseDate
.split('-')
.map(Number);
const release = new Date(releaseYear, releaseMonth - 1, releaseDay);
const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number);
const todayDate = new Date(todayYear, todayMonth - 1, todayDay);
const diffTime = release.getTime() - todayDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}, [isUpcoming, releaseDate]);
const config = useMemo(() => {
const configs = {
playrecord: {
showSourceName: true,
showProgress: true,
showPlayButton: true,
showHeart: true,
showCheckCircle: true,
showDoubanLink: false,
showRating: false,
showYear: false,
},
favorite: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: true,
showCheckCircle: false,
showDoubanLink: false,
showRating: false,
showYear: false,
},
search: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: true, // 移动端菜单中需要显示收藏选项
showCheckCircle: false,
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
showRating: !!rate,
showYear: true,
},
douban: {
showSourceName: false,
showProgress: false,
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
showHeart: false,
showCheckCircle: false,
showDoubanLink: false,
showRating: !!rate,
showYear: false,
},
tmdb: {
showSourceName: false,
showProgress: false,
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
showHeart: false,
showCheckCircle: false,
showDoubanLink: false,
showRating: !!rate,
showYear: false,
},
'source-search': {
showSourceName: false,
showProgress: false,
showPlayButton: true,
showHeart: true,
showCheckCircle: false,
showDoubanLink: true,
showRating: !!rate,
showYear: true,
},
};
return configs[from] || configs.search;
}, [from, isAggregate, douban_id, rate, isUpcoming]);
// 移动端操作菜单配置
const mobileActions = useMemo(() => {
const actions = [];
// 播放操作
if (config.showPlayButton) {
actions.push({
id: 'play',
label: origin === 'live' ? '观看直播' : '播放',
icon: <PlayCircleIcon size={20} />,
onClick: handleClick,
color: 'primary' as const,
});
// 新标签页播放
actions.push({
id: 'play-new-tab',
label: origin === 'live' ? '新标签页观看' : '新标签页播放',
icon: <ExternalLink size={20} />,
onClick: handlePlayInNewTab,
color: 'default' as const,
});
} }
},
[from, actualSource, actualId, onDelete]
);
const handleClick = useCallback(() => { // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
// 即将上映的电影:单击显示上映倒计时提示,不跳转
if (isUpcoming) {
setShowUpcomingInfo(true);
// 2秒后自动隐藏
setTimeout(() => {
setShowUpcomingInfo(false);
}, 2000);
return;
}
onBeforeNavigate?.(); // 收藏/取消收藏操作
if (
config.showHeart &&
from !== 'douban' &&
from !== 'tmdb' &&
actualSource &&
actualId
) {
const currentFavorited =
from === 'search' ? searchFavorited : favorited;
if (origin === 'live' && actualSource && actualId) { if (from === 'search') {
// 直播内容跳转到直播页面 // 搜索结果:根据加载状态显示不同的选项
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; if (searchFavorited !== null) {
router.push(url); // 已加载完成,显示实际的收藏状态
} else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) { actions.push({
// 检测当前是否在 play 页面 id: 'favorite',
const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play'; label: currentFavorited ? '取消收藏' : '添加收藏',
icon: currentFavorited ? (
let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' <Heart size={20} className='fill-red-600 stroke-red-600' />
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; ) : (
<Heart size={20} className='fill-transparent stroke-red-500' />
if (isCurrentlyOnPlayPage) { ),
// 在 play 页面内,添加 _reload 参数强制刷新 onClick: () => {
url += `&_reload=${Date.now()}`; const mockEvent = {
window.location.href = url; preventDefault: () => {},
} else { stopPropagation: () => {},
// 不在 play 页面,正常跳转 } as React.MouseEvent;
router.push(url); handleToggleFavorite(mockEvent);
} },
} else if (actualSource && actualId) { color: currentFavorited
// 检测当前是否在 play 页面 ? ('danger' as const)
const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play'; : ('default' as const),
});
let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( } else {
actualTitle // 正在加载中,显示占位项
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' actions.push({
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' id: 'favorite-loading',
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`; label: '收藏加载中...',
icon: <Heart size={20} />,
if (isCurrentlyOnPlayPage) { onClick: () => {}, // 加载中时不响应点击
// 在 play 页面内,添加 _reload 参数强制刷新 disabled: true,
url += `&_reload=${Date.now()}`; });
window.location.href = url; }
} else { } else {
// 不在 play 页面,正常跳转 // 非搜索结果:直接显示收藏选项
router.push(url);
}
}
}, [
isUpcoming,
origin,
from,
actualSource,
actualId,
router,
actualTitle,
actualYear,
isAggregate,
actualQuery,
actualSearchType,
onBeforeNavigate,
]);
// 新标签页播放处理函数
const handlePlayInNewTab = useCallback(() => {
// 即将上映的电影不跳转
if (isUpcoming) {
return;
}
onBeforeNavigate?.();
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
window.open(url, '_blank');
} else if (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');
} else if (actualSource && actualId) {
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : ''
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
window.open(url, '_blank');
}
}, [
isUpcoming,
origin,
from,
actualSource,
actualId,
actualTitle,
actualYear,
isAggregate,
actualQuery,
actualSearchType,
onBeforeNavigate,
]);
// 检查搜索结果的收藏状态
const checkSearchFavoriteStatus = useCallback(async () => {
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
try {
const fav = await isFavorited(actualSource, actualId);
setSearchFavorited(fav);
} catch (err) {
setSearchFavorited(false);
}
}
}, [from, isAggregate, actualSource, actualId, searchFavorited]);
// 长按操作
const handleLongPress = useCallback(() => {
if (!showMobileActions) { // 防止重复触发
// 立即显示菜单,避免等待数据加载导致动画卡顿
setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
checkSearchFavoriteStatus();
}
}
}, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
// 长按手势hook
const longPressProps = useLongPress({
onLongPress: handleLongPress,
onClick: handleClick, // 保持点击播放功能
longPressDelay: 500,
});
// 计算距离上映的天数(使用本地时区)
const daysUntilRelease = useMemo(() => {
if (!isUpcoming || !releaseDate) return null;
// 获取今天的本地日期(午夜)
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// 将日期字符串解析为本地时区的日期对象
// 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题
const [releaseYear, releaseMonth, releaseDay] = releaseDate.split('-').map(Number);
const release = new Date(releaseYear, releaseMonth - 1, releaseDay);
const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number);
const todayDate = new Date(todayYear, todayMonth - 1, todayDay);
const diffTime = release.getTime() - todayDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}, [isUpcoming, releaseDate]);
const config = useMemo(() => {
const configs = {
playrecord: {
showSourceName: true,
showProgress: true,
showPlayButton: true,
showHeart: true,
showCheckCircle: true,
showDoubanLink: false,
showRating: false,
showYear: false,
},
favorite: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: true,
showCheckCircle: false,
showDoubanLink: false,
showRating: false,
showYear: false,
},
search: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: true, // 移动端菜单中需要显示收藏选项
showCheckCircle: false,
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
showRating: !!rate,
showYear: true,
},
douban: {
showSourceName: false,
showProgress: false,
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
showHeart: false,
showCheckCircle: false,
showDoubanLink: false,
showRating: !!rate,
showYear: false,
},
tmdb: {
showSourceName: false,
showProgress: false,
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
showHeart: false,
showCheckCircle: false,
showDoubanLink: false,
showRating: !!rate,
showYear: false,
},
'source-search': {
showSourceName: false,
showProgress: false,
showPlayButton: true,
showHeart: true,
showCheckCircle: false,
showDoubanLink: true,
showRating: !!rate,
showYear: true,
},
};
return configs[from] || configs.search;
}, [from, isAggregate, douban_id, rate, isUpcoming]);
// 移动端操作菜单配置
const mobileActions = useMemo(() => {
const actions = [];
// 播放操作
if (config.showPlayButton) {
actions.push({
id: 'play',
label: origin === 'live' ? '观看直播' : '播放',
icon: <PlayCircleIcon size={20} />,
onClick: handleClick,
color: 'primary' as const,
});
// 新标签页播放
actions.push({
id: 'play-new-tab',
label: origin === 'live' ? '新标签页观看' : '新标签页播放',
icon: <ExternalLink size={20} />,
onClick: handlePlayInNewTab,
color: 'default' as const,
});
}
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
// 收藏/取消收藏操作
if (config.showHeart && from !== 'douban' && from !== 'tmdb' && actualSource && actualId) {
const currentFavorited = from === 'search' ? searchFavorited : favorited;
if (from === 'search') {
// 搜索结果:根据加载状态显示不同的选项
if (searchFavorited !== null) {
// 已加载完成,显示实际的收藏状态
actions.push({ actions.push({
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 = {
preventDefault: () => { }, preventDefault: () => {},
stopPropagation: () => { }, stopPropagation: () => {},
} as React.MouseEvent; } as React.MouseEvent;
handleToggleFavorite(mockEvent); handleToggleFavorite(mockEvent);
}, },
color: currentFavorited ? ('danger' as const) : ('default' as const), color: currentFavorited
}); ? ('danger' as const)
} else { : ('default' as const),
// 正在加载中,显示占位项
actions.push({
id: 'favorite-loading',
label: '收藏加载中...',
icon: <Heart size={20} />,
onClick: () => { }, // 加载中时不响应点击
disabled: true,
}); });
} }
} else { }
// 非搜索结果:直接显示收藏选项
// 删除播放记录操作
if (
config.showCheckCircle &&
from === 'playrecord' &&
actualSource &&
actualId
) {
actions.push({ actions.push({
id: 'favorite', id: 'delete',
label: currentFavorited ? '取消收藏' : '添加收藏', label: '删除记录',
icon: currentFavorited ? ( icon: <Trash2 size={20} />,
<Heart size={20} className="fill-red-600 stroke-red-600" />
) : (
<Heart size={20} className="fill-transparent stroke-red-500" />
),
onClick: () => { onClick: () => {
const mockEvent = { const mockEvent = {
preventDefault: () => { }, preventDefault: () => {},
stopPropagation: () => { }, stopPropagation: () => {},
} as React.MouseEvent; } as React.MouseEvent;
handleToggleFavorite(mockEvent); handleDeleteRecord(mockEvent);
}, },
color: currentFavorited ? ('danger' as const) : ('default' as const), color: 'danger' as const,
}); });
} }
}
// 删除播放记录操作 // 豆瓣链接操作
if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) { if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) {
actions.push({ actions.push({
id: 'delete', id: 'douban',
label: '删除记录', label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
icon: <Trash2 size={20} />, icon: <Link size={20} />,
onClick: () => { onClick: () => {
const mockEvent = { const url = isBangumi
preventDefault: () => { }, ? `https://bgm.tv/subject/${actualDoubanId.toString()}`
stopPropagation: () => { }, : `https://movie.douban.com/subject/${actualDoubanId.toString()}`;
} as React.MouseEvent; window.open(url, '_blank', 'noopener,noreferrer');
handleDeleteRecord(mockEvent); },
}, color: 'default' as const,
color: 'danger' as const, });
}); }
}
// 豆瓣链接操作 // 详情页面按钮(直播源不显示详情)
if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) { if (origin !== 'live') {
actions.push({ actions.push({
id: 'douban', id: 'detail',
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情', label: '详情',
icon: <Link size={20} />, icon: <Info size={20} />,
onClick: () => { onClick: () => {
const url = isBangumi setShowMobileActions(false);
? `https://bgm.tv/subject/${actualDoubanId.toString()}` // 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`; setTimeout(() => {
window.open(url, '_blank', 'noopener,noreferrer'); setShowDetailPanel(true);
}, }, 250);
color: 'default' as const, },
}); color: 'default' as const,
} });
}
// 详情页面按钮(直播源不显示详情) // AI问片功能
if (origin !== 'live') { if (aiEnabled && actualTitle) {
actions.push({ actions.push({
id: 'detail', id: 'ai-chat',
label: '详情', label: 'AI问片',
icon: <Info size={20} />, icon: <Sparkles size={20} />,
onClick: () => { onClick: () => {
setShowMobileActions(false); setShowMobileActions(false);
// 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成 // 延迟打开 AIChatPanel,确保 MobileActionSheet 完全清理完成
setTimeout(() => { setTimeout(() => {
setShowDetailPanel(true); setShowAIChat(true);
}, 250); }, 250);
}, },
color: 'default' as const, color: 'default' as const,
}); });
} }
// AI问片功能 return actions;
if (aiEnabled && actualTitle) { }, [
actions.push({ config,
id: 'ai-chat', from,
label: 'AI问片', actualSource,
icon: <Sparkles size={20} />, actualId,
onClick: () => { favorited,
setShowMobileActions(false); searchFavorited,
// 延迟打开 AIChatPanel,确保 MobileActionSheet 完全清理完成 actualDoubanId,
setTimeout(() => { isBangumi,
setShowAIChat(true); isAggregate,
}, 250); dynamicSourceNames,
}, handleClick,
color: 'default' as const, handleToggleFavorite,
}); handleDeleteRecord,
} handlePlayInNewTab,
aiEnabled,
actualTitle,
]);
return actions; return (
}, [ <>
config,
from,
actualSource,
actualId,
favorited,
searchFavorited,
actualDoubanId,
isBangumi,
isAggregate,
dynamicSourceNames,
handleClick,
handleToggleFavorite,
handleDeleteRecord,
handlePlayInNewTab,
aiEnabled,
actualTitle,
]);
return (
<>
<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'} ${
showUpcomingInfo ? 'scale-[1.05] z-[500]' : ''
}`}
onClick={handleClick}
{...longPressProps}
style={{
// 禁用所有默认的长按和选择效果
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
// 禁用右键菜单和长按菜单
pointerEvents: 'auto',
} as React.CSSProperties}
onContextMenu={(e) => {
// 阻止默认右键菜单
e.preventDefault();
e.stopPropagation();
// 右键弹出操作菜单
setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
checkSearchFavoriteStatus();
}
return false;
}}
onDragStart={(e) => {
// 阻止拖拽
e.preventDefault();
return false;
}}
>
{/* 海报容器 */}
<div <div
className={`relative overflow-hidden rounded-lg ${origin === 'live' ? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80' : ''} ${ className={`group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500] ${
orientation === 'horizontal' isUpcoming ? 'cursor-default' : 'cursor-pointer'
? 'aspect-[3/2]' } ${showUpcomingInfo ? 'scale-[1.05] z-[500]' : ''}`}
: 'aspect-[2/3]' onClick={handleClick}
}`} {...longPressProps}
style={{ style={
WebkitUserSelect: 'none', {
userSelect: 'none', // 禁用所有默认的长按和选择效果
WebkitTouchCallout: 'none', WebkitUserSelect: 'none',
} as React.CSSProperties} userSelect: 'none',
WebkitTouchCallout: 'none',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
// 禁用右键菜单和长按菜单
pointerEvents: 'auto',
} as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
// 阻止默认右键菜单
e.preventDefault();
e.stopPropagation();
// 右键弹出操作菜单
setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示
if (
from === 'search' &&
!isAggregate &&
actualSource &&
actualId &&
searchFavorited === null
) {
checkSearchFavoriteStatus();
}
return false;
}}
onDragStart={(e) => {
// 阻止拖拽
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
{/* 骨架屏 */} {/* 海报容器 */}
{!isLoading && !isDirectPlaySource && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />} <div
{isDirectPlaySource ? ( className={`relative overflow-hidden rounded-lg ${
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'> origin === 'live'
<Link className='w-8 h-8 text-blue-500' /> ? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80'
</div> : ''
) : (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'> orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'
<Cloud className='w-10 h-10 opacity-80' /> }`}
</div> style={
) : ( {
<Image
src={displayPoster}
alt={actualTitle}
fill
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
onClick={(e) => {
e.stopPropagation();
setShowImageViewer(true);
}}
onError={(e) => {
const img = e.currentTarget as HTMLImageElement;
const fallbackPoster = getDoubanImageFallbackUrl(actualPoster);
if (fallbackPoster && tryApplyDoubanImageFallback(img, actualPoster)) {
setDisplayPoster(fallbackPoster);
return;
}
// 图片加载失败时的重试机制
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
setDisplayPoster(processedPoster);
img.src = processedPoster;
}, 2000);
}
}}
style={{
// 禁用图片的默认长按效果
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
pointerEvents: 'auto', // 改为auto以响应点击事件 } as React.CSSProperties
cursor: 'pointer', // 添加指针样式 }
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
onDragStart={(e) => {
e.preventDefault();
return false;
}}
/>
)}
{/* 悬浮遮罩 */}
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
/> >
{/* 骨架屏 */}
{/* 播放按钮或上映倒计时 */} {!isLoading && !isDirectPlaySource && (
{isUpcoming && daysUntilRelease !== null ? ( <ImagePlaceholder
<div aspectRatio={
data-button="true" orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'
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' />
}`} )}
style={{ {isDirectPlaySource ? (
WebkitUserSelect: 'none', <div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
userSelect: 'none', <Link className='w-8 h-8 text-blue-500' />
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
>
{daysUntilRelease > 0
? `${daysUntilRelease}天后上映`
: daysUntilRelease === 0
? '今日上映'
: '已上映'}
</div> </div>
</div> ) : isNetdiskSource(actualSource) &&
) : config.showPlayButton && ( !actualPoster &&
<div displayPoster === netdiskPosterPlaceholder ? (
data-button="true" <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'>
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' <Cloud className='w-10 h-10 opacity-80' />
style={{ </div>
WebkitUserSelect: 'none', ) : (
userSelect: 'none', <Image
WebkitTouchCallout: 'none', src={displayPoster}
} as React.CSSProperties} alt={actualTitle}
onContextMenu={(e) => { fill
e.preventDefault(); className={
return false; origin === 'live'
}} ? 'object-contain'
> : orientation === 'horizontal'
<PlayCircleIcon ? 'object-cover object-center'
size={50} : 'object-cover'
strokeWidth={0.8} }
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]' referrerPolicy='no-referrer'
style={{ loading='lazy'
WebkitUserSelect: 'none', onLoadingComplete={() => {
userSelect: 'none', setIsLoading(true);
WebkitTouchCallout: 'none', clearBangumiImageTimeout();
} as React.CSSProperties} }}
onClick={(e) => {
e.stopPropagation();
setShowImageViewer(true);
}}
onError={(e) => {
const img = e.currentTarget as HTMLImageElement;
const doubanFallbackPoster =
getDoubanImageFallbackUrl(actualPoster);
if (
doubanFallbackPoster &&
tryApplyDoubanImageFallback(img, actualPoster)
) {
clearBangumiImageTimeout();
setDisplayPoster(doubanFallbackPoster);
return;
}
const bangumiFallbackPoster =
getBangumiImageFallbackUrl(actualPoster);
if (
bangumiFallbackPoster &&
tryApplyBangumiImageFallback(img, actualPoster)
) {
clearBangumiImageTimeout();
setDisplayPoster(bangumiFallbackPoster);
return;
}
// 图片加载失败时的重试机制
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
setDisplayPoster(processedPoster);
img.src = processedPoster;
}, 2000);
}
}}
style={
{
// 禁用图片的默认长按效果
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
pointerEvents: 'auto', // 改为auto以响应点击事件
cursor: 'pointer', // 添加指针样式
} as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
onDragStart={(e) => {
e.preventDefault();
return false;
}}
/> />
</div> )}
)}
{/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */} {/* 悬浮遮罩 */}
{(config.showHeart || config.showCheckCircle) && from !== 'playrecord' && (
<div <div
data-button="true" 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 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',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{config.showCheckCircle && (
<Trash2
onClick={handleDeleteRecord}
size={20}
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
)}
{config.showHeart && from !== 'search' && (
<Heart
onClick={handleToggleFavorite}
size={20}
className={`transition-all duration-300 ease-out ${favorited
? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400'
} hover:scale-[1.1]`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
)}
</div>
)}
{/* 季度徽章 */}
{seasonNumber && (
<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"
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={seasonName || `${seasonNumber}`}
>
S{seasonNumber}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{rate}
</div>
)}
{/* 竖向模式:顶部直链地址显示 */}
{orientation === 'vertical' && isDirectPlaySource && directLinkUrl && (
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='text-[9px] sm:text-[10px] text-yellow-400 line-clamp-2 break-all'
style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={directLinkUrl}
>
{directLinkUrl}
</div>
</div>
)}
{actualEpisodes && actualEpisodes > 1 && orientation === 'vertical' && (
<div
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{/* 集数显示 */}
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{currentEpisode !== undefined && currentEpisode !== null
? `${currentEpisode}/${actualEpisodes}`
: `${actualEpisodes}`}
</div>
{/* 年份显示 */}
{displayYear && (
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{displayYear}
</div>
)}
</div>
)}
{/* 竖向模式:来源名称显示在海报右下角 */}
{orientation === 'vertical' && config.showSourceName && source_name && !cmsData && (
<div
className='absolute bottom-1 right-1 sm:bottom-2 sm:right-2'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<span
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'
}`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{origin === 'live' && (
<Radio size={8} className="inline-block text-white/90 mr-0.5" />
)}
{source_name}
</span>
</div>
)}
{/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
<a
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
} }
target='_blank'
rel='noopener noreferrer'
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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> />
{/* 播放按钮或上映倒计时 */}
{isUpcoming && daysUntilRelease !== null ? (
<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' data-button='true'
style={{ className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
WebkitUserSelect: 'none', showUpcomingInfo
userSelect: 'none', ? 'opacity-100 scale-100'
WebkitTouchCallout: 'none', : 'opacity-0 scale-75'
} as React.CSSProperties} }`}
onContextMenu={(e) => { style={
e.preventDefault(); {
return false;
}}
>
<Link
size={16}
style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
pointerEvents: 'none', } as React.CSSProperties
} as React.CSSProperties} }
/>
</div>
</a>
)}
{/* 聚合播放源指示器 */}
{isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => {
const uniqueSources = Array.from(new Set(dynamicSourceNames));
const sourceCount = uniqueSources.length;
return (
<div
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'
}`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
<div <div
className='relative group/sources' 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', {
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
>
<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'
style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
>
{daysUntilRelease > 0
? `${daysUntilRelease}天后上映`
: daysUntilRelease === 0
? '今日上映'
: '已上映'}
</div>
</div>
) : (
config.showPlayButton && (
<div
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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<PlayCircleIcon
size={50}
strokeWidth={0.8}
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> />
{sourceCount} </div>
</div> )
)}
{/* 播放源详情悬浮框 */} {/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */}
{(() => { {(config.showHeart || config.showCheckCircle) &&
// 优先显示的播放源(常见的主流平台) from !== 'playrecord' && (
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+']; <div
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'
const sortedSources = uniqueSources.sort((a, b) => { style={
const aIndex = prioritySources.indexOf(a); {
const bIndex = prioritySources.indexOf(b); WebkitUserSelect: 'none',
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; userSelect: 'none',
if (aIndex !== -1) return -1; WebkitTouchCallout: 'none',
if (bIndex !== -1) return 1; } as React.CSSProperties
return a.localeCompare(b); }
}); onContextMenu={(e) => {
e.preventDefault();
const maxDisplayCount = 6; // 最多显示6个 return false;
const displaySources = sortedSources.slice(0, maxDisplayCount); }}
const hasMore = sortedSources.length > maxDisplayCount; >
const remainingCount = sortedSources.length - maxDisplayCount; {config.showCheckCircle && (
<Trash2
return ( onClick={handleDeleteRecord}
<div size={20}
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='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) => { }
e.preventDefault(); onContextMenu={(e) => {
return false; e.preventDefault();
}} return false;
> }}
<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' )}
style={{ {config.showHeart && from !== 'search' && (
WebkitUserSelect: 'none', <Heart
userSelect: 'none', onClick={handleToggleFavorite}
WebkitTouchCallout: 'none', size={20}
} as React.CSSProperties} className={`transition-all duration-300 ease-out ${
onContextMenu={(e) => { favorited
e.preventDefault(); ? 'fill-red-600 stroke-red-600'
return false; : 'fill-transparent stroke-white hover:stroke-red-400'
}} } hover:scale-[1.1]`}
> style={
{/* 单列布局 */} {
<div className='space-y-0.5 sm:space-y-1'> WebkitUserSelect: 'none',
{displaySources.map((sourceName, index) => ( userSelect: 'none',
<div key={index} className='flex items-center gap-1 sm:gap-1.5'> WebkitTouchCallout: 'none',
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div> } as React.CSSProperties
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}> }
{sourceName} onContextMenu={(e) => {
</span> e.preventDefault();
</div> return false;
))} }}
</div> />
)}
{/* 显示更多提示 */}
{hasMore && (
<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'>
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} </span>
</div>
</div>
)}
{/* 小箭头 */}
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div>
</div>
</div>
);
})()}
</div> </div>
</div> )}
);
})()}
{/* 横向模式:标题和进度条在海报上 */} {/* 季度徽章 */}
{orientation === 'horizontal' && ( {seasonNumber && (
<>
{/* 顶部渐变遮罩 - 用于标题背景 */}
<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-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', {
userSelect: 'none', WebkitUserSelect: 'none',
WebkitTouchCallout: 'none', userSelect: 'none',
} as React.CSSProperties} WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={seasonName || `${seasonNumber}`}
>
S{seasonNumber}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
{/* 标题 */} {rate}
</div>
)}
{/* 竖向模式:顶部直链地址显示 */}
{orientation === 'vertical' &&
isDirectPlaySource &&
directLinkUrl && (
<div <div
className='mb-1' 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', {
userSelect: 'none', WebkitUserSelect: 'none',
WebkitTouchCallout: 'none', userSelect: 'none',
} as React.CSSProperties} WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
> >
<span
className='block text-sm font-bold truncate text-white'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={actualTitle}
>
{actualTitle}
</span>
</div>
{/* 集数信息 - 只有超过1集时才显示 */}
{currentEpisode && actualEpisodes && actualEpisodes > 1 && (
<div <div
className='text-xs text-white/90' className='text-[9px] sm:text-[10px] text-yellow-400 line-clamp-2 break-all'
style={{ style={
WebkitUserSelect: 'none', {
userSelect: 'none', WebkitUserSelect: 'none',
WebkitTouchCallout: 'none', userSelect: 'none',
} as React.CSSProperties} WebkitTouchCallout: 'none',
onContextMenu={(e) => { } as React.CSSProperties
e.preventDefault(); }
return false;
}}
>
{currentEpisode} · {actualEpisodes}
</div>
)}
{/* 直链地址 */}
{isDirectPlaySource && directLinkUrl && (
<div
className='text-[10px] text-white/75 truncate'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@@ -1358,305 +1239,783 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
> >
{directLinkUrl} {directLinkUrl}
</div> </div>
)} </div>
</div> )}
{/* 底部渐变遮罩 - 用于进度条背景 */} {actualEpisodes &&
actualEpisodes > 1 &&
orientation === 'vertical' && (
<div
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{/* 集数显示 */}
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{currentEpisode !== undefined && currentEpisode !== null
? `${currentEpisode}/${actualEpisodes}`
: `${actualEpisodes}`}
</div>
{/* 年份显示 */}
{displayYear && (
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{displayYear}
</div>
)}
</div>
)}
{/* 竖向模式:来源名称显示在海报右下角 */}
{orientation === 'vertical' &&
config.showSourceName &&
source_name &&
!cmsData && (
<div
className='absolute bottom-1 right-1 sm:bottom-2 sm:right-2'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<span
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'
}`}
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{origin === 'live' && (
<Radio
size={8}
className='inline-block text-white/90 mr-0.5'
/>
)}
{source_name}
</span>
</div>
)}
{/* 豆瓣链接 */}
{config.showDoubanLink &&
actualDoubanId &&
actualDoubanId !== 0 && (
<a
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
}
target='_blank'
rel='noopener noreferrer'
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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<Link
size={16}
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
pointerEvents: 'none',
} as React.CSSProperties
}
/>
</div>
</a>
)}
{/* 聚合播放源指示器 */}
{isAggregate &&
dynamicSourceNames &&
dynamicSourceNames.length > 0 &&
(() => {
const uniqueSources = Array.from(new Set(dynamicSourceNames));
const sourceCount = uniqueSources.length;
return (
<div
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'
}`}
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='relative group/sources'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
>
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{sourceCount}
</div>
{/* 播放源详情悬浮框 */}
{(() => {
// 优先显示的播放源(常见的主流平台)
const prioritySources = [
'爱奇艺',
'腾讯视频',
'优酷',
'芒果TV',
'哔哩哔哩',
'Netflix',
'Disney+',
];
// 按优先级排序播放源
const sortedSources = uniqueSources.sort((a, b) => {
const aIndex = prioritySources.indexOf(a);
const bIndex = prioritySources.indexOf(b);
if (aIndex !== -1 && bIndex !== -1)
return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
const maxDisplayCount = 6; // 最多显示6个
const displaySources = sortedSources.slice(
0,
maxDisplayCount
);
const hasMore = sortedSources.length > maxDisplayCount;
const remainingCount =
sortedSources.length - maxDisplayCount;
return (
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{/* 单列布局 */}
<div className='space-y-0.5 sm:space-y-1'>
{displaySources.map((sourceName, index) => (
<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>
<span
className='truncate text-[10px] sm:text-xs leading-tight'
title={sourceName}
>
{sourceName}
</span>
</div>
))}
</div>
{/* 显示更多提示 */}
{hasMore && (
<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'>
<span className='text-[10px] sm:text-xs font-medium'>
+{remainingCount}
</span>
</div>
</div>
)}
{/* 小箭头 */}
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div>
</div>
</div>
);
})()}
</div>
</div>
);
})()}
{/* 横向模式:标题和进度条在海报上 */}
{orientation === 'horizontal' && (
<>
{/* 顶部渐变遮罩 - 用于标题背景 */}
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{/* 标题 */}
<div
className='mb-1'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
>
<span
className='block text-sm font-bold truncate text-white'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={actualTitle}
>
{actualTitle}
</span>
</div>
{/* 集数信息 - 只有超过1集时才显示 */}
{currentEpisode && actualEpisodes && actualEpisodes > 1 && (
<div
className='text-xs text-white/90'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{currentEpisode} · {actualEpisodes}
</div>
)}
{/* 直链地址 */}
{isDirectPlaySource && directLinkUrl && (
<div
className='text-[10px] text-white/75 truncate'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={directLinkUrl}
>
{directLinkUrl}
</div>
)}
</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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{/* 进度条 */}
{config.showProgress &&
progress !== undefined &&
origin !== 'live' && (
<div
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
>
{/* 来源和时长显示 - 在进度条上方 */}
<div className='flex items-center justify-between mb-1'>
{/* 时长显示 - 左侧 */}
{from === 'playrecord' &&
playTime !== undefined &&
totalTime !== undefined && (
<div
className='text-[10px] text-white/80'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{(() => {
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
// 0分钟时不显示分钟
if (mins === 0) {
return `${secs}`;
}
return `${mins}${secs}`;
};
return formatTime(playTime);
})()}
</div>
)}
{/* 来源 - 右侧 */}
{config.showSourceName && source_name && !cmsData && (
<span
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'
}`}
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{source_name}
</span>
)}
</div>
<div
className='h-1 w-full bg-white/20 rounded-full overflow-hidden'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='h-full bg-white transition-all duration-500 ease-out'
style={
{
width: `${progress}%`,
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
</div>
</div>
)}
{/* 直播时只显示来源 */}
{origin === 'live' &&
config.showSourceName &&
source_name &&
!cmsData && (
<div className='flex items-center justify-end'>
<span
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'
}`}
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<Radio
size={8}
className='inline-block text-white/90 mr-0.5'
/>
{source_name}
</span>
</div>
)}
</div>
</>
)}
</div>
{/* 竖向模式:进度条和标题在海报下方 */}
{orientation === 'vertical' && (
<>
{/* 进度条 */}
{config.showProgress && progress !== undefined && (
<div
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='h-full bg-green-500 transition-all duration-500 ease-out'
style={
{
width: `${progress}%`,
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
</div>
)}
{/* 标题 */}
<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='mt-2 text-center'
style={{ style={
WebkitUserSelect: 'none', {
userSelect: 'none', WebkitUserSelect: 'none',
WebkitTouchCallout: 'none', userSelect: 'none',
} as React.CSSProperties} WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
{/* 进度条 */} <div
{config.showProgress && progress !== undefined && origin !== 'live' && ( className='relative'
<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'> <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'
{from === 'playrecord' && playTime !== undefined && totalTime !== undefined && ( style={
<div {
className='text-[10px] text-white/80'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{(() => {
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
// 0分钟时不显示分钟
if (mins === 0) {
return `${secs}`;
}
return `${mins}${secs}`;
};
return formatTime(playTime);
})()}
</div>
)}
{/* 来源 - 右侧 */}
{config.showSourceName && source_name && !cmsData && (
<span
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'
}`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{source_name}
</span>
)}
</div>
<div
className='h-1 w-full bg-white/20 rounded-full overflow-hidden'
style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
onContextMenu={(e) => { }
e.preventDefault(); onContextMenu={(e) => {
return false; e.preventDefault();
}} return false;
> }}
<div >
className='h-full bg-white transition-all duration-500 ease-out' {actualTitle}
style={{ </span>
width: `${progress}%`, {/* 自定义 tooltip */}
<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'
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{actualTitle}
<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'
style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
onContextMenu={(e) => { }
e.preventDefault(); ></div>
return false;
}}
/>
</div>
</div> </div>
)} </div>
{/* 直播时只显示来源 */}
{origin === 'live' && config.showSourceName && source_name && !cmsData && (
<div className='flex items-center justify-end'>
<span
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'
}`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<Radio size={8} className="inline-block text-white/90 mr-0.5" />
{source_name}
</span>
</div>
)}
</div> </div>
</> </>
)} )}
</div> </div>
{/* 竖向模式:进度条和标题在海报下方 */} {/* 操作菜单 - 支持右键和长按触发 */}
{orientation === 'vertical' && ( <MobileActionSheet
<> isOpen={showMobileActions}
{/* 进度条 */} onClose={() => setShowMobileActions(false)}
{config.showProgress && progress !== undefined && (
<div
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{
width: `${progress}%`,
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
</div>
)}
{/* 标题 */}
<div
className='mt-2 text-center'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='relative'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
>
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{actualTitle}
</span>
{/* 自定义 tooltip */}
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{actualTitle}
<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'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
></div>
</div>
</div>
</div>
</>
)}
</div>
{/* 操作菜单 - 支持右键和长按触发 */}
<MobileActionSheet
isOpen={showMobileActions}
onClose={() => setShowMobileActions(false)}
title={actualTitle}
poster={displayPoster}
actions={mobileActions}
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined}
isAggregate={isAggregate}
sourceName={cmsData ? undefined : source_name}
directLinkUrl={directLinkUrl || undefined}
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
origin={origin}
onPosterClick={() => {
setShowImageViewer(true);
}}
/>
{/* AI问片面板 - 只在打开或正在流式响应时渲染 */}
{aiEnabled && (showAIChat || isAIStreaming) && (
<AIChatPanel
isOpen={showAIChat}
onClose={() => setShowAIChat(false)}
onStreamingChange={setIsAIStreaming}
context={{
title: actualTitle,
year: actualYear,
douban_id: actualDoubanId,
tmdb_id,
type: actualSearchType as 'movie' | 'tv',
currentEpisode,
}}
welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', actualTitle || '') : `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
/>
)}
{/* 详情面板 */}
{showDetailPanel && (
<DetailPanel
isOpen={showDetailPanel}
onClose={() => setShowDetailPanel(false)}
title={actualTitle} title={actualTitle}
poster={displayPoster} poster={displayPoster}
doubanId={actualDoubanId} actions={mobileActions}
bangumiId={isBangumi ? actualDoubanId : undefined} sources={
isBangumi={isBangumi} isAggregate && dynamicSourceNames
tmdbId={tmdb_id} ? Array.from(new Set(dynamicSourceNames))
type={actualSearchType as 'movie' | 'tv'} : undefined
seasonNumber={seasonNumber} }
isAggregate={isAggregate}
sourceName={cmsData ? undefined : source_name}
directLinkUrl={directLinkUrl || undefined}
currentEpisode={currentEpisode} currentEpisode={currentEpisode}
cmsData={cmsData} totalEpisodes={actualEpisodes}
sourceId={id} origin={origin}
source={source} onPosterClick={() => {
setShowImageViewer(true);
}}
/> />
)}
{/* 图片查看器 */} {/* AI问片面板 - 只在打开或正在流式响应时渲染 */}
{showImageViewer && ( {aiEnabled && (showAIChat || isAIStreaming) && (
<ImageViewer <AIChatPanel
isOpen={showImageViewer} isOpen={showAIChat}
onClose={() => setShowImageViewer(false)} onClose={() => setShowAIChat(false)}
imageUrl={actualPoster} onStreamingChange={setIsAIStreaming}
alt={actualTitle} context={{
/> title: actualTitle,
)} year: actualYear,
</> douban_id: actualDoubanId,
); tmdb_id,
} type: actualSearchType as 'movie' | 'tv',
currentEpisode,
}}
welcomeMessage={
aiDefaultMessageWithVideo
? aiDefaultMessageWithVideo.replace(
'{title}',
actualTitle || ''
)
: `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`
}
/>
)}
{/* 详情面板 */}
{showDetailPanel && (
<DetailPanel
isOpen={showDetailPanel}
onClose={() => setShowDetailPanel(false)}
title={actualTitle}
poster={displayPoster}
doubanId={actualDoubanId}
bangumiId={isBangumi ? actualDoubanId : undefined}
isBangumi={isBangumi}
tmdbId={tmdb_id}
type={actualSearchType as 'movie' | 'tv'}
seasonNumber={seasonNumber}
currentEpisode={currentEpisode}
cmsData={cmsData}
sourceId={id}
source={source}
/>
)}
{/* 图片查看器 */}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
onClose={() => setShowImageViewer(false)}
imageUrl={actualPoster}
alt={actualTitle}
/>
)}
</>
);
}
); );
export default memo(VideoCard); export default memo(VideoCard);
+6 -1
View File
@@ -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配置
@@ -101,7 +106,7 @@ export interface AdminConfig {
LiveConfig?: { LiveConfig?: {
key: string; key: string;
name: string; name: string;
url: string; // m3u 地址 url: string; // m3u 地址
ua?: string; ua?: string;
epg?: string; // 节目单 epg?: string; // 节目单
from: 'config' | 'custom'; from: 'config' | 'custom';
+151 -3
View File
@@ -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))}`
);
} }
+57
View File
@@ -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>;
}
+165 -96
View File
@@ -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(
URL: string; configFile: string,
AutoUpdate: boolean; subConfig: {
LastCheck: string; URL: string;
} = { AutoUpdate: boolean;
URL: "", LastCheck: string;
} = {
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,19 +404,20 @@ 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.ServerURL && adminConfig.EmbyConfig &&
!adminConfig.EmbyConfig.Sources; adminConfig.EmbyConfig.ServerURL &&
!adminConfig.EmbyConfig.Sources;
adminConfig = configSelfCheck(adminConfig); adminConfig = configSelfCheck(adminConfig);
cachedConfig = adminConfig; cachedConfig = adminConfig;
@@ -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!, {
role: 'owner', username: ownerUser!,
banned: false, role: 'owner',
}]; 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(
if (seenCustomCategoryKeys.has(category.query + category.type)) { (category) => {
return false; if (seenCustomCategoryKeys.has(category.query + category.type)) {
return false;
}
seenCustomCategoryKeys.add(category.query + category.type);
return true;
} }
seenCustomCategoryKeys.add(category.query + category.type); );
return true;
});
// 直播源去重 // 直播源去重
const seenLiveKeys = new Set<string>(); const seenLiveKeys = new Set<string>();
@@ -627,42 +660,52 @@ 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', {
name: 'Emby', key: 'default',
enabled: oldConfig.Enabled ?? false, name: 'Emby',
ServerURL: oldConfig.ServerURL || '', enabled: oldConfig.Enabled ?? false,
ApiKey: oldConfig.ApiKey, ServerURL: oldConfig.ServerURL || '',
Username: oldConfig.Username, ApiKey: oldConfig.ApiKey,
Password: oldConfig.Password, Username: oldConfig.Username,
UserId: oldConfig.UserId, Password: oldConfig.Password,
AuthToken: oldConfig.AuthToken, UserId: oldConfig.UserId,
Libraries: oldConfig.Libraries, AuthToken: oldConfig.AuthToken,
LastSyncTime: oldConfig.LastSyncTime, Libraries: oldConfig.Libraries,
ItemCount: oldConfig.ItemCount, LastSyncTime: oldConfig.LastSyncTime,
isDefault: true, ItemCount: oldConfig.ItemCount,
}], 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(
if (seenEmbyKeys.has(source.key)) { (source) => {
return false; if (seenEmbyKeys.has(source.key)) {
return false;
}
seenEmbyKeys.add(source.key);
return true;
} }
seenEmbyKeys.add(source.key); );
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', {
name: process.env.OPDS_NAME || '默认书源', id: 'default',
type: 'opds', name: process.env.OPDS_NAME || '默认书源',
url: envUrl, type: 'opds',
enabled: true, url: envUrl,
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none', enabled: true,
username: process.env.OPDS_USERNAME || '', authMode:
password: process.env.OPDS_PASSWORD || '', (process.env.OPDS_AUTH_MODE as
headerName: process.env.OPDS_HEADER_NAME || '', | 'none'
headerValue: process.env.OPDS_HEADER_VALUE || '', | 'basic'
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '', | 'header'
}]; | undefined) || 'none',
username: process.env.OPDS_USERNAME || '',
password: process.env.OPDS_PASSWORD || '',
headerName: process.env.OPDS_HEADER_NAME || '',
headerValue: process.env.OPDS_HEADER_VALUE || '',
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,13 +986,15 @@ 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
key: s.key, .filter((s) => userApiSitesSet.has(s.key))
name: s.name, .map((s) => ({
api: s.api, key: s.key,
detail: s.detail, name: s.name,
proxyMode: s.proxyMode, api: s.api,
})); detail: s.detail,
proxyMode: s.proxyMode,
}));
} }
// 如果没有 enabledApis 配置,则根据 tags 查找 // 如果没有 enabledApis 配置,则根据 tags 查找
@@ -937,21 +1002,25 @@ 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
key: s.key, .filter((s) => enabledApisFromTags.has(s.key))
name: s.name, .map((s) => ({
api: s.api, key: s.key,
detail: s.detail, name: s.name,
proxyMode: s.proxyMode, api: s.api,
})); detail: s.detail,
proxyMode: s.proxyMode,
}));
} }
} }
+250 -27
View File
@@ -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;
@@ -291,22 +498,23 @@ export async function getVideoResolutionFromM3u8(
width >= 3840 width >= 3840
? '4K' ? '4K'
: width >= 2560 : width >= 2560
? '2K' ? '2K'
: width >= 1920 : width >= 1920
? '1080p' ? '1080p'
: width >= 1280 : width >= 1280
? '720p' ? '720p'
: width >= 854 : width >= 854
? '480p' ? '480p'
: width > 0 : width > 0
? 'SD' ? 'SD'
: '未知'; : '未知';
const bitrateStr = estimatedBitrate > 0 const bitrateStr =
? estimatedBitrate >= 1000000 estimatedBitrate > 0
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` ? estimatedBitrate >= 1000000
: `${Math.round(estimatedBitrate / 1000)} Kbps` ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: '未知'; : `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
hls.destroy(); hls.destroy();
video.remove(); video.remove();
@@ -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)
}` }`
); );
} }