/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ 'use client'; import { closestCenter, DndContext, PointerSensor, TouchSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { restrictToParentElement, restrictToVerticalAxis, } from '@dnd-kit/modifiers'; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { AlertCircle, AlertTriangle, Bot, Cat, Check, CheckCircle, ChevronDown, ChevronUp, Database, ExternalLink, FileText, FolderOpen, Globe, Mail, Music, Palette, Settings, Tv, UserPlus, Users, Video, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import AnimeSubscriptionComponent from '@/components/AnimeSubscriptionComponent'; import CorrectDialog from '@/components/CorrectDialog'; import DataMigration from '@/components/DataMigration'; import PageLayout from '@/components/PageLayout'; // 统一按钮样式系统 const buttonStyles = { // 主要操作按钮(蓝色)- 用于配置、设置、确认等 primary: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors', // 成功操作按钮(绿色)- 用于添加、启用、保存等 success: 'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors', // 危险操作按钮(红色)- 用于删除、禁用、重置等 danger: 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors', // 次要操作按钮(灰色)- 用于取消、关闭等 secondary: 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors', // 警告操作按钮(黄色)- 用于批量禁用等 warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors', // 小尺寸主要按钮 primarySmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors', // 小尺寸成功按钮 successSmall: 'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors', // 小尺寸危险按钮 dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors', // 小尺寸次要按钮 secondarySmall: 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors', // 小尺寸警告按钮 warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors', // 圆角小按钮(用于表格操作) roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors', roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-200 transition-colors', roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors', roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors', roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors', roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors', // 禁用状态 disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors', disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors', // 开关按钮样式 toggleOn: 'bg-green-600 dark:bg-green-600', toggleOff: 'bg-gray-200 dark:bg-gray-700', toggleThumb: 'bg-white', toggleThumbOn: 'translate-x-6', toggleThumbOff: 'translate-x-1', // 快速操作按钮样式 quickAction: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors', }; // 通用弹窗组件 interface AlertModalProps { isOpen: boolean; onClose: () => void; type: 'success' | 'error' | 'warning'; title: string; message?: string; timer?: number; showConfirm?: boolean; onConfirm?: () => void; } const AlertModal = ({ isOpen, onClose, type, title, message, timer, showConfirm = false, onConfirm, }: AlertModalProps) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { if (isOpen) { setIsVisible(true); if (timer) { setTimeout(() => { onClose(); }, timer); } } else { setIsVisible(false); } }, [isOpen, timer, onClose]); if (!isOpen) return null; const getIcon = () => { switch (type) { case 'success': return ; case 'error': return ; case 'warning': return ; default: return null; } }; const getBgColor = () => { switch (type) { case 'success': return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'; case 'error': return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'; case 'warning': return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'; default: return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'; } }; return createPortal(
{getIcon()}

{title}

{message && (

{message}

)} {showConfirm ? ( onConfirm ? ( // 确认操作:显示取消和确定按钮
) : ( // 普通提示:只显示确定按钮 ) ) : null}
, document.body ); }; // 弹窗状态管理 const useAlertModal = () => { const [alertModal, setAlertModal] = useState<{ isOpen: boolean; type: 'success' | 'error' | 'warning'; title: string; message?: string; timer?: number; showConfirm?: boolean; onConfirm?: () => void; }>({ isOpen: false, type: 'success', title: '', }); const showAlert = (config: Omit) => { setAlertModal({ ...config, isOpen: true }); }; const hideAlert = () => { setAlertModal((prev) => ({ ...prev, isOpen: false })); }; return { alertModal, showAlert, hideAlert }; }; // 统一弹窗方法(必须在首次使用前定义) const showError = (message: string, showAlert?: (config: any) => void) => { if (showAlert) { showAlert({ type: 'error', title: '错误', message, showConfirm: true }); } else { console.error(message); } }; const showSuccess = (message: string, showAlert?: (config: any) => void) => { if (showAlert) { showAlert({ type: 'success', title: '成功', message, timer: 2000 }); } else { console.log(message); } }; // 通用加载状态管理系统 interface LoadingState { [key: string]: boolean; } const useLoadingState = () => { const [loadingStates, setLoadingStates] = useState({}); const setLoading = (key: string, loading: boolean) => { setLoadingStates((prev) => ({ ...prev, [key]: loading })); }; const isLoading = (key: string) => loadingStates[key] || false; const withLoading = async ( key: string, operation: () => Promise ): Promise => { setLoading(key, true); try { const result = await operation(); return result; } finally { setLoading(key, false); } }; return { loadingStates, setLoading, isLoading, withLoading }; }; interface StandaloneSourceScript { id: string; key: string; name: string; description?: string; enabled: boolean; version: string; code: string; createdAt: number; updatedAt: number; } // 新增站点配置类型 interface SiteConfig { SiteName: string; Announcement: string; SearchDownstreamMaxPage: number; SiteInterfaceCacheTime: number; DoubanProxyType: string; DoubanProxy: string; DoubanImageProxyType: string; DoubanImageProxy: string; DisableYellowFilter: boolean; FluidSearch: boolean; DanmakuApiBase: string; DanmakuApiToken: string; TMDBApiKey?: string; TMDBProxy?: string; TMDBReverseProxy?: string; BannerDataSource?: string; RecommendationDataSource?: string; PansouApiUrl?: string; PansouUsername?: string; PansouPassword?: string; PansouKeywordBlocklist?: string; EnableComments: boolean; EnableRegistration?: boolean; RegistrationRequireTurnstile?: boolean; LoginRequireTurnstile?: boolean; TurnstileSiteKey?: string; TurnstileSecretKey?: string; DefaultUserTags?: string[]; EnableOIDCLogin?: boolean; EnableOIDCRegistration?: boolean; OIDCIssuer?: string; OIDCAuthorizationEndpoint?: string; OIDCTokenEndpoint?: string; OIDCUserInfoEndpoint?: string; OIDCClientId?: string; OIDCClientSecret?: string; OIDCButtonText?: string; } // 视频源数据类型 interface DataSource { name: string; key: string; api: string; detail?: string; disabled?: boolean; from: 'config' | 'custom'; proxyMode?: boolean; weight?: number; } // 直播源数据类型 interface LiveDataSource { name: string; key: string; url: string; ua?: string; epg?: string; channelNumber?: number; disabled?: boolean; from: 'config' | 'custom'; proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式 } // 自定义分类数据类型 interface CustomCategory { name?: string; type: 'movie' | 'tv'; query: string; disabled?: boolean; from: 'config' | 'custom'; } // 可折叠标签组件 interface CollapsibleTabProps { title: string; icon?: React.ReactNode; isExpanded: boolean; onToggle: () => void; children: React.ReactNode; isParent?: boolean; } const CollapsibleTab = ({ title, icon, isExpanded, onToggle, children, isParent = false, }: CollapsibleTabProps) => { return (
{isExpanded &&
{children}
}
); }; // 用户配置组件 interface UserConfigProps { config: AdminConfig | null; role: 'owner' | 'admin' | null; refreshConfig: () => Promise; usersV2: Array<{ username: string; role: 'owner' | 'admin' | 'user'; banned: boolean; tags?: string[]; oidcSub?: string; enabledApis?: string[]; created_at: number; }> | null; userPage: number; userTotalPages: number; userTotal: number; fetchUsersV2: (page: number) => Promise; userListLoading: boolean; } const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalPages, userTotal, fetchUsersV2, userListLoading }: UserConfigProps) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [showAddUserForm, setShowAddUserForm] = useState(false); const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); const [showAddUserGroupForm, setShowAddUserGroupForm] = useState(false); const [showEditUserGroupForm, setShowEditUserGroupForm] = useState(false); const [newUser, setNewUser] = useState({ username: '', password: '', userGroup: '', // 新增用户组字段 }); const [changePasswordUser, setChangePasswordUser] = useState({ username: '', password: '', }); const [newUserGroup, setNewUserGroup] = useState({ name: '', enabledApis: [] as string[], }); const [editingUserGroup, setEditingUserGroup] = useState<{ name: string; enabledApis: string[]; } | null>(null); const [showConfigureApisModal, setShowConfigureApisModal] = useState(false); const [selectedUser, setSelectedUser] = useState<{ username: string; role: 'user' | 'admin' | 'owner'; enabledApis?: string[]; tags?: string[]; } | null>(null); const [selectedApis, setSelectedApis] = useState([]); const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false); const [selectedUserForGroup, setSelectedUserForGroup] = useState<{ username: string; role: 'user' | 'admin' | 'owner'; tags?: string[]; } | null>(null); const [selectedUserGroups, setSelectedUserGroups] = useState([]); const [selectedUsers, setSelectedUsers] = useState>(new Set()); const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false); const [selectedUserGroup, setSelectedUserGroup] = useState(''); const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false); const [deletingUserGroup, setDeletingUserGroup] = useState<{ name: string; affectedUsers: Array<{ username: string; role: 'user' | 'admin' | 'owner'; }>; } | null>(null); const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); const [deletingUser, setDeletingUser] = useState(null); // 当前登录用户名 const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; // 判断是否有旧版用户数据需要迁移 const hasOldUserData = config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ?? 0 > 0; // 使用新版本用户列表(如果可用且没有旧数据),否则使用配置中的用户列表 const displayUsers: Array<{ username: string; role: 'owner' | 'admin' | 'user'; banned?: boolean; enabledApis?: string[]; tags?: string[]; created_at?: number; oidcSub?: string; }> = !hasOldUserData && usersV2 ? usersV2 : (config?.UserConfig?.Users || []); // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 const selectAllUsers = useMemo(() => { const selectableUserCount = displayUsers?.filter( (user) => role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername)) ).length || 0; return selectedUsers.size === selectableUserCount && selectedUsers.size > 0; }, [selectedUsers.size, displayUsers, role, currentUsername]); // 获取用户组列表 const userGroups = config?.UserConfig?.Tags || []; // 处理用户组相关操作 const handleUserGroupAction = async ( action: 'add' | 'edit' | 'delete', groupName: string, enabledApis?: string[] ) => { return withLoading(`userGroup_${action}_${groupName}`, async () => { try { const res = await fetch('/api/admin/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'userGroup', groupAction: action, groupName, enabledApis, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${res.status}`); } await refreshConfig(); if (action === 'add') { setNewUserGroup({ name: '', enabledApis: [] }); setShowAddUserGroupForm(false); } else if (action === 'edit') { setEditingUserGroup(null); setShowEditUserGroupForm(false); } showSuccess( action === 'add' ? '用户组添加成功' : action === 'edit' ? '用户组更新成功' : '用户组删除成功', showAlert ); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; } }); }; const handleAddUserGroup = () => { if (!newUserGroup.name.trim()) return; handleUserGroupAction('add', newUserGroup.name, newUserGroup.enabledApis); }; const handleEditUserGroup = () => { if (!editingUserGroup?.name.trim()) return; handleUserGroupAction( 'edit', editingUserGroup.name, editingUserGroup.enabledApis ); }; const handleDeleteUserGroup = (groupName: string) => { // 计算会受影响的用户数量 const affectedUsers = config?.UserConfig?.Users?.filter( (user) => user.tags && user.tags.includes(groupName) ) || []; setDeletingUserGroup({ name: groupName, affectedUsers: affectedUsers.map((u) => ({ username: u.username, role: u.role, })), }); setShowDeleteUserGroupModal(true); }; const handleConfirmDeleteUserGroup = async () => { if (!deletingUserGroup) return; try { await handleUserGroupAction('delete', deletingUserGroup.name); setShowDeleteUserGroupModal(false); setDeletingUserGroup(null); } catch (err) { // 错误处理已在 handleUserGroupAction 中处理 } }; const handleStartEditUserGroup = (group: { name: string; enabledApis: string[]; }) => { setEditingUserGroup({ ...group }); setShowEditUserGroupForm(true); setShowAddUserGroupForm(false); }; // 为用户分配用户组 const handleAssignUserGroup = async ( username: string, userGroups: string[] ) => { return withLoading(`assignUserGroup_${username}`, async () => { try { const res = await fetch('/api/admin/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetUsername: username, action: 'updateUserGroups', userGroups, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${res.status}`); } await refreshConfig(); showSuccess('用户组分配成功', showAlert); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; } }); }; const handleBanUser = async (uname: string) => { await withLoading(`banUser_${uname}`, () => handleUserAction('ban', uname)); }; const handleUnbanUser = async (uname: string) => { await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname) ); }; const handleSetAdmin = async (uname: string) => { await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname) ); }; const handleRemoveAdmin = async (uname: string) => { await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname) ); }; const handleAddUser = async () => { if (!newUser.username || !newUser.password) return; await withLoading('addUser', async () => { await handleUserAction( 'add', newUser.username, newUser.password, newUser.userGroup ); setNewUser({ username: '', password: '', userGroup: '' }); setShowAddUserForm(false); }); }; const handleChangePassword = async () => { if (!changePasswordUser.username || !changePasswordUser.password) return; await withLoading( `changePassword_${changePasswordUser.username}`, async () => { await handleUserAction( 'changePassword', changePasswordUser.username, changePasswordUser.password ); setChangePasswordUser({ username: '', password: '' }); setShowChangePasswordForm(false); } ); }; const handleShowChangePasswordForm = (username: string) => { setChangePasswordUser({ username, password: '' }); setShowChangePasswordForm(true); setShowAddUserForm(false); // 关闭添加用户表单 }; const handleDeleteUser = (username: string) => { setDeletingUser(username); setShowDeleteUserModal(true); }; const handleConfigureUserApis = (user: { username: string; role: 'user' | 'admin' | 'owner'; enabledApis?: string[]; }) => { setSelectedUser(user); setSelectedApis(user.enabledApis || []); setShowConfigureApisModal(true); }; const handleConfigureUserGroup = (user: { username: string; role: 'user' | 'admin' | 'owner'; tags?: string[]; }) => { setSelectedUserForGroup(user); setSelectedUserGroups(user.tags || []); setShowConfigureUserGroupModal(true); }; const handleSaveUserGroups = async () => { if (!selectedUserForGroup) return; await withLoading( `saveUserGroups_${selectedUserForGroup.username}`, async () => { try { await handleAssignUserGroup( selectedUserForGroup.username, selectedUserGroups ); setShowConfigureUserGroupModal(false); setSelectedUserForGroup(null); setSelectedUserGroups([]); } catch (err) { // 错误处理已在 handleAssignUserGroup 中处理 } } ); }; // 处理用户选择 const handleSelectUser = useCallback((username: string, checked: boolean) => { setSelectedUsers((prev) => { const newSelectedUsers = new Set(prev); if (checked) { newSelectedUsers.add(username); } else { newSelectedUsers.delete(username); } return newSelectedUsers; }); }, []); const handleSelectAllUsers = useCallback( (checked: boolean) => { if (checked) { // 只选择自己有权限操作的用户 const selectableUsernames = config?.UserConfig?.Users?.filter( (user) => role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername)) ).map((u) => u.username) || []; setSelectedUsers(new Set(selectableUsernames)); } else { setSelectedUsers(new Set()); } }, [config?.UserConfig?.Users, role, currentUsername] ); // 批量设置用户组 const handleBatchSetUserGroup = async (userGroup: string) => { if (selectedUsers.size === 0) return; await withLoading('batchSetUserGroup', async () => { try { const res = await fetch('/api/admin/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'batchUpdateUserGroups', usernames: Array.from(selectedUsers), userGroups: userGroup === '' ? [] : [userGroup], }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${res.status}`); } const userCount = selectedUsers.size; setSelectedUsers(new Set()); setShowBatchUserGroupModal(false); setSelectedUserGroup(''); showSuccess( `已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert ); // 刷新配置 await refreshConfig(); } catch (err) { showError('批量设置用户组失败', showAlert); throw err; } }); }; // 提取URL域名的辅助函数 const extractDomain = (url: string): string => { try { const urlObj = new URL(url); return urlObj.hostname; } catch { // 如果URL格式不正确,返回原字符串 return url; } }; const handleSaveUserApis = async () => { if (!selectedUser) return; await withLoading(`saveUserApis_${selectedUser.username}`, async () => { try { const res = await fetch('/api/admin/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetUsername: selectedUser.username, action: 'updateUserApis', enabledApis: selectedApis, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${res.status}`); } // 成功后刷新配置 await refreshConfig(); setShowConfigureApisModal(false); setSelectedUser(null); setSelectedApis([]); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; } }); }; // 通用请求函数 const handleUserAction = async ( action: | 'add' | 'ban' | 'unban' | 'setAdmin' | 'cancelAdmin' | 'changePassword' | 'deleteUser', targetUsername: string, targetPassword?: string, userGroup?: string ) => { try { const res = await fetch('/api/admin/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetUsername, ...(targetPassword ? { targetPassword } : {}), ...(userGroup ? { userGroup } : {}), action, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${res.status}`); } // 成功后刷新配置和用户列表(refreshConfig 已经是 refreshConfigAndUsers) await refreshConfig(); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); } }; const handleConfirmDeleteUser = async () => { if (!deletingUser) return; await withLoading(`deleteUser_${deletingUser}`, async () => { try { await handleUserAction('deleteUser', deletingUser); setShowDeleteUserModal(false); setDeletingUser(null); } catch (err) { // 错误处理已在 handleUserAction 中处理 } }); }; if (!config) { return (
加载中...
); } return (
{/* 用户统计 */}

用户统计

{!hasOldUserData && usersV2 ? userTotal : displayUsers.length}
总用户数
{/* 数据迁移提示 */} {config.UserConfig.Users && config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && (
检测到旧版用户数据

建议迁移到新的用户存储结构,以获得更好的性能和安全性。迁移后用户密码将使用SHA256加密。

)}
{/* 用户组管理 */}

用户组管理

{/* 用户组列表 */}
{userGroups.map((group) => ( ))} {userGroups.length === 0 && ( )}
用户组名称 可用视频源 操作
{group.name}
{group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` : '无限制'}
暂无用户组,请添加用户组来管理用户权限
{/* 用户列表 */}

用户列表

{/* 批量操作按钮 */} {selectedUsers.size > 0 && ( <>
已选择 {selectedUsers.size} 个用户
)}
{/* 添加用户表单 */} {showAddUserForm && (
setNewUser((prev) => ({ ...prev, username: e.target.value, })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' /> setNewUser((prev) => ({ ...prev, password: e.target.value, })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' />
)} {/* 修改密码表单 */} {showChangePasswordForm && (
修改用户密码
setChangePasswordUser((prev) => ({ ...prev, password: e.target.value, })) } className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' />
)} {/* 用户列表 */}
{/* 迁移遮罩层 */} {config.UserConfig.Users && config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && (

需要迁移数据

检测到旧版用户数据,请先迁移到新的存储结构后再进行用户管理操作。

请在上方的"用户统计"区域点击"立即迁移"按钮完成数据迁移。

)}
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */} {(() => { // 如果正在加载,显示加载状态 if (userListLoading) { return ( ); } const sortedUsers = [...displayUsers].sort((a, b) => { type UserInfo = (typeof displayUsers)[number]; const priority = (u: UserInfo) => { if (u.username === currentUsername) return 0; if (u.role === 'owner') return 1; if (u.role === 'admin') return 2; return 3; }; return priority(a) - priority(b); }); return ( {sortedUsers.map((user) => { // 修改密码权限:站长可修改管理员和普通用户密码,管理员可修改普通用户和自己的密码,但任何人都不能修改站长密码 const canChangePassword = user.role !== 'owner' && // 不能修改站长密码 (role === 'owner' || // 站长可以修改管理员和普通用户密码 (role === 'admin' && (user.role === 'user' || user.username === currentUsername))); // 管理员可以修改普通用户和自己的密码 // 删除用户权限:站长可删除除自己外的所有用户,管理员仅可删除普通用户 const canDeleteUser = user.username !== currentUsername && (role === 'owner' || // 站长可以删除除自己外的所有用户 (role === 'admin' && user.role === 'user')); // 管理员仅可删除普通用户 // 其他操作权限:不能操作自己,站长可操作所有用户,管理员可操作普通用户 const canOperate = user.username !== currentUsername && (role === 'owner' || (role === 'admin' && user.role === 'user')); return ( ); })} ); })()}
{(() => { // 检查是否有权限操作任何用户 const hasAnyPermission = config?.UserConfig?.Users?.some( (user) => role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername)) ); return hasAnyPermission ? ( handleSelectAllUsers(e.target.checked)} className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600' /> ) : (
); })()}
用户名 角色 状态 用户组 采集源权限 操作
加载中...
{role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername)) ? ( handleSelectUser( user.username, e.target.checked ) } className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600' /> ) : (
)}
{user.username} {user.oidcSub && ( OIDC )}
{user.role === 'owner' ? '站长' : user.role === 'admin' ? '管理员' : '普通用户'} {!user.banned ? '正常' : '已封禁'}
{user.tags && user.tags.length > 0 ? user.tags.join(', ') : '无用户组'} {/* 配置用户组按钮 */} {(role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername))) && ( )}
{user.enabledApis && user.enabledApis.length > 0 ? `${user.enabledApis.length} 个源` : '无限制'} {/* 配置采集源权限按钮 */} {(role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername))) && ( )}
{/* 修改密码按钮 */} {canChangePassword && ( )} {canOperate && ( <> {/* 其他操作按钮 */} {user.role === 'user' && ( )} {user.role === 'admin' && ( )} {user.role !== 'owner' && (!user.banned ? ( ) : ( ))} )} {/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */} {canDeleteUser && ( )}
{/* 用户列表分页 */} {!hasOldUserData && usersV2 && userTotalPages > 1 && (
共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页
)}
{/* 配置用户采集源权限弹窗 */} {showConfigureApisModal && selectedUser && createPortal(
{ setShowConfigureApisModal(false); setSelectedUser(null); setSelectedApis([]); }} >
e.stopPropagation()} >

配置用户采集源权限 - {selectedUser.username}

配置说明

提示:全不选为无限制,选中的采集源将限制用户只能访问这些源

{/* 采集源选择 - 多列布局 */}

选择可用的采集源:

{config?.SourceConfig?.map((source) => ( ))}
{/* 快速操作按钮 */}
已选择: {selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
{/* 操作按钮 */}
, document.body )} {/* 添加用户组弹窗 */} {showAddUserGroupForm && createPortal(
{ setShowAddUserGroupForm(false); setNewUserGroup({ name: '', enabledApis: [] }); }} >
e.stopPropagation()} >

添加新用户组

{/* 用户组名称 */}
setNewUserGroup((prev) => ({ ...prev, name: e.target.value, })) } className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' />
{/* 可用视频源 */}
{config?.SourceConfig?.map((source) => ( ))}
{/* 快速操作按钮 */}
{/* 操作按钮 */}
, document.body )} {/* 编辑用户组弹窗 */} {showEditUserGroupForm && editingUserGroup && createPortal(
{ setShowEditUserGroupForm(false); setEditingUserGroup(null); }} >
e.stopPropagation()} >

编辑用户组 - {editingUserGroup.name}

{/* 可用视频源 */}
{config?.SourceConfig?.map((source) => ( ))}
{/* 快速操作按钮 */}
{/* 操作按钮 */}
, document.body )} {/* 配置用户组弹窗 */} {showConfigureUserGroupModal && selectedUserForGroup && createPortal(
{ setShowConfigureUserGroupModal(false); setSelectedUserForGroup(null); setSelectedUserGroups([]); }} >
e.stopPropagation()} >

配置用户组 - {selectedUserForGroup.username}

配置说明

提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源

{/* 用户组选择 - 下拉选择器 */}

选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源

{/* 操作按钮 */}
, document.body )} {/* 删除用户组确认弹窗 */} {showDeleteUserGroupModal && deletingUserGroup && createPortal(
{ setShowDeleteUserGroupModal(false); setDeletingUserGroup(null); }} >
e.stopPropagation()} >

确认删除用户组

危险操作警告

删除用户组 {deletingUserGroup.name}{' '} 将影响所有使用该组的用户,此操作不可恢复!

{deletingUserGroup.affectedUsers.length > 0 ? (
⚠️ 将影响 {deletingUserGroup.affectedUsers.length}{' '} 个用户:
{deletingUserGroup.affectedUsers.map((user, index) => (
• {user.username} ({user.role})
))}

这些用户的用户组将被自动移除

) : (
✅ 当前没有用户使用此用户组
)}
{/* 操作按钮 */}
, document.body )} {/* 删除用户确认弹窗 */} {showDeleteUserModal && deletingUser && createPortal(
{ setShowDeleteUserModal(false); setDeletingUser(null); }} >
e.stopPropagation()} >

确认删除用户

危险操作警告

删除用户 {deletingUser}{' '} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!

{/* 操作按钮 */}
, document.body )} {/* 批量设置用户组弹窗 */} {showBatchUserGroupModal && createPortal(
{ setShowBatchUserGroupModal(false); setSelectedUserGroup(''); }} >
e.stopPropagation()} >

批量设置用户组

批量操作说明

将为选中的 {selectedUsers.size} 个用户{' '} 设置用户组,选择"无用户组"为无限制

选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源

{/* 操作按钮 */}
, document.body )} {/* 通用弹窗组件 */}
); }; // 私人影库配置组件 const OpenListConfigComponent = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [enabled, setEnabled] = useState(false); const [url, setUrl] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [rootPaths, setRootPaths] = useState(['/']); const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [scanInterval, setScanInterval] = useState(0); const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid'); const [disableVideoPreview, setDisableVideoPreview] = useState(false); const [videos, setVideos] = useState([]); const [refreshing, setRefreshing] = useState(false); const [scanProgress, setScanProgress] = useState<{ current: number; total: number; currentFolder?: string; } | null>(null); const [correctDialogOpen, setCorrectDialogOpen] = useState(false); const [selectedVideo, setSelectedVideo] = useState(null); useEffect(() => { if (config?.OpenListConfig) { setEnabled(config.OpenListConfig.Enabled || false); setUrl(config.OpenListConfig.URL || ''); setUsername(config.OpenListConfig.Username || ''); setPassword(config.OpenListConfig.Password || ''); setRootPaths(config.OpenListConfig.RootPaths || (config.OpenListConfig.RootPath ? [config.OpenListConfig.RootPath] : ['/'])); setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/'); setScanInterval(config.OpenListConfig.ScanInterval || 0); setScanMode(config.OpenListConfig.ScanMode || 'hybrid'); setDisableVideoPreview(config.OpenListConfig.DisableVideoPreview || false); } }, [config]); useEffect(() => { if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password) { fetchVideos(); } }, [config]); const fetchVideos = async (noCache = false) => { try { setRefreshing(true); const url = `/api/openlist/list?page=1&pageSize=100&includeFailed=true${noCache ? '&noCache=true' : ''}`; const response = await fetch(url); if (response.ok) { const data = await response.json(); setVideos(data.list || []); } } catch (error) { console.error('获取视频列表失败:', error); } finally { setRefreshing(false); } }; const handleSave = async () => { await withLoading('saveOpenList', async () => { try { const response = await fetch('/api/admin/openlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save', Enabled: enabled, URL: url, Username: username, Password: password, RootPaths: rootPaths, OfflineDownloadPath: offlineDownloadPath, ScanInterval: scanInterval, ScanMode: scanMode, DisableVideoPreview: disableVideoPreview, }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || '保存失败'); } showSuccess('保存成功', showAlert); await refreshConfig(); } catch (error) { showError(error instanceof Error ? error.message : '保存失败', showAlert); throw error; } }); }; const handleRefresh = async (clearMetaInfo = false) => { setRefreshing(true); setScanProgress(null); try { const response = await fetch('/api/openlist/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clearMetaInfo }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || '刷新失败'); } const result = await response.json(); const taskId = result.taskId; if (!taskId) { throw new Error('未获取到任务ID'); } // 轮询任务进度 const pollInterval = setInterval(async () => { try { const progressResponse = await fetch( `/api/openlist/scan-progress?taskId=${taskId}` ); if (!progressResponse.ok) { clearInterval(pollInterval); throw new Error('获取进度失败'); } const progressData = await progressResponse.json(); const task = progressData.task; if (task.status === 'running') { setScanProgress(task.progress); } else if (task.status === 'completed') { clearInterval(pollInterval); setScanProgress(null); setRefreshing(false); showSuccess( `扫描完成!新增 ${task.result.new} 个,已存在 ${task.result.existing} 个,失败 ${task.result.errors} 个`, showAlert ); // 先强制从数据库读取视频列表(这会更新缓存) await fetchVideos(true); // 然后再刷新配置(这会触发 useEffect,但此时缓存已经是新的了) await refreshConfig(); } else if (task.status === 'failed') { clearInterval(pollInterval); setScanProgress(null); setRefreshing(false); throw new Error(task.error || '扫描失败'); } } catch (error) { clearInterval(pollInterval); setScanProgress(null); setRefreshing(false); showError( error instanceof Error ? error.message : '获取进度失败', showAlert ); } }, 1000); } catch (error) { setScanProgress(null); setRefreshing(false); showError(error instanceof Error ? error.message : '刷新失败', showAlert); } }; const handleRefreshVideo = async (folder: string) => { try { const response = await fetch('/api/openlist/refresh-video', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || '刷新失败'); } showSuccess('刷新成功', showAlert); } catch (error) { showError(error instanceof Error ? error.message : '刷新失败', showAlert); } }; const handleCorrectSuccess = () => { fetchVideos(true); // 强制从数据库重新读取,不使用缓存 }; const handleCheckConnectivity = async () => { await withLoading('checkOpenList', async () => { try { const response = await fetch('/api/openlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, username, password, }), }); const data = await response.json(); if (response.ok && data.success) { showSuccess('连接成功', showAlert); } else { throw new Error(data.error || '连接失败'); } } catch (error) { showError(error instanceof Error ? error.message : '连接失败', showAlert); throw error; } }); }; const handleDeleteVideo = async (key: string, title: string) => { // 显示确认对话框,直接在 onConfirm 中执行删除操作 showAlert({ type: 'warning', title: '确认删除', message: `确定要删除视频记录"${title}"吗?此操作不会删除实际文件,只会从列表中移除。`, showConfirm: true, onConfirm: async () => { try { const response = await fetch('/api/openlist/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || '删除失败'); } showSuccess('删除成功', showAlert); await fetchVideos(true); // 强制从数据库重新读取 refreshConfig(); // 异步刷新配置以更新资源数量(不等待,避免重复刷新) } catch (error) { showError(error instanceof Error ? error.message : '删除失败', showAlert); } }, }); }; const formatDate = (timestamp?: number) => { if (!timestamp) return '未刷新'; return new Date(timestamp).toLocaleString('zh-CN'); }; return (
{/* 使用说明 */}
使用说明

• 私人影库功能需要配合 OpenList 使用,用于管理和播放您自己的视频文件

• OpenList 是一个开源的网盘聚合程序,支持多种存储后端(本地、阿里云盘、OneDrive 等)

• 配置后,系统会自动扫描指定目录下的视频文件夹,并通过 TMDB 匹配元数据信息

• 定时扫描间隔设置为 0 表示关闭自动扫描,最低间隔为 60 分钟

• 视频文件夹名称为影片名称,精准命名可以提高 TMDB 匹配准确率

{/* 功能开关 */}

启用私人影库功能

关闭后将不显示私人影库入口,也不会执行定时扫描

{/* 配置表单 */}
setUrl(e.target.value)} disabled={!enabled} placeholder='https://your-openlist-server.com' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' />
setUsername(e.target.value)} disabled={!enabled} placeholder='admin' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' />
setPassword(e.target.value)} disabled={!enabled} placeholder='password' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' />
{rootPaths.map((path, index) => (
{ const newPaths = [...rootPaths]; newPaths[index] = e.target.value; setRootPaths(newPaths); }} disabled={!enabled} placeholder='/' className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' /> {rootPaths.length > 1 && ( )}
))}

OpenList 中的视频文件夹路径,可以配置多个根目录

setOfflineDownloadPath(e.target.value)} disabled={!enabled} placeholder='/' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' />

动漫磁力等离线下载任务的保存目录,默认为根目录 /

setScanInterval(parseInt(e.target.value) || 0)} disabled={!enabled} placeholder='0' min='0' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' />

设置为 0 关闭定时扫描,最低 60 分钟

混合模式:先用种子库匹配,失败后降级为名字匹配

禁用预览视频

开启后将直接返回直连链接,不使用视频预览流

{/* 视频列表区域 */} {enabled && config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && (

视频列表

资源数: {config.OpenListConfig.ResourceCount || 0} | 上次更新: {formatDate(config.OpenListConfig.LastRefreshTime)}
{refreshing && scanProgress && (
扫描进度: {scanProgress.current} / {scanProgress.total} {scanProgress.total > 0 ? Math.round((scanProgress.current / scanProgress.total) * 100) : 0} %
0 ? (scanProgress.current / scanProgress.total) * 100 : 0}%`, }} />
{scanProgress.currentFolder && (

正在处理: {scanProgress.currentFolder}

)}
)} {refreshing ? (
加载中...
) : videos.length > 0 ? (
{videos.map((video) => ( ))}
标题 状态 类型 季度 年份 评分 操作
{video.title} {video.failed ? ( 匹配失败 ) : ( 正常 )} {video.mediaType === 'movie' ? '电影' : '剧集'} {video.seasonNumber ? ( S{video.seasonNumber} ) : ( '-' )} {video.releaseDate ? video.releaseDate.split('-')[0] : '-'} {video.voteAverage > 0 ? video.voteAverage.toFixed(1) : '-'}
{!video.failed && ( )}
) : (
暂无视频,请点击"立即扫描"扫描视频库
)}
)} {/* 纠错对话框 */} {selectedVideo && ( setCorrectDialogOpen(false)} videoKey={selectedVideo.id} currentTitle={selectedVideo.title} currentVideo={{ tmdbId: selectedVideo.tmdbId, doubanId: selectedVideo.doubanId, poster: selectedVideo.poster, releaseDate: selectedVideo.releaseDate, overview: selectedVideo.overview, voteAverage: selectedVideo.voteAverage, mediaType: selectedVideo.mediaType, seasonNumber: selectedVideo.seasonNumber, seasonName: selectedVideo.seasonName, }} onCorrect={handleCorrectSuccess} /> )}
); }; // Emby 媒体库配置组件 - 多源管理版本 const EmbyConfigComponent = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); // 源列表状态 const [sources, setSources] = useState([]); const [editingSource, setEditingSource] = useState(null); const [showAddForm, setShowAddForm] = useState(false); const [selectedSources, setSelectedSources] = useState>(new Set()); // 表单状态 const [formData, setFormData] = useState({ key: '', name: '', enabled: true, ServerURL: '', ApiKey: '', Username: '', Password: '', UserId: '', isDefault: false, // 高级选项 removeEmbyPrefix: false, appendMediaSourceId: false, transcodeMp4: false, proxyPlay: false, customUserAgent: '', }); const [authMode, setAuthMode] = useState<'apikey' | 'password'>('apikey'); // 从配置加载源列表 useEffect(() => { if (config?.EmbyConfig?.Sources) { setSources(config.EmbyConfig.Sources); } else if (config?.EmbyConfig?.ServerURL) { // 兼容旧格式 setSources([{ key: 'default', name: 'Emby', enabled: config.EmbyConfig.Enabled || false, ServerURL: config.EmbyConfig.ServerURL, ApiKey: config.EmbyConfig.ApiKey, Username: config.EmbyConfig.Username, Password: config.EmbyConfig.Password, UserId: config.EmbyConfig.UserId, isDefault: true, }]); } }, [config]); // 重置表单 const resetForm = () => { setFormData({ key: '', name: '', enabled: true, ServerURL: '', ApiKey: '', Username: '', Password: '', UserId: '', isDefault: false, // 高级选项 removeEmbyPrefix: false, appendMediaSourceId: false, transcodeMp4: false, proxyPlay: false, customUserAgent: '', }); setAuthMode('apikey'); setEditingSource(null); setShowAddForm(false); }; // 开始编辑 const handleEdit = (source: any) => { setFormData({ ...source }); // 根据现有配置判断认证方式 if (source.ApiKey) { setAuthMode('apikey'); } else if (source.Username) { setAuthMode('password'); } else { setAuthMode('apikey'); } setEditingSource(source); setShowAddForm(false); }; // 开始添加 const handleAdd = () => { resetForm(); setShowAddForm(true); }; // 保存源(添加或更新) const handleSave = async () => { // 验证必填字段 if (!formData.key || !formData.name || !formData.ServerURL) { showError('请填写必填字段:标识符、名称、服务器地址', showAlert); return; } // 根据认证方式验证必填字段 if (authMode === 'apikey') { if (!formData.ApiKey || !formData.UserId) { showError('使用密钥认证时,API Key 和用户 ID 为必填项', showAlert); return; } } else if (authMode === 'password') { if (!formData.Username) { showError('使用账号认证时,用户名为必填项', showAlert); return; } } // 验证key唯一性 if (!editingSource && sources.some(s => s.key === formData.key)) { showError('标识符已存在,请使用其他标识符', showAlert); return; } await withLoading('saveEmbySource', async () => { try { let newSources; if (editingSource) { // 更新现有源 newSources = sources.map(s => s.key === editingSource.key ? formData : s ); } else { // 添加新源 newSources = [...sources, formData]; } // 保存到配置 const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources, }, }), }); if (!response.ok) { throw new Error('保存失败'); } await refreshConfig(); resetForm(); showSuccess(editingSource ? '更新成功' : '添加成功', showAlert); } catch (error) { showError(error instanceof Error ? error.message : '保存失败', showAlert); } }); }; // 删除源 const handleDelete = async (source: any) => { if (!confirm(`确定要删除 "${source.name}" 吗?`)) { return; } await withLoading('deleteEmbySource', async () => { try { const newSources = sources.filter(s => s.key !== source.key); const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources, }, }), }); if (!response.ok) { throw new Error('删除失败'); } await refreshConfig(); showSuccess('删除成功', showAlert); } catch (error) { showError(error instanceof Error ? error.message : '删除失败', showAlert); } }); }; // 切换启用状态 const handleToggleEnabled = async (source: any) => { await withLoading('toggleEmbySource', async () => { try { const newSources = sources.map(s => s.key === source.key ? { ...s, enabled: !s.enabled } : s ); const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources, }, }), }); if (!response.ok) { throw new Error('更新失败'); } await refreshConfig(); showSuccess(source.enabled ? '已禁用' : '已启用', showAlert); } catch (error) { showError(error instanceof Error ? error.message : '更新失败', showAlert); } }); }; // 测试连接 const handleTest = async (source: any) => { await withLoading('testEmbySource', async () => { try { const response = await fetch('/api/admin/emby', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', ServerURL: source.ServerURL, ApiKey: source.ApiKey, Username: source.Username, Password: source.Password, }), }); const data = await response.json(); if (data.success) { showSuccess(data.message || 'Emby 连接测试成功', showAlert); } else { showError(data.message || 'Emby 连接测试失败', showAlert); } } catch (error) { showError(error instanceof Error ? error.message : '测试失败', showAlert); } }); }; // 清除缓存 const handleClearCache = async () => { await withLoading('clearEmbyCache', async () => { try { const response = await fetch('/api/admin/emby', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'clearCache', }), }); const data = await response.json(); if (data.success) { showSuccess(data.message || '缓存清除成功', showAlert); } else { showError(data.message || '缓存清除失败', showAlert); } } catch (error) { showError(error instanceof Error ? error.message : '缓存清除失败', showAlert); } }); }; // 导出配置 const handleExport = async () => { await withLoading('exportEmby', async () => { try { const response = await fetch('/api/admin/emby/export'); if (!response.ok) { const data = await response.json(); showError(data.error || '导出失败', showAlert); return; } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `emby-config-${Date.now()}.json`; a.click(); window.URL.revokeObjectURL(url); showSuccess('导出成功', showAlert); } catch (error) { showError(error instanceof Error ? error.message : '导出失败', showAlert); } }); }; // 导入配置 const handleImport = async () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; await withLoading('importEmby', async () => { try { const text = await file.text(); const data = JSON.parse(text); const response = await fetch('/api/admin/emby/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data }), }); const result = await response.json(); if (result.success) { showSuccess('导入成功', showAlert); await refreshConfig(); } else { showError(result.error || '导入失败', showAlert); } } catch (error) { showError(error instanceof Error ? error.message : '导入失败', showAlert); } }); }; input.click(); }; // 批量启用 const handleBatchEnable = async () => { if (selectedSources.size === 0) return; await withLoading('batchEnableEmby', async () => { try { const newSources = sources.map(s => selectedSources.has(s.key) ? { ...s, enabled: true } : s ); const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), }); if (!response.ok) throw new Error('批量启用失败'); await refreshConfig(); setSelectedSources(new Set()); showSuccess(`已启用 ${selectedSources.size} 个源`, showAlert); } catch (error) { showError(error instanceof Error ? error.message : '批量启用失败', showAlert); } }); }; // 批量禁用 const handleBatchDisable = async () => { if (selectedSources.size === 0) return; await withLoading('batchDisableEmby', async () => { try { const newSources = sources.map(s => selectedSources.has(s.key) ? { ...s, enabled: false } : s ); const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), }); if (!response.ok) throw new Error('批量禁用失败'); await refreshConfig(); setSelectedSources(new Set()); showSuccess(`已禁用 ${selectedSources.size} 个源`, showAlert); } catch (error) { showError(error instanceof Error ? error.message : '批量禁用失败', showAlert); } }); }; // 批量删除 const handleBatchDelete = async () => { if (selectedSources.size === 0) return; showAlert({ type: 'warning', title: '确认批量删除', message: `确定要删除选中的 ${selectedSources.size} 个源吗?此操作不可恢复。`, showConfirm: true, onConfirm: async () => { await withLoading('batchDeleteEmby', async () => { try { const newSources = sources.filter(s => !selectedSources.has(s.key)); const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), }); if (!response.ok) throw new Error('批量删除失败'); await refreshConfig(); setSelectedSources(new Set()); showSuccess(`已删除 ${selectedSources.size} 个源`, showAlert); } catch (error) { showError(error instanceof Error ? error.message : '批量删除失败', showAlert); } }); }, }); }; return (
{/* 源列表 */}

Emby 源列表 ({sources.length})

{selectedSources.size > 0 && (
已选择 {selectedSources.size} 项
)} {sources.length === 0 ? (
暂无Emby源,点击"添加新源"开始配置
) : ( sources.map((source) => (
{ const newSelected = new Set(selectedSources); if (e.target.checked) { newSelected.add(source.key); } else { newSelected.delete(source.key); } setSelectedSources(newSelected); }} className='w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600' />

{source.name}

{source.isDefault && ( 默认 )} {source.enabled ? '已启用' : '已禁用'}

标识符: {source.key}

服务器: {source.ServerURL}

{source.UserId && (

用户ID: {source.UserId}

)}
)) )}
{/* 添加/编辑表单 */} {(showAddForm || editingSource) && (

{editingSource ? '编辑 Emby 源' : '添加新的 Emby 源'}

{/* 标识符 */}
setFormData({ ...formData, key: e.target.value })} disabled={!!editingSource} placeholder='home, office, etc.' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:bg-gray-100 dark:disabled:bg-gray-700' />

唯一标识符,只能包含字母、数字、下划线,创建后不可修改

{/* 名称 */}
setFormData({ ...formData, name: e.target.value })} placeholder='家庭Emby, 公司Emby, etc.' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
{/* 服务器地址 */}
setFormData({ ...formData, ServerURL: e.target.value })} placeholder='http://192.168.1.100:8096' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
{/* 认证方式切换卡 */}
{/* 密钥认证模式 */} {authMode === 'apikey' && ( <> {/* API Key */}
setFormData({ ...formData, ApiKey: e.target.value })} placeholder='输入 Emby API Key' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />

在 Emby 控制台的 API 密钥页面生成

{/* 用户 ID */}
setFormData({ ...formData, UserId: e.target.value })} placeholder='aab507c58e874de6a9bd12388d72f4d2' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />

从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 /Users/[userId]/...

)} {/* 账号认证模式 */} {authMode === 'password' && ( <> {/* 用户名 */}
setFormData({ ...formData, Username: e.target.value })} placeholder='Emby 用户名' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
{/* 密码 */}
setFormData({ ...formData, Password: e.target.value })} placeholder='Emby 密码(如果账号没有密码可留空)' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />

如果 Emby 账号没有设置密码,可以留空

)} {/* 启用开关 */}
{/* 高级选项 */}

高级选项

{/* 选项1: 播放链接移除/emby前缀 */}

启用后将从播放链接中移除 /emby 前缀

{/* 选项2: 拼接MediaSourceId参数 */}

启用后将调用 PlaybackInfo API 获取 MediaSourceId 并添加到播放链接

{/* 选项3: 转码mp4 */}

启用后将使用 stream.mp4 格式并移除 Static 参数

{/* 视频播放代理开关 */}

视频播放代理

启用后视频播放将通过服务器代理

{/* 自定义User-Agent */}
setFormData({ ...formData, customUserAgent: e.target.value })} placeholder='留空使用默认浏览器UA' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-sm' />

用于登录、获取影片和代理视频时的User-Agent,留空则使用默认浏览器UA

{/* 操作按钮 */}
)} {/* 全局操作 */}
); }; // 视频源配置组件 const VideoSourceConfig = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [sources, setSources] = useState([]); const [showAddForm, setShowAddForm] = useState(false); const [orderChanged, setOrderChanged] = useState(false); const [newSource, setNewSource] = useState({ name: '', key: '', api: '', detail: '', disabled: false, from: 'config', }); // 批量操作相关状态 const [selectedSources, setSelectedSources] = useState>( new Set() ); // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 const selectAll = useMemo(() => { return selectedSources.size === sources.length && selectedSources.size > 0; }, [selectedSources.size, sources.length]); // 确认弹窗状态 const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; title: string; message: string; onConfirm: () => void; onCancel: () => void; }>({ isOpen: false, title: '', message: '', onConfirm: () => {}, onCancel: () => {}, }); // 有效性检测相关状态 const [showValidationModal, setShowValidationModal] = useState(false); const [searchKeyword, setSearchKeyword] = useState(''); const [isValidating, setIsValidating] = useState(false); const [validationResults, setValidationResults] = useState< Array<{ key: string; name: string; status: 'valid' | 'no_results' | 'invalid' | 'validating'; message: string; resultCount: number; }> >([]); // dnd-kit 传感器 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5, // 轻微位移即可触发 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 150, // 长按 150ms 后触发,避免与滚动冲突 tolerance: 5, }, }) ); // 初始化 useEffect(() => { if (config?.SourceConfig) { setSources(config.SourceConfig); // 进入时重置 orderChanged setOrderChanged(false); // 重置选择状态 setSelectedSources(new Set()); } }, [config]); // 通用 API 请求 const callSourceApi = async (body: Record) => { try { const resp = await fetch('/api/admin/source', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${resp.status}`); } // 获取响应数据 const data = await resp.json(); // 成功后刷新配置 await refreshConfig(); // 返回响应数据供调用者使用 return data; } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; // 向上抛出方便调用处判断 } }; const handleToggleEnable = (key: string) => { const target = sources.find((s) => s.key === key); if (!target) return; const action = target.disabled ? 'enable' : 'disable'; withLoading(`toggleSource_${key}`, () => callSourceApi({ action, key }) ).catch(() => { console.error('操作失败', action, key); }); }; const handleDelete = (key: string) => { withLoading(`deleteSource_${key}`, () => callSourceApi({ action: 'delete', key }) ).catch(() => { console.error('操作失败', 'delete', key); }); }; const handleToggleProxyMode = (key: string) => { const target = sources.find((s) => s.key === key); if (!target) return; // 更新本地状态 setSources((prev) => prev.map((s) => s.key === key ? { ...s, proxyMode: !s.proxyMode } : s ) ); // 调用API更新 withLoading(`toggleProxyMode_${key}`, async () => { try { const response = await fetch('/api/admin/source', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'toggle_proxy_mode', key, }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${response.status}`); } await refreshConfig(); } catch (error) { // 失败时回滚本地状态 setSources((prev) => prev.map((s) => s.key === key ? { ...s, proxyMode: !s.proxyMode } : s ) ); showError( error instanceof Error ? error.message : '切换代理模式失败', showAlert ); throw error; } }).catch(() => { console.error('操作失败', 'toggle_proxy_mode', key); }); }; const handleUpdateWeight = (key: string, weight: number) => { // 先乐观更新本地状态 setSources((prev) => prev.map((s) => s.key === key ? { ...s, weight } : s ) ); // 调用API更新 withLoading(`updateWeight_${key}`, async () => { try { const response = await fetch('/api/admin/source', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update_weight', key, weight, }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${response.status}`); } await refreshConfig(); } catch (error) { // 失败时回滚本地状态到配置中的值 const originalWeight = config?.SourceConfig?.find(s => s.key === key)?.weight ?? 0; setSources((prev) => prev.map((s) => s.key === key ? { ...s, weight: originalWeight } : s ) ); showError( error instanceof Error ? error.message : '更新权重失败', showAlert ); throw error; } }).catch(() => { console.error('操作失败', 'update_weight', key, weight); }); }; const handleAddSource = () => { if (!newSource.name || !newSource.key || !newSource.api) return; withLoading('addSource', async () => { await callSourceApi({ action: 'add', key: newSource.key, name: newSource.name, api: newSource.api, detail: newSource.detail, }); setNewSource({ name: '', key: '', api: '', detail: '', disabled: false, from: 'custom', }); setShowAddForm(false); }).catch(() => { console.error('操作失败', 'add', newSource); }); }; const handleDragEnd = (event: any) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = sources.findIndex((s) => s.key === active.id); const newIndex = sources.findIndex((s) => s.key === over.id); setSources((prev) => arrayMove(prev, oldIndex, newIndex)); setOrderChanged(true); }; const handleSaveOrder = () => { const order = sources.map((s) => s.key); withLoading('saveSourceOrder', () => callSourceApi({ action: 'sort', order }) ) .then(() => { setOrderChanged(false); }) .catch(() => { console.error('操作失败', 'sort', order); }); }; // 有效性检测函数 const handleValidateSources = async () => { if (!searchKeyword.trim()) { showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空', }); return; } await withLoading('validateSources', async () => { setIsValidating(true); setValidationResults([]); // 清空之前的结果 setShowValidationModal(false); // 立即关闭弹窗 // 初始化所有视频源为检测中状态 const initialResults = sources.map((source) => ({ key: source.key, name: source.name, status: 'validating' as const, message: '检测中...', resultCount: 0, })); setValidationResults(initialResults); try { // 使用EventSource接收流式数据 const eventSource = new EventSource( `/api/admin/source/validate?q=${encodeURIComponent( searchKeyword.trim() )}` ); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); switch (data.type) { case 'start': console.log(`开始检测 ${data.totalSources} 个视频源`); break; case 'source_result': case 'source_error': // 更新验证结果 setValidationResults((prev) => { const existing = prev.find((r) => r.key === data.source); if (existing) { return prev.map((r) => r.key === data.source ? { key: data.source, name: sources.find((s) => s.key === data.source) ?.name || data.source, status: data.status, message: data.status === 'valid' ? '搜索正常' : data.status === 'no_results' ? '无法搜索到结果' : '连接失败', resultCount: data.status === 'valid' ? 1 : 0, } : r ); } else { return [ ...prev, { key: data.source, name: sources.find((s) => s.key === data.source)?.name || data.source, status: data.status, message: data.status === 'valid' ? '搜索正常' : data.status === 'no_results' ? '无法搜索到结果' : '连接失败', resultCount: data.status === 'valid' ? 1 : 0, }, ]; } }); break; case 'complete': console.log( `检测完成,共检测 ${data.completedSources} 个视频源` ); eventSource.close(); setIsValidating(false); break; } } catch (error) { console.error('解析EventSource数据失败:', error); } }; eventSource.onerror = (error) => { console.error('EventSource错误:', error); eventSource.close(); setIsValidating(false); showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试', }); }; // 设置超时,防止长时间等待 setTimeout(() => { if (eventSource.readyState === EventSource.OPEN) { eventSource.close(); setIsValidating(false); showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试', }); } }, 60000); // 60秒超时 } catch (error) { setIsValidating(false); showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误', }); throw error; } }); }; // 获取有效性状态显示 const getValidationStatus = (sourceKey: string) => { const result = validationResults.find((r) => r.key === sourceKey); if (!result) return null; switch (result.status) { case 'validating': return { text: '检测中', className: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300', icon: '⟳', message: result.message, }; case 'valid': return { text: '有效', className: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300', icon: '✓', message: result.message, }; case 'no_results': return { text: '无法搜索', className: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300', icon: '⚠', message: result.message, }; case 'invalid': return { text: '无效', className: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300', icon: '✗', message: result.message, }; default: return null; } }; // 权重输入组件 - 使用本地状态避免输入时失焦 const WeightInput = memo(({ sourceKey, initialWeight }: { sourceKey: string; initialWeight: number }) => { const [localWeight, setLocalWeight] = useState(initialWeight); // 当外部权重变化时同步 useEffect(() => { setLocalWeight(initialWeight); }, [initialWeight]); return ( { const value = parseInt(e.target.value) || 0; const clampedValue = Math.min(100, Math.max(0, value)); setLocalWeight(clampedValue); }} onBlur={(e) => { const newValue = parseInt(e.target.value) || 0; const clampedValue = Math.min(100, Math.max(0, newValue)); const originalWeight = config?.SourceConfig?.find(s => s.key === sourceKey)?.weight ?? 0; // 只有在值发生变化时才调用API if (clampedValue !== originalWeight) { handleUpdateWeight(sourceKey, clampedValue); } }} onPointerDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} className='w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' title='权重范围:0-100,用于排序和优选评分' /> ); }); // 可拖拽行封装 (dnd-kit) const DraggableRow = memo(({ source }: { source: DataSource }) => { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: source.key }); const style = { transform: CSS.Transform.toString(transform), transition, } as React.CSSProperties; return ( handleSelectSource(source.key, e.target.checked)} className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600' /> {source.name} {source.key} {source.api} {source.detail || '-'} {!source.disabled ? '启用中' : '已禁用'} {(() => { const status = getValidationStatus(source.key); if (!status) { return ( 未检测 ); } return ( {status.icon} {status.text} ); })()} {source.from !== 'config' && ( )} ); }); // 全选/取消全选 const handleSelectAll = useCallback( (checked: boolean) => { if (checked) { const allKeys = sources.map((s) => s.key); setSelectedSources(new Set(allKeys)); } else { setSelectedSources(new Set()); } }, [sources] ); // 单个选择 const handleSelectSource = useCallback((key: string, checked: boolean) => { setSelectedSources((prev) => { const newSelected = new Set(prev); if (checked) { newSelected.add(key); } else { newSelected.delete(key); } return newSelected; }); }, []); // 批量操作 const handleBatchOperation = async ( action: 'batch_enable' | 'batch_disable' | 'batch_delete' ) => { if (selectedSources.size === 0) { showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源', }); return; } const keys = Array.from(selectedSources); let confirmMessage = ''; let actionName = ''; switch (action) { case 'batch_enable': confirmMessage = `确定要启用选中的 ${keys.length} 个视频源吗?`; actionName = '批量启用'; break; case 'batch_disable': confirmMessage = `确定要禁用选中的 ${keys.length} 个视频源吗?`; actionName = '批量禁用'; break; case 'batch_delete': confirmMessage = `确定要删除选中的 ${keys.length} 个视频源吗?此操作不可恢复!`; actionName = '批量删除'; break; } // 显示确认弹窗 setConfirmModal({ isOpen: true, title: '确认操作', message: confirmMessage, onConfirm: async () => { try { const result = await withLoading(`batchSource_${action}`, () => callSourceApi({ action, keys }) ); // 根据操作类型和结果显示不同的消息 if (action === 'batch_delete' && result?.deleted !== undefined && result?.skipped !== undefined) { const { deleted, skipped } = result; if (skipped > 0) { showAlert({ type: 'warning', title: '批量删除完成', message: `成功删除了 ${deleted} 个视频源,跳过了 ${skipped} 个配置文件中的源(不可删除)`, timer: 3000, }); } else if (deleted > 0) { showAlert({ type: 'success', title: '批量删除成功', message: `成功删除了 ${deleted} 个视频源`, timer: 2000, }); } else { showAlert({ type: 'warning', title: '无法删除', message: '所选视频源均为配置文件中的源,不可删除', timer: 3000, }); } } else { showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}了 ${keys.length} 个视频源`, timer: 2000, }); } // 重置选择状态 setSelectedSources(new Set()); } catch (err) { showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败', }); } setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => {}, onCancel: () => {}, }); }, onCancel: () => { setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => {}, onCancel: () => {}, }); }, }); }; if (!config) { return (
加载中...
); } return (
{/* 添加视频源表单 */}

视频源列表

{/* 批量操作按钮 - 移动端显示在下一行,PC端显示在左侧 */} {selectedSources.size > 0 && ( <>
已选 {selectedSources.size} 已选择 {selectedSources.size} 个视频源
)}
{showAddForm && (
setNewSource((prev) => ({ ...prev, name: e.target.value })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> setNewSource((prev) => ({ ...prev, key: e.target.value })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> setNewSource((prev) => ({ ...prev, api: e.target.value })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> setNewSource((prev) => ({ ...prev, detail: e.target.value })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
)} {/* 视频源表格 */}
s.key)} strategy={verticalListSortingStrategy} > {sources.map((source) => ( ))}
handleSelectAll(e.target.checked)} className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600' /> 名称 Key API 地址 Detail 地址 状态 代理模式 权重 有效性 操作
{/* 保存排序按钮 */} {orderChanged && (
)} {/* 有效性检测弹窗 */} {showValidationModal && createPortal(
setShowValidationModal(false)} >
e.stopPropagation()} >

视频源有效性检测

请输入检测用的搜索关键词

setSearchKeyword(e.target.value)} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' onKeyPress={(e) => e.key === 'Enter' && handleValidateSources() } />
, document.body )} {/* 通用弹窗组件 */} {/* 批量操作确认弹窗 */} {confirmModal.isOpen && createPortal(
e.stopPropagation()} >

{confirmModal.title}

{confirmModal.message}

{/* 操作按钮 */}
, document.body )}
); }; // 分类配置组件 const CategoryConfig = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [categories, setCategories] = useState([]); const [showAddForm, setShowAddForm] = useState(false); const [orderChanged, setOrderChanged] = useState(false); const [newCategory, setNewCategory] = useState({ name: '', type: 'movie', query: '', disabled: false, from: 'config', }); // dnd-kit 传感器 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5, // 轻微位移即可触发 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 150, // 长按 150ms 后触发,避免与滚动冲突 tolerance: 5, }, }) ); // 初始化 useEffect(() => { if (config?.CustomCategories) { setCategories(config.CustomCategories); // 进入时重置 orderChanged setOrderChanged(false); } }, [config]); // 通用 API 请求 const callCategoryApi = async (body: Record) => { try { const resp = await fetch('/api/admin/category', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.error || `操作失败: ${resp.status}`); } // 成功后刷新配置 await refreshConfig(); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; // 向上抛出方便调用处判断 } }; const handleToggleEnable = (query: string, type: 'movie' | 'tv') => { const target = categories.find((c) => c.query === query && c.type === type); if (!target) return; const action = target.disabled ? 'enable' : 'disable'; withLoading(`toggleCategory_${query}_${type}`, () => callCategoryApi({ action, query, type }) ).catch(() => { console.error('操作失败', action, query, type); }); }; const handleDelete = (query: string, type: 'movie' | 'tv') => { withLoading(`deleteCategory_${query}_${type}`, () => callCategoryApi({ action: 'delete', query, type }) ).catch(() => { console.error('操作失败', 'delete', query, type); }); }; const handleAddCategory = () => { if (!newCategory.name || !newCategory.query) return; withLoading('addCategory', async () => { await callCategoryApi({ action: 'add', name: newCategory.name, type: newCategory.type, query: newCategory.query, }); setNewCategory({ name: '', type: 'movie', query: '', disabled: false, from: 'custom', }); setShowAddForm(false); }).catch(() => { console.error('操作失败', 'add', newCategory); }); }; const handleDragEnd = (event: any) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = categories.findIndex( (c) => `${c.query}:${c.type}` === active.id ); const newIndex = categories.findIndex( (c) => `${c.query}:${c.type}` === over.id ); setCategories((prev) => arrayMove(prev, oldIndex, newIndex)); setOrderChanged(true); }; const handleSaveOrder = () => { const order = categories.map((c) => `${c.query}:${c.type}`); withLoading('saveCategoryOrder', () => callCategoryApi({ action: 'sort', order }) ) .then(() => { setOrderChanged(false); }) .catch(() => { console.error('操作失败', 'sort', order); }); }; // 可拖拽行封装 (dnd-kit) const DraggableRow = ({ category }: { category: CustomCategory }) => { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: `${category.query}:${category.type}` }); const style = { transform: CSS.Transform.toString(transform), transition, } as React.CSSProperties; return ( {category.name || '-'} {category.type === 'movie' ? '电影' : '电视剧'} {category.query} {!category.disabled ? '启用中' : '已禁用'} {category.from !== 'config' && ( )} ); }; if (!config) { return (
加载中...
); } return (
{/* 添加分类表单 */}

自定义分类列表

{showAddForm && (
setNewCategory((prev) => ({ ...prev, name: e.target.value })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> setNewCategory((prev) => ({ ...prev, query: e.target.value })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
)} {/* 分类表格 */}
`${c.query}:${c.type}`)} strategy={verticalListSortingStrategy} > {categories.map((category) => ( ))}
分类名称 类型 搜索关键词 状态 操作
{/* 保存排序按钮 */} {orderChanged && (
)} {/* 通用弹窗组件 */}
); }; const VideoSourceScriptLab = () => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [scripts, setScripts] = useState([]); const [loadingScripts, setLoadingScripts] = useState(true); const [template, setTemplate] = useState(''); const [selectedScriptId, setSelectedScriptId] = useState(null); const [editor, setEditor] = useState<{ id?: string; key: string; name: string; description: string; code: string; enabled: boolean; version?: string; updatedAt?: number; }>({ key: '', name: '', description: '', code: '', enabled: true, }); const [testHook, setTestHook] = useState<'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'>('getSources'); const [testPayload, setTestPayload] = useState( JSON.stringify({}, null, 2) ); const [testOutput, setTestOutput] = useState(''); const importInputRef = useRef(null); const applyEditorFromScript = (script: StandaloneSourceScript | null) => { if (!script) { setEditor({ key: '', name: '', description: '', code: template, enabled: true, }); setSelectedScriptId(null); return; } setEditor({ id: script.id, key: script.key, name: script.name, description: script.description || '', code: script.code, enabled: script.enabled, version: script.version, updatedAt: script.updatedAt, }); setSelectedScriptId(script.id); }; const loadScripts = async (preferId?: string | null) => { setLoadingScripts(true); try { const response = await fetch('/api/admin/source-script', { cache: 'no-store', }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || '加载脚本失败'); } const nextScripts = (data.items || []) as StandaloneSourceScript[]; setScripts(nextScripts); setTemplate(data.template || ''); const targetId = preferId !== undefined ? preferId : selectedScriptId || nextScripts[0]?.id || null; const selected = nextScripts.find((item) => item.id === targetId) || null; if (selected) { applyEditorFromScript(selected); } else { setEditor({ key: '', name: '', description: '', code: data.template || '', enabled: true, }); setSelectedScriptId(null); } } catch (error) { showError(error instanceof Error ? error.message : '加载脚本失败', showAlert); } finally { setLoadingScripts(false); } }; useEffect(() => { loadScripts(); }, []); const handleCreateNew = () => { setSelectedScriptId(null); setEditor({ key: '', name: '', description: '', code: template, enabled: true, }); setTestOutput(''); }; const handleExportCurrent = () => { if (!editor.key || !editor.name || !editor.code) { showError('当前没有可导出的脚本', showAlert); return; } const payload = { key: editor.key, name: editor.name, description: editor.description, code: editor.code, enabled: editor.enabled, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json', }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${editor.key}.json`; link.click(); URL.revokeObjectURL(url); }; const handleImportFile = async ( event: React.ChangeEvent ) => { const file = event.target.files?.[0]; if (!file) return; try { const raw = await file.text(); const parsed = JSON.parse(raw); const items = Array.isArray(parsed) ? parsed : [parsed]; await withLoading('importSourceScript', async () => { const response = await fetch('/api/admin/source-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'import', items, }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || '导入失败'); } showSuccess(`已导入 ${data.items?.length || 0} 个脚本`, showAlert); await loadScripts(data.items?.[0]?.id || null); }); } catch (error) { showError(error instanceof Error ? error.message : '导入失败', showAlert); } finally { event.target.value = ''; } }; const handleSave = async () => { if (!editor.key || !editor.name || !editor.code) { showError('请填写脚本 Key、名称和代码', showAlert); return; } await withLoading('saveSourceScript', async () => { const response = await fetch('/api/admin/source-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save', id: editor.id, key: editor.key, name: editor.name, description: editor.description, code: editor.code, enabled: editor.enabled, }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || '保存失败'); } showSuccess('脚本已保存', showAlert); await loadScripts(data.item?.id || editor.id || null); }).catch((error) => { showError(error instanceof Error ? error.message : '保存失败', showAlert); }); }; const handleDelete = async () => { if (!editor.id) { handleCreateNew(); return; } showAlert({ type: 'warning', title: '删除脚本', message: `确定要删除脚本 "${editor.name}" 吗?`, showConfirm: true, onConfirm: async () => { hideAlert(); await withLoading('deleteSourceScript', async () => { const response = await fetch('/api/admin/source-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id: editor.id, }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || '删除失败'); } showSuccess('脚本已删除', showAlert); await loadScripts(null); }).catch((error) => { showError(error instanceof Error ? error.message : '删除失败', showAlert); }); }, }); }; const handleToggleEnabled = async (id: string) => { await withLoading(`toggleSourceScript_${id}`, async () => { const response = await fetch('/api/admin/source-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'toggle_enabled', id, }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || '更新失败'); } await loadScripts(id); }).catch((error) => { showError(error instanceof Error ? error.message : '更新失败', showAlert); }); }; const handleTest = async () => { let payload = {}; try { payload = testPayload.trim() ? JSON.parse(testPayload) : {}; } catch { showError('测试输入必须是合法 JSON', showAlert); return; } await withLoading('testSourceScript', async () => { const response = await fetch('/api/admin/source-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', key: editor.key || 'test-script', name: editor.name || '测试脚本', code: editor.code, hook: testHook, payload, }), }); const data = await response.json().catch(() => ({})); setTestOutput(JSON.stringify(data, null, 2)); if (!response.ok) { throw new Error(data.error || data.message || '测试失败'); } showSuccess('测试执行完成', showAlert); }).catch((error) => { showError(error instanceof Error ? error.message : '测试失败', showAlert); }); }; useEffect(() => { setTestPayload( testHook === 'getSources' ? JSON.stringify({}, null, 2) : testHook === 'search' ? JSON.stringify({ keyword: '凡人修仙传', page: 1, sourceId: 'main' }, null, 2) : testHook === 'recommend' ? JSON.stringify({ page: 1 }, null, 2) : testHook === 'detail' ? JSON.stringify({ id: 'demo-id', sourceId: 'main' }, null, 2) : JSON.stringify( { sourceId: 'main', lineId: 'default', playUrl: 'https://example.com/video.m3u8', episodeIndex: 0, }, null, 2 ) ); }, [testHook]); return (

脚本列表

{loadingScripts ? (
加载中...
) : scripts.length === 0 ? (
还没有脚本,点右上角新建一个。
) : ( scripts.map((script) => (
)) )}
setEditor((prev) => ({ ...prev, name: e.target.value }))} className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> setEditor((prev) => ({ ...prev, key: e.target.value }))} className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />