增加可视化视频源权重设置
This commit is contained in:
+2231
-597
@@ -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'>
|
||||
支持 PNG、JPG、GIF、WebP 格式,建议尺寸 32x32px,图片URL必须可公开访问
|
||||
支持 PNG、JPG、GIF、WebP 格式,建议尺寸
|
||||
32x32px,图片URL必须可公开访问
|
||||
</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.jpg、background.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>
|
||||
|
||||
{/* 分类配置标签 */}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user