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

This commit is contained in:
mtvpls
2026-05-09 18:46:41 +08:00
parent e3f7d43592
commit c55b4bd044
2 changed files with 3443 additions and 1710 deletions
+2231 -597
View File
@@ -49,7 +49,16 @@ import {
Video,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
memo,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
@@ -239,10 +248,7 @@ const AlertModal = ({
</div>
) : (
// 普通提示:只显示确定按钮
<button
onClick={onClose}
className={buttonStyles.primary}
>
<button onClick={onClose} className={buttonStyles.primary}>
</button>
)
@@ -442,11 +448,13 @@ const CollapsibleTab = ({
isParent = false,
}: CollapsibleTabProps) => {
return (
<div className={`rounded-xl shadow-sm mb-4 overflow-hidden ${
<div
className={`rounded-xl shadow-sm mb-4 overflow-hidden ${
isParent
? '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
onClick={onToggle}
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'>
{icon}
<h3 className={`text-lg font-medium ${
<h3
className={`text-lg font-medium ${
isParent
? 'text-yellow-900 dark:text-yellow-200'
: 'text-gray-900 dark:text-gray-100'
}`}>
}`}
>
{title}
</h3>
</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} />}
</div>
</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>
);
};
@@ -496,7 +516,17 @@ interface UserConfigProps {
userListLoading: boolean;
}
const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalPages, userTotal, fetchUsersV2, userListLoading }: UserConfigProps) => {
const UserConfig = ({
config,
role,
refreshConfig,
usersV2,
userPage,
userTotalPages,
userTotal,
fetchUsersV2,
userListLoading,
}: UserConfigProps) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showAddUserForm, setShowAddUserForm] = useState(false);
@@ -557,7 +587,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
// 判断是否有旧版用户数据需要迁移
const hasOldUserData = config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ?? 0 > 0;
const hasOldUserData =
config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ??
0 > 0;
// 使用新版本用户列表(如果可用且没有旧数据),否则使用配置中的用户列表
const displayUsers: Array<{
@@ -568,7 +600,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
tags?: string[];
created_at?: number;
oidcSub?: string;
}> = !hasOldUserData && usersV2 ? usersV2 : (config?.UserConfig?.Users || []);
}> = !hasOldUserData && usersV2 ? usersV2 : config?.UserConfig?.Users || [];
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAllUsers = useMemo(() => {
@@ -1029,7 +1061,8 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
{/* 数据迁移提示 */}
{config.UserConfig.Users &&
config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && (
config.UserConfig.Users.filter((u) => u.role !== 'owner').length >
0 && (
<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 items-start justify-between'>
<div className='flex-1'>
@@ -1045,18 +1078,22 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
showAlert({
type: 'warning',
title: '确认迁移用户数据',
message: '迁移过程中请勿关闭页面。迁移完成后,所有用户密码将使用SHA256加密存储。',
message:
'迁移过程中请勿关闭页面。迁移完成后,所有用户密码将使用SHA256加密存储。',
showConfirm: true,
onConfirm: async () => {
hideAlert();
await withLoading('migrateUsers', async () => {
try {
const response = await fetch('/api/admin/migrate-users', {
const response = await fetch(
'/api/admin/migrate-users',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
}
);
if (!response.ok) {
const errorData = await response.json();
@@ -1075,7 +1112,8 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
showAlert({
type: 'error',
title: '迁移失败',
message: error.message || '迁移用户数据时发生错误',
message:
error.message || '迁移用户数据时发生错误',
});
}
});
@@ -1084,7 +1122,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
}}
disabled={isLoading('migrateUsers')}
className={`ml-4 ${buttonStyles.warning} ${
isLoading('migrateUsers') ? 'opacity-50 cursor-not-allowed' : ''
isLoading('migrateUsers')
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
{isLoading('migrateUsers') ? '迁移中...' : '立即迁移'}
@@ -1378,7 +1418,8 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
<div className='relative'>
{/* 迁移遮罩层 */}
{config.UserConfig.Users &&
config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && (
config.UserConfig.Users.filter((u) => u.role !== 'owner').length >
0 && (
<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='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='flex items-center gap-3 mb-4'>
@@ -1419,7 +1460,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
<input
type='checkbox'
checked={selectAllUsers}
onChange={(e) => handleSelectAllUsers(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'
/>
) : (
@@ -1472,7 +1515,10 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
return (
<tbody>
<tr>
<td colSpan={7} className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'>
<td
colSpan={7}
className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'
>
...
</td>
</tr>
@@ -1635,7 +1681,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() => handleSetAdmin(user.username)}
onClick={() =>
handleSetAdmin(user.username)
}
disabled={isLoading(
`setAdmin_${user.username}`
)}
@@ -1670,11 +1718,15 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() => handleBanUser(user.username)}
onClick={() =>
handleBanUser(user.username)
}
disabled={isLoading(
`banUser_${user.username}`
)}
className={`${buttonStyles.roundedDanger} ${
className={`${
buttonStyles.roundedDanger
} ${
isLoading(`banUser_${user.username}`)
? 'opacity-50 cursor-not-allowed'
: ''
@@ -1942,7 +1994,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
className={`px-6 py-2.5 text-sm font-medium ${
isLoading(`saveUserApis_${selectedUser?.username}`)
? buttonStyles.disabled
: buttonStyles.primary
: buttonStyles.success
}`}
>
{isLoading(`saveUserApis_${selectedUser?.username}`)
@@ -2038,17 +2090,24 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
>
<input
type='checkbox'
checked={newUserGroup.permissions.includes(permission.key)}
checked={newUserGroup.permissions.includes(
permission.key
)}
onChange={(e) => {
if (e.target.checked) {
setNewUserGroup((prev) => ({
...prev,
permissions: [...prev.permissions, permission.key],
permissions: [
...prev.permissions,
permission.key,
],
}));
} else {
setNewUserGroup((prev) => ({
...prev,
permissions: prev.permissions.filter((item) => item !== permission.key),
permissions: prev.permissions.filter(
(item) => item !== permission.key
),
}));
}
}}
@@ -2357,14 +2416,19 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
>
<input
type='checkbox'
checked={editingUserGroup.permissions.includes(permission.key)}
checked={editingUserGroup.permissions.includes(
permission.key
)}
onChange={(e) => {
if (e.target.checked) {
setEditingUserGroup((prev) =>
prev
? {
...prev,
permissions: [...prev.permissions, permission.key],
permissions: [
...prev.permissions,
permission.key,
],
}
: null
);
@@ -2373,7 +2437,9 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
prev
? {
...prev,
permissions: prev.permissions.filter((item) => item !== permission.key),
permissions: prev.permissions.filter(
(item) => item !== permission.key
),
}
: null
);
@@ -2580,7 +2646,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
`saveUserGroups_${selectedUserForGroup?.username}`
)
? buttonStyles.disabled
: buttonStyles.primary
: buttonStyles.success
}`}
>
{isLoading(
@@ -2961,7 +3027,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
className={`px-6 py-2.5 text-sm font-medium ${
isLoading('batchSetUserGroup')
? buttonStyles.disabled
: buttonStyles.primary
: buttonStyles.success
}`}
>
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
@@ -3005,7 +3071,9 @@ const OpenListConfigComponent = ({
const [rootPaths, setRootPaths] = useState<string[]>(['/']);
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
const [scanInterval, setScanInterval] = useState(0);
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid');
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>(
'hybrid'
);
const [disableVideoPreview, setDisableVideoPreview] = useState(false);
const [videos, setVideos] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
@@ -3023,16 +3091,27 @@ const OpenListConfigComponent = ({
setUrl(config.OpenListConfig.URL || '');
setUsername(config.OpenListConfig.Username || '');
setPassword(config.OpenListConfig.Password || '');
setRootPaths(config.OpenListConfig.RootPaths || (config.OpenListConfig.RootPath ? [config.OpenListConfig.RootPath] : ['/']));
setRootPaths(
config.OpenListConfig.RootPaths ||
(config.OpenListConfig.RootPath
? [config.OpenListConfig.RootPath]
: ['/'])
);
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
setScanInterval(config.OpenListConfig.ScanInterval || 0);
setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
setDisableVideoPreview(config.OpenListConfig.DisableVideoPreview || false);
setDisableVideoPreview(
config.OpenListConfig.DisableVideoPreview || false
);
}
}, [config]);
useEffect(() => {
if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password) {
if (
config?.OpenListConfig?.URL &&
config?.OpenListConfig?.Username &&
config?.OpenListConfig?.Password
) {
fetchVideos();
}
}, [config]);
@@ -3040,7 +3119,9 @@ const OpenListConfigComponent = ({
const fetchVideos = async (noCache = false) => {
try {
setRefreshing(true);
const url = `/api/openlist/list?page=1&pageSize=100&includeFailed=true${noCache ? '&noCache=true' : ''}`;
const url = `/api/openlist/list?page=1&pageSize=100&includeFailed=true${
noCache ? '&noCache=true' : ''
}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
@@ -3081,7 +3162,10 @@ const OpenListConfigComponent = ({
showSuccess('保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error;
}
});
@@ -3205,7 +3289,10 @@ const OpenListConfigComponent = ({
throw new Error(data.error || '连接失败');
}
} catch (error) {
showError(error instanceof Error ? error.message : '连接失败', showAlert);
showError(
error instanceof Error ? error.message : '连接失败',
showAlert
);
throw error;
}
});
@@ -3235,7 +3322,10 @@ const OpenListConfigComponent = ({
await fetchVideos(true); // 强制从数据库重新读取
refreshConfig(); // 异步刷新配置以更新资源数量(不等待,避免重复刷新)
} catch (error) {
showError(error instanceof Error ? error.message : '删除失败', showAlert);
showError(
error instanceof Error ? error.message : '删除失败',
showAlert
);
}
},
});
@@ -3269,12 +3359,20 @@ const OpenListConfigComponent = ({
</span>
</div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> OpenList 使</p>
<p> OpenList OneDrive </p>
<p> TMDB </p>
<p>
OpenList 使
</p>
<p>
OpenList
OneDrive
</p>
<p>
TMDB
</p>
<p> 0 60 </p>
<p> TMDB </p>
</div>
</div>
@@ -3433,7 +3531,9 @@ const OpenListConfigComponent = ({
</label>
<select
value={scanMode}
onChange={(e) => setScanMode(e.target.value as 'torrent' | 'name' | 'hybrid')}
onChange={(e) =>
setScanMode(e.target.value as 'torrent' | 'name' | 'hybrid')
}
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'
>
@@ -3459,7 +3559,9 @@ const OpenListConfigComponent = ({
onClick={() => setDisableVideoPreview(!disableVideoPreview)}
disabled={!enabled}
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' : ''}`}
>
<span
@@ -3473,7 +3575,13 @@ const OpenListConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleCheckConnectivity}
disabled={!enabled || !url || !username || !password || isLoading('checkOpenList')}
disabled={
!enabled ||
!url ||
!username ||
!password ||
isLoading('checkOpenList')
}
className={buttonStyles.primary}
>
{isLoading('checkOpenList') ? '检查中...' : '检查连通性'}
@@ -3489,7 +3597,10 @@ const OpenListConfigComponent = ({
</div>
{/* 视频列表区域 */}
{enabled && config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && (
{enabled &&
config?.OpenListConfig?.URL &&
config?.OpenListConfig?.Username &&
config?.OpenListConfig?.Password && (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
@@ -3497,10 +3608,13 @@ const OpenListConfigComponent = ({
</h3>
<div className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
<span>: {config.OpenListConfig.ResourceCount || 0}</span>
<span>
: {config.OpenListConfig.ResourceCount || 0}
</span>
<span className='mx-2'>|</span>
<span>
: {formatDate(config.OpenListConfig.LastRefreshTime)}
:{' '}
{formatDate(config.OpenListConfig.LastRefreshTime)}
</span>
</div>
</div>
@@ -3530,7 +3644,9 @@ const OpenListConfigComponent = ({
</span>
<span className='text-sm text-blue-700 dark:text-blue-300'>
{scanProgress.total > 0
? Math.round((scanProgress.current / scanProgress.total) * 100)
? Math.round(
(scanProgress.current / scanProgress.total) * 100
)
: 0}
%
</span>
@@ -3539,7 +3655,11 @@ const OpenListConfigComponent = ({
<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}%`,
width: `${
scanProgress.total > 0
? (scanProgress.current / scanProgress.total) * 100
: 0
}%`,
}}
/>
</div>
@@ -3585,7 +3705,12 @@ const OpenListConfigComponent = ({
</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' : ''}>
<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>
@@ -3605,7 +3730,12 @@ const OpenListConfigComponent = ({
</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}`}>
<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>
) : (
@@ -3613,10 +3743,14 @@ const OpenListConfigComponent = ({
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.releaseDate ? video.releaseDate.split('-')[0] : '-'}
{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) : '-'}
{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'>
@@ -3631,16 +3765,25 @@ const OpenListConfigComponent = ({
<button
onClick={() => {
console.log('Video object:', video);
console.log('Video poster field:', video.poster);
console.log(
'Video poster field:',
video.poster
);
setSelectedVideo(video);
setCorrectDialogOpen(true);
}}
className={video.failed ? buttonStyles.warningSmall : buttonStyles.successSmall}
className={
video.failed
? buttonStyles.warningSmall
: buttonStyles.successSmall
}
>
{video.failed ? '立即纠错' : '纠错'}
</button>
<button
onClick={() => handleDeleteVideo(video.id, video.title)}
onClick={() =>
handleDeleteVideo(video.id, video.title)
}
className={buttonStyles.dangerSmall}
>
@@ -3824,7 +3967,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '夸克 Cookie 可读', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -3852,7 +3998,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '移动云盘验证头格式正常', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -3880,7 +4029,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '百度网盘 Cookie 格式正常', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -3909,7 +4061,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '天翼云盘账号密码可用', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -3938,7 +4093,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '123网盘账号密码可用', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -3968,7 +4126,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || 'UC Cookie 可读', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -3996,7 +4157,10 @@ const NetDiskConfigComponent = ({
showSuccess(data.message || '115 Cookie 格式正常', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
showError(
error instanceof Error ? error.message : '校验失败',
showAlert
);
throw error;
}
});
@@ -4118,10 +4282,16 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleValidateMobile}
disabled={!mobileEnabled || !mobileAuthorization || isLoading('validateMobileNetDisk')}
disabled={
!mobileEnabled ||
!mobileAuthorization ||
isLoading('validateMobileNetDisk')
}
className={buttonStyles.primary}
>
{isLoading('validateMobileNetDisk') ? '校验中...' : '校验移动云盘验证头'}
{isLoading('validateMobileNetDisk')
? '校验中...'
: '校验移动云盘验证头'}
</button>
<button
onClick={handleSave}
@@ -4176,10 +4346,16 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleValidateBaidu}
disabled={!baiduEnabled || !baiduCookie || isLoading('validateBaiduNetDisk')}
disabled={
!baiduEnabled ||
!baiduCookie ||
isLoading('validateBaiduNetDisk')
}
className={buttonStyles.primary}
>
{isLoading('validateBaiduNetDisk') ? '校验中...' : '校验百度网盘 Cookie'}
{isLoading('validateBaiduNetDisk')
? '校验中...'
: '校验百度网盘 Cookie'}
</button>
<button
onClick={handleSave}
@@ -4252,10 +4428,17 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleValidateTianyi}
disabled={!tianyiEnabled || !tianyiAccount || !tianyiPassword || isLoading('validateTianyiNetDisk')}
disabled={
!tianyiEnabled ||
!tianyiAccount ||
!tianyiPassword ||
isLoading('validateTianyiNetDisk')
}
className={buttonStyles.primary}
>
{isLoading('validateTianyiNetDisk') ? '校验中...' : '校验天翼云盘账号密码'}
{isLoading('validateTianyiNetDisk')
? '校验中...'
: '校验天翼云盘账号密码'}
</button>
<button
onClick={handleSave}
@@ -4324,10 +4507,17 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleValidatePan123}
disabled={!pan123Enabled || !pan123Account || !pan123Password || isLoading('validatePan123NetDisk')}
disabled={
!pan123Enabled ||
!pan123Account ||
!pan123Password ||
isLoading('validatePan123NetDisk')
}
className={buttonStyles.primary}
>
{isLoading('validatePan123NetDisk') ? '校验中...' : '校验123网盘账号密码'}
{isLoading('validatePan123NetDisk')
? '校验中...'
: '校验123网盘账号密码'}
</button>
<button
onClick={handleSave}
@@ -4410,7 +4600,9 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleValidateUC}
disabled={!ucEnabled || !ucCookie || isLoading('validateUCNetDisk')}
disabled={
!ucEnabled || !ucCookie || isLoading('validateUCNetDisk')
}
className={buttonStyles.primary}
>
{isLoading('validateUCNetDisk') ? '校验中...' : '校验UC配置'}
@@ -4468,10 +4660,16 @@ const NetDiskConfigComponent = ({
<div className='flex gap-3'>
<button
onClick={handleValidatePan115}
disabled={!pan115Enabled || !pan115Cookie || isLoading('validatePan115NetDisk')}
disabled={
!pan115Enabled ||
!pan115Cookie ||
isLoading('validatePan115NetDisk')
}
className={buttonStyles.primary}
>
{isLoading('validatePan115NetDisk') ? '校验中...' : '校验115 Cookie'}
{isLoading('validatePan115NetDisk')
? '校验中...'
: '校验115 Cookie'}
</button>
<button
onClick={handleSave}
@@ -4513,7 +4711,9 @@ const EmbyConfigComponent = ({
const [sources, setSources] = useState<any[]>([]);
const [editingSource, setEditingSource] = useState<any | null>(null);
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({
@@ -4541,7 +4741,8 @@ const EmbyConfigComponent = ({
setSources(config.EmbyConfig.Sources);
} else if (config?.EmbyConfig?.ServerURL) {
// 兼容旧格式
setSources([{
setSources([
{
key: 'default',
name: 'Emby',
enabled: config.EmbyConfig.Enabled || false,
@@ -4551,7 +4752,8 @@ const EmbyConfigComponent = ({
Password: config.EmbyConfig.Password,
UserId: config.EmbyConfig.UserId,
isDefault: true,
}]);
},
]);
}
}, [config]);
@@ -4622,7 +4824,7 @@ const EmbyConfigComponent = ({
}
// 验证key唯一性
if (!editingSource && sources.some(s => s.key === formData.key)) {
if (!editingSource && sources.some((s) => s.key === formData.key)) {
showError('标识符已存在,请使用其他标识符', showAlert);
return;
}
@@ -4632,7 +4834,7 @@ const EmbyConfigComponent = ({
let newSources;
if (editingSource) {
// 更新现有源
newSources = sources.map(s =>
newSources = sources.map((s) =>
s.key === editingSource.key ? formData : s
);
} else {
@@ -4660,7 +4862,10 @@ const EmbyConfigComponent = ({
resetForm();
showSuccess(editingSource ? '更新成功' : '添加成功', showAlert);
} 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 () => {
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', {
method: 'POST',
@@ -4693,7 +4898,10 @@ const EmbyConfigComponent = ({
await refreshConfig();
showSuccess('删除成功', showAlert);
} 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) => {
await withLoading('toggleEmbySource', async () => {
try {
const newSources = sources.map(s =>
const newSources = sources.map((s) =>
s.key === source.key ? { ...s, enabled: !s.enabled } : s
);
@@ -4724,7 +4932,10 @@ const EmbyConfigComponent = ({
await refreshConfig();
showSuccess(source.enabled ? '已禁用' : '已启用', showAlert);
} 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);
}
} 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);
}
} 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);
showSuccess('导出成功', showAlert);
} 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);
}
} 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;
await withLoading('batchEnableEmby', async () => {
try {
const newSources = sources.map(s =>
const newSources = sources.map((s) =>
selectedSources.has(s.key) ? { ...s, enabled: true } : s
);
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }),
body: JSON.stringify({
...config,
EmbyConfig: { Sources: newSources },
}),
});
if (!response.ok) throw new Error('批量启用失败');
await refreshConfig();
setSelectedSources(new Set());
showSuccess(`已启用 ${selectedSources.size} 个源`, showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '批量启用失败', showAlert);
showError(
error instanceof Error ? error.message : '批量启用失败',
showAlert
);
}
});
};
@@ -4871,20 +5100,26 @@ const EmbyConfigComponent = ({
if (selectedSources.size === 0) return;
await withLoading('batchDisableEmby', async () => {
try {
const newSources = sources.map(s =>
const newSources = sources.map((s) =>
selectedSources.has(s.key) ? { ...s, enabled: false } : s
);
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }),
body: JSON.stringify({
...config,
EmbyConfig: { Sources: newSources },
}),
});
if (!response.ok) throw new Error('批量禁用失败');
await refreshConfig();
setSelectedSources(new Set());
showSuccess(`已禁用 ${selectedSources.size} 个源`, showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '批量禁用失败', showAlert);
showError(
error instanceof Error ? error.message : '批量禁用失败',
showAlert
);
}
});
};
@@ -4900,18 +5135,26 @@ const EmbyConfigComponent = ({
onConfirm: async () => {
await withLoading('batchDeleteEmby', async () => {
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', {
method: 'POST',
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('批量删除失败');
await refreshConfig();
setSelectedSources(new Set());
showSuccess(`已删除 ${selectedSources.size} 个源`, showAlert);
} 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})
</h3>
<div className='flex gap-2'>
<button
onClick={handleAdd}
className={buttonStyles.success}
>
<button onClick={handleAdd} className={buttonStyles.success}>
</button>
</div>
@@ -5045,7 +5285,11 @@ const EmbyConfigComponent = ({
<button
onClick={() => handleToggleEnabled(source)}
disabled={isLoading('toggleEmbySource')}
className={source.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall}
className={
source.enabled
? buttonStyles.warningSmall
: buttonStyles.successSmall
}
>
{source.enabled ? '禁用' : '启用'}
</button>
@@ -5092,7 +5336,9 @@ const EmbyConfigComponent = ({
<input
type='text'
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, key: e.target.value })
}
disabled={!!editingSource}
placeholder='home, office, etc.'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:bg-gray-100 dark:disabled:bg-gray-700'
@@ -5110,7 +5356,9 @@ const EmbyConfigComponent = ({
<input
type='text'
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder='家庭Emby, 公司Emby, etc.'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -5124,7 +5372,9 @@ const EmbyConfigComponent = ({
<input
type='text'
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'
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
type='password'
value={formData.ApiKey}
onChange={(e) => setFormData({ ...formData, ApiKey: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, ApiKey: e.target.value })
}
placeholder='输入 Emby API Key'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -5197,12 +5449,15 @@ const EmbyConfigComponent = ({
<input
type='text'
value={formData.UserId}
onChange={(e) => setFormData({ ...formData, UserId: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, UserId: e.target.value })
}
placeholder='aab507c58e874de6a9bd12388d72f4d2'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Emby ID URL /Users/[userId]/...
Emby ID URL
/Users/[userId]/...
</p>
</div>
</>
@@ -5219,7 +5474,9 @@ const EmbyConfigComponent = ({
<input
type='text'
value={formData.Username}
onChange={(e) => setFormData({ ...formData, Username: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, Username: e.target.value })
}
placeholder='Emby 用户名'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -5233,7 +5490,9 @@ const EmbyConfigComponent = ({
<input
type='password'
value={formData.Password}
onChange={(e) => setFormData({ ...formData, Password: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, Password: e.target.value })
}
placeholder='Emby 密码(如果账号没有密码可留空)'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -5250,9 +5509,13 @@ const EmbyConfigComponent = ({
</label>
<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 ${
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
@@ -5280,14 +5543,23 @@ const EmbyConfigComponent = ({
</p>
</div>
<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 ${
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
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>
@@ -5300,18 +5572,28 @@ const EmbyConfigComponent = ({
MediaSourceId参数
</label>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
PlaybackInfo API MediaSourceId
PlaybackInfo API MediaSourceId
</p>
</div>
<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 ${
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
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>
@@ -5328,9 +5610,16 @@ const EmbyConfigComponent = ({
</p>
</div>
<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 ${
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
@@ -5352,9 +5641,13 @@ const EmbyConfigComponent = ({
</p>
</div>
<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 ${
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
@@ -5373,7 +5666,12 @@ const EmbyConfigComponent = ({
<input
type='text'
value={formData.customUserAgent || ''}
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
onChange={(e) =>
setFormData({
...formData,
customUserAgent: e.target.value,
})
}
placeholder='留空使用默认浏览器UA'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-sm'
/>
@@ -5392,10 +5690,7 @@ const EmbyConfigComponent = ({
>
{isLoading('saveEmbySource') ? '保存中...' : '保存'}
</button>
<button
onClick={resetForm}
className={buttonStyles.secondary}
>
<button onClick={resetForm} className={buttonStyles.secondary}>
</button>
</div>
@@ -5480,6 +5775,10 @@ const VideoSourceConfig = ({
// 有效性检测相关状态
const [showValidationModal, setShowValidationModal] = useState(false);
const [showWeightModal, setShowWeightModal] = useState(false);
const [weightDraftSources, setWeightDraftSources] = useState<DataSource[]>(
[]
);
const [searchKeyword, setSearchKeyword] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [validationResults, setValidationResults] = useState<
@@ -5571,9 +5870,7 @@ const VideoSourceConfig = ({
// 更新本地状态
setSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, proxyMode: !s.proxyMode } : s
)
prev.map((s) => (s.key === key ? { ...s, proxyMode: !s.proxyMode } : s))
);
// 调用API更新
@@ -5615,9 +5912,7 @@ const VideoSourceConfig = ({
const handleUpdateWeight = (key: string, weight: number) => {
// 先乐观更新本地状态
setSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, weight } : s
)
prev.map((s) => (s.key === key ? { ...s, weight } : s))
);
// 调用API更新
@@ -5641,7 +5936,8 @@ const VideoSourceConfig = ({
await refreshConfig();
} catch (error) {
// 失败时回滚本地状态到配置中的值
const originalWeight = config?.SourceConfig?.find(s => s.key === key)?.weight ?? 0;
const originalWeight =
config?.SourceConfig?.find((s) => s.key === key)?.weight ?? 0;
setSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, weight: originalWeight } : s
@@ -5682,28 +5978,136 @@ const VideoSourceConfig = ({
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sources.findIndex((s) => s.key === active.id);
const newIndex = sources.findIndex((s) => s.key === over.id);
setSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
const buildRecommendedWeightMap = useCallback((list: DataSource[]) => {
const total = list.length;
return new Map(
list.map((source, index) => {
const recommended =
total <= 1
? 40
: Math.round(((total - index - 1) * 40) / (total - 1));
return [source.key, recommended];
})
);
}, []);
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 handleSaveOrder = () => {
const order = sources.map((s) => s.key);
withLoading('saveSourceOrder', () =>
callSourceApi({ action: 'sort', order })
)
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
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 () => {
if (!searchKeyword.trim()) {
@@ -5884,16 +6288,35 @@ const VideoSourceConfig = ({
}
};
// 权重输入组件 - 使用本地状态避免输入时失焦
const WeightInput = memo(({ sourceKey, initialWeight }: { sourceKey: string; initialWeight: number }) => {
const [localWeight, setLocalWeight] = useState(initialWeight);
const WeightModalInput = memo(
({ sourceKey, weight }: { sourceKey: string; weight: number }) => {
const [localWeight, setLocalWeight] = useState(weight);
// 当外部权重变化时同步
useEffect(() => {
setLocalWeight(initialWeight);
}, [initialWeight]);
setLocalWeight(weight);
}, [weight]);
const commitWeight = (value: number) => {
const clampedValue = Math.min(100, Math.max(0, value));
setLocalWeight(clampedValue);
handleWeightDraftChange(sourceKey, clampedValue);
};
return (
<div
className='flex items-center gap-3'
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<input
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'
@@ -5901,31 +6324,28 @@ const VideoSourceConfig = ({
max='100'
value={localWeight}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
const clampedValue = Math.min(100, Math.max(0, value));
const nextValue = parseInt(e.target.value) || 0;
const clampedValue = Math.min(100, Math.max(0, nextValue));
setLocalWeight(clampedValue);
}}
onBlur={(e) => {
const newValue = parseInt(e.target.value) || 0;
const clampedValue = Math.min(100, Math.max(0, newValue));
const originalWeight = config?.SourceConfig?.find(s => s.key === sourceKey)?.weight ?? 0;
// 只有在值发生变化时才调用API
if (clampedValue !== originalWeight) {
handleUpdateWeight(sourceKey, clampedValue);
}
}}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className='w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
title='权重范围:0-100,用于排序和优选评分'
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>
);
}
);
});
// 可拖拽行封装 (dnd-kit)
const DraggableRow = memo(({ source }: { source: DataSource }) => {
const WeightModalRow = memo(
({
source,
index,
recommendedWeight,
}: {
source: DataSource;
index: number;
recommendedWeight: number;
}) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: source.key });
@@ -5935,19 +6355,58 @@ const VideoSourceConfig = ({
} as React.CSSProperties;
return (
<tr
<div
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
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'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
<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} />
</td>
<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'>
<input
type='checkbox'
@@ -6010,9 +6469,6 @@ const VideoSourceConfig = ({
/>
</button>
</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]'>
{(() => {
const status = getValidationStatus(source.key);
@@ -6137,7 +6593,11 @@ const VideoSourceConfig = ({
);
// 根据操作类型和结果显示不同的消息
if (action === 'batch_delete' && result?.deleted !== undefined && result?.skipped !== undefined) {
if (
action === 'batch_delete' &&
result?.deleted !== undefined &&
result?.skipped !== undefined
) {
const { deleted, skipped } = result;
if (skipped > 0) {
showAlert({
@@ -6268,11 +6728,19 @@ const VideoSourceConfig = ({
<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
onClick={() => setShowValidationModal(true)}
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
}`}
>
@@ -6287,9 +6755,9 @@ const VideoSourceConfig = ({
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={
className={`${
showAddForm ? buttonStyles.secondary : buttonStyles.success
}
} shrink-0 whitespace-nowrap`}
>
{showAddForm ? '取消' : '添加视频源'}
</button>
@@ -6369,7 +6837,6 @@ const VideoSourceConfig = ({
<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'>
<tr>
<th className='w-8' />
<th className='w-12 px-2 py-3 text-center'>
<input
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>
<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>
@@ -6407,42 +6871,156 @@ const VideoSourceConfig = ({
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
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} />
<SourceRow key={source.key} source={source} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
disabled={isLoading('saveSourceOrder')}
className={`px-3 py-1.5 text-sm ${
isLoading('saveSourceOrder')
? buttonStyles.disabled
: buttonStyles.primary
}`}
{showWeightModal &&
createPortal(
<>
<div
className='fixed inset-0 bg-black/60 backdrop-blur-sm z-[10000]'
onClick={handleCloseWeightModal}
onTouchMove={(e) => {
e.preventDefault();
}}
onWheel={(e) => {
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
<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()}
>
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
<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
)}
{/* 有效性检测弹窗 */}
@@ -6486,7 +7064,7 @@ const VideoSourceConfig = ({
className={`px-4 py-2 ${
!searchKeyword.trim()
? buttonStyles.disabled
: buttonStyles.primary
: buttonStyles.success
}`}
>
@@ -6572,7 +7150,7 @@ const VideoSourceConfig = ({
isLoading('batchSource_batch_disable') ||
isLoading('batchSource_batch_delete')
? buttonStyles.disabled
: buttonStyles.primary
: buttonStyles.success
}`}
>
{isLoading('batchSource_batch_enable') ||
@@ -6904,7 +7482,6 @@ const CategoryConfig = ({
<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'>
<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>
@@ -7001,10 +7578,10 @@ const VideoSourceScriptLab = () => {
code: '',
enabled: true,
});
const [testHook, setTestHook] = useState<'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'>('getSources');
const [testPayload, setTestPayload] = useState(
JSON.stringify({}, null, 2)
);
const [testHook, setTestHook] = useState<
'getSources' | 'search' | 'recommend' | 'detail' | 'resolvePlayUrl'
>('getSources');
const [testPayload, setTestPayload] = useState(JSON.stringify({}, null, 2));
const [testOutput, setTestOutput] = useState('');
const importInputRef = useRef<HTMLInputElement | null>(null);
@@ -7068,7 +7645,10 @@ const VideoSourceScriptLab = () => {
setSelectedScriptId(null);
}
} catch (error) {
showError(error instanceof Error ? error.message : '加载脚本失败', showAlert);
showError(
error instanceof Error ? error.message : '加载脚本失败',
showAlert
);
} finally {
setLoadingScripts(false);
}
@@ -7212,7 +7792,10 @@ const VideoSourceScriptLab = () => {
showSuccess('脚本已删除', showAlert);
await loadScripts(null);
}).catch((error) => {
showError(error instanceof Error ? error.message : '删除失败', showAlert);
showError(
error instanceof Error ? error.message : '删除失败',
showAlert
);
});
},
});
@@ -7276,7 +7859,11 @@ const VideoSourceScriptLab = () => {
testHook === 'getSources'
? JSON.stringify({}, null, 2)
: testHook === 'search'
? JSON.stringify({ keyword: '凡人修仙传', page: 1, sourceId: 'main' }, null, 2)
? JSON.stringify(
{ keyword: '凡人修仙传', page: 1, sourceId: 'main' },
null,
2
)
: testHook === 'recommend'
? JSON.stringify({ page: 1 }, null, 2)
: testHook === 'detail'
@@ -7312,18 +7899,29 @@ const VideoSourceScriptLab = () => {
<button
onClick={() => importInputRef.current?.click()}
disabled={isLoading('importSourceScript')}
className={isLoading('importSourceScript') ? buttonStyles.disabledSmall : buttonStyles.primarySmall}
className={
isLoading('importSourceScript')
? buttonStyles.disabledSmall
: buttonStyles.primarySmall
}
>
</button>
<button
onClick={() => loadScripts(selectedScriptId)}
disabled={loadingScripts}
className={loadingScripts ? buttonStyles.disabledSmall : buttonStyles.secondarySmall}
className={
loadingScripts
? buttonStyles.disabledSmall
: buttonStyles.secondarySmall
}
>
</button>
<button onClick={handleCreateNew} className={buttonStyles.successSmall}>
<button
onClick={handleCreateNew}
className={buttonStyles.successSmall}
>
</button>
</div>
@@ -7331,7 +7929,9 @@ const VideoSourceScriptLab = () => {
<div className='space-y-3 max-h-[38rem] overflow-y-auto pr-1'>
{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 ? (
<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>
</div>
<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
onClick={(e) => {
e.stopPropagation();
handleToggleEnabled(script.id);
}}
disabled={isLoading(`toggleSourceScript_${script.id}`)}
className={script.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall}
className={
script.enabled
? buttonStyles.warningSmall
: buttonStyles.successSmall
}
>
{script.enabled ? '停用' : '启用'}
</button>
@@ -7394,14 +8000,18 @@ const VideoSourceScriptLab = () => {
type='text'
placeholder='脚本名称'
value={editor.name}
onChange={(e) => setEditor((prev) => ({ ...prev, name: e.target.value }))}
onChange={(e) =>
setEditor((prev) => ({ ...prev, name: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='脚本 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'
/>
</div>
@@ -7409,7 +8019,9 @@ const VideoSourceScriptLab = () => {
<textarea
placeholder='脚本描述(可选)'
value={editor.description}
onChange={(e) => setEditor((prev) => ({ ...prev, description: e.target.value }))}
onChange={(e) =>
setEditor((prev) => ({ ...prev, description: e.target.value }))
}
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'
/>
@@ -7425,7 +8037,9 @@ const VideoSourceScriptLab = () => {
</div>
<textarea
value={editor.code}
onChange={(e) => setEditor((prev) => ({ ...prev, code: e.target.value }))}
onChange={(e) =>
setEditor((prev) => ({ ...prev, code: e.target.value }))
}
rows={24}
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'
@@ -7436,18 +8050,29 @@ const VideoSourceScriptLab = () => {
<button
onClick={handleSave}
disabled={isLoading('saveSourceScript')}
className={isLoading('saveSourceScript') ? buttonStyles.disabled : buttonStyles.success}
className={
isLoading('saveSourceScript')
? buttonStyles.disabled
: buttonStyles.success
}
>
{isLoading('saveSourceScript') ? '保存中...' : '保存脚本'}
</button>
<button
onClick={handleTest}
disabled={isLoading('testSourceScript')}
className={isLoading('testSourceScript') ? buttonStyles.disabled : buttonStyles.primary}
className={
isLoading('testSourceScript')
? buttonStyles.disabled
: buttonStyles.primary
}
>
{isLoading('testSourceScript') ? '测试中...' : '运行测试'}
</button>
<button onClick={handleExportCurrent} className={buttonStyles.secondary}>
<button
onClick={handleExportCurrent}
className={buttonStyles.secondary}
>
</button>
<button onClick={handleDelete} className={buttonStyles.danger}>
@@ -7463,7 +8088,16 @@ const VideoSourceScriptLab = () => {
</label>
<select
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'
>
<option value='getSources'>getSources</option>
@@ -7920,9 +8554,15 @@ const ThemeConfigComponent = ({
progressThumbPresetId: '',
progressThumbCustomUrl: '',
});
const [loginBackgroundImages, setLoginBackgroundImages] = useState<string[]>(['']);
const [registerBackgroundImages, setRegisterBackgroundImages] = useState<string[]>(['']);
const [homeBackgroundImages, setHomeBackgroundImages] = useState<string[]>(['']);
const [loginBackgroundImages, setLoginBackgroundImages] = useState<string[]>([
'',
]);
const [registerBackgroundImages, setRegisterBackgroundImages] = useState<
string[]
>(['']);
const [homeBackgroundImages, setHomeBackgroundImages] = useState<string[]>([
'',
]);
useEffect(() => {
if (config?.ThemeConfig) {
@@ -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'
title='删除'
>
<svg 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
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>
</button>
)}
@@ -8327,11 +8977,23 @@ const ThemeConfigComponent = ({
))}
<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'
>
<svg 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
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>
<span>URL</span>
</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'
title='删除'
>
<svg 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
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>
</button>
)}
@@ -8377,11 +9049,23 @@ const ThemeConfigComponent = ({
))}
<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'
>
<svg 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
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>
<span>URL</span>
</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'
title='删除'
>
<svg 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
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>
</button>
)}
@@ -8427,11 +9121,23 @@ const ThemeConfigComponent = ({
))}
<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'
>
<svg 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
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>
<span>URL</span>
</button>
@@ -8467,9 +9173,7 @@ const ThemeConfigComponent = ({
}
className='w-4 h-4 text-blue-600'
/>
<span className='text-gray-900 dark:text-gray-100'>
</span>
<span className='text-gray-900 dark:text-gray-100'></span>
</label>
<label className='flex items-center space-x-3 cursor-pointer'>
<input
@@ -8483,9 +9187,7 @@ const ThemeConfigComponent = ({
}
className='w-4 h-4 text-blue-600'
/>
<span className='text-gray-900 dark:text-gray-100'>
</span>
<span className='text-gray-900 dark:text-gray-100'></span>
</label>
<label className='flex items-center space-x-3 cursor-pointer'>
<input
@@ -8499,9 +9201,7 @@ const ThemeConfigComponent = ({
}
className='w-4 h-4 text-blue-600'
/>
<span className='text-gray-900 dark:text-gray-100'>
</span>
<span className='text-gray-900 dark:text-gray-100'></span>
</label>
</div>
@@ -8513,9 +9213,24 @@ const ThemeConfigComponent = ({
</label>
<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: 'emilia', name: '爱蜜莉雅', url: '/icons/q/emilia.png', color: '#f8fafc' },
{
id: 'renako',
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) => (
<button
key={thumb.id}
@@ -8538,7 +9253,8 @@ const ThemeConfigComponent = ({
alt={thumb.name}
className='w-12 h-12 object-contain'
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'>
@@ -8580,11 +9296,14 @@ const ThemeConfigComponent = ({
}
/>
<p className='text-xs text-gray-500 dark:text-gray-400'>
PNGJPGGIFWebP 32x32pxURL必须可公开访问
PNGJPGGIFWebP
32x32pxURL必须可公开访问
</p>
{themeSettings.progressThumbCustomUrl && (
<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
src={themeSettings.progressThumbCustomUrl}
alt='自定义图标预览'
@@ -8722,8 +9441,14 @@ const SiteConfigComponent = ({
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'baidu', 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:
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false,
DanmakuAutoLoadDefault:
config.SiteConfig.DanmakuAutoLoadDefault !== false,
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
TMDBProxy: config.SiteConfig.TMDBProxy || '',
TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban',
RecommendationDataSource: config.SiteConfig.RecommendationDataSource || 'Mixed',
RecommendationDataSource:
config.SiteConfig.RecommendationDataSource || 'Mixed',
PansouApiUrl: config.SiteConfig.PansouApiUrl || '',
PansouUsername: config.SiteConfig.PansouUsername || '',
PansouPassword: config.SiteConfig.PansouPassword || '',
PansouKeywordBlocklist: config.SiteConfig.PansouKeywordBlocklist || '',
MagnetProxy: config.SiteConfig.MagnetProxy || '',
MagnetMikanReverseProxy: config.SiteConfig.MagnetMikanReverseProxy || '',
MagnetMikanReverseProxy:
config.SiteConfig.MagnetMikanReverseProxy || '',
MagnetDmhyReverseProxy: config.SiteConfig.MagnetDmhyReverseProxy || '',
MagnetAcgripReverseProxy: config.SiteConfig.MagnetAcgripReverseProxy || '',
MagnetAcgripReverseProxy:
config.SiteConfig.MagnetAcgripReverseProxy || '',
EnableComments: config.SiteConfig.EnableComments || false,
});
}
@@ -9345,7 +10074,8 @@ const SiteConfigComponent = ({
{siteSettings.DanmakuSourceType !== 'custom' && (
<p className='text-xs text-amber-600 dark:text-amber-400'>
使使
使使
</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'
/>
<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
href='https://www.themoviedb.org/settings/api'
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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
访Cloudflare 使
访Cloudflare
使
</p>
</div>
@@ -9732,7 +10465,9 @@ const SiteConfigComponent = ({
</label>
<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 ${
siteSettings.EnableComments
? buttonStyles.toggleOn
@@ -9867,7 +10602,8 @@ const RegistrationConfigComponent = ({
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false);
const [showEnableRegistrationModal, setShowEnableRegistrationModal] =
useState(false);
const [registrationSettings, setRegistrationSettings] = useState<{
EnableRegistration: boolean;
RequireRegistrationInviteCode: boolean;
@@ -9912,17 +10648,21 @@ const RegistrationConfigComponent = ({
if (config?.SiteConfig) {
setRegistrationSettings({
EnableRegistration: config.SiteConfig.EnableRegistration || false,
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
RequireRegistrationInviteCode:
config.SiteConfig.RequireRegistrationInviteCode || false,
RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '',
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
RegistrationRequireTurnstile:
config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
TurnstileSecretKey: config.SiteConfig.TurnstileSecretKey || '',
DefaultUserTags: config.SiteConfig.DefaultUserTags || [],
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
EnableOIDCRegistration:
config.SiteConfig.EnableOIDCRegistration || false,
OIDCIssuer: config.SiteConfig.OIDCIssuer || '',
OIDCAuthorizationEndpoint: config.SiteConfig.OIDCAuthorizationEndpoint || '',
OIDCAuthorizationEndpoint:
config.SiteConfig.OIDCAuthorizationEndpoint || '',
OIDCTokenEndpoint: config.SiteConfig.OIDCTokenEndpoint || '',
OIDCUserInfoEndpoint: config.SiteConfig.OIDCUserInfoEndpoint || '',
OIDCClientId: config.SiteConfig.OIDCClientId || '',
@@ -9973,7 +10713,8 @@ const RegistrationConfigComponent = ({
const updatedSiteConfig = {
...config.SiteConfig,
...registrationSettings,
RegistrationInviteCode: registrationSettings.RegistrationInviteCode.trim(),
RegistrationInviteCode:
registrationSettings.RegistrationInviteCode.trim(),
};
const resp = await fetch('/api/admin/site', {
@@ -10012,7 +10753,10 @@ const RegistrationConfigComponent = ({
</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>
@@ -10024,7 +10768,11 @@ const RegistrationConfigComponent = ({
</label>
<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 ${
registrationSettings.EnableRegistration
? buttonStyles.toggleOn
@@ -10052,7 +10800,12 @@ const RegistrationConfigComponent = ({
</label>
<select
value={registrationSettings.DefaultUserTags && registrationSettings.DefaultUserTags.length > 0 ? registrationSettings.DefaultUserTags[0] : ''}
value={
registrationSettings.DefaultUserTags &&
registrationSettings.DefaultUserTags.length > 0
? registrationSettings.DefaultUserTags[0]
: ''
}
onChange={(e) => {
const value = e.target.value;
setRegistrationSettings((prev) => ({
@@ -10063,7 +10816,8 @@ 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'
>
<option value=''></option>
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => (
{config?.UserConfig?.Tags &&
config.UserConfig.Tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
{tag.enabledApis && tag.enabledApis.length > 0
@@ -10094,7 +10848,8 @@ const RegistrationConfigComponent = ({
onClick={() =>
setRegistrationSettings((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 ${
@@ -10147,15 +10902,20 @@ const RegistrationConfigComponent = ({
</label>
<button
type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
disabled={
!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
}
onClick={() =>
setRegistrationSettings((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 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.RegistrationRequireTurnstile
? buttonStyles.toggleOn
@@ -10175,8 +10935,12 @@ const RegistrationConfigComponent = ({
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span>
{(!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'>
{' '}
Site Key和Secret Key才能启用
</span>
)}
</p>
</div>
@@ -10188,7 +10952,10 @@ const RegistrationConfigComponent = ({
</label>
<button
type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
disabled={
!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
}
onClick={() =>
setRegistrationSettings((prev) => ({
...prev,
@@ -10196,7 +10963,8 @@ 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 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.LoginRequireTurnstile
? buttonStyles.toggleOn
@@ -10216,8 +10984,12 @@ const RegistrationConfigComponent = ({
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span>
{(!registrationSettings.TurnstileSiteKey ||
!registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'>
{' '}
Site Key和Secret Key才能启用
</span>
)}
</p>
</div>
@@ -10376,7 +11148,9 @@ const RegistrationConfigComponent = ({
const res = await fetch('/api/admin/oidc-discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ issuerUrl: registrationSettings.OIDCIssuer }),
body: JSON.stringify({
issuerUrl: registrationSettings.OIDCIssuer,
}),
});
if (!res.ok) {
@@ -10387,20 +11161,28 @@ const RegistrationConfigComponent = ({
const data = await res.json();
setRegistrationSettings((prev) => ({
...prev,
OIDCAuthorizationEndpoint: data.authorization_endpoint || '',
OIDCAuthorizationEndpoint:
data.authorization_endpoint || '',
OIDCTokenEndpoint: data.token_endpoint || '',
OIDCUserInfoEndpoint: data.userinfo_endpoint || '',
}));
showSuccess('自动发现成功', showAlert);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '自动发现失败,请手动配置端点';
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`}
className={`px-4 py-2 ${
isLoading('oidcDiscover')
? buttonStyles.disabled
: buttonStyles.primary
} rounded-lg whitespace-nowrap sm:w-auto w-full`}
>
{isLoading('oidcDiscover') ? '发现中...' : '自动发现'}
</button>
@@ -10531,7 +11313,10 @@ const RegistrationConfigComponent = ({
readOnly
value={
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
}/api/auth/oidc/callback`
: ''
}
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'
@@ -10539,7 +11324,10 @@ const RegistrationConfigComponent = ({
<button
type='button'
onClick={() => {
const uri = `${(window as any).RUNTIME_CONFIG?.SITE_BASE || window.location.origin}/api/auth/oidc/callback`;
const uri = `${
(window as any).RUNTIME_CONFIG?.SITE_BASE ||
window.location.origin
}/api/auth/oidc/callback`;
navigator.clipboard.writeText(uri);
showSuccess('已复制到剪贴板', showAlert);
}}
@@ -10585,11 +11373,16 @@ const RegistrationConfigComponent = ({
min='0'
max='4'
placeholder='0'
value={registrationSettings.OIDCMinTrustLevel === 0 ? '' : registrationSettings.OIDCMinTrustLevel}
value={
registrationSettings.OIDCMinTrustLevel === 0
? ''
: registrationSettings.OIDCMinTrustLevel
}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
OIDCMinTrustLevel: e.target.value === '' ? 0 : parseInt(e.target.value),
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'
@@ -10989,7 +11782,9 @@ const SuwayomiConfigComponent = ({
const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false);
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 [password, setPassword] = useState('');
const [defaultLang, setDefaultLang] = useState('zh');
@@ -11016,7 +11811,10 @@ const SuwayomiConfigComponent = ({
Username: authMode === 'none' ? '' : username,
Password: authMode === 'none' ? '' : password,
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),
});
@@ -11042,7 +11840,10 @@ const SuwayomiConfigComponent = ({
showSuccess('漫画后端配置已保存', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error;
}
});
@@ -11070,7 +11871,10 @@ const SuwayomiConfigComponent = ({
showSuccess(data.message || '连接成功', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
showError(
error instanceof Error ? error.message : '测试连接失败',
showAlert
);
throw error;
}
});
@@ -11083,8 +11887,14 @@ const SuwayomiConfigComponent = ({
/ Suwayomi
</h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> Suwayomi Server GraphQL </p>
<p> basic_auth simple_login</p>
<p>
Suwayomi Server GraphQL
</p>
<p>
basic_auth
simple_login
</p>
<p> </p>
<p> 使</p>
</div>
@@ -11093,21 +11903,31 @@ const SuwayomiConfigComponent = ({
<div className='space-y-4'>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'></p>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
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
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>
</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
type='text'
value={serverURL}
@@ -11115,11 +11935,15 @@ const SuwayomiConfigComponent = ({
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'
/>
<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>
<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'>
{[
{ value: 'none', label: '无认证' },
@@ -11129,7 +11953,11 @@ const SuwayomiConfigComponent = ({
<button
key={item.value}
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 ${
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'
@@ -11141,14 +11969,17 @@ const SuwayomiConfigComponent = ({
))}
</div>
<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>
</div>
{authMode !== 'none' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<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
type='text'
value={username}
@@ -11158,7 +11989,9 @@ const SuwayomiConfigComponent = ({
/>
</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
type='password'
value={password}
@@ -11172,7 +12005,9 @@ const SuwayomiConfigComponent = ({
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<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
type='text'
value={defaultLang}
@@ -11182,7 +12017,9 @@ const SuwayomiConfigComponent = ({
/>
</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
type='number'
min='1'
@@ -11194,7 +12031,9 @@ const SuwayomiConfigComponent = ({
</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
value={sourceIds}
onChange={(e) => setSourceIds(e.target.value)}
@@ -11235,7 +12074,6 @@ const SuwayomiConfigComponent = ({
);
};
const OPDSConfigComponent = ({
config,
refreshConfig,
@@ -11282,7 +12120,9 @@ const OPDSConfigComponent = ({
}, [sources.length]);
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 = () => {
@@ -11326,10 +12166,13 @@ const OPDSConfigComponent = ({
authMode: source.authMode || 'none',
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
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 || '' : '',
searchTemplate: source.searchTemplate?.trim() || '',
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
preferFormat: source.preferFormat?.length
? source.preferFormat
: ['epub', 'pdf'],
language: source.language?.trim() || '',
});
@@ -11358,7 +12201,10 @@ const OPDSConfigComponent = ({
showSuccess('电子书 OPDS 配置已保存', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error;
}
});
@@ -11386,11 +12232,20 @@ const OPDSConfigComponent = ({
}
const result = Array.isArray(data.results) ? data.results[0] : null;
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 || '测试成功';
showSuccess(summary, showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
showError(
error instanceof Error ? error.message : '测试连接失败',
showAlert
);
throw error;
}
});
@@ -11399,43 +12254,68 @@ const OPDSConfigComponent = ({
return (
<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'>
<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'>
<p> </p>
<p> </p>
<p>
</p>
<p> EPUB 线PDF </p>
</div>
</div>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> OPDS </p>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
OPDS
</p>
</div>
<button
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>
</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
type='number'
min='60000'
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'
/>
</div>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<button type='button' onClick={addSource} className={buttonStyles.primary}>
<Plus size={16} className='inline mr-1' />
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
</h3>
<button
type='button'
onClick={addSource}
className={buttonStyles.primary}
>
<Plus size={16} className='inline mr-1' />
</button>
</div>
@@ -11451,7 +12331,10 @@ const OPDSConfigComponent = ({
{sources.map((source, index) => {
const isEditing = editingIndex === index;
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='flex items-start justify-between gap-3'>
<div className='min-w-0 flex-1'>
@@ -11464,28 +12347,62 @@ const OPDSConfigComponent = ({
</div>
<button
type='button'
onClick={() => 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'}`}
onClick={() =>
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>
</div>
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
<div className='flex items-start justify-between gap-3'>
<span className='shrink-0 text-gray-500 dark:text-gray-400'></span>
<span className='min-w-0 text-right break-all'>{source.url || '-'}</span>
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
</span>
<span className='min-w-0 text-right break-all'>
{source.url || '-'}
</span>
</div>
<div className='flex items-center justify-between gap-3'>
<span className='text-gray-500 dark:text-gray-400'></span>
<span>{source.authMode === 'none' ? '无认证' : source.authMode === 'basic' ? 'Basic Auth' : '自定义 Header'}</span>
<span className='text-gray-500 dark:text-gray-400'>
</span>
<span>
{source.authMode === 'none'
? '无认证'
: source.authMode === 'basic'
? 'Basic Auth'
: '自定义 Header'}
</span>
</div>
<div className='flex items-center justify-between gap-3'>
<span className='text-gray-500 dark:text-gray-400'></span>
<span>{source.searchTemplate?.trim() ? '已配置' : '未配置'}</span>
<span className='text-gray-500 dark:text-gray-400'>
</span>
<span>
{source.searchTemplate?.trim()
? '已配置'
: '未配置'}
</span>
</div>
<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>
</div>
</div>
@@ -11497,69 +12414,175 @@ const OPDSConfigComponent = ({
disabled={isLoading(`testOPDSConfig-${index}`)}
className={buttonStyles.primarySmall}
>
{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'}
{isLoading(`testOPDSConfig-${index}`)
? '测试中...'
: '测试'}
</button>
<button
type='button'
onClick={() => setEditingIndex(isEditing ? null : index)}
onClick={() =>
setEditingIndex(isEditing ? null : index)
}
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 type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>
<Trash2 size={14} className='inline mr-1' />
<button
type='button'
onClick={() => removeSource(index)}
className={buttonStyles.dangerSmall}
>
<Trash2 size={14} className='inline mr-1' />
</button>
</div>
</div>
{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='text-sm font-medium text-gray-900 dark:text-white'> #{index + 1}</div>
<div className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'> 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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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 className='text-sm font-medium text-gray-900 dark:text-white'>
#{index + 1}
</div>
<div className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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'>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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 className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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='basic'>Basic Auth</option>
<option value='header'> Header</option>
</select>
</div>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
{source.authMode === 'basic' && (
<div className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
)}
@@ -11567,12 +12590,34 @@ const OPDSConfigComponent = ({
{source.authMode === 'header' && (
<div className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
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>
)}
@@ -11588,14 +12633,30 @@ const OPDSConfigComponent = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-800/70'>
<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>
<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 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>
<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'>
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>
</thead>
<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'>
<button
type='button'
onClick={() => 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'}`}
onClick={() =>
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>
</td>
<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='mt-1 text-xs text-gray-500 dark:text-gray-400'>{source.language || '未设置语言'}</div>
</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>{source.id || '-'}</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
<div className='max-w-[320px] truncate' title={source.url || ''}>{source.url || '-'}</div>
<div className='font-medium'>
{source.name || `书源 ${index + 1}`}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{source.language || '未设置语言'}
</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'}
{source.id || '-'}
</td>
<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 className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>{source.preferFormat?.join(', ') || '-'}</td>
<td className='px-4 py-3'>
<div className='flex flex-wrap items-center justify-end gap-2'>
<button
type='button'
onClick={() => handleTest(index)}
disabled={isLoading(`testOPDSConfig-${index}`)}
disabled={isLoading(
`testOPDSConfig-${index}`
)}
className={buttonStyles.primarySmall}
>
{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'}
{isLoading(`testOPDSConfig-${index}`)
? '测试中...'
: '测试'}
</button>
<button
type='button'
onClick={() => setEditingIndex(isEditing ? null : index)}
onClick={() =>
setEditingIndex(isEditing ? null : index)
}
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 type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>
<Trash2 size={14} className='inline mr-1' />
<button
type='button'
onClick={() => removeSource(index)}
className={buttonStyles.dangerSmall}
>
<Trash2 size={14} className='inline mr-1' />
</button>
</div>
</td>
@@ -11654,66 +12775,172 @@ const OPDSConfigComponent = ({
{isEditing && (
<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='flex items-center justify-between gap-3'>
<div>
<div className='text-sm font-medium text-gray-900 dark:text-white'> #{index + 1}</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'></div>
<div className='text-sm font-medium text-gray-900 dark:text-white'>
#{index + 1}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</div>
</div>
<button
type='button'
onClick={() => setEditingIndex(null)}
className={buttonStyles.secondarySmall}
>
<ChevronUp size={14} className='inline mr-1' />
<ChevronUp
size={14}
className='inline mr-1'
/>
</button>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'> 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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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 className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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'>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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='basic'>Basic Auth</option>
<option value='header'> Header</option>
<option value='basic'>
Basic Auth
</option>
<option value='header'>
Header
</option>
</select>
</div>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
{source.authMode === 'basic' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'></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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</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>
)}
@@ -11721,12 +12948,34 @@ const OPDSConfigComponent = ({
{source.authMode === 'header' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
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>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>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' />
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
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>
)}
@@ -11746,7 +12995,11 @@ const OPDSConfigComponent = ({
</div>
<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 配置'}
</button>
</div>
@@ -11816,7 +13069,10 @@ const XiaoyaConfigComponent = ({
showSuccess('保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error;
}
});
@@ -11844,7 +13100,10 @@ const XiaoyaConfigComponent = ({
showError(data.message || '连接失败', showAlert);
}
} catch (error) {
showError(error instanceof Error ? error.message : '连接失败', showAlert);
showError(
error instanceof Error ? error.message : '连接失败',
showAlert
);
throw error;
}
});
@@ -11858,7 +13117,9 @@ const XiaoyaConfigComponent = ({
</h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> Alist </p>
<p> TMDb ID () {'{tmdb-id}'}</p>
<p>
TMDb ID () {'{tmdb-id}'}
</p>
<p> NFO poster.jpgbackground.jpg</p>
<p> </p>
</div>
@@ -11956,7 +13217,9 @@ const XiaoyaConfigComponent = ({
<button
onClick={() => setDisableVideoPreview(!disableVideoPreview)}
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
@@ -12053,18 +13316,24 @@ const EmailConfigComponent = ({
const emailConfig: AdminConfig['EmailConfig'] = {
enabled,
provider,
smtp: provider === 'smtp' ? {
smtp:
provider === 'smtp'
? {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
user: smtpUser,
password: smtpPassword,
from: smtpFrom,
} : undefined,
resend: provider === 'resend' ? {
}
: undefined,
resend:
provider === 'resend'
? {
apiKey: resendApiKey,
from: resendFrom,
} : undefined,
}
: undefined,
};
const response = await fetch('/api/admin/email', {
@@ -12084,7 +13353,10 @@ const EmailConfigComponent = ({
showSuccess('保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error;
}
});
@@ -12101,18 +13373,24 @@ const EmailConfigComponent = ({
const emailConfig: AdminConfig['EmailConfig'] = {
enabled: true,
provider,
smtp: provider === 'smtp' ? {
smtp:
provider === 'smtp'
? {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
user: smtpUser,
password: smtpPassword,
from: smtpFrom,
} : undefined,
resend: provider === 'resend' ? {
}
: undefined,
resend:
provider === 'resend'
? {
apiKey: resendApiKey,
from: resendFrom,
} : undefined,
}
: undefined,
};
const response = await fetch('/api/admin/email', {
@@ -12132,7 +13410,10 @@ const EmailConfigComponent = ({
showError(data.error || '发送失败', showAlert);
}
} catch (error) {
showError(error instanceof Error ? error.message : '发送失败', showAlert);
showError(
error instanceof Error ? error.message : '发送失败',
showAlert
);
throw error;
}
});
@@ -12190,7 +13471,9 @@ const EmailConfigComponent = ({
onChange={(e) => setProvider(e.target.value as 'smtp')}
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 className='flex items-center'>
<input
@@ -12200,7 +13483,9 @@ const EmailConfigComponent = ({
onChange={(e) => setProvider(e.target.value as 'resend')}
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>
</div>
</div>
@@ -12208,7 +13493,9 @@ const EmailConfigComponent = ({
{/* 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'>
<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>
@@ -12294,7 +13581,9 @@ const EmailConfigComponent = ({
{/* 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'>
<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>
<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'
/>
<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>
</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 { isLoading, withLoading } = useLoadingState();
const [requests, setRequests] = useState<any[]>([]);
@@ -12389,8 +13693,12 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const [loading, setLoading] = useState(true);
// 求片功能设置
const [enableMovieRequest, setEnableMovieRequest] = useState(config?.SiteConfig?.EnableMovieRequest ?? true);
const [movieRequestCooldown, setMovieRequestCooldown] = useState(config?.SiteConfig?.MovieRequestCooldown ?? 3600);
const [enableMovieRequest, setEnableMovieRequest] = useState(
config?.SiteConfig?.EnableMovieRequest ?? true
);
const [movieRequestCooldown, setMovieRequestCooldown] = useState(
config?.SiteConfig?.MovieRequestCooldown ?? 3600
);
const [savingSettings, setSavingSettings] = useState(false);
useEffect(() => {
@@ -12403,8 +13711,12 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const response = await fetch('/api/movie-requests');
const data = await response.json();
const allRequests = data.requests || [];
setPendingCount(allRequests.filter((r: any) => r.status === 'pending').length);
setFulfilledCount(allRequests.filter((r: any) => r.status === 'fulfilled').length);
setPendingCount(
allRequests.filter((r: any) => r.status === 'pending').length
);
setFulfilledCount(
allRequests.filter((r: any) => r.status === 'fulfilled').length
);
} catch (error) {
console.error('加载求片数量失败:', error);
}
@@ -12413,7 +13725,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const loadRequests = async () => {
setLoading(true);
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();
setRequests(data.requests || []);
} catch (error) {
@@ -12443,7 +13757,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const handleDelete = async (id: string) => {
await withLoading(`delete_${id}`, async () => {
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('删除失败');
showSuccess('删除成功', showAlert);
await loadRequests();
@@ -12488,7 +13804,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
<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'>
<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='flex items-center justify-between'>
<div>
@@ -12521,12 +13839,18 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
type='number'
min='0'
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'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{movieRequestCooldown >= 3600
? `${Math.floor(movieRequestCooldown / 3600)} 小时 ${Math.floor((movieRequestCooldown % 3600) / 60)} 分钟`
? `${Math.floor(
movieRequestCooldown / 3600
)} ${Math.floor(
(movieRequestCooldown % 3600) / 60
)} `
: movieRequestCooldown >= 60
? `${Math.floor(movieRequestCooldown / 60)} 分钟`
: `${movieRequestCooldown}`}
@@ -12545,7 +13869,9 @@ 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'>
<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'>
<button
onClick={() => setFilter('pending')}
@@ -12615,7 +13941,9 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
disabled={isLoading(`fulfill_${req.id}`)}
className={buttonStyles.successSmall}
>
{isLoading(`fulfill_${req.id}`) ? '处理中...' : '标记已上架'}
{isLoading(`fulfill_${req.id}`)
? '处理中...'
: '标记已上架'}
</button>
)}
<button
@@ -12670,7 +13998,9 @@ const AIConfigComponent = ({
// 联网搜索配置
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 [serperApiKey, setSerperApiKey] = useState('');
const [serpApiKey, setSerpApiKey] = useState('');
@@ -12758,7 +14088,10 @@ const AIConfigComponent = ({
showSuccess('AI配置保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
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'>
<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>
</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'
/>
<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>
</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'
/>
<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>
</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'
/>
<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>
</div>
)}
@@ -13006,12 +14365,39 @@ const AIConfigComponent = ({
</h4>
{[
{ key: 'homepage', label: '首页入口', 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 },
{
key: 'homepage',
label: '页入口',
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) => (
<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 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{item.label}
@@ -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'
/>
<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>
</div>
</div>
@@ -13153,7 +14543,11 @@ const AIConfigComponent = ({
<button
onClick={handleSave}
disabled={isLoading('saveAIConfig')}
className={isLoading('saveAIConfig') ? buttonStyles.disabled : buttonStyles.success}
className={
isLoading('saveAIConfig')
? buttonStyles.disabled
: buttonStyles.success
}
>
{isLoading('saveAIConfig') ? '保存中...' : '保存配置'}
</button>
@@ -13225,7 +14619,10 @@ const MusicConfigComponent = ({
showSuccess('音乐配置保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
showError(
error instanceof Error ? error.message : '保存失败',
showAlert
);
throw error;
}
});
@@ -13253,9 +14650,24 @@ const MusicConfigComponent = ({
</span>
</div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> lxserver </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>
<p>
lxserver
</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>
@@ -13337,7 +14749,11 @@ const MusicConfigComponent = ({
<button
onClick={handleSave}
disabled={isLoading('saveMusicConfig')}
className={isLoading('saveMusicConfig') ? buttonStyles.disabled : buttonStyles.success}
className={
isLoading('saveMusicConfig')
? buttonStyles.disabled
: buttonStyles.success
}
>
{isLoading('saveMusicConfig') ? '保存中...' : '保存音乐配置'}
</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 () => {
// 保存旧值用于回滚
const oldMode = liveSources.find((s) => s.key === key)?.proxyMode;
// 乐观更新本地状态
setLiveSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, proxyMode: mode } : s
)
prev.map((s) => (s.key === key ? { ...s, proxyMode: mode } : s))
);
try {
@@ -13473,9 +14890,7 @@ const LiveSourceConfig = ({
} catch (error) {
// 失败时回滚本地状态
setLiveSources((prev) =>
prev.map((s) =>
s.key === key ? { ...s, proxyMode: oldMode } : s
)
prev.map((s) => (s.key === key ? { ...s, proxyMode: oldMode } : s))
);
showError(
error instanceof Error ? error.message : '设置代理模式失败',
@@ -13698,7 +15113,10 @@ const LiveSourceConfig = ({
<select
value={liveSource.proxyMode || 'full'}
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}`)}
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'
min='1'
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'
/>
</div>
@@ -13789,7 +15211,9 @@ const LiveSourceConfig = ({
onClick={handleSaveRefreshInterval}
disabled={isLoading('saveLiveRefreshInterval')}
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') ? '保存中...' : '保存间隔'}
@@ -13818,9 +15242,9 @@ const LiveSourceConfig = ({
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={
className={`${
showAddForm ? buttonStyles.secondary : buttonStyles.success
}
} shrink-0 whitespace-nowrap`}
>
{showAddForm ? '取消' : '添加直播源'}
</button>
@@ -14024,7 +15448,6 @@ const LiveSourceConfig = ({
<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'>
<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>
@@ -14192,11 +15615,15 @@ const WebLiveConfig = ({
const target = webLiveSources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(() => {});
withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(
() => {}
);
};
const handleDelete = (key: string) => {
withLoading(`deleteWebLive_${key}`, () => callApi({ action: 'delete', key })).catch(() => {});
withLoading(`deleteWebLive_${key}`, () =>
callApi({ action: 'delete', key })
).catch(() => {});
};
const handleToggleWebLiveEnabled = async () => {
@@ -14226,7 +15653,11 @@ const WebLiveConfig = ({
};
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 (
@@ -14246,20 +15677,31 @@ const WebLiveConfig = ({
onClick={handleToggleWebLiveEnabled}
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 ${
config.WebLiveEnabled ? buttonStyles.toggleOn : buttonStyles.toggleOff
} ${isLoading('toggleWebLiveEnabled') ? 'opacity-50 cursor-not-allowed' : ''}`}
config.WebLiveEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
} ${
isLoading('toggleWebLiveEnabled')
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full transition-transform ${
buttonStyles.toggleThumb
} ${config.WebLiveEnabled ? buttonStyles.toggleThumbOn : buttonStyles.toggleThumbOff}`}
} ${
config.WebLiveEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
</div>
{/* 免责声明弹窗 */}
{showDisclaimerModal && createPortal(
{showDisclaimerModal &&
createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'>
<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='p-6'>
@@ -14293,9 +15735,17 @@ const WebLiveConfig = ({
<button
onClick={handleConfirmEnable}
disabled={countdown > 0 || isEnabling}
className={countdown > 0 || isEnabling ? buttonStyles.disabled : buttonStyles.danger}
className={
countdown > 0 || isEnabling
? buttonStyles.disabled
: buttonStyles.danger
}
>
{isEnabling ? '启用中...' : countdown > 0 ? `确认 (${countdown}s)` : '确认启用'}
{isEnabling
? '启用中...'
: countdown > 0
? `确认 (${countdown}s)`
: '确认启用'}
</button>
</div>
</div>
@@ -14305,10 +15755,14 @@ const WebLiveConfig = ({
)}
<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
onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
className={
showAddForm ? buttonStyles.secondary : buttonStyles.success
}
>
{showAddForm ? '取消' : '添加网络直播'}
</button>
@@ -14321,12 +15775,16 @@ const WebLiveConfig = ({
type='text'
placeholder='名称'
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'
/>
<select
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'
>
<option value='huya'></option>
@@ -14337,16 +15795,26 @@ const WebLiveConfig = ({
type='text'
placeholder='房间ID'
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'
/>
</div>
<div className='flex justify-end'>
<button
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 ${
!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive')
!newSource.name ||
!newSource.platform ||
!newSource.roomId ||
isLoading('addWebLive')
? buttonStyles.disabled
: buttonStyles.success
}`}
@@ -14360,24 +15828,43 @@ const WebLiveConfig = ({
{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='flex items-center justify-between'>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>: {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>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
: {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 className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<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
type='text'
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'
/>
</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
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'
>
<option value='huya'></option>
@@ -14386,21 +15873,42 @@ const WebLiveConfig = ({
</select>
</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
type='text'
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'
/>
</div>
</div>
<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
onClick={handleEdit}
disabled={!editingSource.name || !editingSource.roomId || isLoading('editWebLive')}
className={`${!editingSource.name || !editingSource.roomId || isLoading('editWebLive') ? buttonStyles.disabled : buttonStyles.success}`}
disabled={
!editingSource.name ||
!editingSource.roomId ||
isLoading('editWebLive')
}
className={`${
!editingSource.name ||
!editingSource.roomId ||
isLoading('editWebLive')
? buttonStyles.disabled
: buttonStyles.success
}`}
>
{isLoading('editWebLive') ? '保存中...' : '保存'}
</button>
@@ -14412,24 +15920,62 @@ const WebLiveConfig = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<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='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 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>
<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>
<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>
</thead>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{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'>
<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 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'>
<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 ? '启用中' : '已禁用'}
</span>
</td>
@@ -14438,7 +15984,15 @@ const WebLiveConfig = ({
<button
onClick={() => handleToggle(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 ? '禁用' : '启用'}
</button>
@@ -14447,14 +16001,22 @@ const WebLiveConfig = ({
<button
onClick={() => setEditingSource(source)}
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
onClick={() => handleDelete(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>
@@ -14569,7 +16131,9 @@ function AdminPageClient() {
const fetchUsersV2 = useCallback(async (page = 1) => {
try {
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) {
const data = await response.json();
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>
<p className='text-gray-600 dark:text-gray-400 mb-6'>
{error}
</p>
<p className='text-gray-600 dark:text-gray-400 mb-6'>{error}</p>
<div className='space-y-3'>
<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'
>
</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'
>
@@ -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='flex items-start gap-3'>
<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'>
<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
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>
</div>
<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='flex items-start gap-3'>
<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'>
<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
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>
</div>
<div className='flex-1'>
@@ -14821,17 +16399,17 @@ function AdminPageClient() {
isExpanded={expandedTabs.registrationConfig}
onToggle={() => toggleTab('registrationConfig')}
>
<RegistrationConfigComponent config={config} refreshConfig={fetchConfig} />
<RegistrationConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 个性化配置标签 */}
<CollapsibleTab
title='个性化配置'
icon={
<Palette
size={20}
className='text-gray-600 dark:text-gray-400'
/>
<Palette size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.themeConfig}
onToggle={() => toggleTab('themeConfig')}
@@ -14907,30 +16485,44 @@ function AdminPageClient() {
isExpanded={expandedTabs.musicConfig}
onToggle={() => toggleTab('musicConfig')}
>
<MusicConfigComponent config={config} refreshConfig={fetchConfig} />
<MusicConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
<CollapsibleTab
title='漫画配置'
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}
onToggle={() => toggleTab('suwayomiConfig')}
>
<SuwayomiConfigComponent config={config} refreshConfig={fetchConfig} />
<SuwayomiConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
<CollapsibleTab
title='电子书配置'
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}
onToggle={() => toggleTab('opdsConfig')}
>
<OPDSConfigComponent config={config} refreshConfig={fetchConfig} />
<OPDSConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 电视直播源配置标签 */}
@@ -14961,7 +16553,10 @@ function AdminPageClient() {
<CollapsibleTab
title='私人影库'
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}
onToggle={() => toggleTab('mediaLibrary')}
@@ -14972,70 +16567,106 @@ function AdminPageClient() {
<CollapsibleTab
title='Openlist配置'
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}
onToggle={() => toggleTab('openListConfig')}
>
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
<OpenListConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* Emby 媒体库子标签 */}
<CollapsibleTab
title='Emby 媒体库'
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}
onToggle={() => toggleTab('embyConfig')}
>
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
<EmbyConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 小雅配置子标签 */}
<CollapsibleTab
title='小雅配置'
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}
onToggle={() => toggleTab('xiaoyaConfig')}
>
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} />
<XiaoyaConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 求片管理子标签 */}
<CollapsibleTab
title='求片管理'
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}
onToggle={() => toggleTab('movieRequests')}
>
<MovieRequestsComponent config={config} refreshConfig={fetchConfig} />
<MovieRequestsComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 追番订阅子标签 */}
<CollapsibleTab
title='追番订阅'
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}
onToggle={() => toggleTab('animeSubscription')}
>
<AnimeSubscriptionComponent config={config} refreshConfig={fetchConfig} />
<AnimeSubscriptionComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
<CollapsibleTab
title='网盘配置'
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}
onToggle={() => toggleTab('netDiskConfig')}
>
<NetDiskConfigComponent config={config} refreshConfig={fetchConfig} />
<NetDiskConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
</div>
</CollapsibleTab>
@@ -15061,7 +16692,10 @@ function AdminPageClient() {
isExpanded={expandedTabs.emailConfig}
onToggle={() => toggleTab('emailConfig')}
>
<EmailConfigComponent config={config} refreshConfig={fetchConfig} />
<EmailConfigComponent
config={config}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 分类配置标签 */}
+123 -24
View File
@@ -9,7 +9,18 @@ import { db } from '@/lib/db';
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 {
action?: Action;
@@ -37,7 +48,19 @@ export async function POST(request: NextRequest) {
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)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -127,17 +150,17 @@ export async function POST(request: NextRequest) {
// 检查并清理用户组和用户的权限数组
// 清理用户组权限
if (adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags.forEach(tag => {
adminConfig.UserConfig.Tags.forEach((tag) => {
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) {
user.enabledApis = user.enabledApis.filter(api => api !== key);
user.enabledApis = user.enabledApis.filter((api) => api !== key);
}
});
break;
@@ -145,9 +168,12 @@ export async function POST(request: NextRequest) {
case 'batch_disable': {
const { keys } = body as { keys?: string[] };
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);
if (entry) {
entry.disabled = true;
@@ -158,9 +184,12 @@ export async function POST(request: NextRequest) {
case 'batch_enable': {
const { keys } = body as { keys?: string[] };
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);
if (entry) {
entry.disabled = false;
@@ -171,13 +200,16 @@ export async function POST(request: NextRequest) {
case 'batch_delete': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
return NextResponse.json(
{ error: '缺少 keys 参数或为空' },
{ status: 400 }
);
}
// 过滤掉 from=config 的源,记录跳过的数量
const keysToDelete: string[] = [];
const skippedKeys: string[] = [];
keys.forEach(key => {
keys.forEach((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry && entry.from === 'config') {
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);
if (idx !== -1) {
adminConfig.SourceConfig.splice(idx, 1);
@@ -198,17 +230,21 @@ export async function POST(request: NextRequest) {
if (keysToDelete.length > 0) {
// 清理用户组权限
if (adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags.forEach(tag => {
adminConfig.UserConfig.Tags.forEach((tag) => {
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) {
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;
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': {
const { key, weight } = body as { key?: string; weight?: number };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
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)
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);
if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
@@ -293,14 +395,11 @@ export async function POST(request: NextRequest) {
responseData.skipped = (body as any)._batchDeleteResult.skipped;
}
return NextResponse.json(
responseData,
{
return NextResponse.json(responseData, {
headers: {
'Cache-Control': 'no-store',
},
}
);
});
} catch (error) {
console.error('视频源管理操作失败:', error);
return NextResponse.json(