增加可视化视频源权重设置
This commit is contained in:
+3317
-1683
@@ -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'>
|
||||||
支持 PNG、JPG、GIF、WebP 格式,建议尺寸 32x32px,图片URL必须可公开访问
|
支持 PNG、JPG、GIF、WebP 格式,建议尺寸
|
||||||
|
32x32px,图片URL必须可公开访问
|
||||||
</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 Endpoint(Token端点)
|
Token Endpoint(Token端点)
|
||||||
</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_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向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_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向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网站有效。设置为0时不判断,1-4表示最低信任等级要求
|
仅LinuxDo网站有效。设置为0时不判断,1-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.jpg、background.jpg)</p>
|
<p>• 支持 NFO 文件元数据(poster.jpg、background.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>
|
||||||
|
|
||||||
{/* 分类配置标签 */}
|
{/* 分类配置标签 */}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user