Files
MoonTVPlus/src/components/UserMenu.tsx
2026-03-26 09:32:19 +08:00

3776 lines
161 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
KeyRound,
LogOut,
Mail,
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 { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { clearAllDanmakuCache } from '@/lib/danmaku/api';
import { CURRENT_VERSION } from '@/lib/version';
import { UpdateStatus } from '@/lib/version_check';
import { FavoritesPanel } from './FavoritesPanel';
import { NotificationPanel } from './NotificationPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
import { DownloadManagementPanel } from './DownloadManagementPanel';
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 [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 [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
// 订阅相关状态
const [subscribeEnabled, setSubscribeEnabled] = useState(false);
const [subscribeUrl, setSubscribeUrl] = useState('');
const [copySuccess, setCopySuccess] = 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);
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen || isReportOpen || isDownloadManagementOpen) {
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;
};
}
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen, isEcoAppsOpen]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
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 [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
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 [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;
}
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 [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
// 豆瓣数据源选项
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 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);
// 确保组件已挂载
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);
}
};
// 首次加载时检查未读通知数量(使用全局标记避免多个实例重复请求)
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(() => {
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);
}
}, []);
// 懒加载订阅 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 type =
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
setStorageType(type);
}
}, []);
// 从 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 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 savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
if (savedTmdbImageBaseUrl !== null) {
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
}
const savedEnableOptimization =
localStorage.getItem('enableOptimization');
if (savedEnableOptimization !== null) {
setEnableOptimization(JSON.parse(savedEnableOptimization));
}
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');
}
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 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);
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);
try {
const response = await fetch('/api/user/email-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
emailNotifications,
}),
});
const messageEl = document.getElementById('email-settings-message');
if (response.ok) {
if (messageEl) {
messageEl.textContent = '保存成功!';
messageEl.className = 'text-xs text-center text-green-600 dark:text-green-400';
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';
messageEl.classList.remove('hidden');
}
}
} catch (error) {
console.error('保存邮件设置失败:', error);
const messageEl = document.getElementById('email-settings-message');
if (messageEl) {
messageEl.textContent = '保存失败,请重试';
messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400';
messageEl.classList.remove('hidden');
}
} 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 (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]);
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 handleSubscribe = async () => {
setIsOpen(false);
setIsSubscribeOpen(true);
setCopySuccess(false);
// 懒加载:打开面板时才请求订阅URL
await fetchSubscribeUrl();
};
const handleCloseSubscribe = () => {
setIsSubscribeOpen(false);
setCopySuccess(false);
};
const handleCopySubscribeUrl = async () => {
try {
await navigator.clipboard.writeText(subscribeUrl);
setCopySuccess(true);
setTimeout(() => {
setCopySuccess(false);
}, 2000);
} catch (error) {
console.error('复制失败:', 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 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 handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyType', value);
}
};
const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrl', 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 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;
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setFluidSearch(defaultFluidSearch);
setTmdbBackdropDisabled(false);
setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
setTmdbImageBaseUrl('https://image.tmdb.org');
setBufferStrategy('medium');
setNextEpisodePreCache(true);
setNextEpisodeDanmakuPreload(true);
setDisableAutoLoadDanmaku(false);
setHomeBannerEnabled(true);
setHomeContinueWatchingEnabled(true);
setHomeModules(defaultHomeModules);
setSearchTraditionalToSimplified(false);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
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('doubanImageProxyType', defaultDoubanImageProxyType);
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
localStorage.setItem('bufferStrategy', 'medium');
localStorage.setItem('nextEpisodePreCache', 'true');
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
localStorage.setItem('disableAutoLoadDanmaku', 'false');
localStorage.setItem('danmakuMaxCount', '0');
localStorage.setItem('danmaku_heatmap_disabled', 'false');
localStorage.setItem('homeBannerEnabled', 'true');
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('弹幕缓存已清除成功!');
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 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-2.5 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='space-y-1'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-0.5'>
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</span>
{/* 邮件设置图标按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
setIsEmailSettingsOpen(true);
// 懒加载:打开面板时才请求邮件设置
loadEmailSettings();
}}
className='p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='邮件通知设置'
>
<Mail className='w-3 h-3 text-gray-500 dark:text-gray-400' />
</button>
{/* 设备管理图标按钮 */}
{storageType !== 'localstorage' && (
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
setIsDeviceManagementOpen(true);
// 懒加载:打开面板时才请求设备列表
loadDevices();
}}
className='p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='设备管理'
>
<Monitor className='w-3 h-3 text-gray-500 dark:text-gray-400' />
</button>
)}
</div>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${(authInfo?.role || 'user') === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: (authInfo?.role || 'user') === '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'
}`}
>
{getRoleText(authInfo?.role || 'user')}
</span>
</div>
<div className='flex items-center justify-between'>
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'>
{authInfo?.username || 'default'}
</div>
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
{storageType === 'localstorage' ? '本地' : storageType}
</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>
)}
{/* 修改密码按钮 */}
{showChangePassword && (
<button
onClick={handleChangePassword}
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'
>
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
)}
{/* 订阅按钮 */}
{subscribeEnabled && (
<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'
>
<Rss 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)}
/>
</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)
}
/>
</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>
)}
</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='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'>
</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 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>
{/* 模块列表 */}
<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>
{/* 恢复默认按钮 */}
<button
onClick={() => {
setHomeModules(defaultHomeModules);
setHomeBannerEnabled(true);
setHomeContinueWatchingEnabled(true);
if (typeof window !== 'undefined') {
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
localStorage.setItem('homeBannerEnabled', 'true');
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/50 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 -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'>
TVBox订阅
</h3>
<button
onClick={handleCloseSubscribe}
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'>
{isLoadingSubscribeUrl ? (
<>
{/* 加载骨架 - 开关 */}
<div>
<div className='h-5 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-3 animate-pulse'></div>
<div className='space-y-2'>
<div className='h-14 bg-gray-200 dark:bg-gray-700 rounded animate-pulse'></div>
<div className='h-14 bg-gray-200 dark:bg-gray-700 rounded animate-pulse'></div>
</div>
</div>
{/* 加载骨架 - 订阅链接 */}
<div>
<div className='h-5 w-28 bg-gray-200 dark:bg-gray-700 rounded mb-2 animate-pulse'></div>
<div className='flex gap-2'>
<div className='flex-1 h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse'></div>
<div className='w-20 h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse'></div>
</div>
<div className='h-4 w-full bg-gray-200 dark:bg-gray-700 rounded mt-1 animate-pulse'></div>
</div>
{/* 加载骨架 - 重置按钮 */}
<div className='pt-2'>
<div className='w-full h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse'></div>
<div className='h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded mt-2 mx-auto animate-pulse'></div>
</div>
</>
) : (
<>
<div className='space-y-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
type='button'
onClick={() => setSubscribeAdFilterEnabled((prev) => !prev)}
className='w-full flex items-center justify-between rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3 text-left bg-gray-50 dark:bg-gray-800/70'
>
<div>
<div className='text-sm font-medium text-gray-800 dark:text-gray-200'>
广
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</div>
</div>
<div className={`relative h-6 w-11 rounded-full transition-colors ${subscribeAdFilterEnabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`}>
<div className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${subscribeAdFilterEnabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
</div>
</button>
<button
type='button'
onClick={() => setSubscribeYellowFilterEnabled((prev) => !prev)}
className='w-full flex items-center justify-between rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3 text-left bg-gray-50 dark:bg-gray-800/70'
>
<div>
<div className='text-sm font-medium text-gray-800 dark:text-gray-200'>
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</div>
</div>
<div className={`relative h-6 w-11 rounded-full transition-colors ${subscribeYellowFilterEnabled ? 'bg-yellow-500' : 'bg-gray-300 dark:bg-gray-600'}`}>
<div className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${subscribeYellowFilterEnabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
</div>
</button>
</div>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</h4>
<div className='flex gap-2'>
<input
type='text'
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
value={subscribeUrl}
readOnly
/>
<button
onClick={handleCopySubscribeUrl}
className='px-4 py-2 bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 text-white text-sm font-medium rounded-md transition-colors flex items-center gap-2 whitespace-nowrap'
>
<Copy className='w-4 h-4' />
{copySuccess ? '已复制' : '复制'}
</button>
</div>
{(subscribeAdFilterEnabled || subscribeYellowFilterEnabled) && (
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
💡
</p>
)}
</div>
{/* 重置Token按钮 */}
<div className='pt-2'>
<button
onClick={handleResetToken}
disabled={isResettingToken}
className='w-full px-4 py-2 bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600 text-white text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
{isResettingToken ? '重置中...' : '重置订阅Token'}
</button>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-2 text-center'>
</p>
{/* 消息提示 */}
<p id='tvbox-token-message' className='text-xs text-center hidden'></p>
</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'>
TVBox应用中使用
</p>
</div>
</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 emailSettingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={() => setIsEmailSettingsOpen(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-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={() => setIsEmailSettingsOpen(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>
{/* 表单 */}
{emailSettingsLoading ? (
<div className='space-y-4'>
{/* 加载骨架屏 */}
<div className='animate-pulse'>
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2'></div>
<div className='h-10 bg-gray-200 dark:bg-gray-700 rounded'></div>
</div>
<div className='animate-pulse'>
<div className='h-20 bg-gray-200 dark:bg-gray-700 rounded'></div>
</div>
<div className='animate-pulse'>
<div className='h-10 bg-gray-200 dark:bg-gray-700 rounded'></div>
</div>
<div className='text-center text-sm text-gray-500 dark:text-gray-400'>
...
</div>
</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='email'
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder='输入您的邮箱地址'
disabled={emailSettingsSaving}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
<div className='flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg'>
<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>
<button
onClick={() => setEmailNotifications(!emailNotifications)}
disabled={emailSettingsSaving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
emailNotifications ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
emailNotifications ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<button
onClick={handleSaveEmailSettings}
disabled={emailSettingsSaving}
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 dark:disabled:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed flex items-center justify-center gap-2'
>
{emailSettingsSaving ? (
<>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
<span>...</span>
</>
) : (
'保存设置'
)}
</button>
<p id='email-settings-message' className='text-xs text-center hidden'></p>
</div>
)}
{/* 提示信息 */}
<div className='mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
<p className='text-xs text-blue-800 dark:text-blue-200'>
💡
</p>
</div>
</div>
</div>
</>
);
// 设备管理面板内容
const deviceManagementPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={() => setIsDeviceManagementOpen(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-[1001] overflow-hidden'
>
<div
className='h-full max-h-[80vh] 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={() => setIsDeviceManagementOpen(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'>
{devicesLoading ? (
<div className='space-y-3'>
{[1, 2, 3].map((i) => (
<div key={i} className='animate-pulse'>
<div className='h-20 bg-gray-200 dark:bg-gray-700 rounded-lg'></div>
</div>
))}
<div className='text-center text-sm text-gray-500 dark:text-gray-400 mt-4'>
...
</div>
</div>
) : devices.length === 0 ? (
<div className='text-center py-8'>
<Monitor className='w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-3' />
<p className='text-sm text-gray-500 dark:text-gray-400'></p>
</div>
) : (
<div className='space-y-3'>
{devices
.sort((a, b) => {
// 当前设备置顶
if (a.isCurrent && !b.isCurrent) return -1;
if (!a.isCurrent && b.isCurrent) return 1;
return 0;
})
.map((device) => {
const DeviceIcon = getDeviceIcon(device.deviceInfo);
return (
<div
key={device.tokenId}
className={`p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border ${
device.isCurrent
? 'border-yellow-400 dark:border-yellow-500'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className='flex items-start justify-between'>
<div className='flex-1'>
<div className='flex items-center gap-2 mb-2'>
<DeviceIcon className='w-4 h-4 text-gray-600 dark:text-gray-400' />
<span className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{device.deviceInfo}
</span>
{device.isCurrent && (
<span className='px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full'>
</span>
)}
</div>
<div className='space-y-1 text-xs text-gray-500 dark:text-gray-400'>
<div>: {new Date(device.createdAt).toLocaleString('zh-CN')}</div>
<div>: {new Date(device.lastUsed).toLocaleString('zh-CN')}</div>
</div>
</div>
{!device.isCurrent && (
<button
onClick={() => handleRevokeDevice(device.tokenId)}
disabled={revoking === device.tokenId}
className='ml-3 px-3 py-1.5 text-xs font-medium text-red-600 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 disabled:opacity-50 disabled:cursor-not-allowed'
>
{revoking === device.tokenId ? '撤销中...' : '撤销'}
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部操作 */}
<div className='p-6 border-t border-gray-200 dark:border-gray-700 space-y-3'>
<button
onClick={handleRevokeAllDevices}
disabled={devices.length === 0}
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 disabled:cursor-not-allowed'
>
</button>
<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/MoonTVPlus/releases/tag/OrionTV%E9%80%82%E9%85%8D%E7%89%882'
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)}
{/* 使用 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
)}
{/* 使用 Portal 将邮件设置面板渲染到 document.body */}
{isEmailSettingsOpen &&
mounted &&
createPortal(emailSettingsPanel, document.body)}
{/* 使用 Portal 将设备管理面板渲染到 document.body */}
{isDeviceManagementOpen &&
mounted &&
createPortal(deviceManagementPanel, document.body)}
{/* 使用 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
)}
</>
);
};