From c55b4bd04448e092f97efc302a8b60dfe5e5cee4 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 9 May 2026 18:46:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=BA=90=E6=9D=83=E9=87=8D=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 5000 +++++++++++++++++++---------- src/app/api/admin/source/route.ts | 153 +- 2 files changed, 3443 insertions(+), 1710 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 31134b0..d52179f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -49,7 +49,16 @@ import { Video, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; -import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Fragment, + memo, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; @@ -239,10 +248,7 @@ const AlertModal = ({ ) : ( // 普通提示:只显示确定按钮 - ) @@ -442,11 +448,13 @@ const CollapsibleTab = ({ isParent = false, }: CollapsibleTabProps) => { return ( -
+
- {isExpanded &&
{children}
} + {isExpanded && ( +
+ {children} +
+ )}
); }; @@ -496,7 +516,17 @@ interface UserConfigProps { userListLoading: boolean; } -const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalPages, userTotal, fetchUsersV2, userListLoading }: UserConfigProps) => { +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); @@ -557,7 +587,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; // 判断是否有旧版用户数据需要迁移 - const hasOldUserData = config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ?? 0 > 0; + const hasOldUserData = + config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ?? + 0 > 0; // 使用新版本用户列表(如果可用且没有旧数据),否则使用配置中的用户列表 const displayUsers: Array<{ @@ -568,7 +600,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP tags?: string[]; created_at?: number; oidcSub?: string; - }> = !hasOldUserData && usersV2 ? usersV2 : (config?.UserConfig?.Users || []); + }> = !hasOldUserData && usersV2 ? usersV2 : config?.UserConfig?.Users || []; // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 const selectAllUsers = useMemo(() => { @@ -1029,69 +1061,77 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP {/* 数据迁移提示 */} {config.UserConfig.Users && - config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( -
-
-
-
- 检测到旧版用户数据 -
-

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

-
- + }); + }, + }); + }} + disabled={isLoading('migrateUsers')} + className={`ml-4 ${buttonStyles.warning} ${ + isLoading('migrateUsers') + ? 'opacity-50 cursor-not-allowed' + : '' + }`} + > + {isLoading('migrateUsers') ? '迁移中...' : '立即迁移'} + +
-
- )} + )} {/* 用户组管理 */} @@ -1378,404 +1418,416 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
{/* 迁移遮罩层 */} {config.UserConfig.Users && - config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( -
-
-
- -

- 需要迁移数据 -

+ 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 ( - - + ); + } - {/* 用户列表分页 */} - {!hasOldUserData && usersV2 && userTotalPages > 1 && ( -
-
- 共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页 -
-
- - - - -
+ 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' || + + + + - - - - - + + + + + + + + {/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */} + {(() => { + // 如果正在加载,显示加载状态 + if (userListLoading) { + return ( + + + - ); - })} - - ); - })()} -
+ + {(() => { + // 检查是否有权限操作任何用户 + const hasAnyPermission = config?.UserConfig?.Users?.some( + (user) => + 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 && ( - - )} + 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} 页 +
+
+ + + + +
+
+ )}
@@ -1942,7 +1994,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP className={`px-6 py-2.5 text-sm font-medium ${ isLoading(`saveUserApis_${selectedUser?.username}`) ? buttonStyles.disabled - : buttonStyles.primary + : buttonStyles.success }`} > {isLoading(`saveUserApis_${selectedUser?.username}`) @@ -2038,17 +2090,24 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP > { if (e.target.checked) { setNewUserGroup((prev) => ({ ...prev, - permissions: [...prev.permissions, permission.key], + permissions: [ + ...prev.permissions, + permission.key, + ], })); } else { setNewUserGroup((prev) => ({ ...prev, - permissions: prev.permissions.filter((item) => item !== permission.key), + permissions: prev.permissions.filter( + (item) => item !== permission.key + ), })); } }} @@ -2357,14 +2416,19 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP > { if (e.target.checked) { setEditingUserGroup((prev) => prev ? { ...prev, - permissions: [...prev.permissions, permission.key], + permissions: [ + ...prev.permissions, + permission.key, + ], } : null ); @@ -2373,7 +2437,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP prev ? { ...prev, - permissions: prev.permissions.filter((item) => item !== permission.key), + permissions: prev.permissions.filter( + (item) => item !== permission.key + ), } : null ); @@ -2580,7 +2646,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP `saveUserGroups_${selectedUserForGroup?.username}` ) ? buttonStyles.disabled - : buttonStyles.primary + : buttonStyles.success }`} > {isLoading( @@ -2961,7 +3027,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP className={`px-6 py-2.5 text-sm font-medium ${ isLoading('batchSetUserGroup') ? buttonStyles.disabled - : buttonStyles.primary + : buttonStyles.success }`} > {isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'} @@ -3005,7 +3071,9 @@ const OpenListConfigComponent = ({ const [rootPaths, setRootPaths] = useState(['/']); const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [scanInterval, setScanInterval] = useState(0); - const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid'); + const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>( + 'hybrid' + ); const [disableVideoPreview, setDisableVideoPreview] = useState(false); const [videos, setVideos] = useState([]); const [refreshing, setRefreshing] = useState(false); @@ -3023,16 +3091,27 @@ const OpenListConfigComponent = ({ setUrl(config.OpenListConfig.URL || ''); setUsername(config.OpenListConfig.Username || ''); setPassword(config.OpenListConfig.Password || ''); - setRootPaths(config.OpenListConfig.RootPaths || (config.OpenListConfig.RootPath ? [config.OpenListConfig.RootPath] : ['/'])); + 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); + setDisableVideoPreview( + config.OpenListConfig.DisableVideoPreview || false + ); } }, [config]); useEffect(() => { - if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password) { + if ( + config?.OpenListConfig?.URL && + config?.OpenListConfig?.Username && + config?.OpenListConfig?.Password + ) { fetchVideos(); } }, [config]); @@ -3040,7 +3119,9 @@ const OpenListConfigComponent = ({ const fetchVideos = async (noCache = false) => { try { setRefreshing(true); - const url = `/api/openlist/list?page=1&pageSize=100&includeFailed=true${noCache ? '&noCache=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(); @@ -3081,7 +3162,10 @@ const OpenListConfigComponent = ({ showSuccess('保存成功', showAlert); await refreshConfig(); } catch (error) { - showError(error instanceof Error ? error.message : '保存失败', showAlert); + showError( + error instanceof Error ? error.message : '保存失败', + showAlert + ); throw error; } }); @@ -3205,7 +3289,10 @@ const OpenListConfigComponent = ({ throw new Error(data.error || '连接失败'); } } catch (error) { - showError(error instanceof Error ? error.message : '连接失败', showAlert); + showError( + error instanceof Error ? error.message : '连接失败', + showAlert + ); throw error; } }); @@ -3235,7 +3322,10 @@ const OpenListConfigComponent = ({ await fetchVideos(true); // 强制从数据库重新读取 refreshConfig(); // 异步刷新配置以更新资源数量(不等待,避免重复刷新) } catch (error) { - showError(error instanceof Error ? error.message : '删除失败', showAlert); + showError( + error instanceof Error ? error.message : '删除失败', + showAlert + ); } }, }); @@ -3269,12 +3359,20 @@ const OpenListConfigComponent = ({
-

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

-

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

-

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

+

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

+

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

+

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

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

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

-
@@ -3433,7 +3531,9 @@ const OpenListConfigComponent = ({ setFormData({ ...formData, key: e.target.value })} + onChange={(e) => + 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' @@ -5110,7 +5356,9 @@ const EmbyConfigComponent = ({ setFormData({ ...formData, name: e.target.value })} + onChange={(e) => + 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' /> @@ -5124,7 +5372,9 @@ const EmbyConfigComponent = ({ setFormData({ ...formData, ServerURL: e.target.value })} + onChange={(e) => + 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' /> @@ -5180,7 +5430,9 @@ const EmbyConfigComponent = ({ setFormData({ ...formData, ApiKey: e.target.value })} + onChange={(e) => + 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' /> @@ -5197,12 +5449,15 @@ const EmbyConfigComponent = ({ setFormData({ ...formData, UserId: e.target.value })} + onChange={(e) => + 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]/... + 从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 + /Users/[userId]/...

@@ -5219,7 +5474,9 @@ const EmbyConfigComponent = ({ setFormData({ ...formData, Username: e.target.value })} + onChange={(e) => + 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' /> @@ -5233,7 +5490,9 @@ const EmbyConfigComponent = ({ setFormData({ ...formData, Password: e.target.value })} + onChange={(e) => + 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' /> @@ -5250,9 +5509,13 @@ const EmbyConfigComponent = ({ 启用此源 @@ -5300,18 +5572,28 @@ const EmbyConfigComponent = ({ 拼接MediaSourceId参数

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

@@ -5328,9 +5610,16 @@ const EmbyConfigComponent = ({

- + - {/* 自定义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 -

-
+ {/* 自定义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 +

+
{/* 操作按钮 */} @@ -5392,10 +5690,7 @@ const EmbyConfigComponent = ({ > {isLoading('saveEmbySource') ? '保存中...' : '保存'} - @@ -5480,6 +5775,10 @@ const VideoSourceConfig = ({ // 有效性检测相关状态 const [showValidationModal, setShowValidationModal] = useState(false); + const [showWeightModal, setShowWeightModal] = useState(false); + const [weightDraftSources, setWeightDraftSources] = useState( + [] + ); const [searchKeyword, setSearchKeyword] = useState(''); const [isValidating, setIsValidating] = useState(false); const [validationResults, setValidationResults] = useState< @@ -5571,9 +5870,7 @@ const VideoSourceConfig = ({ // 更新本地状态 setSources((prev) => - prev.map((s) => - s.key === key ? { ...s, proxyMode: !s.proxyMode } : s - ) + prev.map((s) => (s.key === key ? { ...s, proxyMode: !s.proxyMode } : s)) ); // 调用API更新 @@ -5615,9 +5912,7 @@ const VideoSourceConfig = ({ const handleUpdateWeight = (key: string, weight: number) => { // 先乐观更新本地状态 setSources((prev) => - prev.map((s) => - s.key === key ? { ...s, weight } : s - ) + prev.map((s) => (s.key === key ? { ...s, weight } : s)) ); // 调用API更新 @@ -5641,7 +5936,8 @@ const VideoSourceConfig = ({ await refreshConfig(); } catch (error) { // 失败时回滚本地状态到配置中的值 - const originalWeight = config?.SourceConfig?.find(s => s.key === key)?.weight ?? 0; + const originalWeight = + config?.SourceConfig?.find((s) => s.key === key)?.weight ?? 0; setSources((prev) => prev.map((s) => s.key === key ? { ...s, weight: originalWeight } : s @@ -5682,27 +5978,135 @@ const VideoSourceConfig = ({ }); }; - 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); + const buildRecommendedWeightMap = useCallback((list: DataSource[]) => { + const total = list.length; + return new Map( + list.map((source, index) => { + const recommended = + total <= 1 + ? 40 + : Math.round(((total - index - 1) * 40) / (total - 1)); + return [source.key, recommended]; }) - .catch(() => { - console.error('操作失败', 'sort', order); + ); + }, []); + + const applyRecommendedWeights = useCallback((list: DataSource[]) => { + const total = list.length; + return list.map((source, index) => ({ + ...source, + weight: + total <= 1 ? 40 : Math.round(((total - index - 1) * 40) / (total - 1)), + })); + }, []); + + const openWeightModal = useCallback(() => { + setWeightDraftSources(sources.map((source) => ({ ...source }))); + setShowWeightModal(true); + }, [sources]); + + const handleCloseWeightModal = useCallback(() => { + setShowWeightModal(false); + setWeightDraftSources([]); + }, []); + + useEffect(() => { + if (!showWeightModal) return; + + const isInsideAllowedScroll = (target: EventTarget | null) => { + if (!(target instanceof Node)) return false; + return !!target.parentElement?.closest('[data-weight-modal-scroll]'); + }; + + const preventBackgroundScroll = (event: TouchEvent | WheelEvent) => { + if (isInsideAllowedScroll(event.target)) return; + event.preventDefault(); + }; + + document.addEventListener('touchmove', preventBackgroundScroll, { + passive: false, + }); + document.addEventListener('wheel', preventBackgroundScroll, { + passive: false, + }); + + return () => { + document.removeEventListener( + 'touchmove', + preventBackgroundScroll as EventListener + ); + document.removeEventListener( + 'wheel', + preventBackgroundScroll as EventListener + ); + }; + }, [showWeightModal]); + + const handleWeightDraftChange = useCallback((key: string, weight: number) => { + setWeightDraftSources((prev) => + prev.map((source) => + source.key === key ? { ...source, weight } : source + ) + ); + }, []); + + const handleApplyRecommendedWeights = useCallback(() => { + setWeightDraftSources((prev) => applyRecommendedWeights(prev)); + }, [applyRecommendedWeights]); + + const handleResetWeightDraft = useCallback(() => { + setWeightDraftSources(sources.map((source) => ({ ...source }))); + }, [sources]); + + const handleWeightModalDragEnd = useCallback( + (event: any) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + setWeightDraftSources((prev) => { + const oldIndex = prev.findIndex((source) => source.key === active.id); + const newIndex = prev.findIndex((source) => source.key === over.id); + if (oldIndex === -1 || newIndex === -1) return prev; + return applyRecommendedWeights(arrayMove(prev, oldIndex, newIndex)); }); - }; + }, + [applyRecommendedWeights] + ); + + const recommendedWeightMap = useMemo( + () => buildRecommendedWeightMap(weightDraftSources), + [buildRecommendedWeightMap, weightDraftSources] + ); + + const weightModalChanged = useMemo(() => { + if (weightDraftSources.length !== sources.length) return false; + return weightDraftSources.some((source, index) => { + const current = sources[index]; + return ( + !current || + current.key !== source.key || + (current.weight ?? 0) !== (source.weight ?? 0) + ); + }); + }, [sources, weightDraftSources]); + + const handleSaveWeightConfig = useCallback(() => { + withLoading('saveWeightConfig', async () => { + await callSourceApi({ + action: 'batch_update_weights', + weights: weightDraftSources.map((source) => ({ + key: source.key, + weight: source.weight ?? 0, + })), + order: weightDraftSources.map((source) => source.key), + }); + setSources(weightDraftSources.map((source) => ({ ...source }))); + setOrderChanged(false); + handleCloseWeightModal(); + }).catch(() => { + console.error('操作失败', 'batch_update_weights'); + }); + }, [callSourceApi, handleCloseWeightModal, weightDraftSources, withLoading]); // 有效性检测函数 const handleValidateSources = async () => { @@ -5884,70 +6288,125 @@ const VideoSourceConfig = ({ } }; - // 权重输入组件 - 使用本地状态避免输入时失焦 - const WeightInput = memo(({ sourceKey, initialWeight }: { sourceKey: string; initialWeight: number }) => { - const [localWeight, setLocalWeight] = useState(initialWeight); + const WeightModalInput = memo( + ({ sourceKey, weight }: { sourceKey: string; weight: number }) => { + const [localWeight, setLocalWeight] = useState(weight); - // 当外部权重变化时同步 - useEffect(() => { - setLocalWeight(initialWeight); - }, [initialWeight]); + useEffect(() => { + setLocalWeight(weight); + }, [weight]); - 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; + const commitWeight = (value: number) => { + const clampedValue = Math.min(100, Math.max(0, value)); + setLocalWeight(clampedValue); + handleWeightDraftChange(sourceKey, clampedValue); + }; - // 只有在值发生变化时才调用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 ( - - e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} > - - + commitWeight(parseInt(e.target.value) || 0)} + className='w-full accent-blue-600' + /> + { + const nextValue = parseInt(e.target.value) || 0; + const clampedValue = Math.min(100, Math.max(0, nextValue)); + setLocalWeight(clampedValue); + }} + onBlur={(e) => commitWeight(parseInt(e.target.value) || 0)} + className='w-20 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + /> + + ); + } + ); + + const WeightModalRow = memo( + ({ + source, + index, + recommendedWeight, + }: { + source: DataSource; + index: number; + recommendedWeight: number; + }) => { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: source.key }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } as React.CSSProperties; + + return ( +
+
+ + + #{index + 1} + +
+
+
+ {source.name} +
+
+ {source.key} +
+
+
+ + {source.disabled ? '已禁用' : '启用中'} + +
+
+ + {recommendedWeight} + +
+ +
+ ); + } + ); + + const SourceRow = memo(({ source }: { source: DataSource }) => { + return ( + - - - {(() => { const status = getValidationStatus(source.key); @@ -6137,7 +6593,11 @@ const VideoSourceConfig = ({ ); // 根据操作类型和结果显示不同的消息 - if (action === 'batch_delete' && result?.deleted !== undefined && result?.skipped !== undefined) { + if ( + action === 'batch_delete' && + result?.deleted !== undefined && + result?.skipped !== undefined + ) { const { deleted, skipped } = result; if (skipped > 0) { showAlert({ @@ -6268,11 +6728,19 @@ const VideoSourceConfig = ({
)} -
+
+ @@ -6369,7 +6837,6 @@ const VideoSourceConfig = ({ - - @@ -6407,43 +6871,157 @@ const VideoSourceConfig = ({ - - s.key)} - strategy={verticalListSortingStrategy} - > - - {sources.map((source) => ( - - ))} - - - + + {sources.map((source) => ( + + ))} +
代理模式 - 权重 - 有效性
- {/* 保存排序按钮 */} - {orderChanged && ( -
- -
- )} + {showWeightModal && + createPortal( + <> +
{ + e.preventDefault(); + }} + onWheel={(e) => { + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} + /> +
e.stopPropagation()} + > +
+
+

+ 视频源权重设置 +

+
+ +
+ +
{ + e.stopPropagation(); + }} + onWheel={(e) => { + e.stopPropagation(); + }} + style={{ + touchAction: 'pan-y', + overscrollBehavior: 'contain', + }} + > +
+
+ 排序越靠前,推荐权重越高;拖动后再次生成推荐值时,会把当前列表均匀映射到 + 0~40。 +
+
+ + +
+
+ +
+
+
+
排序
+
视频源
+
状态
+
推荐值
+
生效权重
+
+
+ + source.key)} + strategy={verticalListSortingStrategy} + > +
+ {weightDraftSources.map((source, index) => { + const recommendedWeight = + recommendedWeightMap.get(source.key) ?? 0; + return ( + + ); + })} +
+
+
+
+
+
+
+ +
+
+ + +
+
+
+ , + document.body + )} {/* 有效性检测弹窗 */} {showValidationModal && @@ -6486,7 +7064,7 @@ const VideoSourceConfig = ({ className={`px-4 py-2 ${ !searchKeyword.trim() ? buttonStyles.disabled - : buttonStyles.primary + : buttonStyles.success }`} > 开始检测 @@ -6572,7 +7150,7 @@ const VideoSourceConfig = ({ isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? buttonStyles.disabled - : buttonStyles.primary + : buttonStyles.success }`} > {isLoading('batchSource_batch_enable') || @@ -6904,7 +7482,6 @@ const CategoryConfig = ({ - @@ -7001,10 +7578,10 @@ const VideoSourceScriptLab = () => { code: '', enabled: true, }); - const [testHook, setTestHook] = useState<'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'>('getSources'); - const [testPayload, setTestPayload] = useState( - JSON.stringify({}, null, 2) - ); + 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); @@ -7068,7 +7645,10 @@ const VideoSourceScriptLab = () => { setSelectedScriptId(null); } } catch (error) { - showError(error instanceof Error ? error.message : '加载脚本失败', showAlert); + showError( + error instanceof Error ? error.message : '加载脚本失败', + showAlert + ); } finally { setLoadingScripts(false); } @@ -7212,7 +7792,10 @@ const VideoSourceScriptLab = () => { showSuccess('脚本已删除', showAlert); await loadScripts(null); }).catch((error) => { - showError(error instanceof Error ? error.message : '删除失败', showAlert); + showError( + error instanceof Error ? error.message : '删除失败', + showAlert + ); }); }, }); @@ -7276,20 +7859,24 @@ const VideoSourceScriptLab = () => { testHook === 'getSources' ? JSON.stringify({}, null, 2) : testHook === 'search' - ? JSON.stringify({ keyword: '凡人修仙传', page: 1, sourceId: 'main' }, null, 2) + ? 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', - playUrl: 'https://example.com/video.m3u8', - episodeIndex: 0, - }, - null, - 2 - ) + ? JSON.stringify({ id: 'demo-id', sourceId: 'main' }, null, 2) + : JSON.stringify( + { + sourceId: 'main', + playUrl: 'https://example.com/video.m3u8', + episodeIndex: 0, + }, + null, + 2 + ) ); }, [testHook]); @@ -7312,18 +7899,29 @@ const VideoSourceScriptLab = () => { - @@ -7331,7 +7929,9 @@ const VideoSourceScriptLab = () => {
{loadingScripts ? ( -
加载中...
+
+ 加载中... +
) : scripts.length === 0 ? (
还没有脚本,点右上角新建一个。 @@ -7370,14 +7970,20 @@ const VideoSourceScriptLab = () => {
- {new Date(script.updatedAt).toLocaleString('zh-CN')} + + {new Date(script.updatedAt).toLocaleString('zh-CN')} + @@ -7394,14 +8000,18 @@ const VideoSourceScriptLab = () => { type='text' placeholder='脚本名称' value={editor.name} - onChange={(e) => setEditor((prev) => ({ ...prev, name: e.target.value }))} + onChange={(e) => + 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 }))} + onChange={(e) => + 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' />
@@ -7409,7 +8019,9 @@ const VideoSourceScriptLab = () => {
分类名称