增加可视化视频源权重设置

This commit is contained in:
mtvpls
2026-05-09 18:46:41 +08:00
parent e3f7d43592
commit c55b4bd044
2 changed files with 3443 additions and 1710 deletions
+3317 -1683
View File
@@ -49,7 +49,16 @@ import {
Video, Video,
} from 'lucide-react'; } from 'lucide-react';
import { GripVertical } 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 { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
@@ -239,10 +248,7 @@ const AlertModal = ({
</div> </div>
) : ( ) : (
// 普通提示:只显示确定按钮 // 普通提示:只显示确定按钮
<button <button onClick={onClose} className={buttonStyles.primary}>
onClick={onClose}
className={buttonStyles.primary}
>
</button> </button>
) )
@@ -442,11 +448,13 @@ const CollapsibleTab = ({
isParent = false, isParent = false,
}: CollapsibleTabProps) => { }: CollapsibleTabProps) => {
return ( return (
<div className={`rounded-xl shadow-sm mb-4 overflow-hidden ${ <div
isParent className={`rounded-xl shadow-sm mb-4 overflow-hidden ${
? 'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 ring-2 ring-yellow-400/50 dark:ring-yellow-600/50' isParent
: 'bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700' ? 'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 ring-2 ring-yellow-400/50 dark:ring-yellow-600/50'
}`}> : 'bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'
}`}
>
<button <button
onClick={onToggle} onClick={onToggle}
className={`w-full px-6 py-4 flex items-center justify-between transition-colors ${ className={`w-full px-6 py-4 flex items-center justify-between transition-colors ${
@@ -457,20 +465,32 @@ const CollapsibleTab = ({
> >
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
{icon} {icon}
<h3 className={`text-lg font-medium ${ <h3
isParent className={`text-lg font-medium ${
? 'text-yellow-900 dark:text-yellow-200' isParent
: 'text-gray-900 dark:text-gray-100' ? 'text-yellow-900 dark:text-yellow-200'
}`}> : 'text-gray-900 dark:text-gray-100'
}`}
>
{title} {title}
</h3> </h3>
</div> </div>
<div className={isParent ? 'text-yellow-700 dark:text-yellow-400' : 'text-gray-500 dark:text-gray-400'}> <div
className={
isParent
? 'text-yellow-700 dark:text-yellow-400'
: 'text-gray-500 dark:text-gray-400'
}
>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />} {isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div> </div>
</button> </button>
{isExpanded && <div className={isParent ? 'px-0.5 md:px-6 py-4' : 'px-6 py-4'}>{children}</div>} {isExpanded && (
<div className={isParent ? 'px-0.5 md:px-6 py-4' : 'px-6 py-4'}>
{children}
</div>
)}
</div> </div>
); );
}; };
@@ -496,7 +516,17 @@ interface UserConfigProps {
userListLoading: boolean; 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 { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState(); const { isLoading, withLoading } = useLoadingState();
const [showAddUserForm, setShowAddUserForm] = useState(false); const [showAddUserForm, setShowAddUserForm] = useState(false);
@@ -557,7 +587,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; 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<{ const displayUsers: Array<{
@@ -568,7 +600,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
tags?: string[]; tags?: string[];
created_at?: number; created_at?: number;
oidcSub?: string; oidcSub?: string;
}> = !hasOldUserData && usersV2 ? usersV2 : (config?.UserConfig?.Users || []); }> = !hasOldUserData && usersV2 ? usersV2 : config?.UserConfig?.Users || [];
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算 // 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAllUsers = useMemo(() => { const selectAllUsers = useMemo(() => {
@@ -1029,69 +1061,77 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
{/* 数据迁移提示 */} {/* 数据迁移提示 */}
{config.UserConfig.Users && {config.UserConfig.Users &&
config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( config.UserConfig.Users.filter((u) => u.role !== 'owner').length >
<div className='mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800'> 0 && (
<div className='flex items-start justify-between'> <div className='mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800'>
<div className='flex-1'> <div className='flex items-start justify-between'>
<h5 className='text-sm font-medium text-yellow-800 dark:text-yellow-300 mb-1'> <div className='flex-1'>
<h5 className='text-sm font-medium text-yellow-800 dark:text-yellow-300 mb-1'>
</h5>
<p className='text-xs text-yellow-600 dark:text-yellow-400'> </h5>
使SHA256加密 <p className='text-xs text-yellow-600 dark:text-yellow-400'>
</p> 使SHA256加密
</div> </p>
<button </div>
onClick={() => { <button
showAlert({ onClick={() => {
type: 'warning', showAlert({
title: '确认迁移用户数据', type: 'warning',
message: '迁移过程中请勿关闭页面。迁移完成后,所有用户密码将使用SHA256加密存储。', title: '确认迁移用户数据',
showConfirm: true, message:
onConfirm: async () => { '迁移过程中请勿关闭页面。迁移完成后,所有用户密码将使用SHA256加密存储。',
hideAlert(); showConfirm: true,
await withLoading('migrateUsers', async () => { onConfirm: async () => {
try { hideAlert();
const response = await fetch('/api/admin/migrate-users', { await withLoading('migrateUsers', async () => {
method: 'POST', try {
headers: { const response = await fetch(
'Content-Type': 'application/json', '/api/admin/migrate-users',
}, {
}); method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || '迁移失败'); throw new Error(errorData.error || '迁移失败');
}
showAlert({
type: 'success',
title: '用户数据迁移成功',
message: '所有用户已迁移到新的存储结构',
timer: 2000,
});
await refreshConfig();
} catch (error: any) {
console.error('迁移用户数据失败:', error);
showAlert({
type: 'error',
title: '迁移失败',
message:
error.message || '迁移用户数据时发生错误',
});
} }
});
showAlert({ },
type: 'success', });
title: '用户数据迁移成功', }}
message: '所有用户已迁移到新的存储结构', disabled={isLoading('migrateUsers')}
timer: 2000, className={`ml-4 ${buttonStyles.warning} ${
}); isLoading('migrateUsers')
await refreshConfig(); ? 'opacity-50 cursor-not-allowed'
} catch (error: any) { : ''
console.error('迁移用户数据失败:', error); }`}
showAlert({ >
type: 'error', {isLoading('migrateUsers') ? '迁移中...' : '立即迁移'}
title: '迁移失败', </button>
message: error.message || '迁移用户数据时发生错误', </div>
});
}
});
},
});
}}
disabled={isLoading('migrateUsers')}
className={`ml-4 ${buttonStyles.warning} ${
isLoading('migrateUsers') ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{isLoading('migrateUsers') ? '迁移中...' : '立即迁移'}
</button>
</div> </div>
</div> )}
)}
</div> </div>
{/* 用户组管理 */} {/* 用户组管理 */}
@@ -1378,404 +1418,416 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
<div className='relative'> <div className='relative'>
{/* 迁移遮罩层 */} {/* 迁移遮罩层 */}
{config.UserConfig.Users && {config.UserConfig.Users &&
config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( config.UserConfig.Users.filter((u) => u.role !== 'owner').length >
<div className='absolute inset-0 z-20 backdrop-blur-sm bg-white/30 dark:bg-gray-900/30 rounded-lg flex items-center justify-center'> 0 && (
<div className='bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl border border-yellow-200 dark:border-yellow-800 max-w-md'> <div className='absolute inset-0 z-20 backdrop-blur-sm bg-white/30 dark:bg-gray-900/30 rounded-lg flex items-center justify-center'>
<div className='flex items-center gap-3 mb-4'> <div className='bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl border border-yellow-200 dark:border-yellow-800 max-w-md'>
<AlertTriangle className='w-6 h-6 text-yellow-600 dark:text-yellow-400' /> <div className='flex items-center gap-3 mb-4'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'> <AlertTriangle className='w-6 h-6 text-yellow-600 dark:text-yellow-400' />
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
</h3>
</h3>
</div>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
</p>
<p className='text-xs text-gray-500 dark:text-gray-500'>
"用户统计""立即迁移"
</p>
</div> </div>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
</p>
<p className='text-xs text-gray-500 dark:text-gray-500'>
"用户统计""立即迁移"
</p>
</div> </div>
</div> )}
)}
<div <div
className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative'
data-table='user-list' data-table='user-list'
> >
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'> <thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr> <tr>
<th className='w-4' /> <th className='w-4' />
<th className='w-10 px-1 py-3 text-center'> <th className='w-10 px-1 py-3 text-center'>
{(() => { {(() => {
// 检查是否有权限操作任何用户 // 检查是否有权限操作任何用户
const hasAnyPermission = config?.UserConfig?.Users?.some( const hasAnyPermission = config?.UserConfig?.Users?.some(
(user) => (user) =>
role === 'owner' || role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))
);
return hasAnyPermission ? (
<input
type='checkbox'
checked={selectAllUsers}
onChange={(e) => 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'
/>
) : (
<div className='w-4 h-4' />
);
})()}
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
</tr>
</thead>
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
{(() => {
// 如果正在加载,显示加载状态
if (userListLoading) {
return (
<tbody>
<tr>
<td colSpan={7} className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'>
...
</td>
</tr>
</tbody>
);
}
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 (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{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 (
<tr
key={user.username}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
>
<td className='w-4' />
<td className='w-10 px-1 py-3 text-center'>
{role === 'owner' ||
(role === 'admin' && (role === 'admin' &&
(user.role === 'user' || (user.role === 'user' ||
user.username === currentUsername)) ? ( user.username === currentUsername))
<input );
type='checkbox'
checked={selectedUsers.has(user.username)} return hasAnyPermission ? (
onChange={(e) => <input
handleSelectUser( type='checkbox'
user.username, checked={selectAllUsers}
e.target.checked onChange={(e) =>
) 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' 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'
/> />
) : ( ) : (
<div className='w-4 h-4' /> <div className='w-4 h-4' />
)} );
</td> })()}
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'> </th>
<div className='flex items-center gap-2'> <th
<span>{user.username}</span> scope='col'
{user.oidcSub && ( className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
<span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'> >
OIDC
</span> </th>
)} <th
</div> scope='col'
</td> className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
<td className='px-6 py-4 whitespace-nowrap'> >
<span
className={`px-2 py-1 text-xs rounded-full ${ </th>
user.role === 'owner' <th
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300' scope='col'
: user.role === 'admin' className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300' >
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} </th>
> <th
{user.role === 'owner' scope='col'
? '站长' className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
: user.role === 'admin' >
? '管理员'
: '普通用户'} </th>
</span> <th
</td> scope='col'
<td className='px-6 py-4 whitespace-nowrap'> className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
<span >
className={`px-2 py-1 text-xs rounded-full ${
!user.banned </th>
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300' <th
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300' scope='col'
}`} className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
> >
{!user.banned ? '正常' : '已封禁'}
</span> </th>
</td> </tr>
<td className='px-6 py-4 whitespace-nowrap'> </thead>
<div className='flex items-center space-x-2'> {/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
<span className='text-sm text-gray-900 dark:text-gray-100'> {(() => {
{user.tags && user.tags.length > 0 // 如果正在加载,显示加载状态
? user.tags.join(', ') if (userListLoading) {
: '无用户组'} return (
</span> <tbody>
{/* 配置用户组按钮 */} <tr>
{(role === 'owner' || <td
(role === 'admin' && colSpan={7}
(user.role === 'user' || className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'
user.username === currentUsername))) && ( >
<button ...
onClick={() => handleConfigureUserGroup(user)}
className={buttonStyles.roundedPrimary}
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
</span>
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserApis(user)}
className={buttonStyles.roundedPrimary}
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
<button
onClick={() =>
handleShowChangePasswordForm(user.username)
}
className={buttonStyles.roundedPrimary}
>
</button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() => handleSetAdmin(user.username)}
disabled={isLoading(
`setAdmin_${user.username}`
)}
className={`${buttonStyles.roundedPurple} ${
isLoading(`setAdmin_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
)}
{user.role === 'admin' && (
<button
onClick={() =>
handleRemoveAdmin(user.username)
}
disabled={isLoading(
`removeAdmin_${user.username}`
)}
className={`${
buttonStyles.roundedSecondary
} ${
isLoading(`removeAdmin_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() => handleBanUser(user.username)}
disabled={isLoading(
`banUser_${user.username}`
)}
className={`${buttonStyles.roundedDanger} ${
isLoading(`banUser_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
) : (
<button
onClick={() =>
handleUnbanUser(user.username)
}
disabled={isLoading(
`unbanUser_${user.username}`
)}
className={`${
buttonStyles.roundedSuccess
} ${
isLoading(`unbanUser_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<button
onClick={() => handleDeleteUser(user.username)}
className={buttonStyles.roundedDanger}
>
</button>
)}
</td> </td>
</tr> </tr>
); </tbody>
})} );
</tbody> }
);
})()}
</table>
</div>
{/* 用户列表分页 */} const sortedUsers = [...displayUsers].sort((a, b) => {
{!hasOldUserData && usersV2 && userTotalPages > 1 && ( type UserInfo = (typeof displayUsers)[number];
<div className='mt-4 flex items-center justify-between px-4'> const priority = (u: UserInfo) => {
<div className='text-sm text-gray-600 dark:text-gray-400'> if (u.username === currentUsername) return 0;
{userTotal} {userPage} / {userTotalPages} if (u.role === 'owner') return 1;
</div> if (u.role === 'admin') return 2;
<div className='flex items-center space-x-2'> return 3;
<button };
onClick={() => fetchUsersV2(1)} return priority(a) - priority(b);
disabled={userPage === 1} });
className={`px-3 py-1 text-sm rounded ${ return (
userPage === 1 <tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed' {sortedUsers.map((user) => {
: 'bg-blue-500 hover:bg-blue-600 text-white' // 修改密码权限:站长可修改管理员和普通用户密码,管理员可修改普通用户和自己的密码,但任何人都不能修改站长密码
}`} const canChangePassword =
> user.role !== 'owner' && // 不能修改站长密码
(role === 'owner' || // 站长可以修改管理员和普通用户密码
</button> (role === 'admin' &&
<button (user.role === 'user' ||
onClick={() => fetchUsersV2(userPage - 1)} user.username === currentUsername))); // 管理员可以修改普通用户和自己的密码
disabled={userPage === 1}
className={`px-3 py-1 text-sm rounded ${ // 删除用户权限:站长可删除除自己外的所有用户,管理员仅可删除普通用户
userPage === 1 const canDeleteUser =
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed' user.username !== currentUsername &&
: 'bg-blue-500 hover:bg-blue-600 text-white' (role === 'owner' || // 站长可以删除除自己外的所有用户
}`} (role === 'admin' && user.role === 'user')); // 管理员仅可删除普通用户
>
// 其他操作权限:不能操作自己,站长可操作所有用户,管理员可操作普通用户
</button> const canOperate =
<button user.username !== currentUsername &&
onClick={() => fetchUsersV2(userPage + 1)} (role === 'owner' ||
disabled={userPage === userTotalPages} (role === 'admin' && user.role === 'user'));
className={`px-3 py-1 text-sm rounded ${ return (
userPage === userTotalPages <tr
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed' key={user.username}
: 'bg-blue-500 hover:bg-blue-600 text-white' className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
}`} >
> <td className='w-4' />
<td className='w-10 px-1 py-3 text-center'>
</button> {role === 'owner' ||
<button (role === 'admin' &&
onClick={() => fetchUsersV2(userTotalPages)} (user.role === 'user' ||
disabled={userPage === userTotalPages} user.username === currentUsername)) ? (
className={`px-3 py-1 text-sm rounded ${ <input
userPage === userTotalPages type='checkbox'
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed' checked={selectedUsers.has(user.username)}
: 'bg-blue-500 hover:bg-blue-600 text-white' onChange={(e) =>
}`} handleSelectUser(
> user.username,
e.target.checked
</button> )
</div> }
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'
/>
) : (
<div className='w-4 h-4' />
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
<div className='flex items-center gap-2'>
<span>{user.username}</span>
{user.oidcSub && (
<span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'>
OIDC
</span>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${
user.role === 'owner'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: user.role === 'admin'
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{user.role === 'owner'
? '站长'
: user.role === 'admin'
? '管理员'
: '普通用户'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!user.banned
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!user.banned ? '正常' : '已封禁'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.tags && user.tags.length > 0
? user.tags.join(', ')
: '无用户组'}
</span>
{/* 配置用户组按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserGroup(user)}
className={buttonStyles.roundedPrimary}
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
</span>
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserApis(user)}
className={buttonStyles.roundedPrimary}
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
<button
onClick={() =>
handleShowChangePasswordForm(user.username)
}
className={buttonStyles.roundedPrimary}
>
</button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() =>
handleSetAdmin(user.username)
}
disabled={isLoading(
`setAdmin_${user.username}`
)}
className={`${buttonStyles.roundedPurple} ${
isLoading(`setAdmin_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
)}
{user.role === 'admin' && (
<button
onClick={() =>
handleRemoveAdmin(user.username)
}
disabled={isLoading(
`removeAdmin_${user.username}`
)}
className={`${
buttonStyles.roundedSecondary
} ${
isLoading(`removeAdmin_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() =>
handleBanUser(user.username)
}
disabled={isLoading(
`banUser_${user.username}`
)}
className={`${
buttonStyles.roundedDanger
} ${
isLoading(`banUser_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
) : (
<button
onClick={() =>
handleUnbanUser(user.username)
}
disabled={isLoading(
`unbanUser_${user.username}`
)}
className={`${
buttonStyles.roundedSuccess
} ${
isLoading(`unbanUser_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
</button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<button
onClick={() => handleDeleteUser(user.username)}
className={buttonStyles.roundedDanger}
>
</button>
)}
</td>
</tr>
);
})}
</tbody>
);
})()}
</table>
</div> </div>
)}
{/* 用户列表分页 */}
{!hasOldUserData && usersV2 && userTotalPages > 1 && (
<div className='mt-4 flex items-center justify-between px-4'>
<div className='text-sm text-gray-600 dark:text-gray-400'>
{userTotal} {userPage} / {userTotalPages}
</div>
<div className='flex items-center space-x-2'>
<button
onClick={() => fetchUsersV2(1)}
disabled={userPage === 1}
className={`px-3 py-1 text-sm rounded ${
userPage === 1
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
</button>
<button
onClick={() => fetchUsersV2(userPage - 1)}
disabled={userPage === 1}
className={`px-3 py-1 text-sm rounded ${
userPage === 1
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
</button>
<button
onClick={() => fetchUsersV2(userPage + 1)}
disabled={userPage === userTotalPages}
className={`px-3 py-1 text-sm rounded ${
userPage === userTotalPages
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
</button>
<button
onClick={() => fetchUsersV2(userTotalPages)}
disabled={userPage === userTotalPages}
className={`px-3 py-1 text-sm rounded ${
userPage === userTotalPages
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
@@ -1942,7 +1994,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
className={`px-6 py-2.5 text-sm font-medium ${ className={`px-6 py-2.5 text-sm font-medium ${
isLoading(`saveUserApis_${selectedUser?.username}`) isLoading(`saveUserApis_${selectedUser?.username}`)
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.primary : buttonStyles.success
}`} }`}
> >
{isLoading(`saveUserApis_${selectedUser?.username}`) {isLoading(`saveUserApis_${selectedUser?.username}`)
@@ -2038,17 +2090,24 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
> >
<input <input
type='checkbox' type='checkbox'
checked={newUserGroup.permissions.includes(permission.key)} checked={newUserGroup.permissions.includes(
permission.key
)}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setNewUserGroup((prev) => ({ setNewUserGroup((prev) => ({
...prev, ...prev,
permissions: [...prev.permissions, permission.key], permissions: [
...prev.permissions,
permission.key,
],
})); }));
} else { } else {
setNewUserGroup((prev) => ({ setNewUserGroup((prev) => ({
...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
> >
<input <input
type='checkbox' type='checkbox'
checked={editingUserGroup.permissions.includes(permission.key)} checked={editingUserGroup.permissions.includes(
permission.key
)}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setEditingUserGroup((prev) => setEditingUserGroup((prev) =>
prev prev
? { ? {
...prev, ...prev,
permissions: [...prev.permissions, permission.key], permissions: [
...prev.permissions,
permission.key,
],
} }
: null : null
); );
@@ -2373,7 +2437,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
prev prev
? { ? {
...prev, ...prev,
permissions: prev.permissions.filter((item) => item !== permission.key), permissions: prev.permissions.filter(
(item) => item !== permission.key
),
} }
: null : null
); );
@@ -2580,7 +2646,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
`saveUserGroups_${selectedUserForGroup?.username}` `saveUserGroups_${selectedUserForGroup?.username}`
) )
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.primary : buttonStyles.success
}`} }`}
> >
{isLoading( {isLoading(
@@ -2961,7 +3027,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
className={`px-6 py-2.5 text-sm font-medium ${ className={`px-6 py-2.5 text-sm font-medium ${
isLoading('batchSetUserGroup') isLoading('batchSetUserGroup')
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.primary : buttonStyles.success
}`} }`}
> >
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'} {isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
@@ -3005,7 +3071,9 @@ const OpenListConfigComponent = ({
const [rootPaths, setRootPaths] = useState<string[]>(['/']); const [rootPaths, setRootPaths] = useState<string[]>(['/']);
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
const [scanInterval, setScanInterval] = useState(0); 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 [disableVideoPreview, setDisableVideoPreview] = useState(false);
const [videos, setVideos] = useState<any[]>([]); const [videos, setVideos] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -3023,16 +3091,27 @@ const OpenListConfigComponent = ({
setUrl(config.OpenListConfig.URL || ''); setUrl(config.OpenListConfig.URL || '');
setUsername(config.OpenListConfig.Username || ''); setUsername(config.OpenListConfig.Username || '');
setPassword(config.OpenListConfig.Password || ''); 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 || '/'); setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
setScanInterval(config.OpenListConfig.ScanInterval || 0); setScanInterval(config.OpenListConfig.ScanInterval || 0);
setScanMode(config.OpenListConfig.ScanMode || 'hybrid'); setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
setDisableVideoPreview(config.OpenListConfig.DisableVideoPreview || false); setDisableVideoPreview(
config.OpenListConfig.DisableVideoPreview || false
);
} }
}, [config]); }, [config]);
useEffect(() => { useEffect(() => {
if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password) { if (
config?.OpenListConfig?.URL &&
config?.OpenListConfig?.Username &&
config?.OpenListConfig?.Password
) {
fetchVideos(); fetchVideos();
} }
}, [config]); }, [config]);
@@ -3040,7 +3119,9 @@ const OpenListConfigComponent = ({
const fetchVideos = async (noCache = false) => { const fetchVideos = async (noCache = false) => {
try { try {
setRefreshing(true); 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); const response = await fetch(url);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@@ -3081,7 +3162,10 @@ const OpenListConfigComponent = ({
showSuccess('保存成功', showAlert); showSuccess('保存成功', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3205,7 +3289,10 @@ const OpenListConfigComponent = ({
throw new Error(data.error || '连接失败'); throw new Error(data.error || '连接失败');
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '连接失败', showAlert); showError(
error instanceof Error ? error.message : '连接失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3235,7 +3322,10 @@ const OpenListConfigComponent = ({
await fetchVideos(true); // 强制从数据库重新读取 await fetchVideos(true); // 强制从数据库重新读取
refreshConfig(); // 异步刷新配置以更新资源数量(不等待,避免重复刷新) refreshConfig(); // 异步刷新配置以更新资源数量(不等待,避免重复刷新)
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '删除失败', showAlert); showError(
error instanceof Error ? error.message : '删除失败',
showAlert
);
} }
}, },
}); });
@@ -3269,12 +3359,20 @@ const OpenListConfigComponent = ({
</span> </span>
</div> </div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'> <div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> OpenList 使</p> <p>
<p> OpenList OneDrive </p> OpenList 使
<p> TMDB </p> </p>
<p>
OpenList
OneDrive
</p>
<p>
TMDB
</p>
<p> 0 60 </p> <p> 0 60 </p>
<p> TMDB </p> <p> TMDB </p>
</div> </div>
</div> </div>
@@ -3433,7 +3531,9 @@ const OpenListConfigComponent = ({
</label> </label>
<select <select
value={scanMode} value={scanMode}
onChange={(e) => setScanMode(e.target.value as 'torrent' | 'name' | 'hybrid')} onChange={(e) =>
setScanMode(e.target.value as 'torrent' | 'name' | 'hybrid')
}
disabled={!enabled} disabled={!enabled}
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' 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'
> >
@@ -3459,7 +3559,9 @@ const OpenListConfigComponent = ({
onClick={() => setDisableVideoPreview(!disableVideoPreview)} onClick={() => setDisableVideoPreview(!disableVideoPreview)}
disabled={!enabled} disabled={!enabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
disableVideoPreview ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' disableVideoPreview
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${!enabled ? 'opacity-50 cursor-not-allowed' : ''}`} } ${!enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
<span <span
@@ -3473,7 +3575,13 @@ const OpenListConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleCheckConnectivity} onClick={handleCheckConnectivity}
disabled={!enabled || !url || !username || !password || isLoading('checkOpenList')} disabled={
!enabled ||
!url ||
!username ||
!password ||
isLoading('checkOpenList')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('checkOpenList') ? '检查中...' : '检查连通性'} {isLoading('checkOpenList') ? '检查中...' : '检查连通性'}
@@ -3489,176 +3597,211 @@ const OpenListConfigComponent = ({
</div> </div>
{/* 视频列表区域 */} {/* 视频列表区域 */}
{enabled && config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && ( {enabled &&
<div className='space-y-4'> config?.OpenListConfig?.URL &&
<div className='flex items-center justify-between'> config?.OpenListConfig?.Username &&
<div> config?.OpenListConfig?.Password && (
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'> <div className='space-y-4'>
<div className='flex items-center justify-between'>
</h3> <div>
<div className='mt-1 text-sm text-gray-500 dark:text-gray-400'> <h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
<span>: {config.OpenListConfig.ResourceCount || 0}</span>
<span className='mx-2'>|</span> </h3>
<span> <div className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
: {formatDate(config.OpenListConfig.LastRefreshTime)} <span>
</span> : {config.OpenListConfig.ResourceCount || 0}
</span>
<span className='mx-2'>|</span>
<span>
:{' '}
{formatDate(config.OpenListConfig.LastRefreshTime)}
</span>
</div>
</div>
<div className='flex gap-3'>
<button
onClick={() => handleRefresh(true)}
disabled={refreshing}
className={buttonStyles.warning}
>
{refreshing ? '扫描中...' : '重新扫描'}
</button>
<button
onClick={() => handleRefresh(false)}
disabled={refreshing}
className={buttonStyles.primary}
>
{refreshing ? '扫描中...' : '立即扫描'}
</button>
</div> </div>
</div> </div>
<div className='flex gap-3'>
<button
onClick={() => handleRefresh(true)}
disabled={refreshing}
className={buttonStyles.warning}
>
{refreshing ? '扫描中...' : '重新扫描'}
</button>
<button
onClick={() => handleRefresh(false)}
disabled={refreshing}
className={buttonStyles.primary}
>
{refreshing ? '扫描中...' : '立即扫描'}
</button>
</div>
</div>
{refreshing && scanProgress && ( {refreshing && scanProgress && (
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'> <div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'>
<div className='flex items-center justify-between mb-2'> <div className='flex items-center justify-between mb-2'>
<span className='text-sm font-medium text-blue-900 dark:text-blue-100'> <span className='text-sm font-medium text-blue-900 dark:text-blue-100'>
: {scanProgress.current} / {scanProgress.total} : {scanProgress.current} / {scanProgress.total}
</span> </span>
<span className='text-sm text-blue-700 dark:text-blue-300'> <span className='text-sm text-blue-700 dark:text-blue-300'>
{scanProgress.total > 0 {scanProgress.total > 0
? Math.round((scanProgress.current / scanProgress.total) * 100) ? Math.round(
: 0} (scanProgress.current / scanProgress.total) * 100
% )
</span> : 0}
%
</span>
</div>
<div className='w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 mb-2'>
<div
className='bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300'
style={{
width: `${
scanProgress.total > 0
? (scanProgress.current / scanProgress.total) * 100
: 0
}%`,
}}
/>
</div>
{scanProgress.currentFolder && (
<p className='text-xs text-blue-700 dark:text-blue-300'>
: {scanProgress.currentFolder}
</p>
)}
</div> </div>
<div className='w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 mb-2'> )}
<div
className='bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300'
style={{
width: `${scanProgress.total > 0 ? (scanProgress.current / scanProgress.total) * 100 : 0}%`,
}}
/>
</div>
{scanProgress.currentFolder && (
<p className='text-xs text-blue-700 dark:text-blue-300'>
: {scanProgress.currentFolder}
</p>
)}
</div>
)}
{refreshing ? ( {refreshing ? (
<div className='text-center py-8 text-gray-500 dark:text-gray-400'> <div className='text-center py-8 text-gray-500 dark:text-gray-400'>
... ...
</div> </div>
) : videos.length > 0 ? ( ) : videos.length > 0 ? (
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-800'> <thead className='bg-gray-50 dark:bg-gray-800'>
<tr> <tr>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
</tr>
</thead>
<tbody className='bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700'>
{videos.map((video) => (
<tr key={video.id} className={video.failed ? 'bg-red-50 dark:bg-red-900/10' : ''}>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{video.title}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm'>
{video.failed ? (
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200'>
</span>
) : (
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200'>
</span>
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.mediaType === 'movie' ? '电影' : '剧集'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.seasonNumber ? (
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200' title={video.seasonName || `${video.seasonNumber}`}>
S{video.seasonNumber}
</span>
) : (
'-'
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.releaseDate ? video.releaseDate.split('-')[0] : '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.voteAverage > 0 ? video.voteAverage.toFixed(1) : '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm'>
<div className='flex gap-2 justify-end'>
{!video.failed && (
<button
onClick={() => handleRefreshVideo(video.folder)}
className={buttonStyles.primarySmall}
>
</button>
)}
<button
onClick={() => {
console.log('Video object:', video);
console.log('Video poster field:', video.poster);
setSelectedVideo(video);
setCorrectDialogOpen(true);
}}
className={video.failed ? buttonStyles.warningSmall : buttonStyles.successSmall}
>
{video.failed ? '立即纠错' : '纠错'}
</button>
<button
onClick={() => handleDeleteVideo(video.id, video.title)}
className={buttonStyles.dangerSmall}
>
</button>
</div>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className='bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700'>
</table> {videos.map((video) => (
</div> <tr
) : ( key={video.id}
<div className='text-center py-8 text-gray-500 dark:text-gray-400'> className={
"立即扫描" video.failed ? 'bg-red-50 dark:bg-red-900/10' : ''
</div> }
)} >
</div> <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
)} {video.title}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm'>
{video.failed ? (
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200'>
</span>
) : (
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200'>
</span>
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.mediaType === 'movie' ? '电影' : '剧集'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.seasonNumber ? (
<span
className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'
title={
video.seasonName || `${video.seasonNumber}`
}
>
S{video.seasonNumber}
</span>
) : (
'-'
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.releaseDate
? video.releaseDate.split('-')[0]
: '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.voteAverage > 0
? video.voteAverage.toFixed(1)
: '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm'>
<div className='flex gap-2 justify-end'>
{!video.failed && (
<button
onClick={() => handleRefreshVideo(video.folder)}
className={buttonStyles.primarySmall}
>
</button>
)}
<button
onClick={() => {
console.log('Video object:', video);
console.log(
'Video poster field:',
video.poster
);
setSelectedVideo(video);
setCorrectDialogOpen(true);
}}
className={
video.failed
? buttonStyles.warningSmall
: buttonStyles.successSmall
}
>
{video.failed ? '立即纠错' : '纠错'}
</button>
<button
onClick={() =>
handleDeleteVideo(video.id, video.title)
}
className={buttonStyles.dangerSmall}
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
"立即扫描"
</div>
)}
</div>
)}
<AlertModal <AlertModal
isOpen={alertModal.isOpen} isOpen={alertModal.isOpen}
@@ -3824,7 +3967,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '夸克 Cookie 可读', showAlert); showSuccess(data.message || '夸克 Cookie 可读', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3852,7 +3998,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '移动云盘验证头格式正常', showAlert); showSuccess(data.message || '移动云盘验证头格式正常', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3880,7 +4029,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '百度网盘 Cookie 格式正常', showAlert); showSuccess(data.message || '百度网盘 Cookie 格式正常', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3909,7 +4061,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '天翼云盘账号密码可用', showAlert); showSuccess(data.message || '天翼云盘账号密码可用', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3938,7 +4093,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '123网盘账号密码可用', showAlert); showSuccess(data.message || '123网盘账号密码可用', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3968,7 +4126,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || 'UC Cookie 可读', showAlert); showSuccess(data.message || 'UC Cookie 可读', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -3996,7 +4157,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '115 Cookie 格式正常', showAlert); showSuccess(data.message || '115 Cookie 格式正常', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert); showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -4118,10 +4282,16 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleValidateMobile} onClick={handleValidateMobile}
disabled={!mobileEnabled || !mobileAuthorization || isLoading('validateMobileNetDisk')} disabled={
!mobileEnabled ||
!mobileAuthorization ||
isLoading('validateMobileNetDisk')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('validateMobileNetDisk') ? '校验中...' : '校验移动云盘验证头'} {isLoading('validateMobileNetDisk')
? '校验中...'
: '校验移动云盘验证头'}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -4176,10 +4346,16 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleValidateBaidu} onClick={handleValidateBaidu}
disabled={!baiduEnabled || !baiduCookie || isLoading('validateBaiduNetDisk')} disabled={
!baiduEnabled ||
!baiduCookie ||
isLoading('validateBaiduNetDisk')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('validateBaiduNetDisk') ? '校验中...' : '校验百度网盘 Cookie'} {isLoading('validateBaiduNetDisk')
? '校验中...'
: '校验百度网盘 Cookie'}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -4252,10 +4428,17 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleValidateTianyi} onClick={handleValidateTianyi}
disabled={!tianyiEnabled || !tianyiAccount || !tianyiPassword || isLoading('validateTianyiNetDisk')} disabled={
!tianyiEnabled ||
!tianyiAccount ||
!tianyiPassword ||
isLoading('validateTianyiNetDisk')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('validateTianyiNetDisk') ? '校验中...' : '校验天翼云盘账号密码'} {isLoading('validateTianyiNetDisk')
? '校验中...'
: '校验天翼云盘账号密码'}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -4324,10 +4507,17 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleValidatePan123} onClick={handleValidatePan123}
disabled={!pan123Enabled || !pan123Account || !pan123Password || isLoading('validatePan123NetDisk')} disabled={
!pan123Enabled ||
!pan123Account ||
!pan123Password ||
isLoading('validatePan123NetDisk')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('validatePan123NetDisk') ? '校验中...' : '校验123网盘账号密码'} {isLoading('validatePan123NetDisk')
? '校验中...'
: '校验123网盘账号密码'}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -4410,7 +4600,9 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleValidateUC} onClick={handleValidateUC}
disabled={!ucEnabled || !ucCookie || isLoading('validateUCNetDisk')} disabled={
!ucEnabled || !ucCookie || isLoading('validateUCNetDisk')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('validateUCNetDisk') ? '校验中...' : '校验UC配置'} {isLoading('validateUCNetDisk') ? '校验中...' : '校验UC配置'}
@@ -4468,10 +4660,16 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'> <div className='flex gap-3'>
<button <button
onClick={handleValidatePan115} onClick={handleValidatePan115}
disabled={!pan115Enabled || !pan115Cookie || isLoading('validatePan115NetDisk')} disabled={
!pan115Enabled ||
!pan115Cookie ||
isLoading('validatePan115NetDisk')
}
className={buttonStyles.primary} className={buttonStyles.primary}
> >
{isLoading('validatePan115NetDisk') ? '校验中...' : '校验115 Cookie'} {isLoading('validatePan115NetDisk')
? '校验中...'
: '校验115 Cookie'}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -4513,7 +4711,9 @@ const EmbyConfigComponent = ({
const [sources, setSources] = useState<any[]>([]); const [sources, setSources] = useState<any[]>([]);
const [editingSource, setEditingSource] = useState<any | null>(null); const [editingSource, setEditingSource] = useState<any | null>(null);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set()); const [selectedSources, setSelectedSources] = useState<Set<string>>(
new Set()
);
// 表单状态 // 表单状态
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -4541,17 +4741,19 @@ const EmbyConfigComponent = ({
setSources(config.EmbyConfig.Sources); setSources(config.EmbyConfig.Sources);
} else if (config?.EmbyConfig?.ServerURL) { } else if (config?.EmbyConfig?.ServerURL) {
// 兼容旧格式 // 兼容旧格式
setSources([{ setSources([
key: 'default', {
name: 'Emby', key: 'default',
enabled: config.EmbyConfig.Enabled || false, name: 'Emby',
ServerURL: config.EmbyConfig.ServerURL, enabled: config.EmbyConfig.Enabled || false,
ApiKey: config.EmbyConfig.ApiKey, ServerURL: config.EmbyConfig.ServerURL,
Username: config.EmbyConfig.Username, ApiKey: config.EmbyConfig.ApiKey,
Password: config.EmbyConfig.Password, Username: config.EmbyConfig.Username,
UserId: config.EmbyConfig.UserId, Password: config.EmbyConfig.Password,
isDefault: true, UserId: config.EmbyConfig.UserId,
}]); isDefault: true,
},
]);
} }
}, [config]); }, [config]);
@@ -4622,7 +4824,7 @@ const EmbyConfigComponent = ({
} }
// 验证key唯一性 // 验证key唯一性
if (!editingSource && sources.some(s => s.key === formData.key)) { if (!editingSource && sources.some((s) => s.key === formData.key)) {
showError('标识符已存在,请使用其他标识符', showAlert); showError('标识符已存在,请使用其他标识符', showAlert);
return; return;
} }
@@ -4632,7 +4834,7 @@ const EmbyConfigComponent = ({
let newSources; let newSources;
if (editingSource) { if (editingSource) {
// 更新现有源 // 更新现有源
newSources = sources.map(s => newSources = sources.map((s) =>
s.key === editingSource.key ? formData : s s.key === editingSource.key ? formData : s
); );
} else { } else {
@@ -4660,7 +4862,10 @@ const EmbyConfigComponent = ({
resetForm(); resetForm();
showSuccess(editingSource ? '更新成功' : '添加成功', showAlert); showSuccess(editingSource ? '更新成功' : '添加成功', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
} }
}); });
}; };
@@ -4673,7 +4878,7 @@ const EmbyConfigComponent = ({
await withLoading('deleteEmbySource', async () => { await withLoading('deleteEmbySource', async () => {
try { try {
const newSources = sources.filter(s => s.key !== source.key); const newSources = sources.filter((s) => s.key !== source.key);
const response = await fetch('/api/admin/config', { const response = await fetch('/api/admin/config', {
method: 'POST', method: 'POST',
@@ -4693,7 +4898,10 @@ const EmbyConfigComponent = ({
await refreshConfig(); await refreshConfig();
showSuccess('删除成功', showAlert); showSuccess('删除成功', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '删除失败', showAlert); showError(
error instanceof Error ? error.message : '删除失败',
showAlert
);
} }
}); });
}; };
@@ -4702,7 +4910,7 @@ const EmbyConfigComponent = ({
const handleToggleEnabled = async (source: any) => { const handleToggleEnabled = async (source: any) => {
await withLoading('toggleEmbySource', async () => { await withLoading('toggleEmbySource', async () => {
try { try {
const newSources = sources.map(s => const newSources = sources.map((s) =>
s.key === source.key ? { ...s, enabled: !s.enabled } : s s.key === source.key ? { ...s, enabled: !s.enabled } : s
); );
@@ -4724,7 +4932,10 @@ const EmbyConfigComponent = ({
await refreshConfig(); await refreshConfig();
showSuccess(source.enabled ? '已禁用' : '已启用', showAlert); showSuccess(source.enabled ? '已禁用' : '已启用', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '更新失败', showAlert); showError(
error instanceof Error ? error.message : '更新失败',
showAlert
);
} }
}); });
}; };
@@ -4753,7 +4964,10 @@ const EmbyConfigComponent = ({
showError(data.message || 'Emby 连接测试失败', showAlert); showError(data.message || 'Emby 连接测试失败', showAlert);
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '测试失败', showAlert); showError(
error instanceof Error ? error.message : '测试失败',
showAlert
);
} }
}); });
}; };
@@ -4778,7 +4992,10 @@ const EmbyConfigComponent = ({
showError(data.message || '缓存清除失败', showAlert); showError(data.message || '缓存清除失败', showAlert);
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '缓存清除失败', showAlert); showError(
error instanceof Error ? error.message : '缓存清除失败',
showAlert
);
} }
}); });
}; };
@@ -4802,7 +5019,10 @@ const EmbyConfigComponent = ({
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
showSuccess('导出成功', showAlert); showSuccess('导出成功', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '导出失败', showAlert); showError(
error instanceof Error ? error.message : '导出失败',
showAlert
);
} }
}); });
}; };
@@ -4836,7 +5056,10 @@ const EmbyConfigComponent = ({
showError(result.error || '导入失败', showAlert); showError(result.error || '导入失败', showAlert);
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '导入失败', showAlert); showError(
error instanceof Error ? error.message : '导入失败',
showAlert
);
} }
}); });
}; };
@@ -4848,20 +5071,26 @@ const EmbyConfigComponent = ({
if (selectedSources.size === 0) return; if (selectedSources.size === 0) return;
await withLoading('batchEnableEmby', async () => { await withLoading('batchEnableEmby', async () => {
try { try {
const newSources = sources.map(s => const newSources = sources.map((s) =>
selectedSources.has(s.key) ? { ...s, enabled: true } : s selectedSources.has(s.key) ? { ...s, enabled: true } : s
); );
const response = await fetch('/api/admin/config', { const response = await fetch('/api/admin/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), body: JSON.stringify({
...config,
EmbyConfig: { Sources: newSources },
}),
}); });
if (!response.ok) throw new Error('批量启用失败'); if (!response.ok) throw new Error('批量启用失败');
await refreshConfig(); await refreshConfig();
setSelectedSources(new Set()); setSelectedSources(new Set());
showSuccess(`已启用 ${selectedSources.size} 个源`, showAlert); showSuccess(`已启用 ${selectedSources.size} 个源`, showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '批量启用失败', showAlert); showError(
error instanceof Error ? error.message : '批量启用失败',
showAlert
);
} }
}); });
}; };
@@ -4871,20 +5100,26 @@ const EmbyConfigComponent = ({
if (selectedSources.size === 0) return; if (selectedSources.size === 0) return;
await withLoading('batchDisableEmby', async () => { await withLoading('batchDisableEmby', async () => {
try { try {
const newSources = sources.map(s => const newSources = sources.map((s) =>
selectedSources.has(s.key) ? { ...s, enabled: false } : s selectedSources.has(s.key) ? { ...s, enabled: false } : s
); );
const response = await fetch('/api/admin/config', { const response = await fetch('/api/admin/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), body: JSON.stringify({
...config,
EmbyConfig: { Sources: newSources },
}),
}); });
if (!response.ok) throw new Error('批量禁用失败'); if (!response.ok) throw new Error('批量禁用失败');
await refreshConfig(); await refreshConfig();
setSelectedSources(new Set()); setSelectedSources(new Set());
showSuccess(`已禁用 ${selectedSources.size} 个源`, showAlert); showSuccess(`已禁用 ${selectedSources.size} 个源`, showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '批量禁用失败', showAlert); showError(
error instanceof Error ? error.message : '批量禁用失败',
showAlert
);
} }
}); });
}; };
@@ -4900,18 +5135,26 @@ const EmbyConfigComponent = ({
onConfirm: async () => { onConfirm: async () => {
await withLoading('batchDeleteEmby', async () => { await withLoading('batchDeleteEmby', async () => {
try { try {
const newSources = sources.filter(s => !selectedSources.has(s.key)); const newSources = sources.filter(
(s) => !selectedSources.has(s.key)
);
const response = await fetch('/api/admin/config', { const response = await fetch('/api/admin/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), body: JSON.stringify({
...config,
EmbyConfig: { Sources: newSources },
}),
}); });
if (!response.ok) throw new Error('批量删除失败'); if (!response.ok) throw new Error('批量删除失败');
await refreshConfig(); await refreshConfig();
setSelectedSources(new Set()); setSelectedSources(new Set());
showSuccess(`已删除 ${selectedSources.size} 个源`, showAlert); showSuccess(`已删除 ${selectedSources.size} 个源`, showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '批量删除失败', showAlert); showError(
error instanceof Error ? error.message : '批量删除失败',
showAlert
);
} }
}); });
}, },
@@ -4938,10 +5181,7 @@ const EmbyConfigComponent = ({
Emby ({sources.length}) Emby ({sources.length})
</h3> </h3>
<div className='flex gap-2'> <div className='flex gap-2'>
<button <button onClick={handleAdd} className={buttonStyles.success}>
onClick={handleAdd}
className={buttonStyles.success}
>
</button> </button>
</div> </div>
@@ -5045,7 +5285,11 @@ const EmbyConfigComponent = ({
<button <button
onClick={() => handleToggleEnabled(source)} onClick={() => handleToggleEnabled(source)}
disabled={isLoading('toggleEmbySource')} disabled={isLoading('toggleEmbySource')}
className={source.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall} className={
source.enabled
? buttonStyles.warningSmall
: buttonStyles.successSmall
}
> >
{source.enabled ? '禁用' : '启用'} {source.enabled ? '禁用' : '启用'}
</button> </button>
@@ -5092,7 +5336,9 @@ const EmbyConfigComponent = ({
<input <input
type='text' type='text'
value={formData.key} value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })} onChange={(e) =>
setFormData({ ...formData, key: e.target.value })
}
disabled={!!editingSource} disabled={!!editingSource}
placeholder='home, office, etc.' 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' 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 = ({
<input <input
type='text' type='text'
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder='家庭Emby, 公司Emby, etc.' 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' 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 = ({
<input <input
type='text' type='text'
value={formData.ServerURL} value={formData.ServerURL}
onChange={(e) => setFormData({ ...formData, ServerURL: e.target.value })} onChange={(e) =>
setFormData({ ...formData, ServerURL: e.target.value })
}
placeholder='http://192.168.1.100:8096' 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' 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 = ({
<input <input
type='password' type='password'
value={formData.ApiKey} value={formData.ApiKey}
onChange={(e) => setFormData({ ...formData, ApiKey: e.target.value })} onChange={(e) =>
setFormData({ ...formData, ApiKey: e.target.value })
}
placeholder='输入 Emby API Key' 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' 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 = ({
<input <input
type='text' type='text'
value={formData.UserId} value={formData.UserId}
onChange={(e) => setFormData({ ...formData, UserId: e.target.value })} onChange={(e) =>
setFormData({ ...formData, UserId: e.target.value })
}
placeholder='aab507c58e874de6a9bd12388d72f4d2' 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' 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'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Emby ID URL /Users/[userId]/... Emby ID URL
/Users/[userId]/...
</p> </p>
</div> </div>
</> </>
@@ -5219,7 +5474,9 @@ const EmbyConfigComponent = ({
<input <input
type='text' type='text'
value={formData.Username} value={formData.Username}
onChange={(e) => setFormData({ ...formData, Username: e.target.value })} onChange={(e) =>
setFormData({ ...formData, Username: e.target.value })
}
placeholder='Emby 用户名' 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' 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 = ({
<input <input
type='password' type='password'
value={formData.Password} value={formData.Password}
onChange={(e) => setFormData({ ...formData, Password: e.target.value })} onChange={(e) =>
setFormData({ ...formData, Password: e.target.value })
}
placeholder='Emby 密码(如果账号没有密码可留空)' 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' 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 = ({
</label> </label>
<button <button
onClick={() => setFormData({ ...formData, enabled: !formData.enabled })} onClick={() =>
setFormData({ ...formData, enabled: !formData.enabled })
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' formData.enabled
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
@@ -5280,14 +5543,23 @@ const EmbyConfigComponent = ({
</p> </p>
</div> </div>
<button <button
onClick={() => setFormData({ ...formData, removeEmbyPrefix: !formData.removeEmbyPrefix })} onClick={() =>
setFormData({
...formData,
removeEmbyPrefix: !formData.removeEmbyPrefix,
})
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.removeEmbyPrefix ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' formData.removeEmbyPrefix
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.removeEmbyPrefix ? 'translate-x-6' : 'translate-x-1' formData.removeEmbyPrefix
? 'translate-x-6'
: 'translate-x-1'
}`} }`}
/> />
</button> </button>
@@ -5300,18 +5572,28 @@ const EmbyConfigComponent = ({
MediaSourceId参数 MediaSourceId参数
</label> </label>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
PlaybackInfo API MediaSourceId PlaybackInfo API MediaSourceId
</p> </p>
</div> </div>
<button <button
onClick={() => setFormData({ ...formData, appendMediaSourceId: !formData.appendMediaSourceId })} onClick={() =>
setFormData({
...formData,
appendMediaSourceId: !formData.appendMediaSourceId,
})
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.appendMediaSourceId ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' formData.appendMediaSourceId
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.appendMediaSourceId ? 'translate-x-6' : 'translate-x-1' formData.appendMediaSourceId
? 'translate-x-6'
: 'translate-x-1'
}`} }`}
/> />
</button> </button>
@@ -5328,9 +5610,16 @@ const EmbyConfigComponent = ({
</p> </p>
</div> </div>
<button <button
onClick={() => setFormData({ ...formData, transcodeMp4: !formData.transcodeMp4 })} onClick={() =>
setFormData({
...formData,
transcodeMp4: !formData.transcodeMp4,
})
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.transcodeMp4 ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' formData.transcodeMp4
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
@@ -5352,9 +5641,13 @@ const EmbyConfigComponent = ({
</p> </p>
</div> </div>
<button <button
onClick={() => setFormData({ ...formData, proxyPlay: !formData.proxyPlay })} onClick={() =>
setFormData({ ...formData, proxyPlay: !formData.proxyPlay })
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.proxyPlay ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' formData.proxyPlay
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
@@ -5363,24 +5656,29 @@ const EmbyConfigComponent = ({
}`} }`}
/> />
</button> </button>
</div> </div>
{/* 自定义User-Agent */} {/* 自定义User-Agent */}
<div className='mb-3'> <div className='mb-3'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
User-Agent User-Agent
</label> </label>
<input <input
type='text' type='text'
value={formData.customUserAgent || ''} value={formData.customUserAgent || ''}
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })} onChange={(e) =>
placeholder='留空使用默认浏览器UA' setFormData({
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' ...formData,
/> customUserAgent: e.target.value,
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> })
User-Agent使UA }
</p> placeholder='留空使用默认浏览器UA'
</div> 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'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
User-Agent使UA
</p>
</div>
</div> </div>
{/* 操作按钮 */} {/* 操作按钮 */}
@@ -5392,10 +5690,7 @@ const EmbyConfigComponent = ({
> >
{isLoading('saveEmbySource') ? '保存中...' : '保存'} {isLoading('saveEmbySource') ? '保存中...' : '保存'}
</button> </button>
<button <button onClick={resetForm} className={buttonStyles.secondary}>
onClick={resetForm}
className={buttonStyles.secondary}
>
</button> </button>
</div> </div>
@@ -5480,6 +5775,10 @@ const VideoSourceConfig = ({
// 有效性检测相关状态 // 有效性检测相关状态
const [showValidationModal, setShowValidationModal] = useState(false); const [showValidationModal, setShowValidationModal] = useState(false);
const [showWeightModal, setShowWeightModal] = useState(false);
const [weightDraftSources, setWeightDraftSources] = useState<DataSource[]>(
[]
);
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const [validationResults, setValidationResults] = useState< const [validationResults, setValidationResults] = useState<
@@ -5571,9 +5870,7 @@ const VideoSourceConfig = ({
// 更新本地状态 // 更新本地状态
setSources((prev) => setSources((prev) =>
prev.map((s) => prev.map((s) => (s.key === key ? { ...s, proxyMode: !s.proxyMode } : s))
s.key === key ? { ...s, proxyMode: !s.proxyMode } : s
)
); );
// 调用API更新 // 调用API更新
@@ -5615,9 +5912,7 @@ const VideoSourceConfig = ({
const handleUpdateWeight = (key: string, weight: number) => { const handleUpdateWeight = (key: string, weight: number) => {
// 先乐观更新本地状态 // 先乐观更新本地状态
setSources((prev) => setSources((prev) =>
prev.map((s) => prev.map((s) => (s.key === key ? { ...s, weight } : s))
s.key === key ? { ...s, weight } : s
)
); );
// 调用API更新 // 调用API更新
@@ -5641,7 +5936,8 @@ const VideoSourceConfig = ({
await refreshConfig(); await refreshConfig();
} catch (error) { } 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) => setSources((prev) =>
prev.map((s) => prev.map((s) =>
s.key === key ? { ...s, weight: originalWeight } : s s.key === key ? { ...s, weight: originalWeight } : s
@@ -5682,27 +5978,135 @@ const VideoSourceConfig = ({
}); });
}; };
const handleDragEnd = (event: any) => { const buildRecommendedWeightMap = useCallback((list: DataSource[]) => {
const { active, over } = event; const total = list.length;
if (!over || active.id === over.id) return; return new Map(
const oldIndex = sources.findIndex((s) => s.key === active.id); list.map((source, index) => {
const newIndex = sources.findIndex((s) => s.key === over.id); const recommended =
setSources((prev) => arrayMove(prev, oldIndex, newIndex)); total <= 1
setOrderChanged(true); ? 40
}; : Math.round(((total - index - 1) * 40) / (total - 1));
return [source.key, recommended];
const handleSaveOrder = () => {
const order = sources.map((s) => s.key);
withLoading('saveSourceOrder', () =>
callSourceApi({ action: 'sort', order })
)
.then(() => {
setOrderChanged(false);
}) })
.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 () => { const handleValidateSources = async () => {
@@ -5884,70 +6288,125 @@ const VideoSourceConfig = ({
} }
}; };
// 权重输入组件 - 使用本地状态避免输入时失焦 const WeightModalInput = memo(
const WeightInput = memo(({ sourceKey, initialWeight }: { sourceKey: string; initialWeight: number }) => { ({ sourceKey, weight }: { sourceKey: string; weight: number }) => {
const [localWeight, setLocalWeight] = useState(initialWeight); const [localWeight, setLocalWeight] = useState(weight);
// 当外部权重变化时同步 useEffect(() => {
useEffect(() => { setLocalWeight(weight);
setLocalWeight(initialWeight); }, [weight]);
}, [initialWeight]);
return ( const commitWeight = (value: number) => {
<input const clampedValue = Math.min(100, Math.max(0, value));
type='number' setLocalWeight(clampedValue);
inputMode='numeric' handleWeightDraftChange(sourceKey, clampedValue);
min='0' };
max='100'
value={localWeight}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
const clampedValue = Math.min(100, Math.max(0, value));
setLocalWeight(clampedValue);
}}
onBlur={(e) => {
const newValue = parseInt(e.target.value) || 0;
const clampedValue = Math.min(100, Math.max(0, newValue));
const originalWeight = config?.SourceConfig?.find(s => s.key === sourceKey)?.weight ?? 0;
// 只有在值发生变化时才调用API return (
if (clampedValue !== originalWeight) { <div
handleUpdateWeight(sourceKey, clampedValue); className='flex items-center gap-3'
} onPointerDown={(e) => e.stopPropagation()}
}} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onTouchStart={(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 (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
> >
<GripVertical size={16} /> <input
</td> type='range'
min='0'
max='100'
value={localWeight}
onChange={(e) => commitWeight(parseInt(e.target.value) || 0)}
className='w-full accent-blue-600'
/>
<input
type='number'
inputMode='numeric'
min='0'
max='100'
value={localWeight}
onChange={(e) => {
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'
/>
</div>
);
}
);
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 (
<div
ref={setNodeRef}
style={style}
className='grid grid-cols-[88px_minmax(0,1fr)_112px_112px_220px] items-center gap-3 rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm transition hover:border-blue-200 hover:shadow dark:border-gray-700 dark:bg-gray-800/90 dark:hover:border-blue-800'
>
<div
className='flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 cursor-grab'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
<span className='font-medium text-gray-700 dark:text-gray-200'>
#{index + 1}
</span>
</div>
<div className='min-w-0'>
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
{source.name}
</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
{source.key}
</div>
</div>
<div>
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${
source.disabled
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{source.disabled ? '已禁用' : '启用中'}
</span>
</div>
<div>
<span className='inline-flex rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'>
{recommendedWeight}
</span>
</div>
<WeightModalInput
sourceKey={source.key}
weight={source.weight ?? 0}
/>
</div>
);
}
);
const SourceRow = memo(({ source }: { source: DataSource }) => {
return (
<tr className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'>
<td className='px-2 py-4 text-center'> <td className='px-2 py-4 text-center'>
<input <input
type='checkbox' type='checkbox'
@@ -6010,9 +6469,6 @@ const VideoSourceConfig = ({
/> />
</button> </button>
</td> </td>
<td className='px-6 py-4 whitespace-nowrap' style={{ touchAction: 'auto' }}>
<WeightInput sourceKey={source.key} initialWeight={source.weight ?? 0} />
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'> <td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
{(() => { {(() => {
const status = getValidationStatus(source.key); 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; const { deleted, skipped } = result;
if (skipped > 0) { if (skipped > 0) {
showAlert({ showAlert({
@@ -6268,11 +6728,19 @@ const VideoSourceConfig = ({
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div> <div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div>
</> </>
)} )}
<div className='flex items-center gap-2 order-1 sm:order-2'> <div className='flex items-center gap-2 overflow-x-auto whitespace-nowrap order-1 sm:order-2'>
<button
onClick={openWeightModal}
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
title='拖动排序并批量生成推荐权重'
>
<Settings size={14} />
<span></span>
</button>
<button <button
onClick={() => setShowValidationModal(true)} onClick={() => setShowValidationModal(true)}
disabled={isValidating} disabled={isValidating}
className={`px-3 py-1 text-sm rounded-lg transition-colors flex items-center space-x-1 ${ className={`px-3 py-1 text-sm rounded-lg transition-colors flex shrink-0 items-center space-x-1 whitespace-nowrap ${
isValidating ? buttonStyles.disabled : buttonStyles.primary isValidating ? buttonStyles.disabled : buttonStyles.primary
}`} }`}
> >
@@ -6287,9 +6755,9 @@ const VideoSourceConfig = ({
</button> </button>
<button <button
onClick={() => setShowAddForm(!showAddForm)} onClick={() => setShowAddForm(!showAddForm)}
className={ className={`${
showAddForm ? buttonStyles.secondary : buttonStyles.success showAddForm ? buttonStyles.secondary : buttonStyles.success
} } shrink-0 whitespace-nowrap`}
> >
{showAddForm ? '取消' : '添加视频源'} {showAddForm ? '取消' : '添加视频源'}
</button> </button>
@@ -6369,7 +6837,6 @@ const VideoSourceConfig = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'> <thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr> <tr>
<th className='w-8' />
<th className='w-12 px-2 py-3 text-center'> <th className='w-12 px-2 py-3 text-center'>
<input <input
type='checkbox' type='checkbox'
@@ -6396,9 +6863,6 @@ const VideoSourceConfig = ({
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
@@ -6407,43 +6871,157 @@ const VideoSourceConfig = ({
</th> </th>
</tr> </tr>
</thead> </thead>
<DndContext <tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
sensors={sensors} {sources.map((source) => (
collisionDetection={closestCenter} <SourceRow key={source.key} source={source} />
onDragEnd={handleDragEnd} ))}
autoScroll={false} </tbody>
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={sources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sources.map((source) => (
<DraggableRow key={source.key} source={source} />
))}
</tbody>
</SortableContext>
</DndContext>
</table> </table>
</div> </div>
{/* 保存排序按钮 */} {showWeightModal &&
{orderChanged && ( createPortal(
<div className='flex justify-end'> <>
<button <div
onClick={handleSaveOrder} className='fixed inset-0 bg-black/60 backdrop-blur-sm z-[10000]'
disabled={isLoading('saveSourceOrder')} onClick={handleCloseWeightModal}
className={`px-3 py-1.5 text-sm ${ onTouchMove={(e) => {
isLoading('saveSourceOrder') e.preventDefault();
? buttonStyles.disabled }}
: buttonStyles.primary onWheel={(e) => {
}`} e.preventDefault();
> }}
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'} style={{
</button> touchAction: 'none',
</div> }}
)} />
<div
className='fixed left-1/2 top-1/2 z-[10001] flex w-[calc(100%-1rem)] max-w-6xl max-h-[90vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800'
onClick={(e) => e.stopPropagation()}
>
<div className='flex items-start justify-between gap-4 border-b border-gray-200 dark:border-gray-700 px-6 py-5'>
<div>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
</div>
<button
onClick={handleCloseWeightModal}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-2xl leading-none'
aria-label='关闭权重设置弹窗'
>
×
</button>
</div>
<div
className='flex-1 min-h-0 overflow-y-auto px-0 overscroll-contain'
data-panel-content
data-weight-modal-scroll
onTouchMove={(e) => {
e.stopPropagation();
}}
onWheel={(e) => {
e.stopPropagation();
}}
style={{
touchAction: 'pan-y',
overscrollBehavior: 'contain',
}}
>
<div className='flex flex-wrap items-center justify-between gap-3 px-6 py-4'>
<div className='text-sm text-gray-600 dark:text-gray-400'>
0~40
</div>
<div className='flex flex-wrap items-center gap-2'>
<button
onClick={handleApplyRecommendedWeights}
className={buttonStyles.primarySmall}
>
</button>
<button
onClick={handleResetWeightDraft}
className={buttonStyles.secondarySmall}
>
</button>
</div>
</div>
<div className='px-6 pb-6'>
<div className='overflow-x-auto'>
<div className='grid min-w-[820px] grid-cols-[88px_minmax(0,1fr)_112px_112px_220px] gap-3 px-4 pb-3 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div className='min-w-[820px] rounded-2xl border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/20 p-3'>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleWeightModalDragEnd}
autoScroll={false}
modifiers={[
restrictToVerticalAxis,
restrictToParentElement,
]}
>
<SortableContext
items={weightDraftSources.map((source) => source.key)}
strategy={verticalListSortingStrategy}
>
<div className='space-y-3'>
{weightDraftSources.map((source, index) => {
const recommendedWeight =
recommendedWeightMap.get(source.key) ?? 0;
return (
<WeightModalRow
key={source.key}
source={source}
index={index}
recommendedWeight={recommendedWeight}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
</div>
<div className='flex items-center justify-end gap-3 border-t border-gray-200 dark:border-gray-700 px-6 py-4'>
<div className='flex items-center gap-3'>
<button
onClick={handleCloseWeightModal}
className={buttonStyles.secondary}
>
</button>
<button
onClick={handleSaveWeightConfig}
disabled={
!weightModalChanged || isLoading('saveWeightConfig')
}
className={`px-4 py-2 ${
!weightModalChanged || isLoading('saveWeightConfig')
? buttonStyles.disabled
: buttonStyles.success
}`}
>
{isLoading('saveWeightConfig') ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
</>,
document.body
)}
{/* 有效性检测弹窗 */} {/* 有效性检测弹窗 */}
{showValidationModal && {showValidationModal &&
@@ -6486,7 +7064,7 @@ const VideoSourceConfig = ({
className={`px-4 py-2 ${ className={`px-4 py-2 ${
!searchKeyword.trim() !searchKeyword.trim()
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.primary : buttonStyles.success
}`} }`}
> >
@@ -6572,7 +7150,7 @@ const VideoSourceConfig = ({
isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_disable') ||
isLoading('batchSource_batch_delete') isLoading('batchSource_batch_delete')
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.primary : buttonStyles.success
}`} }`}
> >
{isLoading('batchSource_batch_enable') || {isLoading('batchSource_batch_enable') ||
@@ -6904,7 +7482,6 @@ const CategoryConfig = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'> <thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr> <tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
@@ -7001,10 +7578,10 @@ const VideoSourceScriptLab = () => {
code: '', code: '',
enabled: true, enabled: true,
}); });
const [testHook, setTestHook] = useState<'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'>('getSources'); const [testHook, setTestHook] = useState<
const [testPayload, setTestPayload] = useState( 'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'
JSON.stringify({}, null, 2) >('getSources');
); const [testPayload, setTestPayload] = useState(JSON.stringify({}, null, 2));
const [testOutput, setTestOutput] = useState(''); const [testOutput, setTestOutput] = useState('');
const importInputRef = useRef<HTMLInputElement | null>(null); const importInputRef = useRef<HTMLInputElement | null>(null);
@@ -7068,7 +7645,10 @@ const VideoSourceScriptLab = () => {
setSelectedScriptId(null); setSelectedScriptId(null);
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '加载脚本失败', showAlert); showError(
error instanceof Error ? error.message : '加载脚本失败',
showAlert
);
} finally { } finally {
setLoadingScripts(false); setLoadingScripts(false);
} }
@@ -7212,7 +7792,10 @@ const VideoSourceScriptLab = () => {
showSuccess('脚本已删除', showAlert); showSuccess('脚本已删除', showAlert);
await loadScripts(null); await loadScripts(null);
}).catch((error) => { }).catch((error) => {
showError(error instanceof Error ? error.message : '删除失败', showAlert); showError(
error instanceof Error ? error.message : '删除失败',
showAlert
);
}); });
}, },
}); });
@@ -7276,20 +7859,24 @@ const VideoSourceScriptLab = () => {
testHook === 'getSources' testHook === 'getSources'
? JSON.stringify({}, null, 2) ? JSON.stringify({}, null, 2)
: testHook === 'search' : testHook === 'search'
? JSON.stringify({ keyword: '凡人修仙传', page: 1, sourceId: 'main' }, null, 2) ? JSON.stringify(
{ keyword: '凡人修仙传', page: 1, sourceId: 'main' },
null,
2
)
: testHook === 'recommend' : testHook === 'recommend'
? JSON.stringify({ page: 1 }, null, 2) ? JSON.stringify({ page: 1 }, null, 2)
: testHook === 'detail' : testHook === 'detail'
? JSON.stringify({ id: 'demo-id', sourceId: 'main' }, null, 2) ? JSON.stringify({ id: 'demo-id', sourceId: 'main' }, null, 2)
: JSON.stringify( : JSON.stringify(
{ {
sourceId: 'main', sourceId: 'main',
playUrl: 'https://example.com/video.m3u8', playUrl: 'https://example.com/video.m3u8',
episodeIndex: 0, episodeIndex: 0,
}, },
null, null,
2 2
) )
); );
}, [testHook]); }, [testHook]);
@@ -7312,18 +7899,29 @@ const VideoSourceScriptLab = () => {
<button <button
onClick={() => importInputRef.current?.click()} onClick={() => importInputRef.current?.click()}
disabled={isLoading('importSourceScript')} disabled={isLoading('importSourceScript')}
className={isLoading('importSourceScript') ? buttonStyles.disabledSmall : buttonStyles.primarySmall} className={
isLoading('importSourceScript')
? buttonStyles.disabledSmall
: buttonStyles.primarySmall
}
> >
</button> </button>
<button <button
onClick={() => loadScripts(selectedScriptId)} onClick={() => loadScripts(selectedScriptId)}
disabled={loadingScripts} disabled={loadingScripts}
className={loadingScripts ? buttonStyles.disabledSmall : buttonStyles.secondarySmall} className={
loadingScripts
? buttonStyles.disabledSmall
: buttonStyles.secondarySmall
}
> >
</button> </button>
<button onClick={handleCreateNew} className={buttonStyles.successSmall}> <button
onClick={handleCreateNew}
className={buttonStyles.successSmall}
>
</button> </button>
</div> </div>
@@ -7331,7 +7929,9 @@ const VideoSourceScriptLab = () => {
<div className='space-y-3 max-h-[38rem] overflow-y-auto pr-1'> <div className='space-y-3 max-h-[38rem] overflow-y-auto pr-1'>
{loadingScripts ? ( {loadingScripts ? (
<div className='text-sm text-gray-500 dark:text-gray-400'>...</div> <div className='text-sm text-gray-500 dark:text-gray-400'>
...
</div>
) : scripts.length === 0 ? ( ) : scripts.length === 0 ? (
<div className='p-4 rounded-lg border border-dashed border-gray-300 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400'> <div className='p-4 rounded-lg border border-dashed border-gray-300 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400'>
@@ -7370,14 +7970,20 @@ const VideoSourceScriptLab = () => {
</span> </span>
</div> </div>
<div className='mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'> <div className='mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span>{new Date(script.updatedAt).toLocaleString('zh-CN')}</span> <span>
{new Date(script.updatedAt).toLocaleString('zh-CN')}
</span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleToggleEnabled(script.id); handleToggleEnabled(script.id);
}} }}
disabled={isLoading(`toggleSourceScript_${script.id}`)} disabled={isLoading(`toggleSourceScript_${script.id}`)}
className={script.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall} className={
script.enabled
? buttonStyles.warningSmall
: buttonStyles.successSmall
}
> >
{script.enabled ? '停用' : '启用'} {script.enabled ? '停用' : '启用'}
</button> </button>
@@ -7394,14 +8000,18 @@ const VideoSourceScriptLab = () => {
type='text' type='text'
placeholder='脚本名称' placeholder='脚本名称'
value={editor.name} 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' 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'
/> />
<input <input
type='text' type='text'
placeholder='脚本 Key' placeholder='脚本 Key'
value={editor.key} value={editor.key}
onChange={(e) => 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' 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'
/> />
</div> </div>
@@ -7409,7 +8019,9 @@ const VideoSourceScriptLab = () => {
<textarea <textarea
placeholder='脚本描述(可选)' placeholder='脚本描述(可选)'
value={editor.description} value={editor.description}
onChange={(e) => setEditor((prev) => ({ ...prev, description: e.target.value }))} onChange={(e) =>
setEditor((prev) => ({ ...prev, description: e.target.value }))
}
rows={2} rows={2}
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' 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'
/> />
@@ -7425,7 +8037,9 @@ const VideoSourceScriptLab = () => {
</div> </div>
<textarea <textarea
value={editor.code} value={editor.code}
onChange={(e) => setEditor((prev) => ({ ...prev, code: e.target.value }))} onChange={(e) =>
setEditor((prev) => ({ ...prev, code: e.target.value }))
}
rows={24} rows={24}
spellCheck={false} spellCheck={false}
className='w-full px-3 py-3 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-950 text-gray-100' className='w-full px-3 py-3 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-950 text-gray-100'
@@ -7436,18 +8050,29 @@ const VideoSourceScriptLab = () => {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isLoading('saveSourceScript')} disabled={isLoading('saveSourceScript')}
className={isLoading('saveSourceScript') ? buttonStyles.disabled : buttonStyles.success} className={
isLoading('saveSourceScript')
? buttonStyles.disabled
: buttonStyles.success
}
> >
{isLoading('saveSourceScript') ? '保存中...' : '保存脚本'} {isLoading('saveSourceScript') ? '保存中...' : '保存脚本'}
</button> </button>
<button <button
onClick={handleTest} onClick={handleTest}
disabled={isLoading('testSourceScript')} disabled={isLoading('testSourceScript')}
className={isLoading('testSourceScript') ? buttonStyles.disabled : buttonStyles.primary} className={
isLoading('testSourceScript')
? buttonStyles.disabled
: buttonStyles.primary
}
> >
{isLoading('testSourceScript') ? '测试中...' : '运行测试'} {isLoading('testSourceScript') ? '测试中...' : '运行测试'}
</button> </button>
<button onClick={handleExportCurrent} className={buttonStyles.secondary}> <button
onClick={handleExportCurrent}
className={buttonStyles.secondary}
>
</button> </button>
<button onClick={handleDelete} className={buttonStyles.danger}> <button onClick={handleDelete} className={buttonStyles.danger}>
@@ -7463,7 +8088,16 @@ const VideoSourceScriptLab = () => {
</label> </label>
<select <select
value={testHook} value={testHook}
onChange={(e) => setTestHook(e.target.value as 'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl')} onChange={(e) =>
setTestHook(
e.target.value as
| 'getSources'
| 'search'
| 'recommend'
| 'detail'
| 'resolvePlayUrl'
)
}
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' 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'
> >
<option value='getSources'>getSources</option> <option value='getSources'>getSources</option>
@@ -7920,9 +8554,15 @@ const ThemeConfigComponent = ({
progressThumbPresetId: '', progressThumbPresetId: '',
progressThumbCustomUrl: '', progressThumbCustomUrl: '',
}); });
const [loginBackgroundImages, setLoginBackgroundImages] = useState<string[]>(['']); const [loginBackgroundImages, setLoginBackgroundImages] = useState<string[]>([
const [registerBackgroundImages, setRegisterBackgroundImages] = useState<string[]>(['']); '',
const [homeBackgroundImages, setHomeBackgroundImages] = useState<string[]>(['']); ]);
const [registerBackgroundImages, setRegisterBackgroundImages] = useState<
string[]
>(['']);
const [homeBackgroundImages, setHomeBackgroundImages] = useState<string[]>([
'',
]);
useEffect(() => { useEffect(() => {
if (config?.ThemeConfig) { if (config?.ThemeConfig) {
@@ -8045,7 +8685,7 @@ const ThemeConfigComponent = ({
showAlert({ showAlert({
type: 'success', type: 'success',
title: '保存成功', title: '保存成功',
message: '个性化配置已更新', message: '个性化配置已更新',
timer: 2000, timer: 2000,
}); });
@@ -8318,8 +8958,18 @@ const ThemeConfigComponent = ({
className='px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors' className='px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors'
title='删除' title='删除'
> >
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'> <svg
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /> className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg> </svg>
</button> </button>
)} )}
@@ -8327,11 +8977,23 @@ const ThemeConfigComponent = ({
))} ))}
<button <button
type='button' type='button'
onClick={() => setLoginBackgroundImages([...loginBackgroundImages, ''])} onClick={() =>
setLoginBackgroundImages([...loginBackgroundImages, ''])
}
className='flex items-center gap-2 px-4 py-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors' className='flex items-center gap-2 px-4 py-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors'
> >
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'> <svg
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 4v16m8-8H4' /> className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M12 4v16m8-8H4'
/>
</svg> </svg>
<span>URL</span> <span>URL</span>
</button> </button>
@@ -8368,8 +9030,18 @@ const ThemeConfigComponent = ({
className='px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors' className='px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors'
title='删除' title='删除'
> >
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'> <svg
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /> className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg> </svg>
</button> </button>
)} )}
@@ -8377,11 +9049,23 @@ const ThemeConfigComponent = ({
))} ))}
<button <button
type='button' type='button'
onClick={() => setRegisterBackgroundImages([...registerBackgroundImages, ''])} onClick={() =>
setRegisterBackgroundImages([...registerBackgroundImages, ''])
}
className='flex items-center gap-2 px-4 py-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors' className='flex items-center gap-2 px-4 py-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors'
> >
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'> <svg
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 4v16m8-8H4' /> className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M12 4v16m8-8H4'
/>
</svg> </svg>
<span>URL</span> <span>URL</span>
</button> </button>
@@ -8418,8 +9102,18 @@ const ThemeConfigComponent = ({
className='px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors' className='px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors'
title='删除' title='删除'
> >
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'> <svg
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /> className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg> </svg>
</button> </button>
)} )}
@@ -8427,11 +9121,23 @@ const ThemeConfigComponent = ({
))} ))}
<button <button
type='button' type='button'
onClick={() => setHomeBackgroundImages([...homeBackgroundImages, ''])} onClick={() =>
setHomeBackgroundImages([...homeBackgroundImages, ''])
}
className='flex items-center gap-2 px-4 py-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors' className='flex items-center gap-2 px-4 py-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors'
> >
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'> <svg
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 4v16m8-8H4' /> className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M12 4v16m8-8H4'
/>
</svg> </svg>
<span>URL</span> <span>URL</span>
</button> </button>
@@ -8467,9 +9173,7 @@ const ThemeConfigComponent = ({
} }
className='w-4 h-4 text-blue-600' className='w-4 h-4 text-blue-600'
/> />
<span className='text-gray-900 dark:text-gray-100'> <span className='text-gray-900 dark:text-gray-100'></span>
</span>
</label> </label>
<label className='flex items-center space-x-3 cursor-pointer'> <label className='flex items-center space-x-3 cursor-pointer'>
<input <input
@@ -8483,9 +9187,7 @@ const ThemeConfigComponent = ({
} }
className='w-4 h-4 text-blue-600' className='w-4 h-4 text-blue-600'
/> />
<span className='text-gray-900 dark:text-gray-100'> <span className='text-gray-900 dark:text-gray-100'></span>
</span>
</label> </label>
<label className='flex items-center space-x-3 cursor-pointer'> <label className='flex items-center space-x-3 cursor-pointer'>
<input <input
@@ -8499,9 +9201,7 @@ const ThemeConfigComponent = ({
} }
className='w-4 h-4 text-blue-600' className='w-4 h-4 text-blue-600'
/> />
<span className='text-gray-900 dark:text-gray-100'> <span className='text-gray-900 dark:text-gray-100'></span>
</span>
</label> </label>
</div> </div>
@@ -8513,9 +9213,24 @@ const ThemeConfigComponent = ({
</label> </label>
<div className='grid grid-cols-2 md:grid-cols-3 gap-3'> <div className='grid grid-cols-2 md:grid-cols-3 gap-3'>
{[ {[
{ id: 'renako', name: '玲奈子', url: '/icons/q/renako.png', color: '#ec4899' }, {
{ id: 'irena', name: '伊蕾娜', url: '/icons/q/irena.png', color: '#f8fafc' }, id: 'renako',
{ id: 'emilia', name: '爱蜜莉雅', url: '/icons/q/emilia.png', color: '#f8fafc' }, name: '玲奈子',
url: '/icons/q/renako.png',
color: '#ec4899',
},
{
id: 'irena',
name: '伊蕾娜',
url: '/icons/q/irena.png',
color: '#f8fafc',
},
{
id: 'emilia',
name: '爱蜜莉雅',
url: '/icons/q/emilia.png',
color: '#f8fafc',
},
].map((thumb) => ( ].map((thumb) => (
<button <button
key={thumb.id} key={thumb.id}
@@ -8538,7 +9253,8 @@ const ThemeConfigComponent = ({
alt={thumb.name} alt={thumb.name}
className='w-12 h-12 object-contain' className='w-12 h-12 object-contain'
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="48" height="48"%3E%3Crect width="48" height="48" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3E?%3C/text%3E%3C/svg%3E'; (e.target as HTMLImageElement).src =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="48" height="48"%3E%3Crect width="48" height="48" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3E?%3C/text%3E%3C/svg%3E';
}} }}
/> />
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 text-center'> <span className='text-sm font-medium text-gray-700 dark:text-gray-300 text-center'>
@@ -8580,11 +9296,14 @@ const ThemeConfigComponent = ({
} }
/> />
<p className='text-xs text-gray-500 dark:text-gray-400'> <p className='text-xs text-gray-500 dark:text-gray-400'>
PNGJPGGIFWebP 32x32pxURL必须可公开访问 PNGJPGGIFWebP
32x32pxURL必须可公开访问
</p> </p>
{themeSettings.progressThumbCustomUrl && ( {themeSettings.progressThumbCustomUrl && (
<div className='mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg'> <div className='mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg'>
<p className='text-xs text-gray-600 dark:text-gray-400 mb-2'></p> <p className='text-xs text-gray-600 dark:text-gray-400 mb-2'>
</p>
<img <img
src={themeSettings.progressThumbCustomUrl} src={themeSettings.progressThumbCustomUrl}
alt='自定义图标预览' alt='自定义图标预览'
@@ -8722,8 +9441,14 @@ const SiteConfigComponent = ({
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' }, { value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'baidu', label: '百度图片代理' }, { value: 'baidu', label: '百度图片代理' },
{ value: 'custom', label: '自定义代理' }, { value: 'custom', label: '自定义代理' },
{ value: 'direct', label: '直连(浏览器直接请求豆瓣,可能需要浏览器插件才能正常显示)' }, {
{ value: 'img3', label: '豆瓣官方精品 CDN(阿里云,可能需要浏览器插件才能正常显示)' }, value: 'direct',
label: '直连(浏览器直接请求豆瓣,可能需要浏览器插件才能正常显示)',
},
{
value: 'img3',
label: '豆瓣官方精品 CDN(阿里云,可能需要浏览器插件才能正常显示)',
},
]; ];
// 获取感谢信息 // 获取感谢信息
@@ -8761,20 +9486,24 @@ const SiteConfigComponent = ({
DanmakuApiBase: DanmakuApiBase:
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321', config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false, DanmakuAutoLoadDefault:
config.SiteConfig.DanmakuAutoLoadDefault !== false,
TMDBApiKey: config.SiteConfig.TMDBApiKey || '', TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
TMDBProxy: config.SiteConfig.TMDBProxy || '', TMDBProxy: config.SiteConfig.TMDBProxy || '',
TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '', TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban', BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban',
RecommendationDataSource: config.SiteConfig.RecommendationDataSource || 'Mixed', RecommendationDataSource:
config.SiteConfig.RecommendationDataSource || 'Mixed',
PansouApiUrl: config.SiteConfig.PansouApiUrl || '', PansouApiUrl: config.SiteConfig.PansouApiUrl || '',
PansouUsername: config.SiteConfig.PansouUsername || '', PansouUsername: config.SiteConfig.PansouUsername || '',
PansouPassword: config.SiteConfig.PansouPassword || '', PansouPassword: config.SiteConfig.PansouPassword || '',
PansouKeywordBlocklist: config.SiteConfig.PansouKeywordBlocklist || '', PansouKeywordBlocklist: config.SiteConfig.PansouKeywordBlocklist || '',
MagnetProxy: config.SiteConfig.MagnetProxy || '', MagnetProxy: config.SiteConfig.MagnetProxy || '',
MagnetMikanReverseProxy: config.SiteConfig.MagnetMikanReverseProxy || '', MagnetMikanReverseProxy:
config.SiteConfig.MagnetMikanReverseProxy || '',
MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '', MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '',
MagnetAcgripReverseProxy: config.SiteConfig.MagnetAcgripReverseProxy || '', MagnetAcgripReverseProxy:
config.SiteConfig.MagnetAcgripReverseProxy || '',
EnableComments: config.SiteConfig.EnableComments || false, EnableComments: config.SiteConfig.EnableComments || false,
}); });
} }
@@ -9345,7 +10074,8 @@ const SiteConfigComponent = ({
{siteSettings.DanmakuSourceType !== 'custom' && ( {siteSettings.DanmakuSourceType !== 'custom' && (
<p className='text-xs text-amber-600 dark:text-amber-400'> <p className='text-xs text-amber-600 dark:text-amber-400'>
使使
使使
</p> </p>
)} )}
@@ -9462,7 +10192,9 @@ const SiteConfigComponent = ({
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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
TMDB API Key Key API Key 访{' '} TMDB API
Key Key API
Key 访{' '}
<a <a
href='https://www.themoviedb.org/settings/api' href='https://www.themoviedb.org/settings/api'
target='_blank' target='_blank'
@@ -9545,7 +10277,8 @@ const SiteConfigComponent = ({
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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
访Cloudflare 使 访Cloudflare
使
</p> </p>
</div> </div>
@@ -9732,7 +10465,9 @@ const SiteConfigComponent = ({
</label> </label>
<button <button
type='button' type='button'
onClick={() => handleCommentsToggle(!siteSettings.EnableComments)} onClick={() =>
handleCommentsToggle(!siteSettings.EnableComments)
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.EnableComments siteSettings.EnableComments
? buttonStyles.toggleOn ? buttonStyles.toggleOn
@@ -9867,7 +10602,8 @@ const RegistrationConfigComponent = ({
}) => { }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState(); const { isLoading, withLoading } = useLoadingState();
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false); const [showEnableRegistrationModal, setShowEnableRegistrationModal] =
useState(false);
const [registrationSettings, setRegistrationSettings] = useState<{ const [registrationSettings, setRegistrationSettings] = useState<{
EnableRegistration: boolean; EnableRegistration: boolean;
RequireRegistrationInviteCode: boolean; RequireRegistrationInviteCode: boolean;
@@ -9912,17 +10648,21 @@ const RegistrationConfigComponent = ({
if (config?.SiteConfig) { if (config?.SiteConfig) {
setRegistrationSettings({ setRegistrationSettings({
EnableRegistration: config.SiteConfig.EnableRegistration || false, EnableRegistration: config.SiteConfig.EnableRegistration || false,
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false, RequireRegistrationInviteCode:
config.SiteConfig.RequireRegistrationInviteCode || false,
RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '', RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '',
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false, RegistrationRequireTurnstile:
config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false, LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '', TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
TurnstileSecretKey: config.SiteConfig.TurnstileSecretKey || '', TurnstileSecretKey: config.SiteConfig.TurnstileSecretKey || '',
DefaultUserTags: config.SiteConfig.DefaultUserTags || [], DefaultUserTags: config.SiteConfig.DefaultUserTags || [],
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false, EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false, EnableOIDCRegistration:
config.SiteConfig.EnableOIDCRegistration || false,
OIDCIssuer: config.SiteConfig.OIDCIssuer || '', OIDCIssuer: config.SiteConfig.OIDCIssuer || '',
OIDCAuthorizationEndpoint: config.SiteConfig.OIDCAuthorizationEndpoint || '', OIDCAuthorizationEndpoint:
config.SiteConfig.OIDCAuthorizationEndpoint || '',
OIDCTokenEndpoint: config.SiteConfig.OIDCTokenEndpoint || '', OIDCTokenEndpoint: config.SiteConfig.OIDCTokenEndpoint || '',
OIDCUserInfoEndpoint: config.SiteConfig.OIDCUserInfoEndpoint || '', OIDCUserInfoEndpoint: config.SiteConfig.OIDCUserInfoEndpoint || '',
OIDCClientId: config.SiteConfig.OIDCClientId || '', OIDCClientId: config.SiteConfig.OIDCClientId || '',
@@ -9973,7 +10713,8 @@ const RegistrationConfigComponent = ({
const updatedSiteConfig = { const updatedSiteConfig = {
...config.SiteConfig, ...config.SiteConfig,
...registrationSettings, ...registrationSettings,
RegistrationInviteCode: registrationSettings.RegistrationInviteCode.trim(), RegistrationInviteCode:
registrationSettings.RegistrationInviteCode.trim(),
}; };
const resp = await fetch('/api/admin/site', { const resp = await fetch('/api/admin/site', {
@@ -10012,7 +10753,10 @@ const RegistrationConfigComponent = ({
</h3> </h3>
<details open className='pt-4 border-t border-gray-200 dark:border-gray-700'> <details
open
className='pt-4 border-t border-gray-200 dark:border-gray-700'
>
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'> <summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
</summary> </summary>
@@ -10024,7 +10768,11 @@ const RegistrationConfigComponent = ({
</label> </label>
<button <button
type='button' type='button'
onClick={() => handleRegistrationToggle(!registrationSettings.EnableRegistration)} onClick={() =>
handleRegistrationToggle(
!registrationSettings.EnableRegistration
)
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
registrationSettings.EnableRegistration registrationSettings.EnableRegistration
? buttonStyles.toggleOn ? buttonStyles.toggleOn
@@ -10052,7 +10800,12 @@ const RegistrationConfigComponent = ({
</label> </label>
<select <select
value={registrationSettings.DefaultUserTags && registrationSettings.DefaultUserTags.length > 0 ? registrationSettings.DefaultUserTags[0] : ''} value={
registrationSettings.DefaultUserTags &&
registrationSettings.DefaultUserTags.length > 0
? registrationSettings.DefaultUserTags[0]
: ''
}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
@@ -10063,14 +10816,15 @@ const RegistrationConfigComponent = ({
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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
> >
<option value=''></option> <option value=''></option>
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => ( {config?.UserConfig?.Tags &&
<option key={tag.name} value={tag.name}> config.UserConfig.Tags.map((tag) => (
{tag.name} <option key={tag.name} value={tag.name}>
{tag.enabledApis && tag.enabledApis.length > 0 {tag.name}
? ` (${tag.enabledApis.length} 个源)` {tag.enabledApis && tag.enabledApis.length > 0
: ''} ? ` (${tag.enabledApis.length} 个源)`
</option> : ''}
))} </option>
))}
</select> </select>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
"无用户组" "无用户组"
@@ -10094,7 +10848,8 @@ const RegistrationConfigComponent = ({
onClick={() => onClick={() =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
RequireRegistrationInviteCode: !prev.RequireRegistrationInviteCode, RequireRegistrationInviteCode:
!prev.RequireRegistrationInviteCode,
})) }))
} }
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
@@ -10147,19 +10902,24 @@ const RegistrationConfigComponent = ({
</label> </label>
<button <button
type='button' type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey} disabled={
!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
}
onClick={() => onClick={() =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile, RegistrationRequireTurnstile:
!prev.RegistrationRequireTurnstile,
})) }))
} }
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey !registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600' ? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.RegistrationRequireTurnstile : registrationSettings.RegistrationRequireTurnstile
? buttonStyles.toggleOn ? buttonStyles.toggleOn
: buttonStyles.toggleOff : buttonStyles.toggleOff
}`} }`}
> >
<span <span
@@ -10175,8 +10935,12 @@ const RegistrationConfigComponent = ({
</div> </div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证 Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && ( {(!registrationSettings.TurnstileSiteKey ||
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span> !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'>
{' '}
Site Key和Secret Key才能启用
</span>
)} )}
</p> </p>
</div> </div>
@@ -10188,7 +10952,10 @@ const RegistrationConfigComponent = ({
</label> </label>
<button <button
type='button' type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey} disabled={
!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
}
onClick={() => onClick={() =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
@@ -10196,11 +10963,12 @@ const RegistrationConfigComponent = ({
})) }))
} }
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey !registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600' ? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.LoginRequireTurnstile : registrationSettings.LoginRequireTurnstile
? buttonStyles.toggleOn ? buttonStyles.toggleOn
: buttonStyles.toggleOff : buttonStyles.toggleOff
}`} }`}
> >
<span <span
@@ -10216,8 +10984,12 @@ const RegistrationConfigComponent = ({
</div> </div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证 Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && ( {(!registrationSettings.TurnstileSiteKey ||
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span> !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'>
{' '}
Site Key和Secret Key才能启用
</span>
)} )}
</p> </p>
</div> </div>
@@ -10275,325 +11047,346 @@ const RegistrationConfigComponent = ({
<div className='mt-4 space-y-4'> <div className='mt-4 space-y-4'>
{/* 启用OIDC登录 */} {/* 启用OIDC登录 */}
<div> <div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC登录 OIDC登录
</label> </label>
<button <button
type='button' type='button'
onClick={() => onClick={() =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
EnableOIDCLogin: !prev.EnableOIDCLogin, EnableOIDCLogin: !prev.EnableOIDCLogin,
})) }))
} }
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
registrationSettings.EnableOIDCLogin
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.EnableOIDCLogin registrationSettings.EnableOIDCLogin
? buttonStyles.toggleThumbOn ? buttonStyles.toggleOn
: buttonStyles.toggleThumbOff : buttonStyles.toggleOff
}`} }`}
/> >
</button> <span
</div> className={`inline-block h-4 w-4 transform rounded-full ${
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> buttonStyles.toggleThumb
OIDC登录按钮 } transition-transform ${
</p> registrationSettings.EnableOIDCLogin
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC登录按钮
</p>
</div> </div>
{/* 启用OIDC注册 */} {/* 启用OIDC注册 */}
<div> <div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC注册 OIDC注册
</label> </label>
<button <button
type='button' type='button'
onClick={() => onClick={() =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
EnableOIDCRegistration: !prev.EnableOIDCRegistration, EnableOIDCRegistration: !prev.EnableOIDCRegistration,
})) }))
} }
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
registrationSettings.EnableOIDCRegistration
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.EnableOIDCRegistration registrationSettings.EnableOIDCRegistration
? buttonStyles.toggleThumbOn ? buttonStyles.toggleOn
: buttonStyles.toggleThumbOff : buttonStyles.toggleOff
}`} }`}
/> >
</button> <span
</div> className={`inline-block h-4 w-4 transform rounded-full ${
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> buttonStyles.toggleThumb
OIDC方式注册新用户OIDC登录 } transition-transform ${
</p> registrationSettings.EnableOIDCRegistration
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC方式注册新用户OIDC登录
</p>
</div> </div>
{/* OIDC Issuer */} {/* OIDC Issuer */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Issuer URL OIDC Issuer URL
</label> </label>
<div className='flex flex-col sm:flex-row gap-2'> <div className='flex flex-col sm:flex-row gap-2'>
<input <input
type='text' type='text'
placeholder='https://your-oidc-provider.com/realms/your-realm' placeholder='https://your-oidc-provider.com/realms/your-realm'
value={registrationSettings.OIDCIssuer || ''} value={registrationSettings.OIDCIssuer || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCIssuer: e.target.value, OIDCIssuer: 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-green-500 focus:border-transparent'
/>
<button
type='button'
onClick={async () => {
if (!registrationSettings.OIDCIssuer) {
showError('请先输入Issuer URL', showAlert);
return;
} }
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-green-500 focus:border-transparent'
await withLoading('oidcDiscover', async () => { />
try { <button
const res = await fetch('/api/admin/oidc-discover', { type='button'
method: 'POST', onClick={async () => {
headers: { 'Content-Type': 'application/json' }, if (!registrationSettings.OIDCIssuer) {
body: JSON.stringify({ issuerUrl: registrationSettings.OIDCIssuer }), showError('请先输入Issuer URL', showAlert);
}); return;
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || '获取配置失败');
}
const data = await res.json();
setRegistrationSettings((prev) => ({
...prev,
OIDCAuthorizationEndpoint: data.authorization_endpoint || '',
OIDCTokenEndpoint: data.token_endpoint || '',
OIDCUserInfoEndpoint: data.userinfo_endpoint || '',
}));
showSuccess('自动发现成功', showAlert);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '自动发现失败,请手动配置端点';
showError(errorMessage, showAlert);
throw error;
} }
});
}} await withLoading('oidcDiscover', async () => {
disabled={isLoading('oidcDiscover')} try {
className={`px-4 py-2 ${isLoading('oidcDiscover') ? buttonStyles.disabled : buttonStyles.primary} rounded-lg whitespace-nowrap sm:w-auto w-full`} const res = await fetch('/api/admin/oidc-discover', {
> method: 'POST',
{isLoading('oidcDiscover') ? '发现中...' : '自动发现'} headers: { 'Content-Type': 'application/json' },
</button> body: JSON.stringify({
</div> issuerUrl: registrationSettings.OIDCIssuer,
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> }),
OIDC提供商的Issuer URL"自动发现" });
</p>
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || '获取配置失败');
}
const data = await res.json();
setRegistrationSettings((prev) => ({
...prev,
OIDCAuthorizationEndpoint:
data.authorization_endpoint || '',
OIDCTokenEndpoint: data.token_endpoint || '',
OIDCUserInfoEndpoint: data.userinfo_endpoint || '',
}));
showSuccess('自动发现成功', showAlert);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: '自动发现失败,请手动配置端点';
showError(errorMessage, showAlert);
throw error;
}
});
}}
disabled={isLoading('oidcDiscover')}
className={`px-4 py-2 ${
isLoading('oidcDiscover')
? buttonStyles.disabled
: buttonStyles.primary
} rounded-lg whitespace-nowrap sm:w-auto w-full`}
>
{isLoading('oidcDiscover') ? '发现中...' : '自动发现'}
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC提供商的Issuer URL"自动发现"
</p>
</div> </div>
{/* Authorization Endpoint */} {/* Authorization Endpoint */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Authorization Endpoint Authorization Endpoint
</label> </label>
<input <input
type='text' type='text'
placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/auth' placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/auth'
value={registrationSettings.OIDCAuthorizationEndpoint || ''} value={registrationSettings.OIDCAuthorizationEndpoint || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCAuthorizationEndpoint: e.target.value, OIDCAuthorizationEndpoint: 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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
URL URL
</p> </p>
</div> </div>
{/* Token Endpoint */} {/* Token Endpoint */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Token EndpointToken端点 Token EndpointToken端点
</label> </label>
<input <input
type='text' type='text'
placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/token' placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/token'
value={registrationSettings.OIDCTokenEndpoint || ''} value={registrationSettings.OIDCTokenEndpoint || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCTokenEndpoint: e.target.value, OIDCTokenEndpoint: 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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
token的端点URL token的端点URL
</p> </p>
</div> </div>
{/* UserInfo Endpoint */} {/* UserInfo Endpoint */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
UserInfo Endpoint UserInfo Endpoint
</label> </label>
<input <input
type='text' type='text'
placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/userinfo' placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/userinfo'
value={registrationSettings.OIDCUserInfoEndpoint || ''} value={registrationSettings.OIDCUserInfoEndpoint || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCUserInfoEndpoint: e.target.value, OIDCUserInfoEndpoint: 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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
URL URL
</p> </p>
</div> </div>
{/* OIDC Client ID */} {/* OIDC Client ID */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Client ID OIDC Client ID
</label> </label>
<input <input
type='text' type='text'
placeholder='请输入Client ID' placeholder='请输入Client ID'
value={registrationSettings.OIDCClientId || ''} value={registrationSettings.OIDCClientId || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCClientId: e.target.value, OIDCClientId: 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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC提供商处注册应用后获得的Client ID OIDC提供商处注册应用后获得的Client ID
</p> </p>
</div> </div>
{/* OIDC Client Secret */} {/* OIDC Client Secret */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Client Secret OIDC Client Secret
</label> </label>
<input <input
type='password' type='password'
placeholder='请输入Client Secret' placeholder='请输入Client Secret'
value={registrationSettings.OIDCClientSecret || ''} value={registrationSettings.OIDCClientSecret || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCClientSecret: e.target.value, OIDCClientSecret: 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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC提供商处注册应用后获得的Client Secret OIDC提供商处注册应用后获得的Client Secret
</p> </p>
</div> </div>
{/* OIDC Redirect URI - 只读显示 */} {/* OIDC Redirect URI - 只读显示 */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Redirect URI OIDC Redirect URI
</label> </label>
<div className='relative'> <div className='relative'>
<input <input
type='text' type='text'
readOnly readOnly
value={ value={
typeof window !== 'undefined' typeof window !== 'undefined'
? `${(window as any).RUNTIME_CONFIG?.SITE_BASE || window.location.origin}/api/auth/oidc/callback` ? `${
: '' (window as any).RUNTIME_CONFIG?.SITE_BASE ||
} window.location.origin
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 cursor-default' }/api/auth/oidc/callback`
/> : ''
<button }
type='button' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 cursor-default'
onClick={() => { />
const uri = `${(window as any).RUNTIME_CONFIG?.SITE_BASE || window.location.origin}/api/auth/oidc/callback`; <button
navigator.clipboard.writeText(uri); type='button'
showSuccess('已复制到剪贴板', showAlert); onClick={() => {
}} const uri = `${
className='absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors' (window as any).RUNTIME_CONFIG?.SITE_BASE ||
> window.location.origin
}/api/auth/oidc/callback`;
</button> navigator.clipboard.writeText(uri);
</div> showSuccess('已复制到剪贴板', showAlert);
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> }}
SITE_BASEOIDC提供商KeycloakAuth0等URI className='absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors'
</p> >
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
SITE_BASEOIDC提供商KeycloakAuth0等URI
</p>
</div> </div>
{/* OIDC登录按钮文字 */} {/* OIDC登录按钮文字 */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC登录按钮文字 OIDC登录按钮文字
</label> </label>
<input <input
type='text' type='text'
placeholder='使用OIDC登录' placeholder='使用OIDC登录'
value={registrationSettings.OIDCButtonText || ''} value={registrationSettings.OIDCButtonText || ''}
onChange={(e) => onChange={(e) =>
setRegistrationSettings((prev) => ({ setRegistrationSettings((prev) => ({
...prev, ...prev,
OIDCButtonText: e.target.value, OIDCButtonText: 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-green-500 focus:border-transparent' 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-green-500 focus:border-transparent'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC登录按钮显示的文字,"使用企业账号登录""使用SSO登录""使用OIDC登录" OIDC登录按钮显示的文字,"使用企业账号登录""使用SSO登录""使用OIDC登录"
</p> </p>
</div> </div>
{/* OIDC最低信任等级 */} {/* OIDC最低信任等级 */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label> </label>
<input <input
type='number' type='number'
min='0' min='0'
max='4' max='4'
placeholder='0' placeholder='0'
value={registrationSettings.OIDCMinTrustLevel === 0 ? '' : registrationSettings.OIDCMinTrustLevel} value={
onChange={(e) => registrationSettings.OIDCMinTrustLevel === 0
setRegistrationSettings((prev) => ({ ? ''
...prev, : registrationSettings.OIDCMinTrustLevel
OIDCMinTrustLevel: e.target.value === '' ? 0 : parseInt(e.target.value), }
})) onChange={(e) =>
} setRegistrationSettings((prev) => ({
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-green-500 focus:border-transparent' ...prev,
/> OIDCMinTrustLevel:
e.target.value === '' ? 0 : parseInt(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-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
LinuxDo网站有效01-4 LinuxDo网站有效01-4
</p> </p>
@@ -10989,7 +11782,9 @@ const SuwayomiConfigComponent = ({
const { isLoading, withLoading } = useLoadingState(); const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [serverURL, setServerURL] = useState(''); const [serverURL, setServerURL] = useState('');
const [authMode, setAuthMode] = useState<'none' | 'basic_auth' | 'simple_login'>('none'); const [authMode, setAuthMode] = useState<
'none' | 'basic_auth' | 'simple_login'
>('none');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [defaultLang, setDefaultLang] = useState('zh'); const [defaultLang, setDefaultLang] = useState('zh');
@@ -11016,7 +11811,10 @@ const SuwayomiConfigComponent = ({
Username: authMode === 'none' ? '' : username, Username: authMode === 'none' ? '' : username,
Password: authMode === 'none' ? '' : password, Password: authMode === 'none' ? '' : password,
DefaultLang: defaultLang || 'zh', DefaultLang: defaultLang || 'zh',
SourceIds: sourceIds.split(',').map((item) => item.trim()).filter(Boolean), SourceIds: sourceIds
.split(',')
.map((item) => item.trim())
.filter(Boolean),
MaxSources: Math.max(1, maxSources || 10), MaxSources: Math.max(1, maxSources || 10),
}); });
@@ -11042,7 +11840,10 @@ const SuwayomiConfigComponent = ({
showSuccess('漫画后端配置已保存', showAlert); showSuccess('漫画后端配置已保存', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -11070,7 +11871,10 @@ const SuwayomiConfigComponent = ({
showSuccess(data.message || '连接成功', showAlert); showSuccess(data.message || '连接成功', showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '测试连接失败', showAlert); showError(
error instanceof Error ? error.message : '测试连接失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -11083,8 +11887,14 @@ const SuwayomiConfigComponent = ({
/ Suwayomi / Suwayomi
</h3> </h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'> <div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> Suwayomi Server GraphQL </p> <p>
<p> basic_auth simple_login</p> Suwayomi Server GraphQL
</p>
<p>
basic_auth
simple_login
</p>
<p> </p> <p> </p>
<p> 使</p> <p> 使</p>
</div> </div>
@@ -11093,21 +11903,31 @@ const SuwayomiConfigComponent = ({
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'> <div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div> <div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3> <h3 className='text-sm font-medium text-gray-900 dark:text-white'>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'></p>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div> </div>
<button <button
onClick={() => setEnabled(!enabled)} onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/> />
</button> </button>
</div> </div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Suwayomi </label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Suwayomi
</label>
<input <input
type='text' type='text'
value={serverURL} value={serverURL}
@@ -11115,11 +11935,15 @@ const SuwayomiConfigComponent = ({
placeholder='http://127.0.0.1:4567' placeholder='http://127.0.0.1:4567'
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' 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'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> /api/graphql</p> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
/api/graphql
</p>
</div> </div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<div className='grid grid-cols-1 gap-2 md:grid-cols-3'> <div className='grid grid-cols-1 gap-2 md:grid-cols-3'>
{[ {[
{ value: 'none', label: '无认证' }, { value: 'none', label: '无认证' },
@@ -11129,7 +11953,11 @@ const SuwayomiConfigComponent = ({
<button <button
key={item.value} key={item.value}
type='button' type='button'
onClick={() => setAuthMode(item.value as 'none' | 'basic_auth' | 'simple_login')} onClick={() =>
setAuthMode(
item.value as 'none' | 'basic_auth' | 'simple_login'
)
}
className={`rounded-lg border px-3 py-2 text-sm transition-colors ${ className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
authMode === item.value authMode === item.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-200' ? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-200'
@@ -11141,14 +11969,17 @@ const SuwayomiConfigComponent = ({
))} ))}
</div> </div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
basic_auth 使 Basic Authorization simple_login /login.html Cookie basic_auth 使 Basic Authorization simple_login
/login.html Cookie
</p> </p>
</div> </div>
{authMode !== 'none' && ( {authMode !== 'none' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input <input
type='text' type='text'
value={username} value={username}
@@ -11158,7 +11989,9 @@ const SuwayomiConfigComponent = ({
/> />
</div> </div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input <input
type='password' type='password'
value={password} value={password}
@@ -11172,7 +12005,9 @@ const SuwayomiConfigComponent = ({
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input <input
type='text' type='text'
value={defaultLang} value={defaultLang}
@@ -11182,7 +12017,9 @@ const SuwayomiConfigComponent = ({
/> />
</div> </div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input <input
type='number' type='number'
min='1' min='1'
@@ -11194,7 +12031,9 @@ const SuwayomiConfigComponent = ({
</div> </div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<textarea <textarea
value={sourceIds} value={sourceIds}
onChange={(e) => setSourceIds(e.target.value)} onChange={(e) => setSourceIds(e.target.value)}
@@ -11235,7 +12074,6 @@ const SuwayomiConfigComponent = ({
); );
}; };
const OPDSConfigComponent = ({ const OPDSConfigComponent = ({
config, config,
refreshConfig, refreshConfig,
@@ -11282,7 +12120,9 @@ const OPDSConfigComponent = ({
}, [sources.length]); }, [sources.length]);
const updateSource = (index: number, patch: Partial<BookSource>) => { const updateSource = (index: number, patch: Partial<BookSource>) => {
setSources((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))); setSources((prev) =>
prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))
);
}; };
const addSource = () => { const addSource = () => {
@@ -11326,10 +12166,13 @@ const OPDSConfigComponent = ({
authMode: source.authMode || 'none', authMode: source.authMode || 'none',
username: source.authMode === 'none' ? '' : source.username?.trim() || '', username: source.authMode === 'none' ? '' : source.username?.trim() || '',
password: source.authMode === 'none' ? '' : source.password || '', password: source.authMode === 'none' ? '' : source.password || '',
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '', headerName:
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
headerValue: source.authMode === 'header' ? source.headerValue || '' : '', headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
searchTemplate: source.searchTemplate?.trim() || '', searchTemplate: source.searchTemplate?.trim() || '',
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'], preferFormat: source.preferFormat?.length
? source.preferFormat
: ['epub', 'pdf'],
language: source.language?.trim() || '', language: source.language?.trim() || '',
}); });
@@ -11358,7 +12201,10 @@ const OPDSConfigComponent = ({
showSuccess('电子书 OPDS 配置已保存', showAlert); showSuccess('电子书 OPDS 配置已保存', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -11386,11 +12232,20 @@ const OPDSConfigComponent = ({
} }
const result = Array.isArray(data.results) ? data.results[0] : null; const result = Array.isArray(data.results) ? data.results[0] : null;
const summary = result const summary = result
? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}${result.capability.lastError ? ` (${result.capability.lastError})` : ''}` ? `${result.name}: 分类${
result.capability.catalogSupported ? '√' : '×'
} / ${result.capability.searchSupported ? '√' : '×'}${
result.capability.lastError
? ` (${result.capability.lastError})`
: ''
}`
: data.message || '测试成功'; : data.message || '测试成功';
showSuccess(summary, showAlert); showSuccess(summary, showAlert);
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '测试连接失败', showAlert); showError(
error instanceof Error ? error.message : '测试连接失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -11399,43 +12254,68 @@ const OPDSConfigComponent = ({
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'> <div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'> / OPDS</h3> <h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'>
/ OPDS
</h3>
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'> <div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
<p> </p> <p> </p>
<p> </p> <p>
</p>
<p> EPUB 线PDF </p> <p> EPUB 线PDF </p>
</div> </div>
</div> </div>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'> <div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div> <div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3> <h3 className='text-sm font-medium text-gray-900 dark:text-white'>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> OPDS </p>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
OPDS
</p>
</div> </div>
<button <button
onClick={() => setEnabled(!enabled)} onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'}`} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button> </button>
</div> </div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Feed </label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Feed
</label>
<input <input
type='number' type='number'
min='60000' min='60000'
value={cacheTTL} value={cacheTTL}
onChange={(e) => setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)} onChange={(e) =>
setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)
}
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' 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'
/> />
</div> </div>
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3> <h3 className='text-sm font-medium text-gray-900 dark:text-white'>
<button type='button' onClick={addSource} className={buttonStyles.primary}>
<Plus size={16} className='inline mr-1' /> </h3>
<button
type='button'
onClick={addSource}
className={buttonStyles.primary}
>
<Plus size={16} className='inline mr-1' />
</button> </button>
</div> </div>
@@ -11451,7 +12331,10 @@ const OPDSConfigComponent = ({
{sources.map((source, index) => { {sources.map((source, index) => {
const isEditing = editingIndex === index; const isEditing = editingIndex === index;
return ( return (
<div key={`opds-source-${index}`} className='overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'> <div
key={`opds-source-${index}`}
className='overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'
>
<div className='space-y-3 p-4'> <div className='space-y-3 p-4'>
<div className='flex items-start justify-between gap-3'> <div className='flex items-start justify-between gap-3'>
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
@@ -11464,28 +12347,62 @@ const OPDSConfigComponent = ({
</div> </div>
<button <button
type='button' type='button'
onClick={() => updateSource(index, { enabled: source.enabled === false })} onClick={() =>
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`} updateSource(index, {
enabled: source.enabled === false,
})
}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
source.enabled !== false
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
}`}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
source.enabled !== false
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button> </button>
</div> </div>
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'> <div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
<div className='flex items-start justify-between gap-3'> <div className='flex items-start justify-between gap-3'>
<span className='shrink-0 text-gray-500 dark:text-gray-400'></span> <span className='shrink-0 text-gray-500 dark:text-gray-400'>
<span className='min-w-0 text-right break-all'>{source.url || '-'}</span>
</span>
<span className='min-w-0 text-right break-all'>
{source.url || '-'}
</span>
</div> </div>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<span className='text-gray-500 dark:text-gray-400'></span> <span className='text-gray-500 dark:text-gray-400'>
<span>{source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'}</span>
</span>
<span>
{source.authMode === 'none'
? '无认证'
: source.authMode === 'basic'
? 'Basic Auth'
: '自定义 Header'}
</span>
</div> </div>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<span className='text-gray-500 dark:text-gray-400'></span> <span className='text-gray-500 dark:text-gray-400'>
<span>{source.searchTemplate?.trim() ? '已配置' : '未配置'}</span>
</span>
<span>
{source.searchTemplate?.trim()
? '已配置'
: '未配置'}
</span>
</div> </div>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<span className='text-gray-500 dark:text-gray-400'></span> <span className='text-gray-500 dark:text-gray-400'>
</span>
<span>{source.preferFormat?.join(', ') || '-'}</span> <span>{source.preferFormat?.join(', ') || '-'}</span>
</div> </div>
</div> </div>
@@ -11497,69 +12414,175 @@ const OPDSConfigComponent = ({
disabled={isLoading(`testOPDSConfig-${index}`)} disabled={isLoading(`testOPDSConfig-${index}`)}
className={buttonStyles.primarySmall} className={buttonStyles.primarySmall}
> >
{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'} {isLoading(`testOPDSConfig-${index}`)
? '测试中...'
: '测试'}
</button> </button>
<button <button
type='button' type='button'
onClick={() => setEditingIndex(isEditing ? null : index)} onClick={() =>
setEditingIndex(isEditing ? null : index)
}
className={buttonStyles.secondarySmall} className={buttonStyles.secondarySmall}
> >
{isEditing ? <><ChevronUp size={14} className='inline mr-1' /></> : <><Settings size={14} className='inline mr-1' /></>} {isEditing ? (
<>
<ChevronUp size={14} className='inline mr-1' />
</>
) : (
<>
<Settings size={14} className='inline mr-1' />
</>
)}
</button> </button>
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}> <button
<Trash2 size={14} className='inline mr-1' /> type='button'
onClick={() => removeSource(index)}
className={buttonStyles.dangerSmall}
>
<Trash2 size={14} className='inline mr-1' />
</button> </button>
</div> </div>
</div> </div>
{isEditing && ( {isEditing && (
<div className='space-y-4 border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/40'> <div className='space-y-4 border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/40'>
<div className='text-sm font-medium text-gray-900 dark:text-white'> #{index + 1}</div> <div className='text-sm font-medium text-gray-900 dark:text-white'>
#{index + 1}
</div>
<div className='grid grid-cols-1 gap-4'> <div className='grid grid-cols-1 gap-4'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'> ID</label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> ID
</label>
<input
type='text'
value={source.id}
onChange={(e) =>
updateSource(index, { id: e.target.value })
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.name}
onChange={(e) =>
updateSource(index, { name: e.target.value })
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.url}
onChange={(e) =>
updateSource(index, { url: e.target.value })
}
placeholder='https://example.com/opds'
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div className='grid grid-cols-1 gap-4'> <div className='grid grid-cols-1 gap-4'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'>
</label>
<select
value={source.authMode || 'none'}
onChange={(e) =>
updateSource(index, {
authMode: e.target
.value as BookSource['authMode'],
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
>
<option value='none'></option> <option value='none'></option>
<option value='basic'>Basic Auth</option> <option value='basic'>Basic Auth</option>
<option value='header'> Header</option> <option value='header'> Header</option>
</select> </select>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='zh / en' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.language || ''}
onChange={(e) =>
updateSource(index, {
language: e.target.value,
})
}
placeholder='zh / en'
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.searchTemplate || ''}
onChange={(e) =>
updateSource(index, {
searchTemplate: e.target.value,
})
}
placeholder='https://...{searchTerms}'
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
{source.authMode === 'basic' && ( {source.authMode === 'basic' && (
<div className='grid grid-cols-1 gap-4'> <div className='grid grid-cols-1 gap-4'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.username || ''}
onChange={(e) =>
updateSource(index, {
username: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='password'
value={source.password || ''}
onChange={(e) =>
updateSource(index, {
password: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
)} )}
@@ -11567,12 +12590,34 @@ const OPDSConfigComponent = ({
{source.authMode === 'header' && ( {source.authMode === 'header' && (
<div className='grid grid-cols-1 gap-4'> <div className='grid grid-cols-1 gap-4'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header </label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> Header
</label>
<input
type='text'
value={source.headerName || ''}
onChange={(e) =>
updateSource(index, {
headerName: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header </label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> Header
</label>
<input
type='password'
value={source.headerValue || ''}
onChange={(e) =>
updateSource(index, {
headerValue: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
)} )}
@@ -11588,14 +12633,30 @@ const OPDSConfigComponent = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-800/70'> <thead className='bg-gray-50 dark:bg-gray-800/70'>
<tr> <tr>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th> <th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>ID</th> </th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th> <th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th> </th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th> <th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
<th className='px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'></th> ID
</th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
</th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
</th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
</th>
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
</th>
<th className='px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
</th>
</tr> </tr>
</thead> </thead>
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'> <tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
@@ -11607,46 +12668,106 @@ const OPDSConfigComponent = ({
<td className='px-4 py-3'> <td className='px-4 py-3'>
<button <button
type='button' type='button'
onClick={() => updateSource(index, { enabled: source.enabled === false })} onClick={() =>
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`} updateSource(index, {
enabled: source.enabled === false,
})
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
source.enabled !== false
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
}`}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
source.enabled !== false
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button> </button>
</td> </td>
<td className='px-4 py-3 text-sm text-gray-900 dark:text-gray-100'> <td className='px-4 py-3 text-sm text-gray-900 dark:text-gray-100'>
<div className='font-medium'>{source.name || `书源 ${index + 1}`}</div> <div className='font-medium'>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>{source.language || '未设置语言'}</div> {source.name || `书源 ${index + 1}`}
</td> </div>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>{source.id || '-'}</td> <div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'> {source.language || '未设置语言'}
<div className='max-w-[320px] truncate' title={source.url || ''}>{source.url || '-'}</div> </div>
</td> </td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'> <td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
{source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'} {source.id || '-'}
</td> </td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'> <td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
{source.searchTemplate?.trim() ? '已配置' : '未配置'} <div
className='max-w-[320px] truncate'
title={source.url || ''}
>
{source.url || '-'}
</div>
</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
{source.authMode === 'none'
? '无认证'
: source.authMode === 'basic'
? 'Basic Auth'
: '自定义 Header'}
</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
{source.searchTemplate?.trim()
? '已配置'
: '未配置'}
</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
{source.preferFormat?.join(', ') || '-'}
</td> </td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>{source.preferFormat?.join(', ') || '-'}</td>
<td className='px-4 py-3'> <td className='px-4 py-3'>
<div className='flex flex-wrap items-center justify-end gap-2'> <div className='flex flex-wrap items-center justify-end gap-2'>
<button <button
type='button' type='button'
onClick={() => handleTest(index)} onClick={() => handleTest(index)}
disabled={isLoading(`testOPDSConfig-${index}`)} disabled={isLoading(
`testOPDSConfig-${index}`
)}
className={buttonStyles.primarySmall} className={buttonStyles.primarySmall}
> >
{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'} {isLoading(`testOPDSConfig-${index}`)
? '测试中...'
: '测试'}
</button> </button>
<button <button
type='button' type='button'
onClick={() => setEditingIndex(isEditing ? null : index)} onClick={() =>
setEditingIndex(isEditing ? null : index)
}
className={buttonStyles.secondarySmall} className={buttonStyles.secondarySmall}
> >
{isEditing ? <><ChevronUp size={14} className='inline mr-1' /></> : <><Settings size={14} className='inline mr-1' /></>} {isEditing ? (
<>
<ChevronUp
size={14}
className='inline mr-1'
/>
</>
) : (
<>
<Settings
size={14}
className='inline mr-1'
/>
</>
)}
</button> </button>
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}> <button
<Trash2 size={14} className='inline mr-1' /> type='button'
onClick={() => removeSource(index)}
className={buttonStyles.dangerSmall}
>
<Trash2 size={14} className='inline mr-1' />
</button> </button>
</div> </div>
</td> </td>
@@ -11654,66 +12775,172 @@ const OPDSConfigComponent = ({
{isEditing && ( {isEditing && (
<tr> <tr>
<td colSpan={8} className='bg-gray-50 px-4 py-4 dark:bg-gray-800/40'> <td
colSpan={8}
className='bg-gray-50 px-4 py-4 dark:bg-gray-800/40'
>
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<div> <div>
<div className='text-sm font-medium text-gray-900 dark:text-white'> #{index + 1}</div> <div className='text-sm font-medium text-gray-900 dark:text-white'>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'></div> #{index + 1}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</div>
</div> </div>
<button <button
type='button' type='button'
onClick={() => setEditingIndex(null)} onClick={() => setEditingIndex(null)}
className={buttonStyles.secondarySmall} className={buttonStyles.secondarySmall}
> >
<ChevronUp size={14} className='inline mr-1' /> <ChevronUp
size={14}
className='inline mr-1'
/>
</button> </button>
</div> </div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'> ID</label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> ID
</label>
<input
type='text'
value={source.id}
onChange={(e) =>
updateSource(index, {
id: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.name}
onChange={(e) =>
updateSource(index, {
name: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.url}
onChange={(e) =>
updateSource(index, {
url: e.target.value,
})
}
placeholder='https://example.com/opds'
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'> <div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'>
</label>
<select
value={source.authMode || 'none'}
onChange={(e) =>
updateSource(index, {
authMode: e.target
.value as BookSource['authMode'],
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
>
<option value='none'></option> <option value='none'></option>
<option value='basic'>Basic Auth</option> <option value='basic'>
<option value='header'> Header</option> Basic Auth
</option>
<option value='header'>
Header
</option>
</select> </select>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='zh / en' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.language || ''}
onChange={(e) =>
updateSource(index, {
language: e.target.value,
})
}
placeholder='zh / en'
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.searchTemplate || ''}
onChange={(e) =>
updateSource(index, {
searchTemplate: e.target.value,
})
}
placeholder='https://...{searchTerms}'
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
{source.authMode === 'basic' && ( {source.authMode === 'basic' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='text'
value={source.username || ''}
onChange={(e) =>
updateSource(index, {
username: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' />
</label>
<input
type='password'
value={source.password || ''}
onChange={(e) =>
updateSource(index, {
password: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
)} )}
@@ -11721,12 +12948,34 @@ const OPDSConfigComponent = ({
{source.authMode === 'header' && ( {source.authMode === 'header' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header </label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> Header
</label>
<input
type='text'
value={source.headerName || ''}
onChange={(e) =>
updateSource(index, {
headerName: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
<div> <div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Header </label> <label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
<input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> Header
</label>
<input
type='password'
value={source.headerValue || ''}
onChange={(e) =>
updateSource(index, {
headerValue: e.target.value,
})
}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div> </div>
</div> </div>
)} )}
@@ -11746,7 +12995,11 @@ const OPDSConfigComponent = ({
</div> </div>
<div className='flex gap-3'> <div className='flex gap-3'>
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}> <button
onClick={handleSave}
disabled={isLoading('saveOPDSConfig')}
className={buttonStyles.success}
>
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'} {isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
</button> </button>
</div> </div>
@@ -11816,7 +13069,10 @@ const XiaoyaConfigComponent = ({
showSuccess('保存成功', showAlert); showSuccess('保存成功', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -11844,7 +13100,10 @@ const XiaoyaConfigComponent = ({
showError(data.message || '连接失败', showAlert); showError(data.message || '连接失败', showAlert);
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '连接失败', showAlert); showError(
error instanceof Error ? error.message : '连接失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -11858,7 +13117,9 @@ const XiaoyaConfigComponent = ({
</h3> </h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'> <div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> Alist </p> <p> Alist </p>
<p> TMDb ID () {'{tmdb-id}'}</p> <p>
TMDb ID () {'{tmdb-id}'}
</p>
<p> NFO poster.jpgbackground.jpg</p> <p> NFO poster.jpgbackground.jpg</p>
<p> </p> <p> </p>
</div> </div>
@@ -11956,7 +13217,9 @@ const XiaoyaConfigComponent = ({
<button <button
onClick={() => setDisableVideoPreview(!disableVideoPreview)} onClick={() => setDisableVideoPreview(!disableVideoPreview)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
disableVideoPreview ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' disableVideoPreview
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
@@ -12053,18 +13316,24 @@ const EmailConfigComponent = ({
const emailConfig: AdminConfig['EmailConfig'] = { const emailConfig: AdminConfig['EmailConfig'] = {
enabled, enabled,
provider, provider,
smtp: provider === 'smtp' ? { smtp:
host: smtpHost, provider === 'smtp'
port: smtpPort, ? {
secure: smtpSecure, host: smtpHost,
user: smtpUser, port: smtpPort,
password: smtpPassword, secure: smtpSecure,
from: smtpFrom, user: smtpUser,
} : undefined, password: smtpPassword,
resend: provider === 'resend' ? { from: smtpFrom,
apiKey: resendApiKey, }
from: resendFrom, : undefined,
} : undefined, resend:
provider === 'resend'
? {
apiKey: resendApiKey,
from: resendFrom,
}
: undefined,
}; };
const response = await fetch('/api/admin/email', { const response = await fetch('/api/admin/email', {
@@ -12084,7 +13353,10 @@ const EmailConfigComponent = ({
showSuccess('保存成功', showAlert); showSuccess('保存成功', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -12101,18 +13373,24 @@ const EmailConfigComponent = ({
const emailConfig: AdminConfig['EmailConfig'] = { const emailConfig: AdminConfig['EmailConfig'] = {
enabled: true, enabled: true,
provider, provider,
smtp: provider === 'smtp' ? { smtp:
host: smtpHost, provider === 'smtp'
port: smtpPort, ? {
secure: smtpSecure, host: smtpHost,
user: smtpUser, port: smtpPort,
password: smtpPassword, secure: smtpSecure,
from: smtpFrom, user: smtpUser,
} : undefined, password: smtpPassword,
resend: provider === 'resend' ? { from: smtpFrom,
apiKey: resendApiKey, }
from: resendFrom, : undefined,
} : undefined, resend:
provider === 'resend'
? {
apiKey: resendApiKey,
from: resendFrom,
}
: undefined,
}; };
const response = await fetch('/api/admin/email', { const response = await fetch('/api/admin/email', {
@@ -12132,7 +13410,10 @@ const EmailConfigComponent = ({
showError(data.error || '发送失败', showAlert); showError(data.error || '发送失败', showAlert);
} }
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '发送失败', showAlert); showError(
error instanceof Error ? error.message : '发送失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -12190,7 +13471,9 @@ const EmailConfigComponent = ({
onChange={(e) => setProvider(e.target.value as 'smtp')} onChange={(e) => setProvider(e.target.value as 'smtp')}
className='mr-2' className='mr-2'
/> />
<span className='text-sm text-gray-700 dark:text-gray-300'>SMTP</span> <span className='text-sm text-gray-700 dark:text-gray-300'>
SMTP
</span>
</label> </label>
<label className='flex items-center'> <label className='flex items-center'>
<input <input
@@ -12200,7 +13483,9 @@ const EmailConfigComponent = ({
onChange={(e) => setProvider(e.target.value as 'resend')} onChange={(e) => setProvider(e.target.value as 'resend')}
className='mr-2' className='mr-2'
/> />
<span className='text-sm text-gray-700 dark:text-gray-300'>Resend</span> <span className='text-sm text-gray-700 dark:text-gray-300'>
Resend
</span>
</label> </label>
</div> </div>
</div> </div>
@@ -12208,7 +13493,9 @@ const EmailConfigComponent = ({
{/* SMTP配置 */} {/* SMTP配置 */}
{provider === 'smtp' && ( {provider === 'smtp' && (
<div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'> <div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<h4 className='text-sm font-medium text-gray-900 dark:text-white'>SMTP </h4> <h4 className='text-sm font-medium text-gray-900 dark:text-white'>
SMTP
</h4>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div> <div>
@@ -12294,7 +13581,9 @@ const EmailConfigComponent = ({
{/* Resend配置 */} {/* Resend配置 */}
{provider === 'resend' && ( {provider === 'resend' && (
<div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'> <div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<h4 className='text-sm font-medium text-gray-900 dark:text-white'>Resend </h4> <h4 className='text-sm font-medium text-gray-900 dark:text-white'>
Resend
</h4>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
@@ -12308,7 +13597,16 @@ const EmailConfigComponent = ({
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white'
/> />
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://resend.com/api-keys' target='_blank' rel='noopener noreferrer' className='text-blue-600 hover:underline'>Resend </a> {' '}
<a
href='https://resend.com/api-keys'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:underline'
>
Resend
</a>{' '}
</p> </p>
</div> </div>
@@ -12379,7 +13677,13 @@ const EmailConfigComponent = ({
}; };
// 求片列表组件 // 求片列表组件
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => { const MovieRequestsComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState(); const { isLoading, withLoading } = useLoadingState();
const [requests, setRequests] = useState<any[]>([]); const [requests, setRequests] = useState<any[]>([]);
@@ -12389,8 +13693,12 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 求片功能设置 // 求片功能设置
const [enableMovieRequest, setEnableMovieRequest] = useState(config?.SiteConfig?.EnableMovieRequest ?? true); const [enableMovieRequest, setEnableMovieRequest] = useState(
const [movieRequestCooldown, setMovieRequestCooldown] = useState(config?.SiteConfig?.MovieRequestCooldown ?? 3600); config?.SiteConfig?.EnableMovieRequest ?? true
);
const [movieRequestCooldown, setMovieRequestCooldown] = useState(
config?.SiteConfig?.MovieRequestCooldown ?? 3600
);
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
useEffect(() => { useEffect(() => {
@@ -12403,8 +13711,12 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const response = await fetch('/api/movie-requests'); const response = await fetch('/api/movie-requests');
const data = await response.json(); const data = await response.json();
const allRequests = data.requests || []; const allRequests = data.requests || [];
setPendingCount(allRequests.filter((r: any) => r.status === 'pending').length); setPendingCount(
setFulfilledCount(allRequests.filter((r: any) => r.status === 'fulfilled').length); allRequests.filter((r: any) => r.status === 'pending').length
);
setFulfilledCount(
allRequests.filter((r: any) => r.status === 'fulfilled').length
);
} catch (error) { } catch (error) {
console.error('加载求片数量失败:', error); console.error('加载求片数量失败:', error);
} }
@@ -12413,7 +13725,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const loadRequests = async () => { const loadRequests = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch(`/api/movie-requests?status=${filter}&detail=true`); const response = await fetch(
`/api/movie-requests?status=${filter}&detail=true`
);
const data = await response.json(); const data = await response.json();
setRequests(data.requests || []); setRequests(data.requests || []);
} catch (error) { } catch (error) {
@@ -12443,7 +13757,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
await withLoading(`delete_${id}`, async () => { await withLoading(`delete_${id}`, async () => {
try { try {
const response = await fetch(`/api/movie-requests/${id}`, { method: 'DELETE' }); const response = await fetch(`/api/movie-requests/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('删除失败'); if (!response.ok) throw new Error('删除失败');
showSuccess('删除成功', showAlert); showSuccess('删除成功', showAlert);
await loadRequests(); await loadRequests();
@@ -12488,7 +13804,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
<div className='space-y-4'> <div className='space-y-4'>
{/* 求片功能设置 */} {/* 求片功能设置 */}
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'> <div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'></h3> <h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
</h3>
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <div>
@@ -12521,12 +13839,18 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
type='number' type='number'
min='0' min='0'
value={movieRequestCooldown} value={movieRequestCooldown}
onChange={(e) => setMovieRequestCooldown(parseInt(e.target.value) || 0)} onChange={(e) =>
setMovieRequestCooldown(parseInt(e.target.value) || 0)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/> />
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{movieRequestCooldown >= 3600 {movieRequestCooldown >= 3600
? `${Math.floor(movieRequestCooldown / 3600)} 小时 ${Math.floor((movieRequestCooldown % 3600) / 60)} 分钟` ? `${Math.floor(
movieRequestCooldown / 3600
)} ${Math.floor(
(movieRequestCooldown % 3600) / 60
)} `
: movieRequestCooldown >= 60 : movieRequestCooldown >= 60
? `${Math.floor(movieRequestCooldown / 60)} 分钟` ? `${Math.floor(movieRequestCooldown / 60)} 分钟`
: `${movieRequestCooldown}`} : `${movieRequestCooldown}`}
@@ -12545,92 +13869,96 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
{/* 求片列表 */} {/* 求片列表 */}
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'> <div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'></h3> <h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
</h3>
<div className='flex gap-2 mb-4'> <div className='flex gap-2 mb-4'>
<button <button
onClick={() => setFilter('pending')} onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg ${ className={`px-4 py-2 rounded-lg ${
filter === 'pending' filter === 'pending'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
({pendingCount}) ({pendingCount})
</button> </button>
<button <button
onClick={() => setFilter('fulfilled')} onClick={() => setFilter('fulfilled')}
className={`px-4 py-2 rounded-lg ${ className={`px-4 py-2 rounded-lg ${
filter === 'fulfilled' filter === 'fulfilled'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
({fulfilledCount}) ({fulfilledCount})
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<div className='flex justify-center py-8'> <div className='flex justify-center py-8'>
<div className='w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' /> <div className='w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
</div> </div>
) : requests.length === 0 ? ( ) : requests.length === 0 ? (
<div className='text-center py-8 text-gray-500 dark:text-gray-400'> <div className='text-center py-8 text-gray-500 dark:text-gray-400'>
</div> </div>
) : ( ) : (
<div className='space-y-3'> <div className='space-y-3'>
{requests.map((req) => ( {requests.map((req) => (
<div <div
key={req.id} key={req.id}
className='p-4 bg-gray-50 dark:bg-gray-800 rounded-lg' className='p-4 bg-gray-50 dark:bg-gray-800 rounded-lg'
> >
<div className='flex gap-4'> <div className='flex gap-4'>
{req.poster && ( {req.poster && (
<img <img
src={req.poster} src={req.poster}
alt={req.title} alt={req.title}
className='w-16 h-24 object-cover rounded' className='w-16 h-24 object-cover rounded'
/> />
)} )}
<div className='flex-1'> <div className='flex-1'>
<h3 className='font-medium text-gray-900 dark:text-gray-100'> <h3 className='font-medium text-gray-900 dark:text-gray-100'>
{req.title} {req.year && `(${req.year})`} {req.title} {req.year && `(${req.year})`}
</h3> </h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'> <p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
: {req.requestCount} : {req.requestCount}
</p>
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
{new Date(req.createdAt).toLocaleString('zh-CN')}
</p>
{req.requestedBy && (
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
: {req.requestedBy.join(', ')}
</p> </p>
)} <p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
</div> {new Date(req.createdAt).toLocaleString('zh-CN')}
<div className='flex flex-col gap-2'> </p>
{filter === 'pending' && ( {req.requestedBy && (
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
: {req.requestedBy.join(', ')}
</p>
)}
</div>
<div className='flex flex-col gap-2'>
{filter === 'pending' && (
<button
onClick={() => handleFulfill(req.id)}
disabled={isLoading(`fulfill_${req.id}`)}
className={buttonStyles.successSmall}
>
{isLoading(`fulfill_${req.id}`)
? '处理中...'
: '标记已上架'}
</button>
)}
<button <button
onClick={() => handleFulfill(req.id)} onClick={() => handleDelete(req.id)}
disabled={isLoading(`fulfill_${req.id}`)} disabled={isLoading(`delete_${req.id}`)}
className={buttonStyles.successSmall} className={buttonStyles.dangerSmall}
> >
{isLoading(`fulfill_${req.id}`) ? '处理中...' : '标记已上架'} {isLoading(`delete_${req.id}`) ? '删除中...' : '删除'}
</button> </button>
)} </div>
<button
onClick={() => handleDelete(req.id)}
disabled={isLoading(`delete_${req.id}`)}
className={buttonStyles.dangerSmall}
>
{isLoading(`delete_${req.id}`) ? '删除中...' : '删除'}
</button>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> )}
)}
</div> </div>
<AlertModal <AlertModal
@@ -12670,7 +13998,9 @@ const AIConfigComponent = ({
// 联网搜索配置 // 联网搜索配置
const [enableWebSearch, setEnableWebSearch] = useState(false); const [enableWebSearch, setEnableWebSearch] = useState(false);
const [webSearchProvider, setWebSearchProvider] = useState<'tavily' | 'serper' | 'serpapi'>('tavily'); const [webSearchProvider, setWebSearchProvider] = useState<
'tavily' | 'serper' | 'serpapi'
>('tavily');
const [tavilyApiKey, setTavilyApiKey] = useState(''); const [tavilyApiKey, setTavilyApiKey] = useState('');
const [serperApiKey, setSerperApiKey] = useState(''); const [serperApiKey, setSerperApiKey] = useState('');
const [serpApiKey, setSerpApiKey] = useState(''); const [serpApiKey, setSerpApiKey] = useState('');
@@ -12758,7 +14088,10 @@ const AIConfigComponent = ({
showSuccess('AI配置保存成功', showAlert); showSuccess('AI配置保存成功', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -12898,7 +14231,9 @@ const AIConfigComponent = ({
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3'> <div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3'>
<p className='text-xs text-blue-700 dark:text-blue-400'> <p className='text-xs text-blue-700 dark:text-blue-400'>
💡 <strong>:</strong> ,使( gpt-4o-mini)API Key和Base URL配置 💡 <strong>:</strong>{' '}
,使(
gpt-4o-mini)API Key和Base URL配置
</p> </p>
</div> </div>
</div> </div>
@@ -12955,7 +14290,15 @@ const AIConfigComponent = ({
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/> />
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://tavily.com' target='_blank' className='text-blue-600 hover:underline'>tavily.com</a> {' '}
<a
href='https://tavily.com'
target='_blank'
className='text-blue-600 hover:underline'
>
tavily.com
</a>{' '}
</p> </p>
</div> </div>
)} )}
@@ -12973,7 +14316,15 @@ const AIConfigComponent = ({
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/> />
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://serper.dev' target='_blank' className='text-blue-600 hover:underline'>serper.dev</a> {' '}
<a
href='https://serper.dev'
target='_blank'
className='text-blue-600 hover:underline'
>
serper.dev
</a>{' '}
</p> </p>
</div> </div>
)} )}
@@ -12991,7 +14342,15 @@ const AIConfigComponent = ({
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/> />
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://serpapi.com' target='_blank' className='text-blue-600 hover:underline'>serpapi.com</a> {' '}
<a
href='https://serpapi.com'
target='_blank'
className='text-blue-600 hover:underline'
>
serpapi.com
</a>{' '}
</p> </p>
</div> </div>
)} )}
@@ -13006,12 +14365,39 @@ const AIConfigComponent = ({
</h4> </h4>
{[ {[
{ key: 'homepage', label: '首页入口', desc: '在首页显示AI问片入口', state: enableHomepageEntry, setState: setEnableHomepageEntry }, {
{ key: 'videocard', label: '视频卡片入口', desc: '在视频卡片菜单中显示AI问片选项', state: enableVideoCardEntry, setState: setEnableVideoCardEntry }, key: 'homepage',
{ key: 'playpage', label: '播放页入口', desc: '在视频播放页显示AI问片功能', state: enablePlayPageEntry, setState: setEnablePlayPageEntry }, label: '页入口',
{ key: 'aicomments', label: 'AI评论功能', desc: '在播放页生成AI评论(独立于豆瓣评论)', state: enableAIComments, setState: setEnableAIComments }, desc: '在首页显示AI问片入口',
state: enableHomepageEntry,
setState: setEnableHomepageEntry,
},
{
key: 'videocard',
label: '视频卡片入口',
desc: '在视频卡片菜单中显示AI问片选项',
state: enableVideoCardEntry,
setState: setEnableVideoCardEntry,
},
{
key: 'playpage',
label: '播放页入口',
desc: '在视频播放页显示AI问片功能',
state: enablePlayPageEntry,
setState: setEnablePlayPageEntry,
},
{
key: 'aicomments',
label: 'AI评论功能',
desc: '在播放页生成AI评论(独立于豆瓣评论)',
state: enableAIComments,
setState: setEnableAIComments,
},
].map((item) => ( ].map((item) => (
<div key={item.key} className='flex items-center justify-between py-2'> <div
key={item.key}
className='flex items-center justify-between py-2'
>
<div> <div>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'> <div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{item.label} {item.label}
@@ -13071,10 +14457,10 @@ const AIConfigComponent = ({
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label> </label>
<textarea <textarea
value={systemPrompt} value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)} onChange={(e) => setSystemPrompt(e.target.value)}
rows={4} rows={4}
placeholder='可自定义AI的角色和行为规则...' placeholder='可自定义AI的角色和行为规则...'
@@ -13095,7 +14481,7 @@ const AIConfigComponent = ({
<button <button
onClick={() => setEnableStreaming(!enableStreaming)} onClick={() => setEnableStreaming(!enableStreaming)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enableStreaming ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700' enableStreaming ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`} }`}
> >
<span <span
@@ -13106,7 +14492,7 @@ const AIConfigComponent = ({
</button> </button>
</div> </div>
</div> </div>
</details> </details>
{/* AI默认消息配置 */} {/* AI默认消息配置 */}
<details className='p-4 border border-gray-200 dark:border-gray-700 rounded-lg'> <details className='p-4 border border-gray-200 dark:border-gray-700 rounded-lg'>
@@ -13142,7 +14528,11 @@ const AIConfigComponent = ({
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/> />
<p className='mt-2 text-sm text-gray-600 dark:text-gray-400'> <p className='mt-2 text-sm text-gray-600 dark:text-gray-400'>
AI问片时使 <code className='px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs font-mono'>{'{title}'}</code> AI问片时使{' '}
<code className='px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs font-mono'>
{'{title}'}
</code>{' '}
</p> </p>
</div> </div>
</div> </div>
@@ -13153,7 +14543,11 @@ const AIConfigComponent = ({
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isLoading('saveAIConfig')} disabled={isLoading('saveAIConfig')}
className={isLoading('saveAIConfig') ? buttonStyles.disabled : buttonStyles.success} className={
isLoading('saveAIConfig')
? buttonStyles.disabled
: buttonStyles.success
}
> >
{isLoading('saveAIConfig') ? '保存中...' : '保存配置'} {isLoading('saveAIConfig') ? '保存中...' : '保存配置'}
</button> </button>
@@ -13225,7 +14619,10 @@ const MusicConfigComponent = ({
showSuccess('音乐配置保存成功', showAlert); showSuccess('音乐配置保存成功', showAlert);
await refreshConfig(); await refreshConfig();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert); showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error; throw error;
} }
}); });
@@ -13253,9 +14650,24 @@ const MusicConfigComponent = ({
</span> </span>
</div> </div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'> <div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> lxserver </p> <p>
<p> Base URL Token MoonTV 访 lxserver</p> lxserver
<p> <a href='https://github.com/XCQ0607/lxserver' target='_blank' rel='noreferrer' className='underline hover:text-blue-500'>https://github.com/XCQ0607/lxserver</a></p> </p>
<p>
Base URL Token MoonTV 访
lxserver
</p>
<p>
<a
href='https://github.com/XCQ0607/lxserver'
target='_blank'
rel='noreferrer'
className='underline hover:text-blue-500'
>
https://github.com/XCQ0607/lxserver
</a>
</p>
</div> </div>
</div> </div>
@@ -13337,7 +14749,11 @@ const MusicConfigComponent = ({
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isLoading('saveMusicConfig')} disabled={isLoading('saveMusicConfig')}
className={isLoading('saveMusicConfig') ? buttonStyles.disabled : buttonStyles.success} className={
isLoading('saveMusicConfig')
? buttonStyles.disabled
: buttonStyles.success
}
> >
{isLoading('saveMusicConfig') ? '保存中...' : '保存音乐配置'} {isLoading('saveMusicConfig') ? '保存中...' : '保存音乐配置'}
</button> </button>
@@ -13441,16 +14857,17 @@ const LiveSourceConfig = ({
}); });
}; };
const handleSetProxyMode = (key: string, mode: 'full' | 'm3u8-only' | 'direct') => { const handleSetProxyMode = (
key: string,
mode: 'full' | 'm3u8-only' | 'direct'
) => {
withLoading(`setLiveProxyMode_${key}`, async () => { withLoading(`setLiveProxyMode_${key}`, async () => {
// 保存旧值用于回滚 // 保存旧值用于回滚
const oldMode = liveSources.find((s) => s.key === key)?.proxyMode; const oldMode = liveSources.find((s) => s.key === key)?.proxyMode;
// 乐观更新本地状态 // 乐观更新本地状态
setLiveSources((prev) => setLiveSources((prev) =>
prev.map((s) => prev.map((s) => (s.key === key ? { ...s, proxyMode: mode } : s))
s.key === key ? { ...s, proxyMode: mode } : s
)
); );
try { try {
@@ -13473,9 +14890,7 @@ const LiveSourceConfig = ({
} catch (error) { } catch (error) {
// 失败时回滚本地状态 // 失败时回滚本地状态
setLiveSources((prev) => setLiveSources((prev) =>
prev.map((s) => prev.map((s) => (s.key === key ? { ...s, proxyMode: oldMode } : s))
s.key === key ? { ...s, proxyMode: oldMode } : s
)
); );
showError( showError(
error instanceof Error ? error.message : '设置代理模式失败', error instanceof Error ? error.message : '设置代理模式失败',
@@ -13698,7 +15113,10 @@ const LiveSourceConfig = ({
<select <select
value={liveSource.proxyMode || 'full'} value={liveSource.proxyMode || 'full'}
onChange={(e) => { onChange={(e) => {
handleSetProxyMode(liveSource.key, e.target.value as 'full' | 'm3u8-only' | 'direct'); handleSetProxyMode(
liveSource.key,
e.target.value as 'full' | 'm3u8-only' | 'direct'
);
}} }}
disabled={isLoading(`setLiveProxyMode_${liveSource.key}`)} disabled={isLoading(`setLiveProxyMode_${liveSource.key}`)}
className={`px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 ${ className={`px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 ${
@@ -13781,7 +15199,11 @@ const LiveSourceConfig = ({
type='number' type='number'
min='1' min='1'
value={refreshIntervalHours} value={refreshIntervalHours}
onChange={(e) => setRefreshIntervalHours(Math.max(1, parseInt(e.target.value) || 12))} onChange={(e) =>
setRefreshIntervalHours(
Math.max(1, parseInt(e.target.value) || 12)
)
}
className='px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 w-28 sm:w-40' className='px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 w-28 sm:w-40'
/> />
</div> </div>
@@ -13789,7 +15211,9 @@ const LiveSourceConfig = ({
onClick={handleSaveRefreshInterval} onClick={handleSaveRefreshInterval}
disabled={isLoading('saveLiveRefreshInterval')} disabled={isLoading('saveLiveRefreshInterval')}
className={`px-3 py-1.5 text-sm whitespace-nowrap shrink-0 ${ className={`px-3 py-1.5 text-sm whitespace-nowrap shrink-0 ${
isLoading('saveLiveRefreshInterval') ? buttonStyles.disabled : buttonStyles.success isLoading('saveLiveRefreshInterval')
? buttonStyles.disabled
: buttonStyles.success
}`} }`}
> >
{isLoading('saveLiveRefreshInterval') ? '保存中...' : '保存间隔'} {isLoading('saveLiveRefreshInterval') ? '保存中...' : '保存间隔'}
@@ -13801,29 +15225,29 @@ const LiveSourceConfig = ({
</h4> </h4>
<div className='flex items-center space-x-2'> <div className='flex items-center space-x-2'>
<button <button
onClick={handleRefreshLiveSources} onClick={handleRefreshLiveSources}
disabled={isRefreshing || isLoading('refreshLiveSources')} disabled={isRefreshing || isLoading('refreshLiveSources')}
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${ className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${
isRefreshing || isLoading('refreshLiveSources') isRefreshing || isLoading('refreshLiveSources')
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg' ? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors' : 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
}`} }`}
> >
<span> <span>
{isRefreshing || isLoading('refreshLiveSources') {isRefreshing || isLoading('refreshLiveSources')
? '刷新中...' ? '刷新中...'
: '刷新直播源'} : '刷新直播源'}
</span> </span>
</button> </button>
<button <button
onClick={() => setShowAddForm(!showAddForm)} onClick={() => setShowAddForm(!showAddForm)}
className={ className={`${
showAddForm ? buttonStyles.secondary : buttonStyles.success showAddForm ? buttonStyles.secondary : buttonStyles.success
} } shrink-0 whitespace-nowrap`}
> >
{showAddForm ? '取消' : '添加直播源'} {showAddForm ? '取消' : '添加直播源'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -14024,7 +15448,6 @@ const LiveSourceConfig = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'> <thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr> <tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th> </th>
@@ -14192,11 +15615,15 @@ const WebLiveConfig = ({
const target = webLiveSources.find((s) => s.key === key); const target = webLiveSources.find((s) => s.key === key);
if (!target) return; if (!target) return;
const action = target.disabled ? 'enable' : 'disable'; const action = target.disabled ? 'enable' : 'disable';
withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(() => {}); withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(
() => {}
);
}; };
const handleDelete = (key: string) => { const handleDelete = (key: string) => {
withLoading(`deleteWebLive_${key}`, () => callApi({ action: 'delete', key })).catch(() => {}); withLoading(`deleteWebLive_${key}`, () =>
callApi({ action: 'delete', key })
).catch(() => {});
}; };
const handleToggleWebLiveEnabled = async () => { const handleToggleWebLiveEnabled = async () => {
@@ -14226,7 +15653,11 @@ const WebLiveConfig = ({
}; };
if (!config) { if (!config) {
return <div className='text-center text-gray-500 dark:text-gray-400'>...</div>; return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
} }
return ( return (
@@ -14246,69 +15677,92 @@ const WebLiveConfig = ({
onClick={handleToggleWebLiveEnabled} onClick={handleToggleWebLiveEnabled}
disabled={isLoading('toggleWebLiveEnabled')} disabled={isLoading('toggleWebLiveEnabled')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 ${
config.WebLiveEnabled ? buttonStyles.toggleOn : buttonStyles.toggleOff config.WebLiveEnabled
} ${isLoading('toggleWebLiveEnabled') ? 'opacity-50 cursor-not-allowed' : ''}`} ? buttonStyles.toggleOn
: buttonStyles.toggleOff
} ${
isLoading('toggleWebLiveEnabled')
? 'opacity-50 cursor-not-allowed'
: ''
}`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full transition-transform ${ className={`inline-block h-4 w-4 transform rounded-full transition-transform ${
buttonStyles.toggleThumb buttonStyles.toggleThumb
} ${config.WebLiveEnabled ? buttonStyles.toggleThumbOn : buttonStyles.toggleThumbOff}`} } ${
config.WebLiveEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/> />
</button> </button>
</div> </div>
</div> </div>
{/* 免责声明弹窗 */} {/* 免责声明弹窗 */}
{showDisclaimerModal && createPortal( {showDisclaimerModal &&
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'> createPortal(
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800'> <div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'>
<div className='p-6'> <div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800'>
<div className='flex justify-center mb-4'> <div className='p-6'>
<AlertTriangle className='w-12 h-12 text-red-500' /> <div className='flex justify-center mb-4'>
</div> <AlertTriangle className='w-12 h-12 text-red-500' />
</div>
<h3 className='text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 text-center'> <h3 className='text-xl font-bold text-gray-900 dark:text-gray-100 mb-4 text-center'>
</h3> </h3>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'> <div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
<p className='text-sm text-gray-700 dark:text-gray-300 leading-relaxed'> <p className='text-sm text-gray-700 dark:text-gray-300 leading-relaxed'>
使 使
使使 使使
</p> </p>
</div> </div>
<div className='flex gap-3 justify-center'> <div className='flex gap-3 justify-center'>
<button <button
onClick={() => { onClick={() => {
setShowDisclaimerModal(false); setShowDisclaimerModal(false);
setCountdown(10); setCountdown(10);
}} }}
className={buttonStyles.secondary} className={buttonStyles.secondary}
disabled={isEnabling} disabled={isEnabling}
> >
</button> </button>
<button <button
onClick={handleConfirmEnable} onClick={handleConfirmEnable}
disabled={countdown > 0 || isEnabling} disabled={countdown > 0 || isEnabling}
className={countdown > 0 || isEnabling ? buttonStyles.disabled : buttonStyles.danger} className={
> countdown > 0 || isEnabling
{isEnabling ? '启用中...' : countdown > 0 ? `确认 (${countdown}s)` : '确认启用'} ? buttonStyles.disabled
</button> : buttonStyles.danger
}
>
{isEnabling
? '启用中...'
: countdown > 0
? `确认 (${countdown}s)`
: '确认启用'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>,
</div>, document.body
document.body )}
)}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'></h4> <h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button <button
onClick={() => setShowAddForm(!showAddForm)} onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success} className={
showAddForm ? buttonStyles.secondary : buttonStyles.success
}
> >
{showAddForm ? '取消' : '添加网络直播'} {showAddForm ? '取消' : '添加网络直播'}
</button> </button>
@@ -14321,12 +15775,16 @@ const WebLiveConfig = ({
type='text' type='text'
placeholder='名称' placeholder='名称'
value={newSource.name} value={newSource.name}
onChange={(e) => setNewSource((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) =>
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' 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'
/> />
<select <select
value={newSource.platform} value={newSource.platform}
onChange={(e) => setNewSource((prev) => ({ ...prev, platform: e.target.value }))} onChange={(e) =>
setNewSource((prev) => ({ ...prev, platform: 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' 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'
> >
<option value='huya'></option> <option value='huya'></option>
@@ -14337,16 +15795,26 @@ const WebLiveConfig = ({
type='text' type='text'
placeholder='房间ID' placeholder='房间ID'
value={newSource.roomId} value={newSource.roomId}
onChange={(e) => setNewSource((prev) => ({ ...prev, roomId: e.target.value }))} onChange={(e) =>
setNewSource((prev) => ({ ...prev, roomId: 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' 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'
/> />
</div> </div>
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleAdd} onClick={handleAdd}
disabled={!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive')} disabled={
!newSource.name ||
!newSource.platform ||
!newSource.roomId ||
isLoading('addWebLive')
}
className={`w-full sm:w-auto px-4 py-2 ${ className={`w-full sm:w-auto px-4 py-2 ${
!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive') !newSource.name ||
!newSource.platform ||
!newSource.roomId ||
isLoading('addWebLive')
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.success : buttonStyles.success
}`} }`}
@@ -14360,24 +15828,43 @@ const WebLiveConfig = ({
{editingSource && ( {editingSource && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'> <div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>: {editingSource.name}</h5> <h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
<button onClick={() => setEditingSource(null)} className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'></button> : {editingSource.name}
</h5>
<button
onClick={() => setEditingSource(null)}
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
>
</button>
</div> </div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div> <div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'></label> <label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input <input
type='text' type='text'
value={editingSource.name} value={editingSource.name}
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, name: e.target.value } : null)} onChange={(e) =>
setEditingSource((prev: any) =>
prev ? { ...prev, name: e.target.value } : null
)
}
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' 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'
/> />
</div> </div>
<div> <div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'></label> <label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<select <select
value={editingSource.platform} value={editingSource.platform}
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, platform: e.target.value } : null)} onChange={(e) =>
setEditingSource((prev: any) =>
prev ? { ...prev, platform: e.target.value } : null
)
}
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' 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'
> >
<option value='huya'></option> <option value='huya'></option>
@@ -14386,21 +15873,42 @@ const WebLiveConfig = ({
</select> </select>
</div> </div>
<div> <div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>ID</label> <label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
ID
</label>
<input <input
type='text' type='text'
value={editingSource.roomId} value={editingSource.roomId}
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, roomId: e.target.value } : null)} onChange={(e) =>
setEditingSource((prev: any) =>
prev ? { ...prev, roomId: e.target.value } : null
)
}
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' 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'
/> />
</div> </div>
</div> </div>
<div className='flex justify-end space-x-2'> <div className='flex justify-end space-x-2'>
<button onClick={() => setEditingSource(null)} className={buttonStyles.secondary}></button> <button
onClick={() => setEditingSource(null)}
className={buttonStyles.secondary}
>
</button>
<button <button
onClick={handleEdit} onClick={handleEdit}
disabled={!editingSource.name || !editingSource.roomId || isLoading('editWebLive')} disabled={
className={`${!editingSource.name || !editingSource.roomId || isLoading('editWebLive') ? buttonStyles.disabled : buttonStyles.success}`} !editingSource.name ||
!editingSource.roomId ||
isLoading('editWebLive')
}
className={`${
!editingSource.name ||
!editingSource.roomId ||
isLoading('editWebLive')
? buttonStyles.disabled
: buttonStyles.success
}`}
> >
{isLoading('editWebLive') ? '保存中...' : '保存'} {isLoading('editWebLive') ? '保存中...' : '保存'}
</button> </button>
@@ -14412,24 +15920,62 @@ const WebLiveConfig = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'> <table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'> <thead className='bg-gray-50 dark:bg-gray-900'>
<tr> <tr>
<th className='px-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'></th> <th className='px-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>
<th className='hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'></th>
<th className='hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>ID</th> </th>
<th className='px-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'></th> <th className='hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>
<th className='px-3 sm:px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'></th>
</th>
<th className='hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>
ID
</th>
<th className='px-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>
</th>
<th className='px-3 sm:px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>
</th>
</tr> </tr>
</thead> </thead>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'> <tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{webLiveSources.map((source) => ( {webLiveSources.map((source) => (
<tr key={source.key} className='hover:bg-gray-50 dark:hover:bg-gray-800'> <tr
key={source.key}
className='hover:bg-gray-50 dark:hover:bg-gray-800'
>
<td className='px-3 sm:px-6 py-4 text-sm text-gray-900 dark:text-gray-100'> <td className='px-3 sm:px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>
<div>{source.name}</div> <div>{source.name}</div>
<div className='sm:hidden text-xs text-gray-500 dark:text-gray-400 mt-1'>{source.platform === 'huya' ? '虎牙' : source.platform === 'bilibili' ? '哔哩哔哩' : source.platform === 'douyin' ? '抖音' : source.platform} · {source.roomId}</div> <div className='sm:hidden text-xs text-gray-500 dark:text-gray-400 mt-1'>
{source.platform === 'huya'
? '虎牙'
: source.platform === 'bilibili'
? '哔哩哔哩'
: source.platform === 'douyin'
? '抖音'
: source.platform}{' '}
· {source.roomId}
</div>
</td>
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>
{source.platform === 'huya'
? '虎牙'
: source.platform === 'bilibili'
? '哔哩哔哩'
: source.platform === 'douyin'
? '抖音'
: source.platform}
</td>
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>
{source.roomId}
</td> </td>
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.platform === 'huya' ? '虎牙' : source.platform === 'bilibili' ? '哔哩哔哩' : source.platform === 'douyin' ? '抖音' : source.platform}</td>
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.roomId}</td>
<td className='px-3 sm:px-6 py-4 whitespace-nowrap'> <td className='px-3 sm:px-6 py-4 whitespace-nowrap'>
<span className={`px-2 py-1 text-xs rounded-full whitespace-nowrap ${!source.disabled ? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300' : 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'}`}> <span
className={`px-2 py-1 text-xs rounded-full whitespace-nowrap ${
!source.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!source.disabled ? '启用中' : '已禁用'} {!source.disabled ? '启用中' : '已禁用'}
</span> </span>
</td> </td>
@@ -14438,7 +15984,15 @@ const WebLiveConfig = ({
<button <button
onClick={() => handleToggle(source.key)} onClick={() => handleToggle(source.key)}
disabled={isLoading(`toggleWebLive_${source.key}`)} disabled={isLoading(`toggleWebLive_${source.key}`)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled ? buttonStyles.roundedDanger : buttonStyles.roundedSuccess} ${isLoading(`toggleWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`} className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
!source.disabled
? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess
} ${
isLoading(`toggleWebLive_${source.key}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
> >
{!source.disabled ? '禁用' : '启用'} {!source.disabled ? '禁用' : '启用'}
</button> </button>
@@ -14447,14 +16001,22 @@ const WebLiveConfig = ({
<button <button
onClick={() => setEditingSource(source)} onClick={() => setEditingSource(source)}
disabled={isLoading(`editWebLive_${source.key}`)} disabled={isLoading(`editWebLive_${source.key}`)}
className={`${buttonStyles.roundedPrimary} ${isLoading(`editWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`} className={`${buttonStyles.roundedPrimary} ${
isLoading(`editWebLive_${source.key}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
> >
</button> </button>
<button <button
onClick={() => handleDelete(source.key)} onClick={() => handleDelete(source.key)}
disabled={isLoading(`deleteWebLive_${source.key}`)} disabled={isLoading(`deleteWebLive_${source.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`} className={`${buttonStyles.roundedSecondary} ${
isLoading(`deleteWebLive_${source.key}`)
? 'opacity-50 cursor-not-allowed'
: ''
}`}
> >
</button> </button>
@@ -14569,7 +16131,9 @@ function AdminPageClient() {
const fetchUsersV2 = useCallback(async (page = 1) => { const fetchUsersV2 = useCallback(async (page = 1) => {
try { try {
setUserListLoading(true); setUserListLoading(true);
const response = await fetch(`/api/admin/users?page=${page}&limit=${userLimit}`); const response = await fetch(
`/api/admin/users?page=${page}&limit=${userLimit}`
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setUsersV2(data.users); setUsersV2(data.users);
@@ -14687,18 +16251,16 @@ function AdminPageClient() {
<h2 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4'> <h2 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4'>
访 访
</h2> </h2>
<p className='text-gray-600 dark:text-gray-400 mb-6'> <p className='text-gray-600 dark:text-gray-400 mb-6'>{error}</p>
{error}
</p>
<div className='space-y-3'> <div className='space-y-3'>
<button <button
onClick={() => window.location.href = '/'} onClick={() => (window.location.href = '/')}
className='w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg font-medium transition-colors' className='w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg font-medium transition-colors'
> >
</button> </button>
<button <button
onClick={() => window.location.href = '/login'} onClick={() => (window.location.href = '/login')}
className='w-full px-6 py-3 bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg font-medium transition-colors' className='w-full px-6 py-3 bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg font-medium transition-colors'
> >
@@ -14743,8 +16305,16 @@ function AdminPageClient() {
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'> <div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'>
<div className='flex items-start gap-3'> <div className='flex items-start gap-3'>
<div className='flex-shrink-0 mt-0.5'> <div className='flex-shrink-0 mt-0.5'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='currentColor' viewBox='0 0 20 20'> <svg
<path fillRule='evenodd' d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z' clipRule='evenodd' /> className='w-5 h-5 text-blue-600 dark:text-blue-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg> </svg>
</div> </div>
<div className='flex-1'> <div className='flex-1'>
@@ -14761,8 +16331,16 @@ function AdminPageClient() {
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4'> <div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4'>
<div className='flex items-start gap-3'> <div className='flex items-start gap-3'>
<div className='flex-shrink-0 mt-0.5'> <div className='flex-shrink-0 mt-0.5'>
<svg className='w-5 h-5 text-amber-600 dark:text-amber-400' fill='currentColor' viewBox='0 0 20 20'> <svg
<path fillRule='evenodd' d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l6.518 11.591c.75 1.334-.213 2.99-1.742 2.99H3.48c-1.53 0-2.492-1.656-1.743-2.99L8.257 3.1zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-6a1 1 0 00-1 1v3a1 1 0 102 0V8a1 1 0 00-1-1z' clipRule='evenodd' /> className='w-5 h-5 text-amber-600 dark:text-amber-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l6.518 11.591c.75 1.334-.213 2.99-1.742 2.99H3.48c-1.53 0-2.492-1.656-1.743-2.99L8.257 3.1zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-6a1 1 0 00-1 1v3a1 1 0 102 0V8a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg> </svg>
</div> </div>
<div className='flex-1'> <div className='flex-1'>
@@ -14821,17 +16399,17 @@ function AdminPageClient() {
isExpanded={expandedTabs.registrationConfig} isExpanded={expandedTabs.registrationConfig}
onToggle={() => toggleTab('registrationConfig')} onToggle={() => toggleTab('registrationConfig')}
> >
<RegistrationConfigComponent config={config} refreshConfig={fetchConfig} /> <RegistrationConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* 个性化配置标签 */} {/* 个性化配置标签 */}
<CollapsibleTab <CollapsibleTab
title='个性化配置' title='个性化配置'
icon={ icon={
<Palette <Palette size={20} className='text-gray-600 dark:text-gray-400' />
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.themeConfig} isExpanded={expandedTabs.themeConfig}
onToggle={() => toggleTab('themeConfig')} onToggle={() => toggleTab('themeConfig')}
@@ -14907,30 +16485,44 @@ function AdminPageClient() {
isExpanded={expandedTabs.musicConfig} isExpanded={expandedTabs.musicConfig}
onToggle={() => toggleTab('musicConfig')} onToggle={() => toggleTab('musicConfig')}
> >
<MusicConfigComponent config={config} refreshConfig={fetchConfig} /> <MusicConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
<CollapsibleTab <CollapsibleTab
title='漫画配置' title='漫画配置'
icon={ icon={
<BookOpen size={20} className='text-gray-600 dark:text-gray-400' /> <BookOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.suwayomiConfig} isExpanded={expandedTabs.suwayomiConfig}
onToggle={() => toggleTab('suwayomiConfig')} onToggle={() => toggleTab('suwayomiConfig')}
> >
<SuwayomiConfigComponent config={config} refreshConfig={fetchConfig} /> <SuwayomiConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
<CollapsibleTab <CollapsibleTab
title='电子书配置' title='电子书配置'
icon={ icon={
<BookMarked size={20} className='text-gray-600 dark:text-gray-400' /> <BookMarked
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.opdsConfig} isExpanded={expandedTabs.opdsConfig}
onToggle={() => toggleTab('opdsConfig')} onToggle={() => toggleTab('opdsConfig')}
> >
<OPDSConfigComponent config={config} refreshConfig={fetchConfig} /> <OPDSConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* 电视直播源配置标签 */} {/* 电视直播源配置标签 */}
@@ -14961,7 +16553,10 @@ function AdminPageClient() {
<CollapsibleTab <CollapsibleTab
title='私人影库' title='私人影库'
icon={ icon={
<Database size={20} className='text-yellow-700 dark:text-yellow-400' /> <Database
size={20}
className='text-yellow-700 dark:text-yellow-400'
/>
} }
isExpanded={expandedTabs.mediaLibrary} isExpanded={expandedTabs.mediaLibrary}
onToggle={() => toggleTab('mediaLibrary')} onToggle={() => toggleTab('mediaLibrary')}
@@ -14972,70 +16567,106 @@ function AdminPageClient() {
<CollapsibleTab <CollapsibleTab
title='Openlist配置' title='Openlist配置'
icon={ icon={
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' /> <FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.openListConfig} isExpanded={expandedTabs.openListConfig}
onToggle={() => toggleTab('openListConfig')} onToggle={() => toggleTab('openListConfig')}
> >
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} /> <OpenListConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* Emby 媒体库子标签 */} {/* Emby 媒体库子标签 */}
<CollapsibleTab <CollapsibleTab
title='Emby 媒体库' title='Emby 媒体库'
icon={ icon={
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' /> <FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.embyConfig} isExpanded={expandedTabs.embyConfig}
onToggle={() => toggleTab('embyConfig')} onToggle={() => toggleTab('embyConfig')}
> >
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} /> <EmbyConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* 小雅配置子标签 */} {/* 小雅配置子标签 */}
<CollapsibleTab <CollapsibleTab
title='小雅配置' title='小雅配置'
icon={ icon={
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' /> <FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.xiaoyaConfig} isExpanded={expandedTabs.xiaoyaConfig}
onToggle={() => toggleTab('xiaoyaConfig')} onToggle={() => toggleTab('xiaoyaConfig')}
> >
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} /> <XiaoyaConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* 求片管理子标签 */} {/* 求片管理子标签 */}
<CollapsibleTab <CollapsibleTab
title='求片管理' title='求片管理'
icon={ icon={
<Video size={20} className='text-gray-600 dark:text-gray-400' /> <Video
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.movieRequests} isExpanded={expandedTabs.movieRequests}
onToggle={() => toggleTab('movieRequests')} onToggle={() => toggleTab('movieRequests')}
> >
<MovieRequestsComponent config={config} refreshConfig={fetchConfig} /> <MovieRequestsComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* 追番订阅子标签 */} {/* 追番订阅子标签 */}
<CollapsibleTab <CollapsibleTab
title='追番订阅' title='追番订阅'
icon={ icon={
<Cat size={20} className='text-gray-600 dark:text-gray-400' /> <Cat
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.animeSubscription} isExpanded={expandedTabs.animeSubscription}
onToggle={() => toggleTab('animeSubscription')} onToggle={() => toggleTab('animeSubscription')}
> >
<AnimeSubscriptionComponent config={config} refreshConfig={fetchConfig} /> <AnimeSubscriptionComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
<CollapsibleTab <CollapsibleTab
title='网盘配置' title='网盘配置'
icon={ icon={
<Cloud size={20} className='text-gray-600 dark:text-gray-400' /> <Cloud
size={20}
className='text-gray-600 dark:text-gray-400'
/>
} }
isExpanded={expandedTabs.netDiskConfig} isExpanded={expandedTabs.netDiskConfig}
onToggle={() => toggleTab('netDiskConfig')} onToggle={() => toggleTab('netDiskConfig')}
> >
<NetDiskConfigComponent config={config} refreshConfig={fetchConfig} /> <NetDiskConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
</div> </div>
</CollapsibleTab> </CollapsibleTab>
@@ -15061,7 +16692,10 @@ function AdminPageClient() {
isExpanded={expandedTabs.emailConfig} isExpanded={expandedTabs.emailConfig}
onToggle={() => toggleTab('emailConfig')} onToggle={() => toggleTab('emailConfig')}
> >
<EmailConfigComponent config={config} refreshConfig={fetchConfig} /> <EmailConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab> </CollapsibleTab>
{/* 分类配置标签 */} {/* 分类配置标签 */}
+126 -27
View File
@@ -9,7 +9,18 @@ import { db } from '@/lib/db';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
// 支持的操作类型 // 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode' | 'update_weight'; type Action =
| 'add'
| 'disable'
| 'enable'
| 'delete'
| 'sort'
| 'batch_disable'
| 'batch_enable'
| 'batch_delete'
| 'toggle_proxy_mode'
| 'update_weight'
| 'batch_update_weights';
interface BaseBody { interface BaseBody {
action?: Action; action?: Action;
@@ -37,7 +48,19 @@ export async function POST(request: NextRequest) {
const username = authInfo.username; const username = authInfo.username;
// 基础校验 // 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode', 'update_weight']; const ACTIONS: Action[] = [
'add',
'disable',
'enable',
'delete',
'sort',
'batch_disable',
'batch_enable',
'batch_delete',
'toggle_proxy_mode',
'update_weight',
'batch_update_weights',
];
if (!username || !action || !ACTIONS.includes(action)) { if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
@@ -127,17 +150,17 @@ export async function POST(request: NextRequest) {
// 检查并清理用户组和用户的权限数组 // 检查并清理用户组和用户的权限数组
// 清理用户组权限 // 清理用户组权限
if (adminConfig.UserConfig.Tags) { if (adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags.forEach(tag => { adminConfig.UserConfig.Tags.forEach((tag) => {
if (tag.enabledApis) { if (tag.enabledApis) {
tag.enabledApis = tag.enabledApis.filter(api => api !== key); tag.enabledApis = tag.enabledApis.filter((api) => api !== key);
} }
}); });
} }
// 清理用户权限 // 清理用户权限
adminConfig.UserConfig.Users.forEach(user => { adminConfig.UserConfig.Users.forEach((user) => {
if (user.enabledApis) { if (user.enabledApis) {
user.enabledApis = user.enabledApis.filter(api => api !== key); user.enabledApis = user.enabledApis.filter((api) => api !== key);
} }
}); });
break; break;
@@ -145,9 +168,12 @@ export async function POST(request: NextRequest) {
case 'batch_disable': { case 'batch_disable': {
const { keys } = body as { keys?: string[] }; const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) { if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 }); return NextResponse.json(
{ error: '缺少 keys 参数或为空' },
{ status: 400 }
);
} }
keys.forEach(key => { keys.forEach((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry) { if (entry) {
entry.disabled = true; entry.disabled = true;
@@ -158,9 +184,12 @@ export async function POST(request: NextRequest) {
case 'batch_enable': { case 'batch_enable': {
const { keys } = body as { keys?: string[] }; const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) { if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 }); return NextResponse.json(
{ error: '缺少 keys 参数或为空' },
{ status: 400 }
);
} }
keys.forEach(key => { keys.forEach((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry) { if (entry) {
entry.disabled = false; entry.disabled = false;
@@ -171,13 +200,16 @@ export async function POST(request: NextRequest) {
case 'batch_delete': { case 'batch_delete': {
const { keys } = body as { keys?: string[] }; const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) { if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 }); return NextResponse.json(
{ error: '缺少 keys 参数或为空' },
{ status: 400 }
);
} }
// 过滤掉 from=config 的源,记录跳过的数量 // 过滤掉 from=config 的源,记录跳过的数量
const keysToDelete: string[] = []; const keysToDelete: string[] = [];
const skippedKeys: string[] = []; const skippedKeys: string[] = [];
keys.forEach(key => { keys.forEach((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry && entry.from === 'config') { if (entry && entry.from === 'config') {
skippedKeys.push(key); skippedKeys.push(key);
@@ -187,7 +219,7 @@ export async function POST(request: NextRequest) {
}); });
// 批量删除 // 批量删除
keysToDelete.forEach(key => { keysToDelete.forEach((key) => {
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key); const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
if (idx !== -1) { if (idx !== -1) {
adminConfig.SourceConfig.splice(idx, 1); adminConfig.SourceConfig.splice(idx, 1);
@@ -198,17 +230,21 @@ export async function POST(request: NextRequest) {
if (keysToDelete.length > 0) { if (keysToDelete.length > 0) {
// 清理用户组权限 // 清理用户组权限
if (adminConfig.UserConfig.Tags) { if (adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags.forEach(tag => { adminConfig.UserConfig.Tags.forEach((tag) => {
if (tag.enabledApis) { if (tag.enabledApis) {
tag.enabledApis = tag.enabledApis.filter(api => !keysToDelete.includes(api)); tag.enabledApis = tag.enabledApis.filter(
(api) => !keysToDelete.includes(api)
);
} }
}); });
} }
// 清理用户权限 // 清理用户权限
adminConfig.UserConfig.Users.forEach(user => { adminConfig.UserConfig.Users.forEach((user) => {
if (user.enabledApis) { if (user.enabledApis) {
user.enabledApis = user.enabledApis.filter(api => !keysToDelete.includes(api)); user.enabledApis = user.enabledApis.filter(
(api) => !keysToDelete.includes(api)
);
} }
}); });
} }
@@ -254,14 +290,80 @@ export async function POST(request: NextRequest) {
entry.proxyMode = !entry.proxyMode; entry.proxyMode = !entry.proxyMode;
break; break;
} }
case 'batch_update_weights': {
const { weights, order } = body as {
weights?: Array<{ key?: string; weight?: number }>;
order?: string[];
};
if (!Array.isArray(weights) || weights.length === 0) {
return NextResponse.json(
{ error: '缺少 weights 参数或为空' },
{ status: 400 }
);
}
for (const item of weights) {
if (!item?.key) {
return NextResponse.json(
{ error: 'weights 中存在无效 key' },
{ status: 400 }
);
}
if (
typeof item.weight !== 'number' ||
item.weight < 0 ||
item.weight > 100
) {
return NextResponse.json(
{ error: '权重必须是 0-100 之间的数字' },
{ status: 400 }
);
}
const entry = adminConfig.SourceConfig.find(
(source) => source.key === item.key
);
if (!entry) {
return NextResponse.json(
{ error: `源不存在: ${item.key}` },
{ status: 404 }
);
}
entry.weight = item.weight;
}
if (Array.isArray(order)) {
const map = new Map(
adminConfig.SourceConfig.map((source) => [source.key, source])
);
const newList: typeof adminConfig.SourceConfig = [];
order.forEach((key) => {
const item = map.get(key);
if (item) {
newList.push(item);
map.delete(key);
}
});
adminConfig.SourceConfig.forEach((item) => {
if (map.has(item.key)) newList.push(item);
});
adminConfig.SourceConfig = newList;
}
break;
}
case 'update_weight': { case 'update_weight': {
const { key, weight } = body as { key?: string; weight?: number }; const { key, weight } = body as { key?: string; weight?: number };
if (!key) if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 }); return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
if (weight === undefined || weight === null) if (weight === undefined || weight === null)
return NextResponse.json({ error: '缺少 weight 参数' }, { status: 400 }); return NextResponse.json(
{ error: '缺少 weight 参数' },
{ status: 400 }
);
if (typeof weight !== 'number' || weight < 0 || weight > 100) if (typeof weight !== 'number' || weight < 0 || weight > 100)
return NextResponse.json({ error: '权重必须是 0-100 之间的数字' }, { status: 400 }); return NextResponse.json(
{ error: '权重必须是 0-100 之间的数字' },
{ status: 400 }
);
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry) if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 }); return NextResponse.json({ error: '源不存在' }, { status: 404 });
@@ -293,14 +395,11 @@ export async function POST(request: NextRequest) {
responseData.skipped = (body as any)._batchDeleteResult.skipped; responseData.skipped = (body as any)._batchDeleteResult.skipped;
} }
return NextResponse.json( return NextResponse.json(responseData, {
responseData, headers: {
{ 'Cache-Control': 'no-store',
headers: { },
'Cache-Control': 'no-store', });
},
}
);
} catch (error) { } catch (error) {
console.error('视频源管理操作失败:', error); console.error('视频源管理操作失败:', error);
return NextResponse.json( return NextResponse.json(