/* 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, Check, CheckCircle, ChevronDown, ChevronUp, Database, ExternalLink, FileText, FolderOpen, Palette, Settings, Tv, UserPlus, Users, Video, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; 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 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; BannerDataSource?: string; RecommendationDataSource?: string; PansouApiUrl?: string; PansouUsername?: string; PansouPassword?: 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; } // 直播源数据类型 interface LiveDataSource { name: string; key: string; url: string; ua?: string; epg?: string; channelNumber?: number; disabled?: boolean; from: 'config' | 'custom'; } // 自定义分类数据类型 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; } const CollapsibleTab = ({ title, icon, isExpanded, onToggle, children, }: 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 [rootPath, setRootPath] = useState('/'); const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [scanInterval, setScanInterval] = useState(0); 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 || ''); setRootPath(config.OpenListConfig.RootPath || '/'); setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/'); setScanInterval(config.OpenListConfig.ScanInterval || 0); } }, [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, RootPath: rootPath, OfflineDownloadPath: offlineDownloadPath, ScanInterval: scanInterval, }), }); 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' />
setRootPath(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' />

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} /> )}
); }; // 视频源配置组件 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}`); } // 成功后刷新配置 await refreshConfig(); } 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 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; } }; // 可拖拽行封装 (dnd-kit) const DraggableRow = ({ 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 { await withLoading(`batchSource_${action}`, () => callSourceApi({ action, keys }) ); 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 ConfigFileComponent = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [configContent, setConfigContent] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [autoUpdate, setAutoUpdate] = useState(false); const [lastCheckTime, setLastCheckTime] = useState(''); useEffect(() => { if (config?.ConfigFile) { setConfigContent(config.ConfigFile); } if (config?.ConfigSubscribtion) { setSubscriptionUrl(config.ConfigSubscribtion.URL); setAutoUpdate(config.ConfigSubscribtion.AutoUpdate); setLastCheckTime(config.ConfigSubscribtion.LastCheck || ''); } }, [config]); // 拉取订阅配置 const handleFetchConfig = async () => { if (!subscriptionUrl.trim()) { showError('请输入订阅URL', showAlert); return; } await withLoading('fetchConfig', async () => { try { const resp = await fetch('/api/admin/config_subscription/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: subscriptionUrl }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.error || `拉取失败: ${resp.status}`); } const data = await resp.json(); if (data.configContent) { setConfigContent(data.configContent); // 更新本地配置的最后检查时间 const currentTime = new Date().toISOString(); setLastCheckTime(currentTime); showSuccess('配置拉取成功', showAlert); } else { showError('拉取失败:未获取到配置内容', showAlert); } } catch (err) { showError(err instanceof Error ? err.message : '拉取失败', showAlert); throw err; } }); }; // 处理文件上传 const handleFileUpload = async ( event: React.ChangeEvent ) => { const file = event.target.files?.[0]; if (!file) return; // 检查文件类型 if (!file.name.toLowerCase().endsWith('.json')) { showError('请上传JSON格式的文件', showAlert); return; } await withLoading('uploadConfig', async () => { try { const fileContent = await file.text(); // 验证JSON格式 let parsedConfig; try { parsedConfig = JSON.parse(fileContent); } catch (parseError) { showError('JSON格式错误,请检查文件内容', showAlert); return; } // 检查是否包含api_site字段 if (!parsedConfig.api_site) { showError('配置文件必须包含api_site字段', showAlert); return; } // 根据api字段进行去重 const existingConfig = configContent ? JSON.parse(configContent) : { api_site: {} }; const existingApis = new Set(); // 收集现有配置中的所有api Object.values(existingConfig.api_site || {}).forEach((site: any) => { if (site.api) { existingApis.add(site.api); } }); // 合并新配置,去重处理 const mergedApiSite = { ...existingConfig.api_site }; let duplicateCount = 0; Object.entries(parsedConfig.api_site || {}).forEach( ([key, site]: [string, any]) => { if (site.api && existingApis.has(site.api)) { duplicateCount++; // 跳过重复的api return; } mergedApiSite[key] = site; } ); const mergedConfig = { ...parsedConfig, api_site: mergedApiSite, }; // 更新配置内容 setConfigContent(JSON.stringify(mergedConfig, null, 2)); const message = duplicateCount > 0 ? `配置上传成功,跳过了 ${duplicateCount} 个重复的API` : '配置上传成功'; showSuccess(message, showAlert); } catch (err) { showError( err instanceof Error ? err.message : '文件上传失败', showAlert ); throw err; } }); // 清空文件输入 event.target.value = ''; }; // 保存配置文件 const handleSave = async () => { await withLoading('saveConfig', async () => { try { const resp = await fetch('/api/admin/config_file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ configFile: configContent, subscriptionUrl, autoUpdate, lastCheckTime: lastCheckTime || new Date().toISOString(), }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.error || `保存失败: ${resp.status}`); } showSuccess('配置文件保存成功', showAlert); await refreshConfig(); } catch (err) { showError(err instanceof Error ? err.message : '保存失败', showAlert); throw err; } }); }; if (!config) { return (
加载中...
); } return (
{/* 配置订阅区域 */}

配置订阅

最后更新:{' '} {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
{/* 订阅URL输入 */}
setSubscriptionUrl(e.target.value)} placeholder='https://example.com/config.json' disabled={false} className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500' />

输入配置文件的订阅地址,要求 JSON 格式,且使用 Base58 编码

{/* 拉取配置按钮 */}
{/* 自动更新开关 */}

启用后系统将定期自动拉取最新配置

{/* 配置文件编辑区域 */}