Files
MoonTVPlus/src/components/UserMenu.tsx
T
2026-06-01 02:05:05 +08:00

4944 lines
210 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable no-console,@typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
'use client';
import {
Bell,
Check,
ChevronDown,
ChevronUp,
Copy,
Download,
ExternalLink,
Eye,
EyeOff,
Gauge,
Globe,
Home,
LogOut,
MessageSquare,
Monitor,
MoveDown,
MoveUp,
Package,
Router as RouterIcon,
Rss,
Settings,
Shield,
Sliders,
Smartphone,
Star,
Tablet,
User,
X,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { clearAllDanmakuCache, getDanmakuCacheStats } from '@/lib/danmaku/api';
import { clearBangumiImageFallbackCache } from '@/lib/utils';
import { CURRENT_VERSION } from '@/lib/version';
import { UpdateStatus } from '@/lib/version_check';
import { DeviceManagementPanel } from './DeviceManagementPanel';
import { DownloadManagementPanel } from './DownloadManagementPanel';
import { EmailSettingsPanel } from './EmailSettingsPanel';
import { FavoritesPanel } from './FavoritesPanel';
import { NotificationPanel } from './NotificationPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
import { PersonalCenterPanel } from './PersonalCenterPanel';
import TVRemotePanel from './tv/TVRemotePanel';
import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
interface AuthInfo {
username?: string;
role?: 'owner' | 'admin' | 'user';
}
export const UserMenu: React.FC = () => {
const router = useRouter();
const { updateStatus, isChecking } = useVersionCheck();
const [isOpen, setIsOpen] = useState(false);
const [isProfileCenterOpen, setIsProfileCenterOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] =
useState(false);
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
const [isReportOpen, setIsReportOpen] = useState(false);
const [isDownloadManagementOpen, setIsDownloadManagementOpen] =
useState(false);
const [isTVRemoteOpen, setIsTVRemoteOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [displayStorageType, setDisplayStorageType] =
useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
// 订阅相关状态
const [subscribeEnabled, setSubscribeEnabled] = useState(false);
const [tvModeEnabled, setTvModeEnabled] = useState(true);
const [subscribeUrl, setSubscribeUrl] = useState('');
const [copySuccess, setCopySuccess] = useState(false);
const [orionBaseUrlCopySuccess, setOrionBaseUrlCopySuccess] = useState(false);
const [tvboxToken, setTvboxToken] = useState('');
const [isResettingToken, setIsResettingToken] = useState(false);
const [isLoadingSubscribeUrl, setIsLoadingSubscribeUrl] = useState(false);
const [subscribeAdFilterEnabled, setSubscribeAdFilterEnabled] =
useState(false);
const [subscribeYellowFilterEnabled, setSubscribeYellowFilterEnabled] =
useState(false);
// Web 电视扫码登录入口(手机摄像头扫描电视端二维码)
const [isTvQrScannerOpen, setIsTvQrScannerOpen] = useState(false);
const [tvQrScannerStatus, setTvQrScannerStatus] = useState('');
const [tvQrScannerError, setTvQrScannerError] = useState('');
const tvQrVideoRef = useRef<HTMLVideoElement | null>(null);
const tvQrStreamRef = useRef<MediaStream | null>(null);
const tvQrScanStopRef = useRef(false);
const [tvAccessTab, setTvAccessTab] = useState<'tvbox' | 'orion' | 'web'>('tvbox');
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (
isProfileCenterOpen ||
isSettingsOpen ||
isChangePasswordOpen ||
isSubscribeOpen ||
isOfflineDownloadPanelOpen ||
isEmailSettingsOpen ||
isDeviceManagementOpen ||
isEcoAppsOpen ||
isReportOpen ||
isDownloadManagementOpen ||
isTvQrScannerOpen ||
isTVRemoteOpen
) {
const body = document.body;
const html = document.documentElement;
// 保存原始样式
const originalBodyOverflow = body.style.overflow;
const originalHtmlOverflow = html.style.overflow;
// 只设置 overflow 来阻止滚动
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
return () => {
// 恢复所有原始样式
body.style.overflow = originalBodyOverflow;
html.style.overflow = originalHtmlOverflow;
};
}
}, [
isProfileCenterOpen,
isSettingsOpen,
isChangePasswordOpen,
isSubscribeOpen,
isOfflineDownloadPanelOpen,
isEmailSettingsOpen,
isDeviceManagementOpen,
isEcoAppsOpen,
isReportOpen,
isDownloadManagementOpen,
isTvQrScannerOpen,
isTVRemoteOpen,
]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
const [preferStrategy, setPreferStrategy] = useState<'fast' | 'full'>('fast');
const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒)
const [fluidSearch, setFluidSearch] = useState(true);
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [enableTrailers, setEnableTrailers] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState(
'cmliussss-cdn-tencent'
);
const [doubanDataSourceBackup, setDoubanDataSourceBackup] =
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(
'cmliussss-cdn-tencent'
);
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] =
useState('server');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] =
useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] =
useState(false);
const [isAnimeDropdownOpen, setIsAnimeDropdownOpen] = useState(false);
const [isAnimeBackupDropdownOpen, setIsAnimeBackupDropdownOpen] =
useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
const [
isDoubanImageProxyBackupDropdownOpen,
setIsDoubanImageProxyBackupDropdownOpen,
] = useState(false);
const [bufferStrategy, setBufferStrategy] = useState('medium');
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] =
useState(true);
const [disableAutoLoadDanmaku, setDisableAutoLoadDanmaku] = useState(false);
const [danmakuMaxCount, setDanmakuMaxCount] = useState(0);
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] =
useState(false);
const [exactSearch, setExactSearch] = useState(true);
const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6);
const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6);
const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>(
'browser'
);
const [filesystemSavePath, setFilesystemSavePath] = useState<string>('');
// 邮件通知设置
const [userEmail, setUserEmail] = useState('');
const [emailNotifications, setEmailNotifications] = useState(false);
const [emailSettingsLoading, setEmailSettingsLoading] = useState(false);
const [emailSettingsSaving, setEmailSettingsSaving] = useState(false);
const [emailSettingsMessage, setEmailSettingsMessage] = useState('');
const [emailSettingsMessageType, setEmailSettingsMessageType] = useState<
'success' | 'error' | null
>(null);
// 设备管理状态
const [devices, setDevices] = useState<any[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [revoking, setRevoking] = useState<string | null>(null);
// 确认对话框状态
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => undefined,
});
// 折叠面板状态
const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(false);
// TMDB 图片设置
const [tmdbImageBaseUrl, setTmdbImageBaseUrl] = useState(
'https://image.tmdb.org'
);
const [isUsageSectionOpen, setIsUsageSectionOpen] = useState(false);
const [isDownloadSectionOpen, setIsDownloadSectionOpen] = useState(false);
const [isBufferSectionOpen, setIsBufferSectionOpen] = useState(false);
const [isDanmakuSectionOpen, setIsDanmakuSectionOpen] = useState(false);
const [isHomepageSectionOpen, setIsHomepageSectionOpen] = useState(false);
// 首页模块配置
interface HomeModule {
id: string;
name: string;
enabled: boolean;
order: number;
}
type HomeBannerHeightScale = '1' | '1.5' | '2';
const defaultHomeModules: HomeModule[] = [
{ id: 'hotMovies', name: '热门电影', enabled: true, order: 0 },
{ id: 'hotDuanju', name: '热播短剧', enabled: true, order: 1 },
{ id: 'bangumiCalendar', name: '新番放送', enabled: true, order: 2 },
{ id: 'hotTvShows', name: '热门剧集', enabled: true, order: 3 },
{ id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 },
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
];
const [homeModules, setHomeModules] =
useState<HomeModule[]>(defaultHomeModules);
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
const [homeBannerHeightScale, setHomeBannerHeightScale] =
useState<HomeBannerHeightScale>('1');
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] =
useState(true);
const homeBannerHeightOptions: {
value: HomeBannerHeightScale;
label: string;
description: string;
}[] = [
{ value: '1', label: '标准', description: '1x' },
{ value: '1.5', label: '增高', description: '1.5x' },
{ value: '2', label: '特高', description: '2x' },
];
// 豆瓣数据源选项
const doubanDataSourceOptions = [
{ value: 'direct', label: '直连(服务器直接请求豆瓣)' },
{ value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss(腾讯云)',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'custom', label: '自定义代理' },
];
const animeDataSourceOptions = [
{ value: 'direct', label: '直连(浏览器直连 Bangumi' },
{ value: 'server-proxy', label: '服务器代理(由服务器访问 Bangumi)' },
{ value: 'custom-baseurl', label: '自定义 Base URL' },
];
// 豆瓣图片代理选项
const doubanImageProxyTypeOptions = [
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss(腾讯云)',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'baidu', label: '百度图片代理' },
{ value: 'custom', label: '自定义代理' },
{
value: 'direct',
label: '直连(浏览器直接请求豆瓣,可能需要浏览器插件才能正常显示)',
},
{
value: 'img3',
label: '豆瓣官方精品 CDN(阿里云,可能需要浏览器插件才能正常显示)',
},
];
// 缓冲策略选项
const bufferStrategyOptions = [
{ value: 'low', label: '低缓冲(省流量)' },
{ value: 'medium', label: '中缓冲(推荐)' },
{ value: 'high', label: '高缓冲(流畅播放)' },
{ value: 'ultra', label: '超高缓冲(极速体验)' },
];
// 修改密码相关状态
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState('');
// 清除弹幕缓存相关状态
const [isClearingCache, setIsClearingCache] = useState(false);
const [clearCacheMessage, setClearCacheMessage] = useState<string | null>(
null
);
const [danmakuCacheUsage, setDanmakuCacheUsage] = useState('计算中...');
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 加载未读通知数量
const loadUnreadCount = async () => {
try {
const response = await fetch('/api/notifications');
if (response.ok) {
const data = await response.json();
const count = data.unreadCount || 0;
setUnreadCount(count);
// 同步到全局,让其他 UserMenu 实例也能获取
if (typeof window !== 'undefined') {
(window as any).__unreadNotificationCount = count;
}
}
} catch (error) {
console.error('加载未读通知数量失败:', error);
}
};
const formatCacheSize = useCallback((size: number) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}, []);
const loadDanmakuCacheUsage = useCallback(async () => {
try {
const stats = await getDanmakuCacheStats();
setDanmakuCacheUsage(formatCacheSize(stats.totalSize));
} catch (error) {
console.error('获取弹幕缓存占用失败:', error);
setDanmakuCacheUsage('获取失败');
}
}, [formatCacheSize]);
// 首次加载时检查未读通知数量(使用全局标记避免多个实例重复请求)
useEffect(() => {
if (typeof window === 'undefined') return;
// 检查是否已经有其他实例在加载
const globalWindow = window as any;
if (globalWindow.__loadingNotifications) {
// 如果正在加载,等待加载完成后获取结果
const checkInterval = setInterval(() => {
if (
!globalWindow.__loadingNotifications &&
globalWindow.__unreadNotificationCount !== undefined
) {
setUnreadCount(globalWindow.__unreadNotificationCount);
clearInterval(checkInterval);
}
}, 100);
return () => clearInterval(checkInterval);
}
// 检查是否已经加载过
if (globalWindow.__unreadNotificationCount !== undefined) {
setUnreadCount(globalWindow.__unreadNotificationCount);
return;
}
// 标记正在加载
globalWindow.__loadingNotifications = true;
loadUnreadCount().finally(() => {
globalWindow.__loadingNotifications = false;
});
}, []);
useEffect(() => {
if (!mounted || !isSettingsOpen || !isDanmakuSectionOpen) return;
void (async () => {
await loadDanmakuCacheUsage();
})();
}, [loadDanmakuCacheUsage, mounted, isSettingsOpen, isDanmakuSectionOpen]);
// 监听通知更新事件
useEffect(() => {
const handleNotificationsUpdated = () => {
// 清除缓存,强制重新加载
if (typeof window !== 'undefined') {
delete (window as any).__unreadNotificationCount;
}
loadUnreadCount();
};
window.addEventListener('notificationsUpdated', handleNotificationsUpdated);
return () => {
window.removeEventListener(
'notificationsUpdated',
handleNotificationsUpdated
);
};
}, []);
// 从运行时配置读取订阅是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
const enabled =
(window as any).RUNTIME_CONFIG?.ENABLE_TVBOX_SUBSCRIBE || false;
setSubscribeEnabled(enabled);
setTvModeEnabled((window as any).RUNTIME_CONFIG?.ENABLE_TV_MODE !== false);
}
}, []);
// 懒加载订阅 URL - 只在打开订阅面板时请求
const fetchSubscribeUrl = async () => {
setIsLoadingSubscribeUrl(true);
try {
// 获取用户的 TVBox token
const response = await fetch('/api/user/tvbox-token');
if (response.ok) {
const data = await response.json();
const token = data.token;
setTvboxToken(token);
setSubscribeUrl(
buildSubscribeUrl(
token,
subscribeAdFilterEnabled,
subscribeYellowFilterEnabled
)
);
}
} catch (error) {
console.error('获取订阅URL失败:', error);
} finally {
setIsLoadingSubscribeUrl(false);
}
};
// 重置 TVBox token
const handleResetToken = async () => {
setConfirmDialog({
isOpen: true,
title: '重置订阅Token',
message: '确定要重置订阅token吗?重置后旧的订阅链接将失效。',
onConfirm: async () => {
setConfirmDialog({ ...confirmDialog, isOpen: false });
setIsResettingToken(true);
try {
const response = await fetch('/api/user/tvbox-token/reset', {
method: 'POST',
});
const messageEl = document.getElementById('tvbox-token-message');
if (response.ok) {
const data = await response.json();
const token = data.token;
setTvboxToken(token);
setSubscribeUrl(
buildSubscribeUrl(
token,
subscribeAdFilterEnabled,
subscribeYellowFilterEnabled
)
);
if (messageEl) {
messageEl.textContent = '订阅token已重置!';
messageEl.className =
'text-xs text-center text-green-600 dark:text-green-400 mt-2';
messageEl.classList.remove('hidden');
setTimeout(() => {
messageEl.classList.add('hidden');
}, 3000);
}
} else {
const data = await response.json();
if (messageEl) {
messageEl.textContent = data.error || '重置失败,请重试';
messageEl.className =
'text-xs text-center text-red-600 dark:text-red-400 mt-2';
messageEl.classList.remove('hidden');
}
}
} catch (error) {
console.error('重置token失败:', error);
const messageEl = document.getElementById('tvbox-token-message');
if (messageEl) {
messageEl.textContent = '重置失败,请重试';
messageEl.className =
'text-xs text-center text-red-600 dark:text-red-400 mt-2';
messageEl.classList.remove('hidden');
}
} finally {
setIsResettingToken(false);
}
},
});
};
const buildSubscribeUrl = (
token: string,
adFilter: boolean,
yellowFilter: boolean
) => {
const currentOrigin = window.location.origin;
const url = new URL('/api/tvbox/subscribe', currentOrigin);
url.searchParams.set('token', token);
if (adFilter) {
url.searchParams.set('adFilter', 'true');
}
if (yellowFilter) {
url.searchParams.set('yellowFilter', 'true');
}
return url.toString();
};
// 获取认证信息和存储类型
useEffect(() => {
if (typeof window !== 'undefined') {
const auth = getAuthInfoFromBrowserCookie();
setAuthInfo(auth);
const runtimeConfig = (window as any).RUNTIME_CONFIG || {};
const type = runtimeConfig.STORAGE_TYPE || 'localstorage';
const displayType = runtimeConfig.DISPLAY_STORAGE_TYPE || type;
setStorageType(type);
setDisplayStorageType(displayType);
}
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanDataSource = localStorage.getItem('doubanDataSource');
const defaultDoubanProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
'cmliussss-cdn-tencent';
if (savedDoubanDataSource !== null) {
setDoubanDataSource(savedDoubanDataSource);
} else if (defaultDoubanProxyType) {
setDoubanDataSource(defaultDoubanProxyType);
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
const defaultDoubanProxy =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
} else if (defaultDoubanProxy) {
setDoubanProxyUrl(defaultDoubanProxy);
}
const savedDoubanDataSourceBackup = localStorage.getItem(
'doubanDataSourceBackup'
);
setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
const savedDoubanProxyUrlBackup = localStorage.getItem(
'doubanProxyUrlBackup'
);
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(
'doubanImageProxyType'
);
const defaultDoubanImageProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
'cmliussss-cdn-tencent';
if (savedDoubanImageProxyType !== null) {
setDoubanImageProxyType(savedDoubanImageProxyType);
} else if (defaultDoubanImageProxyType) {
setDoubanImageProxyType(defaultDoubanImageProxyType);
}
const savedDoubanImageProxyUrl = localStorage.getItem(
'doubanImageProxyUrl'
);
const defaultDoubanImageProxyUrl =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
if (savedDoubanImageProxyUrl !== null) {
setDoubanImageProxyUrl(savedDoubanImageProxyUrl);
} else if (defaultDoubanImageProxyUrl) {
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
}
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
'doubanImageProxyTypeBackup'
);
setDoubanImageProxyTypeBackup(
savedDoubanImageProxyTypeBackup || 'server'
);
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
'doubanImageProxyUrlBackup'
);
setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
if (savedTmdbImageBaseUrl !== null) {
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
}
const savedEnableOptimization =
localStorage.getItem('enableOptimization');
if (savedEnableOptimization !== null) {
setEnableOptimization(JSON.parse(savedEnableOptimization));
}
const savedPreferStrategy = localStorage.getItem('preferStrategy');
if (savedPreferStrategy === 'fast' || savedPreferStrategy === 'full') {
setPreferStrategy(savedPreferStrategy);
}
const savedSpeedTestTimeout = localStorage.getItem('speedTestTimeout');
if (savedSpeedTestTimeout !== null) {
setSpeedTestTimeout(Number(savedSpeedTestTimeout));
}
const savedFluidSearch = localStorage.getItem('fluidSearch');
const defaultFluidSearch =
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
if (savedFluidSearch !== null) {
setFluidSearch(JSON.parse(savedFluidSearch));
} else if (defaultFluidSearch !== undefined) {
setFluidSearch(defaultFluidSearch);
}
const savedTmdbBackdropDisabled = localStorage.getItem(
'tmdb_backdrop_disabled'
);
if (savedTmdbBackdropDisabled !== null) {
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
}
const savedEnableTrailers = localStorage.getItem('enableTrailers');
if (savedEnableTrailers !== null) {
setEnableTrailers(savedEnableTrailers === 'true');
}
const savedBufferStrategy = localStorage.getItem('bufferStrategy');
if (savedBufferStrategy !== null) {
setBufferStrategy(savedBufferStrategy);
}
const savedNextEpisodePreCache = localStorage.getItem(
'nextEpisodePreCache'
);
if (savedNextEpisodePreCache !== null) {
setNextEpisodePreCache(savedNextEpisodePreCache === 'true');
}
const savedNextEpisodeDanmakuPreload = localStorage.getItem(
'nextEpisodeDanmakuPreload'
);
if (savedNextEpisodeDanmakuPreload !== null) {
setNextEpisodeDanmakuPreload(savedNextEpisodeDanmakuPreload === 'true');
}
const savedDisableAutoLoadDanmaku = localStorage.getItem(
'disableAutoLoadDanmaku'
);
if (savedDisableAutoLoadDanmaku !== null) {
setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true');
} else {
const runtimeDefault =
(window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT !== false;
setDisableAutoLoadDanmaku(!runtimeDefault);
}
const savedDanmakuMaxCount = localStorage.getItem('danmakuMaxCount');
if (savedDanmakuMaxCount !== null) {
setDanmakuMaxCount(parseInt(savedDanmakuMaxCount, 10));
}
const savedDanmakuHeatmapDisabled = localStorage.getItem(
'danmaku_heatmap_disabled'
);
if (savedDanmakuHeatmapDisabled !== null) {
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
}
const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled');
if (savedHomeBannerEnabled !== null) {
setHomeBannerEnabled(savedHomeBannerEnabled === 'true');
}
const savedHomeBannerHeightScale = localStorage.getItem(
'homeBannerHeightScale'
);
if (
savedHomeBannerHeightScale === '1' ||
savedHomeBannerHeightScale === '1.5' ||
savedHomeBannerHeightScale === '2'
) {
setHomeBannerHeightScale(savedHomeBannerHeightScale);
}
const savedHomeContinueWatchingEnabled = localStorage.getItem(
'homeContinueWatchingEnabled'
);
if (savedHomeContinueWatchingEnabled !== null) {
setHomeContinueWatchingEnabled(
savedHomeContinueWatchingEnabled === 'true'
);
}
// 加载首页模块配置
const savedHomeModules = localStorage.getItem('homeModules');
if (savedHomeModules !== null) {
try {
setHomeModules(JSON.parse(savedHomeModules));
} catch (error) {
console.error('解析首页模块配置失败:', error);
}
}
// 加载搜索繁体转简体设置
const savedSearchTraditionalToSimplified = localStorage.getItem(
'searchTraditionalToSimplified'
);
if (savedSearchTraditionalToSimplified !== null) {
setSearchTraditionalToSimplified(
savedSearchTraditionalToSimplified === 'true'
);
}
// 加载精确搜索设置
const savedExactSearch = localStorage.getItem('exactSearch');
if (savedExactSearch !== null) {
setExactSearch(savedExactSearch === 'true');
}
// 加载最大同时下载限制设置
const savedMaxConcurrentDownloads = localStorage.getItem(
'maxConcurrentDownloads'
);
if (savedMaxConcurrentDownloads !== null) {
setMaxConcurrentDownloads(Number(savedMaxConcurrentDownloads));
}
// 加载单任务线程数设置
const savedDownloadThreadsPerTask = localStorage.getItem(
'downloadThreadsPerTask'
);
if (savedDownloadThreadsPerTask !== null) {
setDownloadThreadsPerTask(Number(savedDownloadThreadsPerTask));
}
// 加载下载模式设置
const savedDownloadMode = localStorage.getItem('downloadMode');
if (
savedDownloadMode === 'browser' ||
savedDownloadMode === 'filesystem'
) {
setDownloadMode(savedDownloadMode);
}
// 加载保存路径设置
const savedFilesystemSavePath =
localStorage.getItem('filesystemSavePath');
if (savedFilesystemSavePath !== null) {
setFilesystemSavePath(savedFilesystemSavePath);
}
}
}, []);
// 加载邮件通知设置
const loadEmailSettings = async () => {
setEmailSettingsLoading(true);
setEmailSettingsMessage('');
setEmailSettingsMessageType(null);
try {
const response = await fetch('/api/user/email-settings');
if (response.ok) {
const data = await response.json();
setUserEmail(data.email || '');
setEmailNotifications(data.emailNotifications || false);
}
} catch (error) {
console.error('加载邮件设置失败:', error);
} finally {
setEmailSettingsLoading(false);
}
};
// 保存邮件通知设置
const handleSaveEmailSettings = async () => {
setEmailSettingsSaving(true);
setEmailSettingsMessage('');
setEmailSettingsMessageType(null);
try {
const response = await fetch('/api/user/email-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
emailNotifications,
}),
});
if (response.ok) {
setEmailSettingsMessage('保存成功!');
setEmailSettingsMessageType('success');
setTimeout(() => {
setEmailSettingsMessage('');
setEmailSettingsMessageType(null);
}, 3000);
} else {
const data = await response.json();
setEmailSettingsMessage(data.error || '保存失败');
setEmailSettingsMessageType('error');
}
} catch (error) {
console.error('保存邮件设置失败:', error);
setEmailSettingsMessage('保存失败,请重试');
setEmailSettingsMessageType('error');
} finally {
setEmailSettingsSaving(false);
}
};
// 加载设备列表
const loadDevices = async () => {
setDevicesLoading(true);
try {
const response = await fetch('/api/auth/devices');
if (response.ok) {
const data = await response.json();
setDevices(data.devices || []);
}
} catch (error) {
console.error('加载设备列表失败:', error);
} finally {
setDevicesLoading(false);
}
};
// 撤销单个设备
const handleRevokeDevice = async (tokenId: string) => {
setConfirmDialog({
isOpen: true,
title: '撤销设备登录',
message: '确定要撤销该设备的登录吗?',
onConfirm: async () => {
setConfirmDialog({ ...confirmDialog, isOpen: false });
setRevoking(tokenId);
try {
const response = await fetch('/api/auth/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tokenId }),
});
if (response.ok) {
// 重新加载设备列表
await loadDevices();
} else {
alert('撤销失败,请重试');
}
} catch (error) {
console.error('撤销设备失败:', error);
alert('撤销失败,请重试');
} finally {
setRevoking(null);
}
},
});
};
// 撤销所有设备
const handleRevokeAllDevices = async () => {
setConfirmDialog({
isOpen: true,
title: '登出所有设备',
message:
'确定要登出所有设备吗?这将清除所有设备的登录状态(包括当前设备)。',
onConfirm: async () => {
setConfirmDialog({ ...confirmDialog, isOpen: false });
try {
const response = await fetch('/api/auth/devices', {
method: 'POST',
});
if (response.ok) {
// 登出所有设备后,重定向到首页
window.location.href = '/';
} else {
alert('操作失败,请重试');
}
} catch (error) {
console.error('登出所有设备失败:', error);
alert('操作失败,请重试');
}
},
});
};
// 根据设备类型返回对应的图标
const getDeviceIcon = (deviceInfo: string) => {
const info = deviceInfo.toLowerCase();
if (
info.includes('mobile') ||
info.includes('iphone') ||
info.includes('android')
) {
return Smartphone;
}
if (info.includes('tablet') || info.includes('ipad')) {
return Tablet;
}
return Monitor;
};
// 点击外部区域关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource"]')) {
setIsDoubanDropdownOpen(false);
}
}
};
if (isDoubanDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanBackupDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource-backup"]')) {
setIsDoubanBackupDropdownOpen(false);
}
}
};
if (isDoubanBackupDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanBackupDropdownOpen]);
useEffect(() => {
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(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy"]')) {
setIsDoubanImageProxyDropdownOpen(false);
}
}
};
if (isDoubanImageProxyDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyBackupDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy-backup"]')) {
setIsDoubanImageProxyBackupDropdownOpen(false);
}
}
};
if (isDoubanImageProxyBackupDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyBackupDropdownOpen]);
const handleMenuClick = () => {
setIsOpen(!isOpen);
};
const handleCloseMenu = () => {
setIsOpen(false);
};
const handleLogout = async () => {
try {
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注销请求失败:', error);
}
window.location.href = '/';
};
const handleAdminPanel = () => {
router.push('/admin');
};
const handleChangePassword = () => {
setIsOpen(false);
setIsChangePasswordOpen(true);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const handleCloseChangePassword = () => {
setIsChangePasswordOpen(false);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const stopTvQrScanner = useCallback(() => {
tvQrScanStopRef.current = true;
if (tvQrStreamRef.current) {
tvQrStreamRef.current.getTracks().forEach((track) => track.stop());
tvQrStreamRef.current = null;
}
if (tvQrVideoRef.current) {
tvQrVideoRef.current.srcObject = null;
}
}, []);
const closeTvQrScanner = useCallback(() => {
stopTvQrScanner();
setIsTvQrScannerOpen(false);
setTvQrScannerStatus('');
setTvQrScannerError('');
}, [stopTvQrScanner]);
const handleQrLoginResult = useCallback((rawValue: string) => {
try {
const url = new URL(rawValue, window.location.origin);
if (url.origin !== window.location.origin || url.pathname !== '/qr-login') {
setTvQrScannerError('未识别到本站电视登录二维码,请扫描电视屏幕上的二维码。');
return false;
}
const token = url.searchParams.get('token');
if (!token) {
setTvQrScannerError('二维码缺少登录凭证,请刷新电视端二维码后重试。');
return false;
}
setTvQrScannerStatus('识别成功,正在打开确认登录页...');
stopTvQrScanner();
window.location.href = `/qr-login?token=${encodeURIComponent(token)}`;
return true;
} catch {
setTvQrScannerError('二维码内容无效,请扫描电视端显示的登录二维码。');
return false;
}
}, [stopTvQrScanner]);
const startTvQrScanner = useCallback(async () => {
setIsTvQrScannerOpen(true);
setTvQrScannerError('');
setTvQrScannerStatus('正在打开手机摄像头...');
tvQrScanStopRef.current = false;
if (!navigator.mediaDevices?.getUserMedia) {
setTvQrScannerError('当前浏览器不支持调用摄像头,请使用手机浏览器或系统相机扫描电视端二维码。');
setTvQrScannerStatus('');
return;
}
const BarcodeDetectorCtor = (window as any).BarcodeDetector;
if (!BarcodeDetectorCtor) {
setTvQrScannerError('当前浏览器不支持网页内二维码识别,请使用系统相机扫描电视端二维码。');
setTvQrScannerStatus('');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' } },
audio: false,
});
tvQrStreamRef.current = stream;
if (!tvQrVideoRef.current) return;
tvQrVideoRef.current.srcObject = stream;
await tvQrVideoRef.current.play();
const detector = new BarcodeDetectorCtor({ formats: ['qr_code'] });
setTvQrScannerStatus('请将电视屏幕上的登录二维码放入取景框');
const scan = async () => {
if (tvQrScanStopRef.current || !tvQrVideoRef.current) return;
try {
const barcodes = await detector.detect(tvQrVideoRef.current);
const rawValue = barcodes?.[0]?.rawValue;
if (rawValue && handleQrLoginResult(rawValue)) return;
} catch (error) {
console.error('二维码识别失败:', error);
}
window.setTimeout(scan, 350);
};
scan();
} catch (error) {
console.error('打开摄像头失败:', error);
setTvQrScannerError('无法打开摄像头,请检查浏览器相机权限后重试。');
setTvQrScannerStatus('');
stopTvQrScanner();
}
}, [handleQrLoginResult, stopTvQrScanner]);
useEffect(() => {
return () => stopTvQrScanner();
}, [stopTvQrScanner]);
const handleSubscribe = async () => {
setIsOpen(false);
setIsSubscribeOpen(true);
setCopySuccess(false);
setOrionBaseUrlCopySuccess(false);
// 懒加载: TVBox 订阅启用时才请求订阅 URL
if (subscribeEnabled) {
await fetchSubscribeUrl();
} else {
setIsLoadingSubscribeUrl(false);
}
};
const handleCloseSubscribe = () => {
setIsSubscribeOpen(false);
setCopySuccess(false);
setOrionBaseUrlCopySuccess(false);
};
const handleCopySubscribeUrl = async () => {
try {
await navigator.clipboard.writeText(subscribeUrl);
setCopySuccess(true);
setTimeout(() => {
setCopySuccess(false);
}, 2000);
} catch (error) {
console.error('复制失败:', error);
}
};
const handleCopyOrionBaseUrl = async () => {
try {
await navigator.clipboard.writeText(window.location.origin);
setOrionBaseUrlCopySuccess(true);
setTimeout(() => {
setOrionBaseUrlCopySuccess(false);
}, 2000);
} catch (error) {
console.error('复制OrionTV Base URL失败:', error);
}
};
useEffect(() => {
if (!tvboxToken || !isSubscribeOpen) return;
setSubscribeUrl(
buildSubscribeUrl(
tvboxToken,
subscribeAdFilterEnabled,
subscribeYellowFilterEnabled
)
);
}, [
tvboxToken,
subscribeAdFilterEnabled,
subscribeYellowFilterEnabled,
isSubscribeOpen,
]);
const handleSubmitChangePassword = async () => {
setPasswordError('');
// 验证密码
if (!newPassword) {
setPasswordError('新密码不得为空');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('两次输入的密码不一致');
return;
}
setPasswordLoading(true);
try {
const response = await fetch('/api/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
newPassword,
}),
});
const data = await response.json();
if (!response.ok) {
setPasswordError(data.error || '修改密码失败');
return;
}
// 修改成功,关闭弹窗并登出
setIsChangePasswordOpen(false);
await handleLogout();
} catch (error) {
setPasswordError('网络错误,请稍后重试');
} finally {
setPasswordLoading(false);
}
};
const handleSettings = () => {
setIsOpen(false);
setIsSettingsOpen(true);
};
const handleCloseSettings = () => {
setIsSettingsOpen(false);
};
// 设置相关的处理函数
const handleAggregateToggle = (value: boolean) => {
setDefaultAggregateSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
}
};
const handleDoubanProxyUrlChange = (value: string) => {
setDoubanProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrl', value);
}
};
const handleOptimizationToggle = (value: boolean) => {
setEnableOptimization(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableOptimization', JSON.stringify(value));
}
};
const handlePreferStrategyChange = (value: 'fast' | 'full') => {
setPreferStrategy(value);
if (typeof window !== 'undefined') {
localStorage.setItem('preferStrategy', value);
}
};
const handleSpeedTestTimeoutChange = (value: number) => {
setSpeedTestTimeout(value);
if (typeof window !== 'undefined') {
localStorage.setItem('speedTestTimeout', String(value));
}
};
const handleMaxConcurrentDownloadsChange = (value: number) => {
setMaxConcurrentDownloads(value);
if (typeof window !== 'undefined') {
localStorage.setItem('maxConcurrentDownloads', String(value));
}
};
const handleDownloadThreadsPerTaskChange = (value: number) => {
setDownloadThreadsPerTask(value);
if (typeof window !== 'undefined') {
localStorage.setItem('downloadThreadsPerTask', String(value));
}
};
const handleDownloadModeChange = (mode: 'browser' | 'filesystem') => {
// 如果选择 filesystem 模式,先检测浏览器是否支持
if (
mode === 'filesystem' &&
typeof window !== 'undefined' &&
!('showDirectoryPicker' in window)
) {
setConfirmDialog({
isOpen: true,
title: '浏览器不支持',
message:
'您的浏览器不支持 File System Access API,请使用 Chrome 86+ 或 Edge 86+',
onConfirm: () => {
setConfirmDialog({ ...confirmDialog, isOpen: false });
},
});
return;
}
setDownloadMode(mode);
if (typeof window !== 'undefined') {
localStorage.setItem('downloadMode', mode);
}
};
const handleSelectSavePath = async () => {
try {
const dirHandle = await (window as any).showDirectoryPicker();
setFilesystemSavePath(dirHandle.name);
localStorage.setItem('filesystemSavePath', dirHandle.name);
// 保存目录句柄到 IndexedDB
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
// 使用 Promise 包装 IndexedDB 操作
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open(dbName, 2); // 使用版本 2,与 download-db.ts 保持一致
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 创建 dirHandles 表(如果不存在)
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
// 创建 activeTasks 表(如果不存在)
if (!db.objectStoreNames.contains('activeTasks')) {
const activeStore = db.createObjectStore('activeTasks', {
keyPath: 'id',
});
activeStore.createIndex('status', 'status', { unique: false });
activeStore.createIndex('createdAt', 'createdAt', {
unique: false,
});
}
// 创建 completedTasks 表(如果不存在)
if (!db.objectStoreNames.contains('completedTasks')) {
const completedStore = db.createObjectStore('completedTasks', {
keyPath: 'id',
});
completedStore.createIndex('source', 'source', { unique: false });
completedStore.createIndex('videoId', 'videoId', { unique: false });
completedStore.createIndex('completedAt', 'completedAt', {
unique: false,
});
completedStore.createIndex('sourceVideoId', ['source', 'videoId'], {
unique: false,
});
}
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const putRequest = store.put(dirHandle, 'downloadDir');
putRequest.onsuccess = () => {
db.close();
resolve();
};
putRequest.onerror = () => {
db.close();
reject(new Error('保存目录句柄失败'));
};
};
request.onerror = () => {
reject(new Error('无法打开 IndexedDB'));
};
});
} catch (err) {
console.error('选择目录失败:', err);
}
};
const handleFluidSearchToggle = (value: boolean) => {
setFluidSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('fluidSearch', JSON.stringify(value));
}
};
const handleTmdbBackdropDisabledToggle = (value: boolean) => {
setTmdbBackdropDisabled(value);
if (typeof window !== 'undefined') {
localStorage.setItem('tmdb_backdrop_disabled', String(value));
}
};
const handleEnableTrailersToggle = (value: boolean) => {
setEnableTrailers(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableTrailers', String(value));
}
};
const handleDoubanDataSourceChange = (value: string) => {
setDoubanDataSource(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanDataSource', value);
}
};
const handleDoubanDataSourceBackupChange = (value: string) => {
setDoubanDataSourceBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanDataSourceBackup', value);
}
};
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) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyType', value);
}
};
const handleDoubanImageProxyTypeBackupChange = (value: string) => {
setDoubanImageProxyTypeBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyTypeBackup', value);
}
};
const handleDoubanProxyUrlBackupChange = (value: string) => {
setDoubanProxyUrlBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrlBackup', value);
}
};
const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrl', value);
}
};
const handleDoubanImageProxyUrlBackupChange = (value: string) => {
setDoubanImageProxyUrlBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrlBackup', value);
}
};
const handleTmdbImageBaseUrlChange = (value: string) => {
setTmdbImageBaseUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('tmdbImageBaseUrl', value);
}
};
const handleBufferStrategyChange = (value: string) => {
setBufferStrategy(value);
if (typeof window !== 'undefined') {
localStorage.setItem('bufferStrategy', value);
}
};
// 将滑块值转换为策略值
const getBufferStrategyFromSlider = (sliderValue: number): string => {
const strategies = ['low', 'medium', 'high', 'ultra'];
return strategies[sliderValue] || 'medium';
};
// 将策略值转换为滑块值
const getSliderValueFromStrategy = (strategy: string): number => {
const strategies = ['low', 'medium', 'high', 'ultra'];
const index = strategies.indexOf(strategy);
return index >= 0 ? index : 1; // 默认返回 1 (medium)
};
const handleNextEpisodePreCacheToggle = (value: boolean) => {
setNextEpisodePreCache(value);
if (typeof window !== 'undefined') {
localStorage.setItem('nextEpisodePreCache', String(value));
}
};
const handleNextEpisodeDanmakuPreloadToggle = (value: boolean) => {
setNextEpisodeDanmakuPreload(value);
if (typeof window !== 'undefined') {
localStorage.setItem('nextEpisodeDanmakuPreload', String(value));
}
};
const handleDisableAutoLoadDanmakuToggle = (value: boolean) => {
setDisableAutoLoadDanmaku(value);
if (typeof window !== 'undefined') {
localStorage.setItem('disableAutoLoadDanmaku', String(value));
}
};
const handleDanmakuMaxCountChange = (value: number) => {
setDanmakuMaxCount(value);
if (typeof window !== 'undefined') {
localStorage.setItem('danmakuMaxCount', String(value));
}
};
const handleDanmakuHeatmapDisabledToggle = (value: boolean) => {
setDanmakuHeatmapDisabled(value);
if (typeof window !== 'undefined') {
localStorage.setItem('danmaku_heatmap_disabled', String(value));
}
};
const handleSearchTraditionalToSimplifiedToggle = (value: boolean) => {
setSearchTraditionalToSimplified(value);
if (typeof window !== 'undefined') {
localStorage.setItem('searchTraditionalToSimplified', String(value));
}
};
const handleExactSearchToggle = (value: boolean) => {
setExactSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('exactSearch', String(value));
}
};
const handleHomeBannerToggle = (value: boolean) => {
setHomeBannerEnabled(value);
if (typeof window !== 'undefined') {
localStorage.setItem('homeBannerEnabled', String(value));
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
const handleHomeBannerHeightScaleChange = (value: HomeBannerHeightScale) => {
setHomeBannerHeightScale(value);
if (typeof window !== 'undefined') {
localStorage.setItem('homeBannerHeightScale', value);
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
const handleHomeContinueWatchingToggle = (value: boolean) => {
setHomeContinueWatchingEnabled(value);
if (typeof window !== 'undefined') {
localStorage.setItem('homeContinueWatchingEnabled', String(value));
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
// 首页模块配置处理函数
const handleHomeModuleToggle = (id: string, enabled: boolean) => {
const updatedModules = homeModules.map((module) =>
module.id === id ? { ...module, enabled } : module
);
setHomeModules(updatedModules);
if (typeof window !== 'undefined') {
localStorage.setItem('homeModules', JSON.stringify(updatedModules));
// 触发自定义事件通知首页刷新
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
const handleHomeModuleMoveUp = (index: number) => {
if (index === 0) return;
const updatedModules = [...homeModules];
const temp = updatedModules[index];
updatedModules[index] = updatedModules[index - 1];
updatedModules[index - 1] = temp;
// 更新order
updatedModules.forEach((module, idx) => {
module.order = idx;
});
setHomeModules(updatedModules);
if (typeof window !== 'undefined') {
localStorage.setItem('homeModules', JSON.stringify(updatedModules));
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
const handleHomeModuleMoveDown = (index: number) => {
if (index === homeModules.length - 1) return;
const updatedModules = [...homeModules];
const temp = updatedModules[index];
updatedModules[index] = updatedModules[index + 1];
updatedModules[index + 1] = temp;
// 更新order
updatedModules.forEach((module, idx) => {
module.order = idx;
});
setHomeModules(updatedModules);
if (typeof window !== 'undefined') {
localStorage.setItem('homeModules', JSON.stringify(updatedModules));
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
// 获取感谢信息
const getThanksInfo = (dataSource: string) => {
switch (dataSource) {
case 'cors-proxy-zwei':
return {
text: 'Thanks to @Zwei',
url: 'https://github.com/bestzwei',
};
case 'cmliussss-cdn-tencent':
case 'cmliussss-cdn-ali':
return {
text: 'Thanks to @CMLiussss',
url: 'https://github.com/cmliu',
};
default:
return null;
}
};
const handleResetSettings = () => {
const defaultDoubanProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
'cmliussss-cdn-tencent';
const defaultDoubanProxy =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
const defaultDoubanImageProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
'cmliussss-cdn-tencent';
const defaultDoubanImageProxyUrl =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
const defaultFluidSearch =
(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);
setEnableOptimization(true);
setPreferStrategy('fast');
setFluidSearch(defaultFluidSearch);
setTmdbBackdropDisabled(false);
setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
setDoubanDataSourceBackup('direct');
setDoubanProxyUrlBackup('');
setAnimeDataSource(defaultAnimeDataSource);
setAnimeDataSourceBackup('server-proxy');
setAnimeCustomBaseUrl(defaultAnimeBaseUrl);
setAnimeImageBaseUrl(defaultAnimeImageBaseUrl);
setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
setDoubanImageProxyTypeBackup('server');
setDoubanImageProxyUrlBackup('');
setTmdbImageBaseUrl('https://image.tmdb.org');
setBufferStrategy('medium');
setNextEpisodePreCache(true);
setNextEpisodeDanmakuPreload(true);
const defaultDanmakuAutoLoad =
(typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT !== false) ||
false;
setDisableAutoLoadDanmaku(!defaultDanmakuAutoLoad);
setHomeBannerEnabled(true);
setHomeBannerHeightScale('1');
setHomeContinueWatchingEnabled(true);
setHomeModules(defaultHomeModules);
setSearchTraditionalToSimplified(false);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('preferStrategy', 'fast');
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
localStorage.setItem('tmdb_backdrop_disabled', 'false');
localStorage.setItem('enableTrailers', 'false');
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
localStorage.setItem('doubanDataSourceBackup', 'direct');
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('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
localStorage.setItem('doubanImageProxyUrlBackup', '');
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
localStorage.setItem('bufferStrategy', 'medium');
localStorage.setItem('nextEpisodePreCache', 'true');
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
localStorage.setItem(
'disableAutoLoadDanmaku',
String(!defaultDanmakuAutoLoad)
);
localStorage.setItem('danmakuMaxCount', '0');
localStorage.setItem('danmaku_heatmap_disabled', 'false');
localStorage.setItem('homeBannerEnabled', 'true');
localStorage.setItem('homeBannerHeightScale', '1');
localStorage.setItem('homeContinueWatchingEnabled', 'true');
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
localStorage.setItem('searchTraditionalToSimplified', 'false');
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
}
};
// 清除弹幕缓存
const handleClearDanmakuCache = async () => {
setIsClearingCache(true);
setClearCacheMessage(null);
try {
await clearAllDanmakuCache();
setClearCacheMessage('弹幕缓存已清除成功!');
setDanmakuCacheUsage('0 B');
console.log('弹幕缓存已清除');
// 3秒后自动清除提示
setTimeout(() => {
setClearCacheMessage(null);
}, 3000);
} catch (error) {
console.error('清除弹幕缓存失败:', error);
setClearCacheMessage('清除失败,请重试');
// 3秒后自动清除提示
setTimeout(() => {
setClearCacheMessage(null);
}, 3000);
} finally {
setIsClearingCache(false);
}
};
// 检查是否显示管理面板按钮
const showAdminPanel =
(authInfo?.role === 'owner' || authInfo?.role === 'admin') &&
storageType !== 'localstorage';
// 检查是否显示离线下载按钮
const showOfflineDownload =
(authInfo?.role === 'owner' || authInfo?.role === 'admin') &&
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.ENABLE_OFFLINE_DOWNLOAD === true;
// 检查是否显示修改密码按钮
const showChangePassword =
authInfo?.role !== 'owner' && storageType !== 'localstorage';
// 角色中文映射
const getRoleText = (role?: string) => {
switch (role) {
case 'owner':
return '站长';
case 'admin':
return '管理员';
case 'user':
return '用户';
default:
return '';
}
};
const currentUsername = authInfo?.username || 'default';
const currentRole = authInfo?.role || 'user';
const currentRoleText = getRoleText(currentRole);
const shouldShowRoleBadge = currentRole !== 'user';
const avatarText = currentUsername.trim().charAt(0).toUpperCase() || 'D';
const roleBadgeClassName =
currentRole === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: currentRole === 'admin'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
const handleOpenProfileCenter = () => {
setIsOpen(false);
setIsProfileCenterOpen(true);
};
// 菜单面板内容
const menuPanel = (
<>
{/* 背景遮罩 - 普通菜单无需模糊 */}
<div
className='fixed inset-0 bg-transparent z-[1000]'
onClick={handleCloseMenu}
/>
{/* 菜单面板 */}
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'>
{/* 用户信息区域 */}
<div className='px-3 py-1 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
<div className='flex items-start justify-between gap-3'>
<button
onClick={handleOpenProfileCenter}
className='flex items-center gap-3 rounded-xl px-2 py-1 text-left hover:bg-white/70 dark:hover:bg-gray-700/40 transition-colors'
>
<div className='relative flex h-11 w-11 items-center justify-center rounded-full bg-blue-500 text-lg font-semibold text-white shadow-sm'>
<span>{avatarText}</span>
{shouldShowRoleBadge && (
<span
className={`absolute left-1/2 top-[calc(100%-6px)] z-10 -translate-x-1/2 inline-flex min-w-[26px] items-center justify-center whitespace-nowrap rounded-full px-1.5 py-[2px] text-[8px] leading-none font-medium shadow-sm ${roleBadgeClassName}`}
>
{currentRoleText}
</span>
)}
</div>
<div className='min-w-0'>
<span className='block max-w-[84px] truncate text-sm font-semibold text-gray-900 dark:text-gray-100 leading-none'>
{currentUsername}
</span>
</div>
</button>
<div className='pt-1 text-right'>
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
<div>数据存储</div>
<div className='mt-0.5'>
{displayStorageType === 'localstorage'
? '本地'
: displayStorageType}
</div>
</div>
</div>
</div>
</div>
{/* 菜单项 */}
<div className='py-1'>
{/* 通知按钮 */}
<button
onClick={() => {
setIsOpen(false);
setIsNotificationPanelOpen(true);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm relative'
>
<Bell className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>通知中心</span>
{unreadCount > 0 && (
<span className='ml-auto px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded-full'>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* 我的收藏按钮 */}
<button
onClick={() => {
setIsOpen(false);
setIsFavoritesPanelOpen(true);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm relative'
>
<Star className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>我的收藏</span>
</button>
{/* 设置按钮 */}
<button
onClick={handleSettings}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>设置</span>
</button>
{/* 管理面板按钮 */}
{showAdminPanel && (
<button
onClick={handleAdminPanel}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>管理面板</span>
</button>
)}
{/* 离线下载按钮 */}
{showOfflineDownload && (
<button
onClick={() => {
setIsOfflineDownloadPanelOpen(true);
setIsOpen(false);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Download className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>离线下载</span>
</button>
)}
{/* 电视访问按钮 */}
<button
onClick={handleSubscribe}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Monitor className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>电视访问</span>
</button>
{/* 生态应用按钮 */}
<button
onClick={() => {
setIsOpen(false);
setIsEcoAppsOpen(true);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Package className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>生态应用</span>
</button>
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
{/* 登出按钮 */}
<button
onClick={handleLogout}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm'
>
<LogOut className='w-4 h-4' />
<span className='font-medium'>登出</span>
</button>
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
{/* 版本信息 */}
<button
onClick={() => {
setIsVersionPanelOpen(true);
handleCloseMenu();
}}
className='w-full px-3 py-2 text-center flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors text-xs'
>
<div className='flex items-center gap-1'>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking &&
updateStatus &&
updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`w-2 h-2 rounded-full -translate-y-2 ${
updateStatus === UpdateStatus.HAS_UPDATE
? 'bg-yellow-500'
: updateStatus === UpdateStatus.NO_UPDATE
? 'bg-green-400'
: ''
}`}
></div>
)}
</div>
</button>
</div>
</div>
</>
);
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseSettings}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col'>
{/* 内容容器 - 独立的滚动区域 */}
<div
className='flex-1 px-4 py-6 md:p-6 overflow-y-auto'
data-panel-content
style={{
touchAction: 'pan-y', // 只允许垂直滚动
overscrollBehavior: 'contain', // 防止滚动冒泡
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
本地设置
</h3>
<button
onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
>
恢复默认
</button>
</div>
<button
onClick={handleCloseSettings}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 设置项 */}
<div className='space-y-3 md:space-y-4'>
{/* 豆瓣设置 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-visible'>
<button
onClick={() => setIsDoubanSectionOpen(!isDoubanSectionOpen)}
className='w-full px-3 py-2.5 md:px-4 md:py-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors flex items-center justify-between'
>
<div className='flex items-center gap-2'>
<Globe className='w-5 h-5 text-gray-600 dark:text-gray-400' />
<h3 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
数据源设置
</h3>
</div>
{isDoubanSectionOpen ? (
<ChevronUp className='w-5 h-5 text-gray-600 dark:text-gray-400' />
) : (
<ChevronDown className='w-5 h-5 text-gray-600 dark:text-gray-400' />
)}
</button>
{isDoubanSectionOpen && (
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
{/* 豆瓣数据源选择 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣数据代理
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
选择获取豆瓣数据的方式
</p>
</div>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanDropdownOpen(!isDoubanDropdownOpen)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSource
)?.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 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(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 ${
doubanDataSource === 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>
{doubanDataSource === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanDataSource) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(doubanDataSource)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanDataSource)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanDataSource === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣代理地址
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
自定义代理服务器地址
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) =>
handleDoubanProxyUrlChange(e.target.value)
}
/>
{!doubanProxyUrl.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
未填写地址时将自动按直连处理
</p>
)}
</div>
)}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣数据备用渠道
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
主渠道失败后自动切换,默认直连
</p>
</div>
<div
className='relative'
data-dropdown='douban-datasource-backup'
>
<button
type='button'
onClick={() =>
setIsDoubanBackupDropdownOpen(
!isDoubanBackupDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSourceBackup
)?.label
}
</button>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanBackupDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{isDoubanBackupDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceBackupChange(
option.value
);
setIsDoubanBackupDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
doubanDataSourceBackup === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSourceBackup === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
</div>
{doubanDataSourceBackup === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣备用代理地址
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
备用渠道为自定义代理时生效
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrlBackup}
onChange={(e) =>
handleDoubanProxyUrlBackupChange(e.target.value)
}
/>
{!doubanProxyUrlBackup.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
未填写地址时备用渠道将自动按直连处理
</p>
)}
</div>
)}
{/* 分割线 */}
<div 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>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣图片代理
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
选择获取豆瓣图片的方式
</p>
</div>
<div
className='relative'
data-dropdown='douban-image-proxy'
>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyType
)?.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 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeChange(option.value);
setIsDoubanImageProxyDropdownOpen(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 ${
doubanImageProxyType === 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>
{doubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(doubanImageProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanImageProxyType === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣图片代理地址
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
自定义图片代理服务器地址
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrl}
onChange={(e) =>
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
{!doubanImageProxyUrl.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
未填写地址时将自动按服务器代理处理
</p>
)}
</div>
)}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣图片备用渠道
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
主图片渠道失败后自动切换,默认服务器代理
</p>
</div>
<div
className='relative'
data-dropdown='douban-image-proxy-backup'
>
<button
type='button'
onClick={() =>
setIsDoubanImageProxyBackupDropdownOpen(
!isDoubanImageProxyBackupDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) =>
option.value === doubanImageProxyTypeBackup
)?.label
}
</button>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanImageProxyBackupDropdownOpen
? 'rotate-180'
: ''
}`}
/>
</div>
{isDoubanImageProxyBackupDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeBackupChange(
option.value
);
setIsDoubanImageProxyBackupDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
doubanImageProxyTypeBackup === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyTypeBackup === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
</div>
{doubanImageProxyTypeBackup === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
豆瓣图片备用代理地址
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
备用图片渠道为自定义代理时生效
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrlBackup}
onChange={(e) =>
handleDoubanImageProxyUrlBackupChange(e.target.value)
}
/>
{!doubanImageProxyUrlBackup.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
未填写地址时备用图片渠道将自动按服务器代理处理
</p>
)}
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* TMDB 图片网络请求地址设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
TMDB 图片网络请求地址
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
TMDB 图片的 Base URL默认: https://image.tmdb.org
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://image.tmdb.org'
value={tmdbImageBaseUrl}
onChange={(e) =>
handleTmdbImageBaseUrlChange(e.target.value)
}
/>
</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 className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-visible'>
<button
onClick={() => setIsUsageSectionOpen(!isUsageSectionOpen)}
className='w-full px-3 py-2.5 md:px-4 md:py-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors flex items-center justify-between'
>
<div className='flex items-center gap-2'>
<Sliders className='w-5 h-5 text-gray-600 dark:text-gray-400' />
<h3 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
通用设置
</h3>
</div>
{isUsageSectionOpen ? (
<ChevronUp className='w-5 h-5 text-gray-600 dark:text-gray-400' />
) : (
<ChevronDown className='w-5 h-5 text-gray-600 dark:text-gray-400' />
)}
</button>
{isUsageSectionOpen && (
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
{/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
默认聚合搜索结果
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
搜索时默认按标题和年份聚合显示结果
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={defaultAggregateSearch}
onChange={(e) =>
handleAggregateToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 优选和测速 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
优选和测速
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
如出现播放器劫持问题可关闭
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableOptimization}
onChange={(e) =>
handleOptimizationToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 测速超时设置 */}
{enableOptimization && (
<div className='ml-4 mt-2 space-y-2'>
<div className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<span className='text-xs text-gray-600 dark:text-gray-400'>
优选策略
</span>
<div className='inline-flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800'>
<button
type='button'
onClick={() => handlePreferStrategyChange('fast')}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
preferStrategy === 'fast'
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
快速优选
</button>
<button
type='button'
onClick={() => handlePreferStrategyChange('full')}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
preferStrategy === 'full'
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
全量优选
</button>
</div>
</div>
</div>
<div className='flex items-center justify-between'>
<span className='text-xs text-gray-600 dark:text-gray-400'>
换源面板测速超时
</span>
<span className='text-xs font-medium text-gray-700 dark:text-gray-300'>
{speedTestTimeout / 1000}
</span>
</div>
<div className='flex items-center gap-2'>
<input
type='range'
min='4000'
max='30000'
step='1000'
value={speedTestTimeout}
onChange={(e) =>
handleSpeedTestTimeoutChange(Number(e.target.value))
}
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
style={{
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
((speedTestTimeout - 4000) / (30000 - 4000)) * 100
}%, #e5e7eb ${
((speedTestTimeout - 4000) / (30000 - 4000)) * 100
}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
<button
onClick={() => handleSpeedTestTimeoutChange(4000)}
className={`px-2 py-0.5 rounded ${
speedTestTimeout === 4000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
4
</button>
<button
onClick={() => handleSpeedTestTimeoutChange(10000)}
className={`px-2 py-0.5 rounded ${
speedTestTimeout === 10000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
10
</button>
<button
onClick={() => handleSpeedTestTimeoutChange(20000)}
className={`px-2 py-0.5 rounded ${
speedTestTimeout === 20000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
20
</button>
<button
onClick={() => handleSpeedTestTimeoutChange(30000)}
className={`px-2 py-0.5 rounded ${
speedTestTimeout === 30000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
30
</button>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 italic'>
注:此设置仅对换源面板测速生效,优选播放源时仍使用4秒超时
</p>
</div>
)}
{/* 流式搜索 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
流式搜索输出
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
启用搜索结果实时流式输出,关闭后使用传统一次性搜索
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={fluidSearch}
onChange={(e) =>
handleFluidSearchToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 禁用背景图渲染 */}
<div className='flex items-center justify-between'>
<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'>
关闭播放页面的TMDB背景图显示(需手动刷新页面生效)
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={tmdbBackdropDisabled}
onChange={(e) =>
handleTmdbBackdropDisabledToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 启用预告片 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
首页预告片
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
在首页轮播图中显示视频预告片(需刷新页面生效)
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableTrailers}
onChange={(e) =>
handleEnableTrailersToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 搜索繁体转简体 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
搜索繁体转简体
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
搜索时自动将繁体中文转换为简体中文
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={searchTraditionalToSimplified}
onChange={(e) =>
handleSearchTraditionalToSimplifiedToggle(
e.target.checked
)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 精确搜索 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
精确搜索
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
开启后,搜索结果将过滤掉不包含搜索词的内容
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={exactSearch}
onChange={(e) =>
handleExactSearchToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
</div>
)}
</div>
{/* 下载设置 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-visible'>
<button
onClick={() => setIsDownloadSectionOpen(!isDownloadSectionOpen)}
className='w-full px-3 py-2.5 md:px-4 md:py-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors flex items-center justify-between'
>
<div className='flex items-center gap-2'>
<Download className='w-5 h-5 text-gray-600 dark:text-gray-400' />
<h3 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
下载设置
</h3>
</div>
{isDownloadSectionOpen ? (
<ChevronUp className='w-5 h-5 text-gray-600 dark:text-gray-400' />
) : (
<ChevronDown className='w-5 h-5 text-gray-600 dark:text-gray-400' />
)}
</button>
{isDownloadSectionOpen && (
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
{/* 最大同时下载限制 */}
<div className='space-y-2'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
最大同时下载限制
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
控制播放页面下载时的同时下载数量
</p>
</div>
<div className='flex items-center justify-between'>
<span className='text-xs text-gray-600 dark:text-gray-400'>
同时下载数量
</span>
<span className='text-xs font-medium text-gray-700 dark:text-gray-300'>
{maxConcurrentDownloads}
</span>
</div>
<div className='flex items-center gap-2'>
<input
type='range'
min='1'
max='10'
step='1'
value={maxConcurrentDownloads}
onChange={(e) =>
handleMaxConcurrentDownloadsChange(
Number(e.target.value)
)
}
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
style={{
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
((maxConcurrentDownloads - 1) / (10 - 1)) * 100
}%, #e5e7eb ${
((maxConcurrentDownloads - 1) / (10 - 1)) * 100
}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
<button
onClick={() => handleMaxConcurrentDownloadsChange(1)}
className={`px-2 py-0.5 rounded ${
maxConcurrentDownloads === 1
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
1
</button>
<button
onClick={() => handleMaxConcurrentDownloadsChange(10)}
className={`px-2 py-0.5 rounded ${
maxConcurrentDownloads === 10
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
10
</button>
</div>
</div>
{/* 单任务线程数 */}
<div className='space-y-2'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
单任务线程数
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
控制每个下载任务使用的线程数量,线程越多下载越快但占用资源越多
</p>
</div>
<div className='flex items-center justify-between'>
<span className='text-xs text-gray-600 dark:text-gray-400'>
线程数量
</span>
<span className='text-xs font-medium text-gray-700 dark:text-gray-300'>
{downloadThreadsPerTask}
</span>
</div>
<div className='flex items-center gap-2'>
<input
type='range'
min='1'
max='32'
step='1'
value={downloadThreadsPerTask}
onChange={(e) =>
handleDownloadThreadsPerTaskChange(
Number(e.target.value)
)
}
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
style={{
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
((downloadThreadsPerTask - 1) / (32 - 1)) * 100
}%, #e5e7eb ${
((downloadThreadsPerTask - 1) / (32 - 1)) * 100
}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
<button
onClick={() => handleDownloadThreadsPerTaskChange(1)}
className={`px-2 py-0.5 rounded ${
downloadThreadsPerTask === 1
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
1
</button>
<button
onClick={() => handleDownloadThreadsPerTaskChange(32)}
className={`px-2 py-0.5 rounded ${
downloadThreadsPerTask === 32
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
32
</button>
</div>
</div>
{/* 下载模式 */}
<div className='space-y-2'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
下载模式
</h4>
</div>
<div className='space-y-2'>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='radio'
name='downloadMode'
value='browser'
checked={downloadMode === 'browser'}
onChange={() => handleDownloadModeChange('browser')}
className='w-4 h-4 text-green-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
浏览器下载(合并为单文件)
</span>
</label>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='radio'
name='downloadMode'
value='filesystem'
checked={downloadMode === 'filesystem'}
onChange={() =>
handleDownloadModeChange('filesystem')
}
className='w-4 h-4 text-green-500'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
File System API(保存分片到本地目录)
</span>
</label>
</div>
{/* 保存路径选择(仅在 filesystem 模式显示) */}
{downloadMode === 'filesystem' && (
<div className='mt-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg space-y-2'>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300'>
保存路径
</label>
<div className='flex gap-2'>
<input
type='text'
value={filesystemSavePath}
readOnly
placeholder='点击选择保存目录'
className='flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
/>
<button
onClick={handleSelectSavePath}
className='px-4 py-2 text-sm bg-green-500 text-white rounded hover:bg-green-600 transition-colors'
>
选择目录
</button>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
需要 Chrome 86+ Edge 86+ 浏览器支持
</p>
</div>
)}
</div>
{/* 下载文件管理 */}
<div className='space-y-2'>
<button
onClick={() => setIsDownloadManagementOpen(true)}
className='w-full px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors flex items-center justify-center gap-2'
>
<Package className='w-4 h-4' />
下载文件管理
</button>
</div>
</div>
)}
</div>
{/* 缓冲设置 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-visible'>
<button
onClick={() => setIsBufferSectionOpen(!isBufferSectionOpen)}
className='w-full px-3 py-2.5 md:px-4 md:py-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors flex items-center justify-between'
>
<div className='flex items-center gap-2'>
<Gauge className='w-5 h-5 text-gray-600 dark:text-gray-400' />
<h3 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
缓冲设置
</h3>
</div>
{isBufferSectionOpen ? (
<ChevronUp className='w-5 h-5 text-gray-600 dark:text-gray-400' />
) : (
<ChevronDown className='w-5 h-5 text-gray-600 dark:text-gray-400' />
)}
</button>
{isBufferSectionOpen && (
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
调整播放器缓冲策略(仅在播放页面生效)
</p>
</div>
{/* 缓冲策略 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
缓冲策略
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
设置视频缓冲块大小,影响播放流畅度和流量消耗
</p>
</div>
{/* 滑块控件 */}
<div className='space-y-2'>
<input
type='range'
min='0'
max='3'
step='1'
value={getSliderValueFromStrategy(bufferStrategy)}
onChange={(e) => {
const sliderValue = parseInt(e.target.value);
const strategy =
getBufferStrategyFromSlider(sliderValue);
handleBufferStrategyChange(strategy);
}}
className='w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500'
style={{
background: `linear-gradient(to right, rgb(34 197 94) 0%, rgb(34 197 94) ${
(getSliderValueFromStrategy(bufferStrategy) / 3) *
100
}%, rgb(229 231 235) ${
(getSliderValueFromStrategy(bufferStrategy) / 3) *
100
}%, rgb(229 231 235) 100%)`,
}}
/>
{/* 标签显示 */}
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400 px-1'>
<span
className={
bufferStrategy === 'low'
? 'font-semibold text-green-600 dark:text-green-400'
: ''
}
>
低缓冲
</span>
<span
className={
bufferStrategy === 'medium'
? 'font-semibold text-green-600 dark:text-green-400'
: ''
}
>
中缓冲
</span>
<span
className={
bufferStrategy === 'high'
? 'font-semibold text-green-600 dark:text-green-400'
: ''
}
>
高缓冲
</span>
<span
className={
bufferStrategy === 'ultra'
? 'font-semibold text-green-600 dark:text-green-400'
: ''
}
>
超高缓冲
</span>
</div>
{/* 当前选择的说明 */}
<div className='text-center text-sm font-medium text-gray-700 dark:text-gray-300 mt-2'>
{
bufferStrategyOptions.find(
(option) => option.value === bufferStrategy
)?.label
}
</div>
</div>
</div>
{/* 下集预缓冲 */}
<div className='flex items-center justify-between'>
<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'>
播放进度达到90%时,自动预缓冲下一集内容
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={nextEpisodePreCache}
onChange={(e) =>
handleNextEpisodePreCacheToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
</div>
)}
</div>
{/* 弹幕设置 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-visible'>
<button
onClick={() => setIsDanmakuSectionOpen(!isDanmakuSectionOpen)}
className='w-full px-3 py-2.5 md:px-4 md:py-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors flex items-center justify-between'
>
<div className='flex items-center gap-2'>
<MessageSquare className='w-5 h-5 text-gray-600 dark:text-gray-400' />
<h3 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
弹幕设置
</h3>
</div>
{isDanmakuSectionOpen ? (
<ChevronUp className='w-5 h-5 text-gray-600 dark:text-gray-400' />
) : (
<ChevronDown className='w-5 h-5 text-gray-600 dark:text-gray-400' />
)}
</button>
{isDanmakuSectionOpen && (
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
{/* 禁用自动装填弹幕 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
禁用自动装填弹幕
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
开启后,播放页面不会自动匹配弹幕,只能手动匹配
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={disableAutoLoadDanmaku}
onChange={(e) =>
handleDisableAutoLoadDanmakuToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 下集弹幕预加载 */}
<div className='flex items-center justify-between'>
<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'>
播放进度达到90%时,自动预加载下一集弹幕
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={nextEpisodeDanmakuPreload}
onChange={(e) =>
handleNextEpisodeDanmakuPreloadToggle(
e.target.checked
)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 禁用弹幕热力图 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
禁用弹幕热力图
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
开启后不显示弹幕热力图和热力图开关
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={danmakuHeatmapDisabled}
onChange={(e) =>
handleDanmakuHeatmapDisabledToggle(e.target.checked)
}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 弹幕加载上限 */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-xs text-gray-600 dark:text-gray-400'>
弹幕加载上限
</span>
<span className='text-xs font-medium text-gray-700 dark:text-gray-300'>
{danmakuMaxCount === 0
? '无上限'
: `${danmakuMaxCount} 条`}
</span>
</div>
<div className='flex items-center gap-2'>
<input
type='range'
min='0'
max='10000'
step='100'
value={danmakuMaxCount}
onChange={(e) =>
handleDanmakuMaxCountChange(parseInt(e.target.value))
}
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
style={{
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
(danmakuMaxCount / 10000) * 100
}%, #e5e7eb ${
(danmakuMaxCount / 10000) * 100
}%, #e5e7eb 100%)`,
}}
/>
</div>
<div
className='relative text-xs text-gray-500 dark:text-gray-400'
style={{ height: '24px' }}
>
<button
onClick={() => handleDanmakuMaxCountChange(0)}
className={`absolute px-2 py-0.5 rounded ${
danmakuMaxCount === 0
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
style={{ left: '0%', transform: 'translateX(0%)' }}
>
无上限
</button>
<button
onClick={() => handleDanmakuMaxCountChange(3000)}
className={`absolute px-2 py-0.5 rounded ${
danmakuMaxCount === 3000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
style={{ left: '30%', transform: 'translateX(-50%)' }}
>
3000
</button>
<button
onClick={() => handleDanmakuMaxCountChange(5000)}
className={`absolute px-2 py-0.5 rounded ${
danmakuMaxCount === 5000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
style={{ left: '50%', transform: 'translateX(-50%)' }}
>
5000
</button>
<button
onClick={() => handleDanmakuMaxCountChange(10000)}
className={`absolute px-2 py-0.5 rounded ${
danmakuMaxCount === 10000
? 'bg-green-500 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
style={{ left: '100%', transform: 'translateX(-100%)' }}
>
10000
</button>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
限制加载的弹幕数量,减少性能消耗
</p>
</div>
{/* 清除弹幕缓存 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
弹幕缓存管理
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
弹幕缓存空间占用:{danmakuCacheUsage}
</p>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
清除所有已缓存的弹幕数据
</p>
</div>
<button
onClick={handleClearDanmakuCache}
disabled={isClearingCache}
className='w-full px-4 py-2.5 bg-red-500 hover:bg-red-600 disabled:bg-red-400 dark:bg-red-600 dark:hover:bg-red-700 dark:disabled:bg-red-500 text-white text-sm font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md disabled:cursor-not-allowed flex items-center justify-center gap-2'
>
{isClearingCache ? (
<>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
<span>清除中...</span>
</>
) : (
<>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
/>
</svg>
<span>清除弹幕缓存</span>
</>
)}
</button>
{/* 成功/失败提示 */}
{clearCacheMessage && (
<div
className={`text-sm p-3 rounded-lg border ${
clearCacheMessage.includes('成功')
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'
}`}
>
{clearCacheMessage}
</div>
)}
</div>
</div>
)}
</div>
{/* 首页设置 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-visible'>
<button
onClick={() => setIsHomepageSectionOpen(!isHomepageSectionOpen)}
className='w-full px-3 py-2.5 md:px-4 md:py-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors flex items-center justify-between'
>
<div className='flex items-center gap-2'>
<Home className='w-5 h-5 text-gray-600 dark:text-gray-400' />
<h3 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
首页设置
</h3>
</div>
{isHomepageSectionOpen ? (
<ChevronUp className='w-5 h-5 text-gray-600 dark:text-gray-400' />
) : (
<ChevronDown className='w-5 h-5 text-gray-600 dark:text-gray-400' />
)}
</button>
{isHomepageSectionOpen && (
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
<div>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-3'>
配置首页轮播图显示效果,以及首页模块布局
</p>
</div>
{/* 轮播图配置 */}
<div className='space-y-2'>
<div>
<h4 className='text-sm font-semibold text-gray-800 dark:text-gray-200'>
轮播图配置
</h4>
</div>
<div className='p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3'>
<div>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
轮播图高度
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
调整首页轮播图显示高度
</p>
</div>
<div className='grid grid-cols-3 gap-2'>
{homeBannerHeightOptions.map((option) => (
<button
key={option.value}
onClick={() =>
handleHomeBannerHeightScaleChange(option.value)
}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
homeBannerHeightScale === option.value
? 'bg-blue-500 border-blue-500 text-white shadow-sm'
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={`${option.label}${option.description}`}
>
<span>{option.label}</span>
<span
className={`ml-1 text-xs ${
homeBannerHeightScale === option.value
? 'text-blue-100'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{option.description}
</span>
</button>
))}
</div>
</div>
</div>
{/* 模块显示与排序 */}
<div className='space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-3'>
<div>
<h4 className='text-sm font-semibold text-gray-800 dark:text-gray-200'>
模块显示与排序
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
控制首页组件和内容模块的显示/隐藏与顺序
</p>
</div>
<div className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<button
onClick={() =>
handleHomeBannerToggle(!homeBannerEnabled)
}
className='flex-shrink-0'
title={homeBannerEnabled ? '点击隐藏' : '点击显示'}
>
{homeBannerEnabled ? (
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
) : (
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
)}
</button>
<div className='flex-1'>
<span
className={`text-sm font-medium ${
homeBannerEnabled
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'
}`}
>
首页轮播图
</span>
</div>
</div>
<div className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<button
onClick={() =>
handleHomeContinueWatchingToggle(
!homeContinueWatchingEnabled
)
}
className='flex-shrink-0'
title={
homeContinueWatchingEnabled ? '点击隐藏' : '点击显示'
}
>
{homeContinueWatchingEnabled ? (
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
) : (
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
)}
</button>
<div className='flex-1'>
<span
className={`text-sm font-medium ${
homeContinueWatchingEnabled
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'
}`}
>
继续观看
</span>
</div>
</div>
{/* 模块列表 */}
<div className='space-y-2'>
{homeModules.map((module, index) => (
<div
key={module.id}
className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'
>
{/* 左侧:显示/隐藏开关 */}
<button
onClick={() =>
handleHomeModuleToggle(module.id, !module.enabled)
}
className='flex-shrink-0'
title={module.enabled ? '点击隐藏' : '点击显示'}
>
{module.enabled ? (
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
) : (
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
)}
</button>
{/* 中间:模块名称 */}
<div className='flex-1'>
<span
className={`text-sm font-medium ${
module.enabled
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{module.name}
</span>
</div>
{/* 右侧:上下移动按钮 */}
<div className='flex gap-1'>
<button
onClick={() => handleHomeModuleMoveUp(index)}
disabled={index === 0}
className='p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
title='上移'
>
<MoveUp className='w-4 h-4 text-gray-600 dark:text-gray-400' />
</button>
<button
onClick={() => handleHomeModuleMoveDown(index)}
disabled={index === homeModules.length - 1}
className='p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
title='下移'
>
<MoveDown className='w-4 h-4 text-gray-600 dark:text-gray-400' />
</button>
</div>
</div>
))}
</div>
</div>
{/* 恢复默认按钮 */}
<button
onClick={() => {
setHomeModules(defaultHomeModules);
setHomeBannerEnabled(true);
setHomeBannerHeightScale('1');
setHomeContinueWatchingEnabled(true);
if (typeof window !== 'undefined') {
localStorage.setItem(
'homeModules',
JSON.stringify(defaultHomeModules)
);
localStorage.setItem('homeBannerEnabled', 'true');
localStorage.setItem('homeBannerHeightScale', '1');
localStorage.setItem(
'homeContinueWatchingEnabled',
'true'
);
window.dispatchEvent(
new CustomEvent('homeModulesUpdated')
);
}
}}
className='w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg transition-colors'
>
恢复默认配置
</button>
{/* 提示信息 */}
<div className='text-xs text-gray-500 dark:text-gray-400 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<p>
💡
提示:点击眼睛图标可显示/隐藏模块,使用箭头按钮调整模块顺序
</p>
</div>
</div>
)}
</div>
</div>
{/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
这些设置保存在本地浏览器中
</p>
</div>
</div>
</div>
</>
);
// 电视访问面板内容
const subscribePanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/60 backdrop-blur-sm z-[1000]'
onClick={handleCloseSubscribe}
onTouchMove={(e) => {
e.preventDefault();
}}
onWheel={(e) => {
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 电视访问面板 */}
<div className='fixed top-1/2 left-1/2 z-[1001] max-h-[92vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-3xl border border-slate-200/70 bg-white shadow-2xl shadow-black/30 dark:border-white/10 dark:bg-slate-950'>
<div
className='max-h-[92vh] overflow-y-auto p-6 sm:p-7'
data-panel-content
onTouchMove={(e) => {
e.stopPropagation();
}}
style={{
touchAction: 'auto',
}}
>
{isTvQrScannerOpen ? (
<div className='relative -m-6 min-h-[72vh] overflow-hidden bg-black sm:-m-7'>
<video
ref={tvQrVideoRef}
className='absolute inset-0 h-full w-full object-cover'
muted
playsInline
/>
<div className='pointer-events-none absolute inset-0 grid place-items-center'>
<div className='h-64 w-64 rounded-xl border-4 border-white/90 [box-shadow:0_0_0_9999px_rgba(0,0,0,0.58),0_0_30px_rgba(244,63,94,0.55)] sm:h-80 sm:w-80' />
</div>
<div className='absolute left-0 right-0 top-0 flex items-start justify-between gap-4 bg-gradient-to-b from-black/75 to-transparent p-5 text-white sm:p-7'>
<div>
<div className='inline-flex items-center gap-2 rounded-full bg-rose-500/25 px-3 py-1 text-xs font-black text-rose-100 ring-1 ring-rose-300/20'>
<Smartphone className='h-4 w-4' />
手机相机扫码
</div>
<h3 className='mt-3 text-2xl font-black'>扫描电视登录二维码</h3>
<p className='mt-1 text-sm text-white/75'>识别成功后会进入手机确认登录页</p>
</div>
<button
type='button'
onClick={closeTvQrScanner}
className='flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-white/15 text-white backdrop-blur transition hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70'
aria-label='关闭扫码'
>
<X className='h-5 w-5' />
</button>
</div>
<div className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/85 to-transparent p-5 sm:p-7'>
{tvQrScannerStatus && (
<p className='rounded-2xl bg-white/12 px-4 py-3 text-center text-sm font-black text-white backdrop-blur'>
{tvQrScannerStatus}
</p>
)}
{tvQrScannerError && (
<p className='mt-3 rounded-2xl bg-red-500/20 px-4 py-3 text-center text-sm font-black text-red-100 ring-1 ring-red-300/20 backdrop-blur'>
{tvQrScannerError}
</p>
)}
</div>
</div>
) : (
<>
{/* 标题栏 */}
<div className='mb-6 flex items-start justify-between gap-4'>
<div>
<div className='inline-flex items-center gap-2 rounded-full bg-green-500/10 px-3 py-1 text-xs font-bold text-green-600 dark:text-green-400'>
<Monitor className='h-4 w-4' />
TV ACCESS
</div>
<h3 className='mt-3 text-2xl font-black text-slate-900 dark:text-slate-50'>
电视访问
</h3>
</div>
<button
onClick={handleCloseSubscribe}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 dark:hover:bg-white/10 dark:hover:text-white'
aria-label='Close'
>
<X className='h-5 w-5' />
</button>
</div>
<div className='mb-5 grid grid-cols-3 rounded-2xl bg-slate-100 p-1 dark:bg-white/10'>
{[
{ key: 'tvbox' as const, label: 'TVBox 订阅', icon: Rss },
{ key: 'orion' as const, label: 'OrionTV', icon: Download },
{ key: 'web' as const, label: 'Web 电视', icon: Monitor },
].map((item) => {
const Icon = item.icon;
const active = tvAccessTab === item.key;
return (
<button
key={item.key}
type='button'
onClick={() => {
setTvAccessTab(item.key);
if (item.key !== 'web') closeTvQrScanner();
}}
className={`flex cursor-pointer items-center justify-center gap-2 rounded-xl px-3 py-3 text-sm font-black transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500/70 ${
active
? 'bg-white text-slate-950 shadow-sm dark:bg-slate-950 dark:text-white'
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
}`}
>
<Icon className='h-4 w-4' />
<span className='hidden sm:inline'>{item.label}</span>
<span className='sm:hidden'>{item.key === 'tvbox' ? 'TVBox' : item.key === 'orion' ? 'Orion' : 'Web'}</span>
</button>
);
})}
</div>
{tvAccessTab === 'tvbox' && (
<section className='rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-white/10 dark:bg-white/[0.04]'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-green-500 text-white shadow-lg shadow-green-500/25'>
<Rss className='h-5 w-5' />
</div>
<div>
<h4 className='text-lg font-black text-slate-900 dark:text-slate-100'>
TVBox 订阅
</h4>
<p className='mt-1 text-sm text-slate-600 dark:text-slate-400'>
复制订阅链接到 TVBox 使用
</p>
</div>
</div>
<span
className={`rounded-full px-2.5 py-1 text-xs font-bold ${
subscribeEnabled
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-slate-200 text-slate-500 dark:bg-white/10 dark:text-slate-400'
}`}
>
{subscribeEnabled ? '已启用' : '未启用'}
</span>
</div>
{!subscribeEnabled ? (
<div className='mt-5 rounded-xl border border-dashed border-slate-300 bg-white/70 px-4 py-3 text-sm font-semibold text-slate-500 dark:border-white/10 dark:bg-white/5 dark:text-slate-400'>
TVBox 订阅功能未启用
</div>
) : isLoadingSubscribeUrl ? (
<div className='mt-5 space-y-3'>
<div className='h-14 animate-pulse rounded-xl bg-slate-200 dark:bg-white/10' />
<div className='h-14 animate-pulse rounded-xl bg-slate-200 dark:bg-white/10' />
<div className='h-10 animate-pulse rounded-xl bg-slate-200 dark:bg-white/10' />
</div>
) : (
<div className='mt-5 space-y-4'>
<div className='grid gap-3 sm:grid-cols-2'>
<button
type='button'
onClick={() => setSubscribeAdFilterEnabled((prev) => !prev)}
className='flex w-full cursor-pointer items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-left transition hover:border-green-400 dark:border-white/10 dark:bg-slate-900/70'
>
<div>
<div className='text-sm font-bold text-slate-800 dark:text-slate-200'>去广告</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>开启后通过代理处理播放链接</div>
</div>
<span className={`h-5 w-9 rounded-full p-0.5 transition ${subscribeAdFilterEnabled ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-700'}`}>
<span className={`block h-4 w-4 rounded-full bg-white transition ${subscribeAdFilterEnabled ? 'translate-x-4' : ''}`} />
</span>
</button>
<button
type='button'
onClick={() => setSubscribeYellowFilterEnabled((prev) => !prev)}
className='flex w-full cursor-pointer items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-left transition hover:border-yellow-400 dark:border-white/10 dark:bg-slate-900/70'
>
<div>
<div className='text-sm font-bold text-slate-800 dark:text-slate-200'>黄色过滤</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>过滤代理搜索中的黄色内容</div>
</div>
<span className={`h-5 w-9 rounded-full p-0.5 transition ${subscribeYellowFilterEnabled ? 'bg-yellow-500' : 'bg-slate-300 dark:bg-slate-700'}`}>
<span className={`block h-4 w-4 rounded-full bg-white transition ${subscribeYellowFilterEnabled ? 'translate-x-4' : ''}`} />
</span>
</button>
</div>
<div>
<h4 className='mb-2 text-sm font-medium text-slate-700 dark:text-slate-300'>
订阅链接
</h4>
<div className='flex gap-2'>
<input
type='text'
className='min-w-0 flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:ring-2 focus:ring-green-500 dark:border-white/10 dark:bg-slate-900 dark:text-slate-200'
value={subscribeUrl}
readOnly
/>
<button
onClick={handleCopySubscribeUrl}
className='inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-green-600 px-4 py-2.5 text-sm font-black text-white transition hover:bg-green-700'
>
<Copy className='h-4 w-4' />
{copySuccess ? '已复制' : '复制'}
</button>
</div>
{(subscribeAdFilterEnabled || subscribeYellowFilterEnabled) && (
<p className='mt-2 rounded-xl border border-yellow-400/25 bg-yellow-400/10 px-3 py-2 text-xs font-semibold text-yellow-700 dark:text-yellow-300'>
💡 代理模式已开启,某些源可能因为区域或兼容问题无法播放
</p>
)}
</div>
<div className='pt-1'>
<button
onClick={handleResetToken}
disabled={isResettingToken}
className='w-full cursor-pointer rounded-xl bg-red-600 px-4 py-2.5 text-sm font-black text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60'
>
{isResettingToken ? '重置中...' : '重置订阅Token'}
</button>
<p className='mt-2 text-center text-xs text-slate-500 dark:text-slate-400'>
⚠️ 重置后旧链接将失效
</p>
<p id='tvbox-token-message' className='hidden text-center text-xs'></p>
</div>
</div>
)}
</section>
)}
{tvAccessTab === 'orion' && (
<section className='rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-white/10 dark:bg-white/[0.04]'>
<div className='flex items-center gap-3'>
<img
src='/icons/OrionTV.png'
alt='OrionTV'
className='h-11 w-11 rounded-2xl object-cover shadow-lg shadow-indigo-500/20'
/>
<div>
<h4 className='text-lg font-black text-slate-900 dark:text-slate-100'>
OrionTV
</h4>
<p className='mt-1 text-sm text-slate-600 dark:text-slate-400'>
Android TV 专用客户端
</p>
</div>
</div>
<p className='mt-5 text-sm leading-6 text-slate-600 dark:text-slate-400'>
可直接作为 MoonTV Plus 电视端使用,适合安装到 Android TV / 电视盒子。
</p>
<div className='mt-5'>
<h5 className='mb-2 text-sm font-bold text-slate-700 dark:text-slate-300'>
Base URL
</h5>
<div className='flex gap-2'>
<input
type='text'
readOnly
value={typeof window !== 'undefined' ? window.location.origin : ''}
className='min-w-0 flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 outline-none dark:border-white/10 dark:bg-slate-900 dark:text-slate-200'
/>
<button
type='button'
onClick={handleCopyOrionBaseUrl}
className='inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-black text-white transition hover:bg-indigo-700'
>
<Copy className='h-4 w-4' />
{orionBaseUrlCopySuccess ? '已复制' : '复制'}
</button>
</div>
<p className='mt-2 text-xs text-slate-500 dark:text-slate-400'>
OrionTV 中填写该地址作为后端服务地址。
</p>
</div>
<a
href='https://github.com/mtvpls/OrionTV_Build/tags'
target='_blank'
rel='noopener noreferrer'
className='mt-5 inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-black text-white transition hover:bg-indigo-700'
>
下载 OrionTV
<ExternalLink className='h-4 w-4' />
</a>
</section>
)}
{tvAccessTab === 'web' && (
<section className='rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-white/10 dark:bg-white/[0.04]'>
<div className='flex items-center gap-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-rose-500 text-white shadow-lg shadow-rose-500/25'>
<Monitor className='h-5 w-5' />
</div>
<div>
<h4 className='text-lg font-black text-slate-900 dark:text-slate-100'>
Web 电视
</h4>
<p className='mt-1 text-sm text-slate-600 dark:text-slate-400'>
手机扫描电视屏幕二维码并确认登录
</p>
</div>
</div>
<p className='mt-5 text-sm leading-6 text-slate-600 dark:text-slate-400'>
{tvModeEnabled
? '电视端打开 /tv 后会显示二维码;手机在这里打开相机扫描并确认登录。'
: '当前部署未开启 TV 模式,/tv 页面和 Web 电视遥控不可用。'}
</p>
{!tvModeEnabled && (
<div className='mt-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800 dark:border-amber-300/20 dark:bg-amber-400/10 dark:text-amber-200'>
TV 模式未开启。请在环境变量中设置 ENABLE_TV_MODE=true 后重启服务。
</div>
)}
<div className='mt-5 grid gap-2 sm:grid-cols-2'>
<button
type='button'
onClick={startTvQrScanner}
disabled={!tvModeEnabled}
className='inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-rose-600 px-4 py-3 text-sm font-black text-white transition hover:bg-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/70 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 dark:disabled:bg-white/10 dark:disabled:text-slate-500'
>
打开相机扫码登录
<Smartphone className='h-4 w-4' />
</button>
<button
type='button'
onClick={() => {
if (!tvModeEnabled) return;
setIsSubscribeOpen(false);
setIsTVRemoteOpen(true);
}}
disabled={!tvModeEnabled}
className='inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500/70 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200 dark:disabled:bg-white/10 dark:disabled:text-slate-500'
>
<Sliders className='h-4 w-4' />
远程电视遥控器
</button>
</div>
</section>
)}
</>
)}
</div>
</div>
</>
);
// 修改密码面板内容
const changePasswordPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseChangePassword}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 修改密码面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
{/* 内容容器 - 独立的滚动区域 */}
<div
className='h-full p-6'
data-panel-content
onTouchMove={(e) => {
// 阻止事件冒泡到遮罩层,但允许内部滚动
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许所有触摸操作
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
修改密码
</h3>
<button
onClick={handleCloseChangePassword}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 表单 */}
<div className='space-y-4'>
{/* 新密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
新密码
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请输入新密码'
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 确认密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
确认密码
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请再次输入新密码'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 错误信息 */}
{passwordError && (
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
{passwordError}
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={handleCloseChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
disabled={passwordLoading}
>
取消
</button>
<button
onClick={handleSubmitChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
disabled={passwordLoading || !newPassword || !confirmPassword}
>
{passwordLoading ? '修改中...' : '确认修改'}
</button>
</div>
{/* 底部说明 */}
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
修改密码后需要重新登录
</p>
</div>
</div>
</div>
</>
);
// 举报信息弹窗
const reportPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1002]'
onClick={() => setIsReportOpen(false)}
onTouchMove={(e) => {
e.preventDefault();
}}
onWheel={(e) => {
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 举报信息面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1003] overflow-hidden'>
<div
className='h-full max-h-[70vh] flex flex-col'
data-panel-content
onTouchMove={(e) => {
e.stopPropagation();
}}
style={{
touchAction: 'auto',
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
耻辱柱
</h3>
<button
onClick={() => setIsReportOpen(false)}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 内容区域 */}
<div className='flex-1 overflow-y-auto p-6'>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-gray-800 dark:text-gray-200 leading-relaxed'>
抄袭狗
<span className='font-bold text-red-600 dark:text-red-400'>
SzeMeng76
</span>
毫无廉耻,盯着本项目的commit区,疯狂抄袭。警告亦全当看不见,实为开源界耻辱。
</p>
<p className='text-gray-800 dark:text-gray-200 leading-relaxed mt-3'>
超分,观影室,豆瓣反爬,精确搜索等等等等,直接抄袭,最不要脸的就是,刚更新一版,几小时后直接抄走。
</p>
<p className='text-gray-800 dark:text-gray-200 leading-relaxed mt-3'>
<span className='font-semibold text-red-600 dark:text-red-400'>
2026-02-25
</span>
抄袭emby功能
</p>
</div>
</div>
{/* 底部按钮 */}
<div className='p-6 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={() => setIsReportOpen(false)}
className='w-full px-4 py-2.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 font-medium rounded-lg transition-colors'
>
我知道了
</button>
</div>
</div>
</div>
</>
);
// 生态应用面板内容
const ecoAppsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={() => setIsEcoAppsOpen(false)}
onTouchMove={(e) => {
e.preventDefault();
}}
onWheel={(e) => {
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 生态应用面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
<div
className='h-full max-h-[85vh] flex flex-col'
data-panel-content
onTouchMove={(e) => {
e.stopPropagation();
}}
style={{
touchAction: 'auto',
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
生态应用
</h3>
<div className='flex items-center gap-2'>
{/* 举报按钮 */}
<button
onClick={() => setIsReportOpen(true)}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-lg'
aria-label='Report'
title='举报抄袭'
>
🐶
</button>
{/* 关闭按钮 */}
<button
onClick={() => setIsEcoAppsOpen(false)}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
</div>
{/* 应用列表 */}
<div className='flex-1 overflow-y-auto p-6'>
<div className='grid gap-6 md:grid-cols-1'>
{/* MoonTVPlus-PC 客户端 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<img
src='/logo.png'
alt='MoonTVPlus-PC'
className='w-16 h-16 rounded-xl object-cover'
/>
<div className='absolute -bottom-1 -right-1 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center shadow-lg'>
<Monitor className='w-3.5 h-3.5 text-white' />
</div>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
MoonTVPlus-PC客户端
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
专为Windows开发的客户端,完美支持私人影库mkv视频
</p>
<a
href='https://github.com/mtvpls/MoonTVPlus-PC/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
下载
<ExternalLink className='w-3 h-3' />
</a>
</div>
</div>
</div>
{/* Selene 跨平台客户端 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<img
src='/icons/Selene.png'
alt='Selene'
className='w-16 h-16 rounded-xl object-cover'
/>
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-orange-500 text-white text-[10px] font-bold rounded'>
二开
</span>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
Selene 跨平台客户端
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
多平台客户端
</p>
<div className='flex flex-wrap gap-2'>
<a
href='https://github.com/mtvpls/Selene-Build/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
下载
<ExternalLink className='w-3 h-3' />
</a>
</div>
</div>
</div>
</div>
{/* OrionTV TV专用客户端 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<img
src='/icons/OrionTV.png'
alt='OrionTV'
className='w-16 h-16 rounded-xl object-cover'
/>
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-orange-500 text-white text-[10px] font-bold rounded'>
二开
</span>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
OrionTV TV专用客户端
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
tv专用
</p>
<a
href='https://github.com/mtvpls/OrionTV_Build/tags'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
下载
<ExternalLink className='w-3 h-3' />
</a>
</div>
</div>
</div>
{/* 私人影库转码器 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<div className='w-16 h-16 rounded-xl bg-amber-500 flex items-center justify-center shadow-sm'>
<RouterIcon className='w-8 h-8 text-white' />
</div>
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-red-500 text-white text-[10px] font-bold rounded'>
MKV转码
</span>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
私人影库转码器
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
为私人影库中的 MKV
视频提供转码播放能力,可解析内封字幕并解决部分视频无音频问题,但通常需要较高的本机性能配置。
</p>
<a
href='https://github.com/mtvpls/moontvplus-transcoder/tags'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
下载
<ExternalLink className='w-3 h-3' />
</a>
</div>
</div>
</div>
</div>
</div>
{/* 底部说明 */}
<div className='p-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
选择适合您设备的客户端下载使用
</p>
</div>
</div>
</div>
</>
);
return (
<>
<div className='relative'>
<button
onClick={handleMenuClick}
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
aria-label='User Menu'
>
<User className='w-full h-full' />
</button>
{/* 版本更新红点 */}
{updateStatus === UpdateStatus.HAS_UPDATE && (
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
)}
{/* 未读通知红点 */}
{unreadCount > 0 && (
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-red-500 rounded-full'></div>
)}
</div>
{/* 使用 Portal 将菜单面板渲染到 document.body */}
{isOpen && mounted && createPortal(menuPanel, document.body)}
<PersonalCenterPanel
isOpen={isProfileCenterOpen}
mounted={mounted}
onClose={() => setIsProfileCenterOpen(false)}
username={currentUsername}
roleText={currentRoleText}
showRoleBadge={shouldShowRoleBadge}
avatarText={avatarText}
roleBadgeClassName={roleBadgeClassName}
showDeviceManagement={storageType !== 'localstorage'}
showChangePassword={showChangePassword}
onOpenEmailSettings={() => {
setIsProfileCenterOpen(false);
setIsEmailSettingsOpen(true);
loadEmailSettings();
}}
onOpenDeviceManagement={() => {
setIsProfileCenterOpen(false);
setIsDeviceManagementOpen(true);
loadDevices();
}}
onOpenChangePassword={() => {
setIsProfileCenterOpen(false);
handleChangePassword();
}}
/>
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
{/* 使用 Portal 将修改密码面板渲染到 document.body */}
{isChangePasswordOpen &&
mounted &&
createPortal(changePasswordPanel, document.body)}
{/* 使用 Portal 将订阅面板渲染到 document.body */}
{isSubscribeOpen &&
mounted &&
createPortal(subscribePanel, document.body)}
{/* 版本面板 */}
<VersionPanel
isOpen={isVersionPanelOpen}
onClose={() => setIsVersionPanelOpen(false)}
/>
{/* 离线下载面板 */}
<OfflineDownloadPanel
isOpen={isOfflineDownloadPanelOpen}
onClose={() => setIsOfflineDownloadPanelOpen(false)}
/>
{/* 使用 Portal 将通知面板渲染到 document.body */}
{isNotificationPanelOpen &&
mounted &&
createPortal(
<NotificationPanel
isOpen={isNotificationPanelOpen}
onClose={() => {
setIsNotificationPanelOpen(false);
// 不需要在这里刷新,NotificationPanel 内部会触发事件
}}
/>,
document.body
)}
{/* 使用 Portal 将收藏面板渲染到 document.body */}
{isFavoritesPanelOpen &&
mounted &&
createPortal(
<FavoritesPanel
isOpen={isFavoritesPanelOpen}
onClose={() => setIsFavoritesPanelOpen(false)}
/>,
document.body
)}
{/* 使用 Portal 将下载文件管理面板渲染到 document.body */}
{isDownloadManagementOpen &&
mounted &&
createPortal(
<DownloadManagementPanel
isOpen={isDownloadManagementOpen}
onClose={() => setIsDownloadManagementOpen(false)}
/>,
document.body
)}
<EmailSettingsPanel
isOpen={isEmailSettingsOpen}
mounted={mounted}
onClose={() => setIsEmailSettingsOpen(false)}
userEmail={userEmail}
onUserEmailChange={setUserEmail}
emailNotifications={emailNotifications}
onEmailNotificationsChange={setEmailNotifications}
emailSettingsLoading={emailSettingsLoading}
emailSettingsSaving={emailSettingsSaving}
onSave={handleSaveEmailSettings}
statusMessage={emailSettingsMessage}
statusType={emailSettingsMessageType}
/>
<DeviceManagementPanel
isOpen={isDeviceManagementOpen}
mounted={mounted}
onClose={() => setIsDeviceManagementOpen(false)}
devices={devices}
devicesLoading={devicesLoading}
revoking={revoking}
onRevokeDevice={handleRevokeDevice}
onRevokeAllDevices={handleRevokeAllDevices}
getDeviceIcon={getDeviceIcon}
/>
<TVRemotePanel
isOpen={isTVRemoteOpen}
mounted={mounted}
onClose={() => setIsTVRemoteOpen(false)}
/>
{/* 使用 Portal 将生态应用面板渲染到 document.body */}
{isEcoAppsOpen && mounted && createPortal(ecoAppsPanel, document.body)}
{/* 使用 Portal 将举报信息面板渲染到 document.body */}
{isReportOpen && mounted && createPortal(reportPanel, document.body)}
{/* 确认对话框 */}
{confirmDialog.isOpen &&
mounted &&
createPortal(
<div className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md m-4'>
{/* 标题 */}
<div className='p-6 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{confirmDialog.title}
</h3>
</div>
{/* 内容 */}
<div className='p-6'>
<p className='text-gray-700 dark:text-gray-300'>
{confirmDialog.message}
</p>
</div>
{/* 按钮 */}
<div className='p-6 pt-0 flex gap-3 justify-end'>
<button
onClick={() =>
setConfirmDialog({ ...confirmDialog, isOpen: false })
}
className='px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors'
>
取消
</button>
<button
onClick={confirmDialog.onConfirm}
className='px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 rounded-lg transition-colors'
>
确定
</button>
</div>
</div>
</div>,
document.body
)}
</>
);
};