增加自定义去广告功能
This commit is contained in:
+2358
-1231
@@ -50,35 +50,53 @@ import PageLayout from '@/components/PageLayout';
|
|||||||
// 统一按钮样式系统
|
// 统一按钮样式系统
|
||||||
const buttonStyles = {
|
const buttonStyles = {
|
||||||
// 主要操作按钮(蓝色)- 用于配置、设置、确认等
|
// 主要操作按钮(蓝色)- 用于配置、设置、确认等
|
||||||
primary: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors',
|
primary:
|
||||||
|
'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors',
|
||||||
// 成功操作按钮(绿色)- 用于添加、启用、保存等
|
// 成功操作按钮(绿色)- 用于添加、启用、保存等
|
||||||
success: 'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors',
|
success:
|
||||||
|
'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors',
|
||||||
// 危险操作按钮(红色)- 用于删除、禁用、重置等
|
// 危险操作按钮(红色)- 用于删除、禁用、重置等
|
||||||
danger: 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors',
|
danger:
|
||||||
|
'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors',
|
||||||
// 次要操作按钮(灰色)- 用于取消、关闭等
|
// 次要操作按钮(灰色)- 用于取消、关闭等
|
||||||
secondary: 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors',
|
secondary:
|
||||||
|
'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors',
|
||||||
// 警告操作按钮(黄色)- 用于批量禁用等
|
// 警告操作按钮(黄色)- 用于批量禁用等
|
||||||
warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors',
|
warning:
|
||||||
|
'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors',
|
||||||
// 小尺寸主要按钮
|
// 小尺寸主要按钮
|
||||||
primarySmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors',
|
primarySmall:
|
||||||
|
'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors',
|
||||||
// 小尺寸成功按钮
|
// 小尺寸成功按钮
|
||||||
successSmall: 'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors',
|
successSmall:
|
||||||
|
'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors',
|
||||||
// 小尺寸危险按钮
|
// 小尺寸危险按钮
|
||||||
dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors',
|
dangerSmall:
|
||||||
|
'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors',
|
||||||
// 小尺寸次要按钮
|
// 小尺寸次要按钮
|
||||||
secondarySmall: 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors',
|
secondarySmall:
|
||||||
|
'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors',
|
||||||
// 小尺寸警告按钮
|
// 小尺寸警告按钮
|
||||||
warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors',
|
warningSmall:
|
||||||
|
'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors',
|
||||||
// 圆角小按钮(用于表格操作)
|
// 圆角小按钮(用于表格操作)
|
||||||
roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors',
|
roundedPrimary:
|
||||||
roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-200 transition-colors',
|
'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors',
|
||||||
roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors',
|
roundedSuccess:
|
||||||
roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors',
|
'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-200 transition-colors',
|
||||||
roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors',
|
roundedDanger:
|
||||||
roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors',
|
'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors',
|
||||||
|
roundedSecondary:
|
||||||
|
'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors',
|
||||||
|
roundedWarning:
|
||||||
|
'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors',
|
||||||
|
roundedPurple:
|
||||||
|
'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors',
|
||||||
// 禁用状态
|
// 禁用状态
|
||||||
disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors',
|
disabled:
|
||||||
disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors',
|
'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors',
|
||||||
|
disabledSmall:
|
||||||
|
'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors',
|
||||||
// 开关按钮样式
|
// 开关按钮样式
|
||||||
toggleOn: 'bg-green-600 dark:bg-green-600',
|
toggleOn: 'bg-green-600 dark:bg-green-600',
|
||||||
toggleOff: 'bg-gray-200 dark:bg-gray-700',
|
toggleOff: 'bg-gray-200 dark:bg-gray-700',
|
||||||
@@ -86,7 +104,8 @@ const buttonStyles = {
|
|||||||
toggleThumbOn: 'translate-x-6',
|
toggleThumbOn: 'translate-x-6',
|
||||||
toggleThumbOff: 'translate-x-1',
|
toggleThumbOff: 'translate-x-1',
|
||||||
// 快速操作按钮样式
|
// 快速操作按钮样式
|
||||||
quickAction: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors',
|
quickAction:
|
||||||
|
'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 通用弹窗组件
|
// 通用弹窗组件
|
||||||
@@ -107,7 +126,7 @@ const AlertModal = ({
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
timer,
|
timer,
|
||||||
showConfirm = false
|
showConfirm = false,
|
||||||
}: AlertModalProps) => {
|
}: AlertModalProps) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
@@ -129,11 +148,11 @@ const AlertModal = ({
|
|||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return <CheckCircle className="w-8 h-8 text-green-500" />;
|
return <CheckCircle className='w-8 h-8 text-green-500' />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <AlertCircle className="w-8 h-8 text-red-500" />;
|
return <AlertCircle className='w-8 h-8 text-red-500' />;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return <AlertTriangle className="w-8 h-8 text-yellow-500" />;
|
return <AlertTriangle className='w-8 h-8 text-yellow-500' />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -153,21 +172,25 @@ const AlertModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
|
<div
|
||||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full border ${getBgColor()} transition-all duration-200 ${isVisible ? 'scale-100' : 'scale-95'}`}>
|
className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${
|
||||||
<div className="p-6 text-center">
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
<div className="flex justify-center mb-4">
|
}`}
|
||||||
{getIcon()}
|
>
|
||||||
</div>
|
<div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full border ${getBgColor()} transition-all duration-200 ${
|
||||||
|
isVisible ? 'scale-100' : 'scale-95'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='p-6 text-center'>
|
||||||
|
<div className='flex justify-center mb-4'>{getIcon()}</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
<p className='text-gray-600 dark:text-gray-400 mb-4'>{message}</p>
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
@@ -205,7 +228,7 @@ const useAlertModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hideAlert = () => {
|
const hideAlert = () => {
|
||||||
setAlertModal(prev => ({ ...prev, isOpen: false }));
|
setAlertModal((prev) => ({ ...prev, isOpen: false }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return { alertModal, showAlert, hideAlert };
|
return { alertModal, showAlert, hideAlert };
|
||||||
@@ -237,12 +260,15 @@ const useLoadingState = () => {
|
|||||||
const [loadingStates, setLoadingStates] = useState<LoadingState>({});
|
const [loadingStates, setLoadingStates] = useState<LoadingState>({});
|
||||||
|
|
||||||
const setLoading = (key: string, loading: boolean) => {
|
const setLoading = (key: string, loading: boolean) => {
|
||||||
setLoadingStates(prev => ({ ...prev, [key]: loading }));
|
setLoadingStates((prev) => ({ ...prev, [key]: loading }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLoading = (key: string) => loadingStates[key] || false;
|
const isLoading = (key: string) => loadingStates[key] || false;
|
||||||
|
|
||||||
const withLoading = async (key: string, operation: () => Promise<any>): Promise<any> => {
|
const withLoading = async (
|
||||||
|
key: string,
|
||||||
|
operation: () => Promise<any>
|
||||||
|
): Promise<any> => {
|
||||||
setLoading(key, true);
|
setLoading(key, true);
|
||||||
try {
|
try {
|
||||||
const result = await operation();
|
const result = await operation();
|
||||||
@@ -380,7 +406,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [selectedApis, setSelectedApis] = useState<string[]>([]);
|
const [selectedApis, setSelectedApis] = useState<string[]>([]);
|
||||||
const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false);
|
const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] =
|
||||||
|
useState(false);
|
||||||
const [selectedUserForGroup, setSelectedUserForGroup] = useState<{
|
const [selectedUserForGroup, setSelectedUserForGroup] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
role: 'user' | 'admin' | 'owner';
|
role: 'user' | 'admin' | 'owner';
|
||||||
@@ -390,10 +417,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
||||||
const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false);
|
const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false);
|
||||||
const [selectedUserGroup, setSelectedUserGroup] = useState<string>('');
|
const [selectedUserGroup, setSelectedUserGroup] = useState<string>('');
|
||||||
const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false);
|
const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] =
|
||||||
|
useState(false);
|
||||||
const [deletingUserGroup, setDeletingUserGroup] = useState<{
|
const [deletingUserGroup, setDeletingUserGroup] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
affectedUsers: Array<{ username: string; role: 'user' | 'admin' | 'owner' }>;
|
affectedUsers: Array<{
|
||||||
|
username: string;
|
||||||
|
role: 'user' | 'admin' | 'owner';
|
||||||
|
}>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showDeleteUserModal, setShowDeleteUserModal] = useState(false);
|
const [showDeleteUserModal, setShowDeleteUserModal] = useState(false);
|
||||||
const [deletingUser, setDeletingUser] = useState<string | null>(null);
|
const [deletingUser, setDeletingUser] = useState<string | null>(null);
|
||||||
@@ -403,12 +434,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
|
|
||||||
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
|
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
|
||||||
const selectAllUsers = useMemo(() => {
|
const selectAllUsers = useMemo(() => {
|
||||||
const selectableUserCount = config?.UserConfig?.Users?.filter(user =>
|
const selectableUserCount =
|
||||||
(role === 'owner' ||
|
config?.UserConfig?.Users?.filter(
|
||||||
(role === 'admin' &&
|
(user) =>
|
||||||
(user.role === 'user' ||
|
role === 'owner' ||
|
||||||
user.username === currentUsername)))
|
(role === 'admin' &&
|
||||||
).length || 0;
|
(user.role === 'user' || user.username === currentUsername))
|
||||||
|
).length || 0;
|
||||||
return selectedUsers.size === selectableUserCount && selectedUsers.size > 0;
|
return selectedUsers.size === selectableUserCount && selectedUsers.size > 0;
|
||||||
}, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]);
|
}, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]);
|
||||||
|
|
||||||
@@ -449,7 +481,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
setShowEditUserGroupForm(false);
|
setShowEditUserGroupForm(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
showSuccess(action === 'add' ? '用户组添加成功' : action === 'edit' ? '用户组更新成功' : '用户组删除成功', showAlert);
|
showSuccess(
|
||||||
|
action === 'add'
|
||||||
|
? '用户组添加成功'
|
||||||
|
: action === 'edit'
|
||||||
|
? '用户组更新成功'
|
||||||
|
: '用户组删除成功',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err instanceof Error ? err.message : '操作失败', showAlert);
|
showError(err instanceof Error ? err.message : '操作失败', showAlert);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -464,18 +503,26 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
|
|
||||||
const handleEditUserGroup = () => {
|
const handleEditUserGroup = () => {
|
||||||
if (!editingUserGroup?.name.trim()) return;
|
if (!editingUserGroup?.name.trim()) return;
|
||||||
handleUserGroupAction('edit', editingUserGroup.name, editingUserGroup.enabledApis);
|
handleUserGroupAction(
|
||||||
|
'edit',
|
||||||
|
editingUserGroup.name,
|
||||||
|
editingUserGroup.enabledApis
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUserGroup = (groupName: string) => {
|
const handleDeleteUserGroup = (groupName: string) => {
|
||||||
// 计算会受影响的用户数量
|
// 计算会受影响的用户数量
|
||||||
const affectedUsers = config?.UserConfig?.Users?.filter(user =>
|
const affectedUsers =
|
||||||
user.tags && user.tags.includes(groupName)
|
config?.UserConfig?.Users?.filter(
|
||||||
) || [];
|
(user) => user.tags && user.tags.includes(groupName)
|
||||||
|
) || [];
|
||||||
|
|
||||||
setDeletingUserGroup({
|
setDeletingUserGroup({
|
||||||
name: groupName,
|
name: groupName,
|
||||||
affectedUsers: affectedUsers.map(u => ({ username: u.username, role: u.role }))
|
affectedUsers: affectedUsers.map((u) => ({
|
||||||
|
username: u.username,
|
||||||
|
role: u.role,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
setShowDeleteUserGroupModal(true);
|
setShowDeleteUserGroupModal(true);
|
||||||
};
|
};
|
||||||
@@ -492,14 +539,20 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartEditUserGroup = (group: { name: string; enabledApis: string[] }) => {
|
const handleStartEditUserGroup = (group: {
|
||||||
|
name: string;
|
||||||
|
enabledApis: string[];
|
||||||
|
}) => {
|
||||||
setEditingUserGroup({ ...group });
|
setEditingUserGroup({ ...group });
|
||||||
setShowEditUserGroupForm(true);
|
setShowEditUserGroupForm(true);
|
||||||
setShowAddUserGroupForm(false);
|
setShowAddUserGroupForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 为用户分配用户组
|
// 为用户分配用户组
|
||||||
const handleAssignUserGroup = async (username: string, userGroups: string[]) => {
|
const handleAssignUserGroup = async (
|
||||||
|
username: string,
|
||||||
|
userGroups: string[]
|
||||||
|
) => {
|
||||||
return withLoading(`assignUserGroup_${username}`, async () => {
|
return withLoading(`assignUserGroup_${username}`, async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/user', {
|
const res = await fetch('/api/admin/user', {
|
||||||
@@ -531,21 +584,32 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnbanUser = async (uname: string) => {
|
const handleUnbanUser = async (uname: string) => {
|
||||||
await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname));
|
await withLoading(`unbanUser_${uname}`, () =>
|
||||||
|
handleUserAction('unban', uname)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetAdmin = async (uname: string) => {
|
const handleSetAdmin = async (uname: string) => {
|
||||||
await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname));
|
await withLoading(`setAdmin_${uname}`, () =>
|
||||||
|
handleUserAction('setAdmin', uname)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveAdmin = async (uname: string) => {
|
const handleRemoveAdmin = async (uname: string) => {
|
||||||
await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname));
|
await withLoading(`removeAdmin_${uname}`, () =>
|
||||||
|
handleUserAction('cancelAdmin', uname)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddUser = async () => {
|
const handleAddUser = async () => {
|
||||||
if (!newUser.username || !newUser.password) return;
|
if (!newUser.username || !newUser.password) return;
|
||||||
await withLoading('addUser', async () => {
|
await withLoading('addUser', async () => {
|
||||||
await handleUserAction('add', newUser.username, newUser.password, newUser.userGroup);
|
await handleUserAction(
|
||||||
|
'add',
|
||||||
|
newUser.username,
|
||||||
|
newUser.password,
|
||||||
|
newUser.userGroup
|
||||||
|
);
|
||||||
setNewUser({ username: '', password: '', userGroup: '' });
|
setNewUser({ username: '', password: '', userGroup: '' });
|
||||||
setShowAddUserForm(false);
|
setShowAddUserForm(false);
|
||||||
});
|
});
|
||||||
@@ -553,15 +617,18 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePassword = async () => {
|
||||||
if (!changePasswordUser.username || !changePasswordUser.password) return;
|
if (!changePasswordUser.username || !changePasswordUser.password) return;
|
||||||
await withLoading(`changePassword_${changePasswordUser.username}`, async () => {
|
await withLoading(
|
||||||
await handleUserAction(
|
`changePassword_${changePasswordUser.username}`,
|
||||||
'changePassword',
|
async () => {
|
||||||
changePasswordUser.username,
|
await handleUserAction(
|
||||||
changePasswordUser.password
|
'changePassword',
|
||||||
);
|
changePasswordUser.username,
|
||||||
setChangePasswordUser({ username: '', password: '' });
|
changePasswordUser.password
|
||||||
setShowChangePasswordForm(false);
|
);
|
||||||
});
|
setChangePasswordUser({ username: '', password: '' });
|
||||||
|
setShowChangePasswordForm(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowChangePasswordForm = (username: string) => {
|
const handleShowChangePasswordForm = (username: string) => {
|
||||||
@@ -598,21 +665,27 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
const handleSaveUserGroups = async () => {
|
const handleSaveUserGroups = async () => {
|
||||||
if (!selectedUserForGroup) return;
|
if (!selectedUserForGroup) return;
|
||||||
|
|
||||||
await withLoading(`saveUserGroups_${selectedUserForGroup.username}`, async () => {
|
await withLoading(
|
||||||
try {
|
`saveUserGroups_${selectedUserForGroup.username}`,
|
||||||
await handleAssignUserGroup(selectedUserForGroup.username, selectedUserGroups);
|
async () => {
|
||||||
setShowConfigureUserGroupModal(false);
|
try {
|
||||||
setSelectedUserForGroup(null);
|
await handleAssignUserGroup(
|
||||||
setSelectedUserGroups([]);
|
selectedUserForGroup.username,
|
||||||
} catch (err) {
|
selectedUserGroups
|
||||||
// 错误处理已在 handleAssignUserGroup 中处理
|
);
|
||||||
|
setShowConfigureUserGroupModal(false);
|
||||||
|
setSelectedUserForGroup(null);
|
||||||
|
setSelectedUserGroups([]);
|
||||||
|
} catch (err) {
|
||||||
|
// 错误处理已在 handleAssignUserGroup 中处理
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理用户选择
|
// 处理用户选择
|
||||||
const handleSelectUser = useCallback((username: string, checked: boolean) => {
|
const handleSelectUser = useCallback((username: string, checked: boolean) => {
|
||||||
setSelectedUsers(prev => {
|
setSelectedUsers((prev) => {
|
||||||
const newSelectedUsers = new Set(prev);
|
const newSelectedUsers = new Set(prev);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
newSelectedUsers.add(username);
|
newSelectedUsers.add(username);
|
||||||
@@ -623,20 +696,24 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectAllUsers = useCallback((checked: boolean) => {
|
const handleSelectAllUsers = useCallback(
|
||||||
if (checked) {
|
(checked: boolean) => {
|
||||||
// 只选择自己有权限操作的用户
|
if (checked) {
|
||||||
const selectableUsernames = config?.UserConfig?.Users?.filter(user =>
|
// 只选择自己有权限操作的用户
|
||||||
(role === 'owner' ||
|
const selectableUsernames =
|
||||||
(role === 'admin' &&
|
config?.UserConfig?.Users?.filter(
|
||||||
(user.role === 'user' ||
|
(user) =>
|
||||||
user.username === currentUsername)))
|
role === 'owner' ||
|
||||||
).map(u => u.username) || [];
|
(role === 'admin' &&
|
||||||
setSelectedUsers(new Set(selectableUsernames));
|
(user.role === 'user' || user.username === currentUsername))
|
||||||
} else {
|
).map((u) => u.username) || [];
|
||||||
setSelectedUsers(new Set());
|
setSelectedUsers(new Set(selectableUsernames));
|
||||||
}
|
} else {
|
||||||
}, [config?.UserConfig?.Users, role, currentUsername]);
|
setSelectedUsers(new Set());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config?.UserConfig?.Users, role, currentUsername]
|
||||||
|
);
|
||||||
|
|
||||||
// 批量设置用户组
|
// 批量设置用户组
|
||||||
const handleBatchSetUserGroup = async (userGroup: string) => {
|
const handleBatchSetUserGroup = async (userGroup: string) => {
|
||||||
@@ -663,7 +740,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
setSelectedUsers(new Set());
|
setSelectedUsers(new Set());
|
||||||
setShowBatchUserGroupModal(false);
|
setShowBatchUserGroupModal(false);
|
||||||
setSelectedUserGroup('');
|
setSelectedUserGroup('');
|
||||||
showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert);
|
showSuccess(
|
||||||
|
`已为 ${userCount} 个用户设置用户组: ${userGroup}`,
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
|
|
||||||
// 刷新配置
|
// 刷新配置
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
@@ -674,8 +754,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 提取URL域名的辅助函数
|
// 提取URL域名的辅助函数
|
||||||
const extractDomain = (url: string): string => {
|
const extractDomain = (url: string): string => {
|
||||||
try {
|
try {
|
||||||
@@ -796,8 +874,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 用户组管理 */}
|
{/* 用户组管理 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between mb-3'>
|
<div className='flex items-center justify-between mb-3'>
|
||||||
@@ -812,7 +888,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
setEditingUserGroup(null);
|
setEditingUserGroup(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={showAddUserGroupForm ? buttonStyles.secondary : buttonStyles.primary}
|
className={
|
||||||
|
showAddUserGroupForm
|
||||||
|
? buttonStyles.secondary
|
||||||
|
: buttonStyles.primary
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showAddUserGroupForm ? '取消' : '添加用户组'}
|
{showAddUserGroupForm ? '取消' : '添加用户组'}
|
||||||
</button>
|
</button>
|
||||||
@@ -836,7 +916,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
{userGroups.map((group) => (
|
{userGroups.map((group) => (
|
||||||
<tr key={group.name} className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'>
|
<tr
|
||||||
|
key={group.name}
|
||||||
|
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
|
||||||
|
>
|
||||||
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
|
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
{group.name}
|
{group.name}
|
||||||
</td>
|
</td>
|
||||||
@@ -853,7 +936,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleStartEditUserGroup(group)}
|
onClick={() => handleStartEditUserGroup(group)}
|
||||||
disabled={isLoading(`userGroup_edit_${group.name}`)}
|
disabled={isLoading(`userGroup_edit_${group.name}`)}
|
||||||
className={`${buttonStyles.roundedPrimary} ${isLoading(`userGroup_edit_${group.name}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`${buttonStyles.roundedPrimary} ${
|
||||||
|
isLoading(`userGroup_edit_${group.name}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
@@ -868,7 +955,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
))}
|
))}
|
||||||
{userGroups.length === 0 && (
|
{userGroups.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className='px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400'>
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className='px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400'
|
||||||
|
>
|
||||||
暂无用户组,请添加用户组来管理用户权限
|
暂无用户组,请添加用户组来管理用户权限
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -910,7 +1000,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
setChangePasswordUser({ username: '', password: '' });
|
setChangePasswordUser({ username: '', password: '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={showAddUserForm ? buttonStyles.secondary : buttonStyles.success}
|
className={
|
||||||
|
showAddUserForm ? buttonStyles.secondary : buttonStyles.success
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showAddUserForm ? '取消' : '添加用户'}
|
{showAddUserForm ? '取消' : '添加用户'}
|
||||||
</button>
|
</button>
|
||||||
@@ -927,7 +1019,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
placeholder='用户名'
|
placeholder='用户名'
|
||||||
value={newUser.username}
|
value={newUser.username}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewUser((prev) => ({ ...prev, username: e.target.value }))
|
setNewUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username: 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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
/>
|
/>
|
||||||
@@ -936,7 +1031,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
placeholder='密码'
|
placeholder='密码'
|
||||||
value={newUser.password}
|
value={newUser.password}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewUser((prev) => ({ ...prev, password: e.target.value }))
|
setNewUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: 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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
/>
|
/>
|
||||||
@@ -948,14 +1046,21 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
<select
|
<select
|
||||||
value={newUser.userGroup}
|
value={newUser.userGroup}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewUser((prev) => ({ ...prev, userGroup: e.target.value }))
|
setNewUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
userGroup: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
>
|
>
|
||||||
<option value=''>无用户组(无限制)</option>
|
<option value=''>无用户组(无限制)</option>
|
||||||
{userGroups.map((group) => (
|
{userGroups.map((group) => (
|
||||||
<option key={group.name} value={group.name}>
|
<option key={group.name} value={group.name}>
|
||||||
{group.name} ({group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` : '无限制'})
|
{group.name} (
|
||||||
|
{group.enabledApis && group.enabledApis.length > 0
|
||||||
|
? `${group.enabledApis.length} 个源`
|
||||||
|
: '无限制'}
|
||||||
|
)
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -963,8 +1068,18 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddUser}
|
onClick={handleAddUser}
|
||||||
disabled={!newUser.username || !newUser.password || isLoading('addUser')}
|
disabled={
|
||||||
className={!newUser.username || !newUser.password || isLoading('addUser') ? buttonStyles.disabled : buttonStyles.success}
|
!newUser.username ||
|
||||||
|
!newUser.password ||
|
||||||
|
isLoading('addUser')
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
!newUser.username ||
|
||||||
|
!newUser.password ||
|
||||||
|
isLoading('addUser')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isLoading('addUser') ? '添加中...' : '添加'}
|
{isLoading('addUser') ? '添加中...' : '添加'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1001,10 +1116,20 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
disabled={!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`)}
|
disabled={
|
||||||
className={`w-full sm:w-auto ${!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
|
!changePasswordUser.password ||
|
||||||
|
isLoading(`changePassword_${changePasswordUser.username}`)
|
||||||
|
}
|
||||||
|
className={`w-full sm:w-auto ${
|
||||||
|
!changePasswordUser.password ||
|
||||||
|
isLoading(`changePassword_${changePasswordUser.username}`)
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading(`changePassword_${changePasswordUser.username}`) ? '修改中...' : '修改密码'}
|
{isLoading(`changePassword_${changePasswordUser.username}`)
|
||||||
|
? '修改中...'
|
||||||
|
: '修改密码'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1020,7 +1145,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 用户列表 */}
|
{/* 用户列表 */}
|
||||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="user-list">
|
<div
|
||||||
|
className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative'
|
||||||
|
data-table='user-list'
|
||||||
|
>
|
||||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1028,11 +1156,12 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
<th className='w-10 px-1 py-3 text-center'>
|
<th className='w-10 px-1 py-3 text-center'>
|
||||||
{(() => {
|
{(() => {
|
||||||
// 检查是否有权限操作任何用户
|
// 检查是否有权限操作任何用户
|
||||||
const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
|
const hasAnyPermission = config?.UserConfig?.Users?.some(
|
||||||
(role === 'owner' ||
|
(user) =>
|
||||||
(role === 'admin' &&
|
role === 'owner' ||
|
||||||
(user.role === 'user' ||
|
(role === 'admin' &&
|
||||||
user.username === currentUsername)))
|
(user.role === 'user' ||
|
||||||
|
user.username === currentUsername))
|
||||||
);
|
);
|
||||||
|
|
||||||
return hasAnyPermission ? (
|
return hasAnyPermission ? (
|
||||||
@@ -1126,14 +1255,19 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
>
|
>
|
||||||
<td className='w-4' />
|
<td className='w-4' />
|
||||||
<td className='w-10 px-1 py-3 text-center'>
|
<td className='w-10 px-1 py-3 text-center'>
|
||||||
{(role === 'owner' ||
|
{role === 'owner' ||
|
||||||
(role === 'admin' &&
|
(role === 'admin' &&
|
||||||
(user.role === 'user' ||
|
(user.role === 'user' ||
|
||||||
user.username === currentUsername))) ? (
|
user.username === currentUsername)) ? (
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
checked={selectedUsers.has(user.username)}
|
checked={selectedUsers.has(user.username)}
|
||||||
onChange={(e) => handleSelectUser(user.username, e.target.checked)}
|
onChange={(e) =>
|
||||||
|
handleSelectUser(
|
||||||
|
user.username,
|
||||||
|
e.target.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
|
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'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1145,26 +1279,28 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap'>
|
<td className='px-6 py-4 whitespace-nowrap'>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs rounded-full ${user.role === 'owner'
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
user.role === 'owner'
|
||||||
: user.role === 'admin'
|
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||||
|
: user.role === 'admin'
|
||||||
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
|
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.role === 'owner'
|
{user.role === 'owner'
|
||||||
? '站长'
|
? '站长'
|
||||||
: user.role === 'admin'
|
: user.role === 'admin'
|
||||||
? '管理员'
|
? '管理员'
|
||||||
: '普通用户'}
|
: '普通用户'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap'>
|
<td className='px-6 py-4 whitespace-nowrap'>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs rounded-full ${!user.banned
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
!user.banned
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
}`}
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!user.banned ? '正常' : '已封禁'}
|
{!user.banned ? '正常' : '已封禁'}
|
||||||
</span>
|
</span>
|
||||||
@@ -1181,13 +1317,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
(role === 'admin' &&
|
(role === 'admin' &&
|
||||||
(user.role === 'user' ||
|
(user.role === 'user' ||
|
||||||
user.username === currentUsername))) && (
|
user.username === currentUsername))) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleConfigureUserGroup(user)}
|
onClick={() => handleConfigureUserGroup(user)}
|
||||||
className={buttonStyles.roundedPrimary}
|
className={buttonStyles.roundedPrimary}
|
||||||
>
|
>
|
||||||
配置
|
配置
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap'>
|
<td className='px-6 py-4 whitespace-nowrap'>
|
||||||
@@ -1202,13 +1338,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
(role === 'admin' &&
|
(role === 'admin' &&
|
||||||
(user.role === 'user' ||
|
(user.role === 'user' ||
|
||||||
user.username === currentUsername))) && (
|
user.username === currentUsername))) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleConfigureUserApis(user)}
|
onClick={() => handleConfigureUserApis(user)}
|
||||||
className={buttonStyles.roundedPrimary}
|
className={buttonStyles.roundedPrimary}
|
||||||
>
|
>
|
||||||
配置
|
配置
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||||
@@ -1229,8 +1365,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
{user.role === 'user' && (
|
{user.role === 'user' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSetAdmin(user.username)}
|
onClick={() => handleSetAdmin(user.username)}
|
||||||
disabled={isLoading(`setAdmin_${user.username}`)}
|
disabled={isLoading(
|
||||||
className={`${buttonStyles.roundedPurple} ${isLoading(`setAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
`setAdmin_${user.username}`
|
||||||
|
)}
|
||||||
|
className={`${buttonStyles.roundedPurple} ${
|
||||||
|
isLoading(`setAdmin_${user.username}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
设为管理
|
设为管理
|
||||||
</button>
|
</button>
|
||||||
@@ -1240,8 +1382,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleRemoveAdmin(user.username)
|
handleRemoveAdmin(user.username)
|
||||||
}
|
}
|
||||||
disabled={isLoading(`removeAdmin_${user.username}`)}
|
disabled={isLoading(
|
||||||
className={`${buttonStyles.roundedSecondary} ${isLoading(`removeAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
`removeAdmin_${user.username}`
|
||||||
|
)}
|
||||||
|
className={`${
|
||||||
|
buttonStyles.roundedSecondary
|
||||||
|
} ${
|
||||||
|
isLoading(`removeAdmin_${user.username}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
取消管理
|
取消管理
|
||||||
</button>
|
</button>
|
||||||
@@ -1250,8 +1400,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
(!user.banned ? (
|
(!user.banned ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBanUser(user.username)}
|
onClick={() => handleBanUser(user.username)}
|
||||||
disabled={isLoading(`banUser_${user.username}`)}
|
disabled={isLoading(
|
||||||
className={`${buttonStyles.roundedDanger} ${isLoading(`banUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
`banUser_${user.username}`
|
||||||
|
)}
|
||||||
|
className={`${buttonStyles.roundedDanger} ${
|
||||||
|
isLoading(`banUser_${user.username}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
封禁
|
封禁
|
||||||
</button>
|
</button>
|
||||||
@@ -1260,8 +1416,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleUnbanUser(user.username)
|
handleUnbanUser(user.username)
|
||||||
}
|
}
|
||||||
disabled={isLoading(`unbanUser_${user.username}`)}
|
disabled={isLoading(
|
||||||
className={`${buttonStyles.roundedSuccess} ${isLoading(`unbanUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
`unbanUser_${user.username}`
|
||||||
|
)}
|
||||||
|
className={`${
|
||||||
|
buttonStyles.roundedSuccess
|
||||||
|
} ${
|
||||||
|
isLoading(`unbanUser_${user.username}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
解封
|
解封
|
||||||
</button>
|
</button>
|
||||||
@@ -1289,199 +1453,97 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 配置用户采集源权限弹窗 */}
|
{/* 配置用户采集源权限弹窗 */}
|
||||||
{showConfigureApisModal && selectedUser && createPortal(
|
{showConfigureApisModal &&
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
selectedUser &&
|
||||||
setShowConfigureApisModal(false);
|
createPortal(
|
||||||
setSelectedUser(null);
|
<div
|
||||||
setSelectedApis([]);
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
}}>
|
onClick={() => {
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
|
setShowConfigureApisModal(false);
|
||||||
<div className='p-6'>
|
setSelectedUser(null);
|
||||||
<div className='flex items-center justify-between mb-6'>
|
setSelectedApis([]);
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
}}
|
||||||
配置用户采集源权限 - {selectedUser.username}
|
>
|
||||||
</h3>
|
<div
|
||||||
<button
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto'
|
||||||
onClick={() => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
setShowConfigureApisModal(false);
|
>
|
||||||
setSelectedUser(null);
|
<div className='p-6'>
|
||||||
setSelectedApis([]);
|
<div className='flex items-center justify-between mb-6'>
|
||||||
}}
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
配置用户采集源权限 - {selectedUser.username}
|
||||||
>
|
</h3>
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
|
||||||
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
|
|
||||||
</svg>
|
|
||||||
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
|
||||||
配置说明
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
|
|
||||||
提示:全不选为无限制,选中的采集源将限制用户只能访问这些源
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 采集源选择 - 多列布局 */}
|
|
||||||
<div className='mb-6'>
|
|
||||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
|
|
||||||
选择可用的采集源:
|
|
||||||
</h4>
|
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
|
||||||
{config?.SourceConfig?.map((source) => (
|
|
||||||
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
checked={selectedApis.includes(source.key)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedApis([...selectedApis, source.key]);
|
|
||||||
} else {
|
|
||||||
setSelectedApis(selectedApis.filter(api => api !== source.key));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
|
||||||
/>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
|
||||||
{source.name}
|
|
||||||
</div>
|
|
||||||
{source.api && (
|
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
|
|
||||||
{extractDomain(source.api)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 快速操作按钮 */}
|
|
||||||
<div className='flex flex-wrap items-center justify-between mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg'>
|
|
||||||
<div className='flex space-x-2'>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedApis([])}
|
|
||||||
className={buttonStyles.quickAction}
|
|
||||||
>
|
|
||||||
全不选(无限制)
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
|
setShowConfigureApisModal(false);
|
||||||
setSelectedApis(allApis);
|
setSelectedUser(null);
|
||||||
|
setSelectedApis([]);
|
||||||
}}
|
}}
|
||||||
className={buttonStyles.quickAction}
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
>
|
>
|
||||||
全选
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
|
||||||
已选择:<span className='font-medium text-blue-600 dark:text-blue-400'>
|
|
||||||
{selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
<div className='mb-6'>
|
||||||
<div className='flex justify-end space-x-3'>
|
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||||
<button
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
onClick={() => {
|
<svg
|
||||||
setShowConfigureApisModal(false);
|
className='w-5 h-5 text-blue-600 dark:text-blue-400'
|
||||||
setSelectedUser(null);
|
fill='none'
|
||||||
setSelectedApis([]);
|
stroke='currentColor'
|
||||||
}}
|
viewBox='0 0 24 24'
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
>
|
||||||
>
|
<path
|
||||||
取消
|
strokeLinecap='round'
|
||||||
</button>
|
strokeLinejoin='round'
|
||||||
<button
|
strokeWidth={2}
|
||||||
onClick={handleSaveUserApis}
|
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
disabled={isLoading(`saveUserApis_${selectedUser?.username}`)}
|
/>
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserApis_${selectedUser?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
|
</svg>
|
||||||
>
|
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
||||||
{isLoading(`saveUserApis_${selectedUser?.username}`) ? '配置中...' : '确认配置'}
|
配置说明
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
|
||||||
</div>
|
提示:全不选为无限制,选中的采集源将限制用户只能访问这些源
|
||||||
</div>,
|
</p>
|
||||||
document.body
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 添加用户组弹窗 */}
|
|
||||||
{showAddUserGroupForm && createPortal(
|
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
|
||||||
setShowAddUserGroupForm(false);
|
|
||||||
setNewUserGroup({ name: '', enabledApis: [] });
|
|
||||||
}}>
|
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className='p-6'>
|
|
||||||
<div className='flex items-center justify-between mb-6'>
|
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
|
||||||
添加新用户组
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddUserGroupForm(false);
|
|
||||||
setNewUserGroup({ name: '', enabledApis: [] });
|
|
||||||
}}
|
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
|
||||||
>
|
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-6'>
|
|
||||||
{/* 用户组名称 */}
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
|
||||||
用户组名称
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
placeholder='请输入用户组名称'
|
|
||||||
value={newUserGroup.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewUserGroup((prev) => ({ ...prev, name: 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-blue-500 focus:border-transparent'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 可用视频源 */}
|
{/* 采集源选择 - 多列布局 */}
|
||||||
<div>
|
<div className='mb-6'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
|
||||||
可用视频源
|
选择可用的采集源:
|
||||||
</label>
|
</h4>
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||||
{config?.SourceConfig?.map((source) => (
|
{config?.SourceConfig?.map((source) => (
|
||||||
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
|
<label
|
||||||
|
key={source.key}
|
||||||
|
className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
checked={newUserGroup.enabledApis.includes(source.key)}
|
checked={selectedApis.includes(source.key)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setNewUserGroup(prev => ({
|
setSelectedApis([...selectedApis, source.key]);
|
||||||
...prev,
|
|
||||||
enabledApis: [...prev.enabledApis, source.key]
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
setNewUserGroup(prev => ({
|
setSelectedApis(
|
||||||
...prev,
|
selectedApis.filter((api) => api !== source.key)
|
||||||
enabledApis: prev.enabledApis.filter(api => api !== source.key)
|
);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
||||||
@@ -1499,479 +1561,511 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 快速操作按钮 */}
|
{/* 快速操作按钮 */}
|
||||||
<div className='mt-4 flex space-x-2'>
|
<div className='flex flex-wrap items-center justify-between mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg'>
|
||||||
|
<div className='flex space-x-2'>
|
||||||
<button
|
<button
|
||||||
onClick={() => setNewUserGroup(prev => ({ ...prev, enabledApis: [] }))}
|
onClick={() => setSelectedApis([])}
|
||||||
className={buttonStyles.quickAction}
|
className={buttonStyles.quickAction}
|
||||||
>
|
>
|
||||||
全不选(无限制)
|
全不选(无限制)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
|
const allApis =
|
||||||
setNewUserGroup(prev => ({ ...prev, enabledApis: allApis }));
|
config?.SourceConfig?.filter(
|
||||||
|
(source) => !source.disabled
|
||||||
|
).map((s) => s.key) || [];
|
||||||
|
setSelectedApis(allApis);
|
||||||
}}
|
}}
|
||||||
className={buttonStyles.quickAction}
|
className={buttonStyles.quickAction}
|
||||||
>
|
>
|
||||||
全选
|
全选
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
已选择:
|
||||||
{/* 操作按钮 */}
|
<span className='font-medium text-blue-600 dark:text-blue-400'>
|
||||||
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
{selectedApis.length > 0
|
||||||
<button
|
? `${selectedApis.length} 个源`
|
||||||
onClick={() => {
|
: '无限制'}
|
||||||
setShowAddUserGroupForm(false);
|
|
||||||
setNewUserGroup({ name: '', enabledApis: [] });
|
|
||||||
}}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAddUserGroup}
|
|
||||||
disabled={!newUserGroup.name.trim() || isLoading('userGroup_add_new')}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${!newUserGroup.name.trim() || isLoading('userGroup_add_new') ? buttonStyles.disabled : buttonStyles.primary}`}
|
|
||||||
>
|
|
||||||
{isLoading('userGroup_add_new') ? '添加中...' : '添加用户组'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 编辑用户组弹窗 */}
|
|
||||||
{showEditUserGroupForm && editingUserGroup && createPortal(
|
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
|
||||||
setShowEditUserGroupForm(false);
|
|
||||||
setEditingUserGroup(null);
|
|
||||||
}}>
|
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className='p-6'>
|
|
||||||
<div className='flex items-center justify-between mb-6'>
|
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
|
||||||
编辑用户组 - {editingUserGroup.name}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowEditUserGroupForm(false);
|
|
||||||
setEditingUserGroup(null);
|
|
||||||
}}
|
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
|
||||||
>
|
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-6'>
|
|
||||||
{/* 可用视频源 */}
|
|
||||||
<div>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
|
|
||||||
可用视频源
|
|
||||||
</label>
|
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
|
||||||
{config?.SourceConfig?.map((source) => (
|
|
||||||
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
checked={editingUserGroup.enabledApis.includes(source.key)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setEditingUserGroup(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
enabledApis: [...prev.enabledApis, source.key]
|
|
||||||
} : null);
|
|
||||||
} else {
|
|
||||||
setEditingUserGroup(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
enabledApis: prev.enabledApis.filter(api => api !== source.key)
|
|
||||||
} : null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className='rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700'
|
|
||||||
/>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
|
||||||
{source.name}
|
|
||||||
</div>
|
|
||||||
{source.api && (
|
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
|
|
||||||
{extractDomain(source.api)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 快速操作按钮 */}
|
|
||||||
<div className='mt-4 flex space-x-2'>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingUserGroup(prev => prev ? { ...prev, enabledApis: [] } : null)}
|
|
||||||
className={buttonStyles.quickAction}
|
|
||||||
>
|
|
||||||
全不选(无限制)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
|
|
||||||
setEditingUserGroup(prev => prev ? { ...prev, enabledApis: allApis } : null);
|
|
||||||
}}
|
|
||||||
className={buttonStyles.quickAction}
|
|
||||||
>
|
|
||||||
全选
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowEditUserGroupForm(false);
|
|
||||||
setEditingUserGroup(null);
|
|
||||||
}}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleEditUserGroup}
|
|
||||||
disabled={isLoading(`userGroup_edit_${editingUserGroup?.name}`)}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.primary}`}
|
|
||||||
>
|
|
||||||
{isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? '保存中...' : '保存修改'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 配置用户组弹窗 */}
|
|
||||||
{showConfigureUserGroupModal && selectedUserForGroup && createPortal(
|
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
|
||||||
setShowConfigureUserGroupModal(false);
|
|
||||||
setSelectedUserForGroup(null);
|
|
||||||
setSelectedUserGroups([]);
|
|
||||||
}}>
|
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className='p-6'>
|
|
||||||
<div className='flex items-center justify-between mb-6'>
|
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
|
||||||
配置用户组 - {selectedUserForGroup.username}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowConfigureUserGroupModal(false);
|
|
||||||
setSelectedUserForGroup(null);
|
|
||||||
setSelectedUserGroups([]);
|
|
||||||
}}
|
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
|
||||||
>
|
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
|
||||||
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
|
|
||||||
</svg>
|
|
||||||
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
|
||||||
配置说明
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
|
|
||||||
提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户组选择 - 下拉选择器 */}
|
|
||||||
<div className='mb-6'>
|
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
|
||||||
选择用户组:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedUserGroups.length > 0 ? selectedUserGroups[0] : ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setSelectedUserGroups(value ? [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-blue-500 focus:border-transparent transition-colors'
|
|
||||||
>
|
|
||||||
<option value=''>无用户组(无限制)</option>
|
|
||||||
{userGroups.map((group) => (
|
|
||||||
<option key={group.name} value={group.name}>
|
|
||||||
{group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className='flex justify-end space-x-3'>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowConfigureUserGroupModal(false);
|
|
||||||
setSelectedUserForGroup(null);
|
|
||||||
setSelectedUserGroups([]);
|
|
||||||
}}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveUserGroups}
|
|
||||||
disabled={isLoading(`saveUserGroups_${selectedUserForGroup?.username}`)}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
|
|
||||||
>
|
|
||||||
{isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? '配置中...' : '确认配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 删除用户组确认弹窗 */}
|
|
||||||
{showDeleteUserGroupModal && deletingUserGroup && createPortal(
|
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
|
||||||
setShowDeleteUserGroupModal(false);
|
|
||||||
setDeletingUserGroup(null);
|
|
||||||
}}>
|
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className='p-6'>
|
|
||||||
<div className='flex items-center justify-between mb-6'>
|
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
|
||||||
确认删除用户组
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowDeleteUserGroupModal(false);
|
|
||||||
setDeletingUserGroup(null);
|
|
||||||
}}
|
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
|
||||||
>
|
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
|
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
|
||||||
<svg className='w-5 h-5 text-red-600 dark:text-red-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z' />
|
|
||||||
</svg>
|
|
||||||
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
|
|
||||||
危险操作警告
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className='text-sm text-red-700 dark:text-red-400'>
|
|
||||||
删除用户组 <strong>{deletingUserGroup.name}</strong> 将影响所有使用该组的用户,此操作不可恢复!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deletingUserGroup.affectedUsers.length > 0 ? (
|
|
||||||
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
|
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
|
||||||
<svg className='w-5 h-5 text-yellow-600 dark:text-yellow-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
|
|
||||||
</svg>
|
|
||||||
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
|
|
||||||
⚠️ 将影响 {deletingUserGroup.affectedUsers.length} 个用户:
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-1'>
|
|
||||||
{deletingUserGroup.affectedUsers.map((user, index) => (
|
|
||||||
<div key={index} className='text-sm text-yellow-700 dark:text-yellow-300'>
|
|
||||||
• {user.username} ({user.role})
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className='text-xs text-yellow-600 dark:text-yellow-400 mt-2'>
|
|
||||||
这些用户的用户组将被自动移除
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4'>
|
|
||||||
<div className='flex items-center space-x-2'>
|
|
||||||
<svg className='w-5 h-5 text-green-600 dark:text-green-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
|
||||||
</svg>
|
|
||||||
<span className='text-sm font-medium text-green-800 dark:text-green-300'>
|
|
||||||
✅ 当前没有用户使用此用户组
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className='flex justify-end space-x-3'>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowDeleteUserGroupModal(false);
|
|
||||||
setDeletingUserGroup(null);
|
|
||||||
}}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirmDeleteUserGroup}
|
|
||||||
disabled={isLoading(`userGroup_delete_${deletingUserGroup?.name}`)}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.danger}`}
|
|
||||||
>
|
|
||||||
{isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? '删除中...' : '确认删除'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 删除用户确认弹窗 */}
|
|
||||||
{showDeleteUserModal && deletingUser && createPortal(
|
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
|
||||||
setShowDeleteUserModal(false);
|
|
||||||
setDeletingUser(null);
|
|
||||||
}}>
|
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className='p-6'>
|
|
||||||
<div className='flex items-center justify-between mb-6'>
|
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
|
||||||
确认删除用户
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowDeleteUserModal(false);
|
|
||||||
setDeletingUser(null);
|
|
||||||
}}
|
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
|
||||||
>
|
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
|
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
|
||||||
<svg className='w-5 h-5 text-red-600 dark:text-red-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z' />
|
|
||||||
</svg>
|
|
||||||
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
|
|
||||||
危险操作警告
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className='text-sm text-red-700 dark:text-red-400'>
|
|
||||||
删除用户 <strong>{deletingUser}</strong> 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex justify-end space-x-3'>
|
<div className='flex justify-end space-x-3'>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDeleteUserModal(false);
|
setShowConfigureApisModal(false);
|
||||||
setDeletingUser(null);
|
setSelectedUser(null);
|
||||||
|
setSelectedApis([]);
|
||||||
}}
|
}}
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirmDeleteUser}
|
onClick={handleSaveUserApis}
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.danger}`}
|
disabled={isLoading(
|
||||||
|
`saveUserApis_${selectedUser?.username}`
|
||||||
|
)}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
isLoading(`saveUserApis_${selectedUser?.username}`)
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
确认删除
|
{isLoading(`saveUserApis_${selectedUser?.username}`)
|
||||||
|
? '配置中...'
|
||||||
|
: '确认配置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 批量设置用户组弹窗 */}
|
{/* 添加用户组弹窗 */}
|
||||||
{showBatchUserGroupModal && createPortal(
|
{showAddUserGroupForm &&
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
|
createPortal(
|
||||||
setShowBatchUserGroupModal(false);
|
<div
|
||||||
setSelectedUserGroup('');
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
}}>
|
onClick={() => {
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
|
setShowAddUserGroupForm(false);
|
||||||
<div className='p-6'>
|
setNewUserGroup({ name: '', enabledApis: [] });
|
||||||
<div className='flex items-center justify-between mb-6'>
|
}}
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
>
|
||||||
批量设置用户组
|
<div
|
||||||
</h3>
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto'
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={() => {
|
>
|
||||||
setShowBatchUserGroupModal(false);
|
<div className='p-6'>
|
||||||
setSelectedUserGroup('');
|
<div className='flex items-center justify-between mb-6'>
|
||||||
}}
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
添加新用户组
|
||||||
>
|
</h3>
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
<button
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
onClick={() => {
|
||||||
</svg>
|
setShowAddUserGroupForm(false);
|
||||||
</button>
|
setNewUserGroup({ name: '', enabledApis: [] });
|
||||||
</div>
|
}}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
<div className='mb-6'>
|
>
|
||||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'>
|
<svg
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
className='w-6 h-6'
|
||||||
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
fill='none'
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
</button>
|
||||||
批量操作说明
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className='text-sm text-blue-700 dark:text-blue-400'>
|
|
||||||
将为选中的 <strong>{selectedUsers.size} 个用户</strong> 设置用户组,选择"无用户组"为无限制
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className='space-y-6'>
|
||||||
|
{/* 用户组名称 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
用户组名称
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='请输入用户组名称'
|
||||||
|
value={newUserGroup.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewUserGroup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: 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-blue-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可用视频源 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
|
||||||
|
可用视频源
|
||||||
|
</label>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
||||||
|
{config?.SourceConfig?.map((source) => (
|
||||||
|
<label
|
||||||
|
key={source.key}
|
||||||
|
className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={newUserGroup.enabledApis.includes(
|
||||||
|
source.key
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setNewUserGroup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabledApis: [
|
||||||
|
...prev.enabledApis,
|
||||||
|
source.key,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setNewUserGroup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabledApis: prev.enabledApis.filter(
|
||||||
|
(api) => api !== source.key
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
||||||
|
/>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||||
|
{source.name}
|
||||||
|
</div>
|
||||||
|
{source.api && (
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
|
||||||
|
{extractDomain(source.api)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快速操作按钮 */}
|
||||||
|
<div className='mt-4 flex space-x-2'>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setNewUserGroup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabledApis: [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={buttonStyles.quickAction}
|
||||||
|
>
|
||||||
|
全不选(无限制)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const allApis =
|
||||||
|
config?.SourceConfig?.filter(
|
||||||
|
(source) => !source.disabled
|
||||||
|
).map((s) => s.key) || [];
|
||||||
|
setNewUserGroup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabledApis: allApis,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={buttonStyles.quickAction}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddUserGroupForm(false);
|
||||||
|
setNewUserGroup({ name: '', enabledApis: [] });
|
||||||
|
}}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddUserGroup}
|
||||||
|
disabled={
|
||||||
|
!newUserGroup.name.trim() ||
|
||||||
|
isLoading('userGroup_add_new')
|
||||||
|
}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
!newUserGroup.name.trim() ||
|
||||||
|
isLoading('userGroup_add_new')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('userGroup_add_new')
|
||||||
|
? '添加中...'
|
||||||
|
: '添加用户组'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑用户组弹窗 */}
|
||||||
|
{showEditUserGroupForm &&
|
||||||
|
editingUserGroup &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditUserGroupForm(false);
|
||||||
|
setEditingUserGroup(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
编辑用户组 - {editingUserGroup.name}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditUserGroupForm(false);
|
||||||
|
setEditingUserGroup(null);
|
||||||
|
}}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 可用视频源 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
|
||||||
|
可用视频源
|
||||||
|
</label>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
||||||
|
{config?.SourceConfig?.map((source) => (
|
||||||
|
<label
|
||||||
|
key={source.key}
|
||||||
|
className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={editingUserGroup.enabledApis.includes(
|
||||||
|
source.key
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setEditingUserGroup((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
enabledApis: [
|
||||||
|
...prev.enabledApis,
|
||||||
|
source.key,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setEditingUserGroup((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
enabledApis: prev.enabledApis.filter(
|
||||||
|
(api) => api !== source.key
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700'
|
||||||
|
/>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||||
|
{source.name}
|
||||||
|
</div>
|
||||||
|
{source.api && (
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
|
||||||
|
{extractDomain(source.api)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快速操作按钮 */}
|
||||||
|
<div className='mt-4 flex space-x-2'>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditingUserGroup((prev) =>
|
||||||
|
prev ? { ...prev, enabledApis: [] } : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={buttonStyles.quickAction}
|
||||||
|
>
|
||||||
|
全不选(无限制)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const allApis =
|
||||||
|
config?.SourceConfig?.filter(
|
||||||
|
(source) => !source.disabled
|
||||||
|
).map((s) => s.key) || [];
|
||||||
|
setEditingUserGroup((prev) =>
|
||||||
|
prev ? { ...prev, enabledApis: allApis } : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={buttonStyles.quickAction}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditUserGroupForm(false);
|
||||||
|
setEditingUserGroup(null);
|
||||||
|
}}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditUserGroup}
|
||||||
|
disabled={isLoading(
|
||||||
|
`userGroup_edit_${editingUserGroup?.name}`
|
||||||
|
)}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
isLoading(`userGroup_edit_${editingUserGroup?.name}`)
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading(`userGroup_edit_${editingUserGroup?.name}`)
|
||||||
|
? '保存中...'
|
||||||
|
: '保存修改'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 配置用户组弹窗 */}
|
||||||
|
{showConfigureUserGroupModal &&
|
||||||
|
selectedUserForGroup &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
|
onClick={() => {
|
||||||
|
setShowConfigureUserGroupModal(false);
|
||||||
|
setSelectedUserForGroup(null);
|
||||||
|
setSelectedUserGroups([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
配置用户组 - {selectedUserForGroup.username}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowConfigureUserGroupModal(false);
|
||||||
|
setSelectedUserForGroup(null);
|
||||||
|
setSelectedUserGroups([]);
|
||||||
|
}}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-blue-600 dark:text-blue-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
||||||
|
配置说明
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
|
||||||
|
提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户组选择 - 下拉选择器 */}
|
||||||
|
<div className='mb-6'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
选择用户组:
|
选择用户组:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => setSelectedUserGroup(e.target.value)}
|
value={
|
||||||
|
selectedUserGroups.length > 0 ? selectedUserGroups[0] : ''
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSelectedUserGroups(value ? [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-blue-500 focus:border-transparent transition-colors'
|
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 transition-colors'
|
||||||
value={selectedUserGroup}
|
|
||||||
>
|
>
|
||||||
<option value=''>无用户组(无限制)</option>
|
<option value=''>无用户组(无限制)</option>
|
||||||
{userGroups.map((group) => (
|
{userGroups.map((group) => (
|
||||||
<option key={group.name} value={group.name}>
|
<option key={group.name} value={group.name}>
|
||||||
{group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
|
{group.name}{' '}
|
||||||
|
{group.enabledApis && group.enabledApis.length > 0
|
||||||
|
? `(${group.enabledApis.length} 个源)`
|
||||||
|
: ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -1979,32 +2073,421 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
|
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex justify-end space-x-3'>
|
<div className='flex justify-end space-x-3'>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowBatchUserGroupModal(false);
|
setShowConfigureUserGroupModal(false);
|
||||||
setSelectedUserGroup('');
|
setSelectedUserForGroup(null);
|
||||||
}}
|
setSelectedUserGroups([]);
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
}}
|
||||||
>
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
取消
|
>
|
||||||
</button>
|
取消
|
||||||
<button
|
</button>
|
||||||
onClick={() => handleBatchSetUserGroup(selectedUserGroup)}
|
<button
|
||||||
disabled={isLoading('batchSetUserGroup')}
|
onClick={handleSaveUserGroups}
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${isLoading('batchSetUserGroup') ? buttonStyles.disabled : buttonStyles.primary}`}
|
disabled={isLoading(
|
||||||
>
|
`saveUserGroups_${selectedUserForGroup?.username}`
|
||||||
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
|
)}
|
||||||
</button>
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
isLoading(
|
||||||
|
`saveUserGroups_${selectedUserForGroup?.username}`
|
||||||
|
)
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading(
|
||||||
|
`saveUserGroups_${selectedUserForGroup?.username}`
|
||||||
|
)
|
||||||
|
? '配置中...'
|
||||||
|
: '确认配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
|
{/* 删除用户组确认弹窗 */}
|
||||||
|
{showDeleteUserGroupModal &&
|
||||||
|
deletingUserGroup &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteUserGroupModal(false);
|
||||||
|
setDeletingUserGroup(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
确认删除用户组
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteUserGroupModal(false);
|
||||||
|
setDeletingUserGroup(null);
|
||||||
|
}}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-red-600 dark:text-red-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
|
||||||
|
危险操作警告
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-red-700 dark:text-red-400'>
|
||||||
|
删除用户组 <strong>{deletingUserGroup.name}</strong>{' '}
|
||||||
|
将影响所有使用该组的用户,此操作不可恢复!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deletingUserGroup.affectedUsers.length > 0 ? (
|
||||||
|
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-yellow-600 dark:text-yellow-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
|
||||||
|
⚠️ 将影响 {deletingUserGroup.affectedUsers.length}{' '}
|
||||||
|
个用户:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
{deletingUserGroup.affectedUsers.map((user, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='text-sm text-yellow-700 dark:text-yellow-300'
|
||||||
|
>
|
||||||
|
• {user.username} ({user.role})
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className='text-xs text-yellow-600 dark:text-yellow-400 mt-2'>
|
||||||
|
这些用户的用户组将被自动移除
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4'>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-green-600 dark:text-green-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M5 13l4 4L19 7'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-green-800 dark:text-green-300'>
|
||||||
|
✅ 当前没有用户使用此用户组
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteUserGroupModal(false);
|
||||||
|
setDeletingUserGroup(null);
|
||||||
|
}}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmDeleteUserGroup}
|
||||||
|
disabled={isLoading(
|
||||||
|
`userGroup_delete_${deletingUserGroup?.name}`
|
||||||
|
)}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
isLoading(`userGroup_delete_${deletingUserGroup?.name}`)
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.danger
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading(`userGroup_delete_${deletingUserGroup?.name}`)
|
||||||
|
? '删除中...'
|
||||||
|
: '确认删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除用户确认弹窗 */}
|
||||||
|
{showDeleteUserModal &&
|
||||||
|
deletingUser &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteUserModal(false);
|
||||||
|
setDeletingUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
确认删除用户
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteUserModal(false);
|
||||||
|
setDeletingUser(null);
|
||||||
|
}}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-red-600 dark:text-red-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
|
||||||
|
危险操作警告
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-red-700 dark:text-red-400'>
|
||||||
|
删除用户 <strong>{deletingUser}</strong>{' '}
|
||||||
|
将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteUserModal(false);
|
||||||
|
setDeletingUser(null);
|
||||||
|
}}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmDeleteUser}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.danger}`}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 批量设置用户组弹窗 */}
|
||||||
|
{showBatchUserGroupModal &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
|
onClick={() => {
|
||||||
|
setShowBatchUserGroupModal(false);
|
||||||
|
setSelectedUserGroup('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
批量设置用户组
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBatchUserGroupModal(false);
|
||||||
|
setSelectedUserGroup('');
|
||||||
|
}}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-blue-600 dark:text-blue-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
||||||
|
批量操作说明
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-blue-700 dark:text-blue-400'>
|
||||||
|
将为选中的 <strong>{selectedUsers.size} 个用户</strong>{' '}
|
||||||
|
设置用户组,选择"无用户组"为无限制
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
选择用户组:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
onChange={(e) => setSelectedUserGroup(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-blue-500 focus:border-transparent transition-colors'
|
||||||
|
value={selectedUserGroup}
|
||||||
|
>
|
||||||
|
<option value=''>无用户组(无限制)</option>
|
||||||
|
{userGroups.map((group) => (
|
||||||
|
<option key={group.name} value={group.name}>
|
||||||
|
{group.name}{' '}
|
||||||
|
{group.enabledApis && group.enabledApis.length > 0
|
||||||
|
? `(${group.enabledApis.length} 个源)`
|
||||||
|
: ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBatchUserGroupModal(false);
|
||||||
|
setSelectedUserGroup('');
|
||||||
|
}}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBatchSetUserGroup(selectedUserGroup)}
|
||||||
|
disabled={isLoading('batchSetUserGroup')}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
isLoading('batchSetUserGroup')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 通用弹窗组件 */}
|
{/* 通用弹窗组件 */}
|
||||||
<AlertModal
|
<AlertModal
|
||||||
@@ -2016,11 +2499,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
timer={alertModal.timer}
|
timer={alertModal.timer}
|
||||||
showConfirm={alertModal.showConfirm}
|
showConfirm={alertModal.showConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 视频源配置组件
|
// 视频源配置组件
|
||||||
const VideoSourceConfig = ({
|
const VideoSourceConfig = ({
|
||||||
@@ -2045,7 +2526,9 @@ const VideoSourceConfig = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 批量操作相关状态
|
// 批量操作相关状态
|
||||||
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
|
const [selectedSources, setSelectedSources] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
|
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
|
||||||
const selectAll = useMemo(() => {
|
const selectAll = useMemo(() => {
|
||||||
@@ -2063,21 +2546,23 @@ const VideoSourceConfig = ({
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
title: '',
|
title: '',
|
||||||
message: '',
|
message: '',
|
||||||
onConfirm: () => { },
|
onConfirm: () => {},
|
||||||
onCancel: () => { }
|
onCancel: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 有效性检测相关状态
|
// 有效性检测相关状态
|
||||||
const [showValidationModal, setShowValidationModal] = useState(false);
|
const [showValidationModal, setShowValidationModal] = useState(false);
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const [validationResults, setValidationResults] = useState<Array<{
|
const [validationResults, setValidationResults] = useState<
|
||||||
key: string;
|
Array<{
|
||||||
name: string;
|
key: string;
|
||||||
status: 'valid' | 'no_results' | 'invalid' | 'validating';
|
name: string;
|
||||||
message: string;
|
status: 'valid' | 'no_results' | 'invalid' | 'validating';
|
||||||
resultCount: number;
|
message: string;
|
||||||
}>>([]);
|
resultCount: number;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
// dnd-kit 传感器
|
// dnd-kit 传感器
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -2131,13 +2616,17 @@ const VideoSourceConfig = ({
|
|||||||
const target = sources.find((s) => s.key === key);
|
const target = sources.find((s) => s.key === key);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const action = target.disabled ? 'enable' : 'disable';
|
const action = target.disabled ? 'enable' : 'disable';
|
||||||
withLoading(`toggleSource_${key}`, () => callSourceApi({ action, key })).catch(() => {
|
withLoading(`toggleSource_${key}`, () =>
|
||||||
|
callSourceApi({ action, key })
|
||||||
|
).catch(() => {
|
||||||
console.error('操作失败', action, key);
|
console.error('操作失败', action, key);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (key: string) => {
|
const handleDelete = (key: string) => {
|
||||||
withLoading(`deleteSource_${key}`, () => callSourceApi({ action: 'delete', key })).catch(() => {
|
withLoading(`deleteSource_${key}`, () =>
|
||||||
|
callSourceApi({ action: 'delete', key })
|
||||||
|
).catch(() => {
|
||||||
console.error('操作失败', 'delete', key);
|
console.error('操作失败', 'delete', key);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2177,7 +2666,9 @@ const VideoSourceConfig = ({
|
|||||||
|
|
||||||
const handleSaveOrder = () => {
|
const handleSaveOrder = () => {
|
||||||
const order = sources.map((s) => s.key);
|
const order = sources.map((s) => s.key);
|
||||||
withLoading('saveSourceOrder', () => callSourceApi({ action: 'sort', order }))
|
withLoading('saveSourceOrder', () =>
|
||||||
|
callSourceApi({ action: 'sort', order })
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setOrderChanged(false);
|
setOrderChanged(false);
|
||||||
})
|
})
|
||||||
@@ -2189,7 +2680,11 @@ const VideoSourceConfig = ({
|
|||||||
// 有效性检测函数
|
// 有效性检测函数
|
||||||
const handleValidateSources = async () => {
|
const handleValidateSources = async () => {
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' });
|
showAlert({
|
||||||
|
type: 'warning',
|
||||||
|
title: '请输入搜索关键词',
|
||||||
|
message: '搜索关键词不能为空',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2199,18 +2694,22 @@ const VideoSourceConfig = ({
|
|||||||
setShowValidationModal(false); // 立即关闭弹窗
|
setShowValidationModal(false); // 立即关闭弹窗
|
||||||
|
|
||||||
// 初始化所有视频源为检测中状态
|
// 初始化所有视频源为检测中状态
|
||||||
const initialResults = sources.map(source => ({
|
const initialResults = sources.map((source) => ({
|
||||||
key: source.key,
|
key: source.key,
|
||||||
name: source.name,
|
name: source.name,
|
||||||
status: 'validating' as const,
|
status: 'validating' as const,
|
||||||
message: '检测中...',
|
message: '检测中...',
|
||||||
resultCount: 0
|
resultCount: 0,
|
||||||
}));
|
}));
|
||||||
setValidationResults(initialResults);
|
setValidationResults(initialResults);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用EventSource接收流式数据
|
// 使用EventSource接收流式数据
|
||||||
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`);
|
const eventSource = new EventSource(
|
||||||
|
`/api/admin/source/validate?q=${encodeURIComponent(
|
||||||
|
searchKeyword.trim()
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -2224,32 +2723,53 @@ const VideoSourceConfig = ({
|
|||||||
case 'source_result':
|
case 'source_result':
|
||||||
case 'source_error':
|
case 'source_error':
|
||||||
// 更新验证结果
|
// 更新验证结果
|
||||||
setValidationResults(prev => {
|
setValidationResults((prev) => {
|
||||||
const existing = prev.find(r => r.key === data.source);
|
const existing = prev.find((r) => r.key === data.source);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return prev.map(r => r.key === data.source ? {
|
return prev.map((r) =>
|
||||||
key: data.source,
|
r.key === data.source
|
||||||
name: sources.find(s => s.key === data.source)?.name || data.source,
|
? {
|
||||||
status: data.status,
|
key: data.source,
|
||||||
message: data.status === 'valid' ? '搜索正常' :
|
name:
|
||||||
data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
|
sources.find((s) => s.key === data.source)
|
||||||
resultCount: data.status === 'valid' ? 1 : 0
|
?.name || data.source,
|
||||||
} : r);
|
status: data.status,
|
||||||
|
message:
|
||||||
|
data.status === 'valid'
|
||||||
|
? '搜索正常'
|
||||||
|
: data.status === 'no_results'
|
||||||
|
? '无法搜索到结果'
|
||||||
|
: '连接失败',
|
||||||
|
resultCount: data.status === 'valid' ? 1 : 0,
|
||||||
|
}
|
||||||
|
: r
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return [...prev, {
|
return [
|
||||||
key: data.source,
|
...prev,
|
||||||
name: sources.find(s => s.key === data.source)?.name || data.source,
|
{
|
||||||
status: data.status,
|
key: data.source,
|
||||||
message: data.status === 'valid' ? '搜索正常' :
|
name:
|
||||||
data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
|
sources.find((s) => s.key === data.source)?.name ||
|
||||||
resultCount: data.status === 'valid' ? 1 : 0
|
data.source,
|
||||||
}];
|
status: data.status,
|
||||||
|
message:
|
||||||
|
data.status === 'valid'
|
||||||
|
? '搜索正常'
|
||||||
|
: data.status === 'no_results'
|
||||||
|
? '无法搜索到结果'
|
||||||
|
: '连接失败',
|
||||||
|
resultCount: data.status === 'valid' ? 1 : 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'complete':
|
case 'complete':
|
||||||
console.log(`检测完成,共检测 ${data.completedSources} 个视频源`);
|
console.log(
|
||||||
|
`检测完成,共检测 ${data.completedSources} 个视频源`
|
||||||
|
);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
break;
|
break;
|
||||||
@@ -2263,7 +2783,11 @@ const VideoSourceConfig = ({
|
|||||||
console.error('EventSource错误:', error);
|
console.error('EventSource错误:', error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
|
showAlert({
|
||||||
|
type: 'error',
|
||||||
|
title: '验证失败',
|
||||||
|
message: '连接错误,请重试',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置超时,防止长时间等待
|
// 设置超时,防止长时间等待
|
||||||
@@ -2271,13 +2795,20 @@ const VideoSourceConfig = ({
|
|||||||
if (eventSource.readyState === EventSource.OPEN) {
|
if (eventSource.readyState === EventSource.OPEN) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' });
|
showAlert({
|
||||||
|
type: 'warning',
|
||||||
|
title: '验证超时',
|
||||||
|
message: '检测超时,请重试',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 60000); // 60秒超时
|
}, 60000); // 60秒超时
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' });
|
showAlert({
|
||||||
|
type: 'error',
|
||||||
|
title: '验证失败',
|
||||||
|
message: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2285,37 +2816,41 @@ const VideoSourceConfig = ({
|
|||||||
|
|
||||||
// 获取有效性状态显示
|
// 获取有效性状态显示
|
||||||
const getValidationStatus = (sourceKey: string) => {
|
const getValidationStatus = (sourceKey: string) => {
|
||||||
const result = validationResults.find(r => r.key === sourceKey);
|
const result = validationResults.find((r) => r.key === sourceKey);
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'validating':
|
case 'validating':
|
||||||
return {
|
return {
|
||||||
text: '检测中',
|
text: '检测中',
|
||||||
className: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300',
|
className:
|
||||||
|
'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300',
|
||||||
icon: '⟳',
|
icon: '⟳',
|
||||||
message: result.message
|
message: result.message,
|
||||||
};
|
};
|
||||||
case 'valid':
|
case 'valid':
|
||||||
return {
|
return {
|
||||||
text: '有效',
|
text: '有效',
|
||||||
className: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300',
|
className:
|
||||||
|
'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300',
|
||||||
icon: '✓',
|
icon: '✓',
|
||||||
message: result.message
|
message: result.message,
|
||||||
};
|
};
|
||||||
case 'no_results':
|
case 'no_results':
|
||||||
return {
|
return {
|
||||||
text: '无法搜索',
|
text: '无法搜索',
|
||||||
className: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300',
|
className:
|
||||||
|
'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300',
|
||||||
icon: '⚠',
|
icon: '⚠',
|
||||||
message: result.message
|
message: result.message,
|
||||||
};
|
};
|
||||||
case 'invalid':
|
case 'invalid':
|
||||||
return {
|
return {
|
||||||
text: '无效',
|
text: '无效',
|
||||||
className: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300',
|
className:
|
||||||
|
'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300',
|
||||||
icon: '✗',
|
icon: '✗',
|
||||||
message: result.message
|
message: result.message,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -2374,10 +2909,11 @@ const VideoSourceConfig = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs rounded-full ${!source.disabled
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
!source.disabled
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
}`}
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!source.disabled ? '启用中' : '已禁用'}
|
{!source.disabled ? '启用中' : '已禁用'}
|
||||||
</span>
|
</span>
|
||||||
@@ -2393,7 +2929,10 @@ const VideoSourceConfig = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${status.className}`} title={status.message}>
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${status.className}`}
|
||||||
|
title={status.message}
|
||||||
|
>
|
||||||
{status.icon} {status.text}
|
{status.icon} {status.text}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -2403,10 +2942,15 @@ const VideoSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleEnable(source.key)}
|
onClick={() => handleToggleEnable(source.key)}
|
||||||
disabled={isLoading(`toggleSource_${source.key}`)}
|
disabled={isLoading(`toggleSource_${source.key}`)}
|
||||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
|
||||||
? buttonStyles.roundedDanger
|
!source.disabled
|
||||||
: buttonStyles.roundedSuccess
|
? buttonStyles.roundedDanger
|
||||||
} transition-colors ${isLoading(`toggleSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
: buttonStyles.roundedSuccess
|
||||||
|
} transition-colors ${
|
||||||
|
isLoading(`toggleSource_${source.key}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!source.disabled ? '禁用' : '启用'}
|
{!source.disabled ? '禁用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
@@ -2414,7 +2958,11 @@ const VideoSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(source.key)}
|
onClick={() => handleDelete(source.key)}
|
||||||
disabled={isLoading(`deleteSource_${source.key}`)}
|
disabled={isLoading(`deleteSource_${source.key}`)}
|
||||||
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`${buttonStyles.roundedSecondary} ${
|
||||||
|
isLoading(`deleteSource_${source.key}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
@@ -2425,18 +2973,21 @@ const VideoSourceConfig = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 全选/取消全选
|
// 全选/取消全选
|
||||||
const handleSelectAll = useCallback((checked: boolean) => {
|
const handleSelectAll = useCallback(
|
||||||
if (checked) {
|
(checked: boolean) => {
|
||||||
const allKeys = sources.map(s => s.key);
|
if (checked) {
|
||||||
setSelectedSources(new Set(allKeys));
|
const allKeys = sources.map((s) => s.key);
|
||||||
} else {
|
setSelectedSources(new Set(allKeys));
|
||||||
setSelectedSources(new Set());
|
} else {
|
||||||
}
|
setSelectedSources(new Set());
|
||||||
}, [sources]);
|
}
|
||||||
|
},
|
||||||
|
[sources]
|
||||||
|
);
|
||||||
|
|
||||||
// 单个选择
|
// 单个选择
|
||||||
const handleSelectSource = useCallback((key: string, checked: boolean) => {
|
const handleSelectSource = useCallback((key: string, checked: boolean) => {
|
||||||
setSelectedSources(prev => {
|
setSelectedSources((prev) => {
|
||||||
const newSelected = new Set(prev);
|
const newSelected = new Set(prev);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
newSelected.add(key);
|
newSelected.add(key);
|
||||||
@@ -2448,9 +2999,15 @@ const VideoSourceConfig = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 批量操作
|
// 批量操作
|
||||||
const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
|
const handleBatchOperation = async (
|
||||||
|
action: 'batch_enable' | 'batch_disable' | 'batch_delete'
|
||||||
|
) => {
|
||||||
if (selectedSources.size === 0) {
|
if (selectedSources.size === 0) {
|
||||||
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' });
|
showAlert({
|
||||||
|
type: 'warning',
|
||||||
|
title: '请先选择要操作的视频源',
|
||||||
|
message: '请选择至少一个视频源',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2480,18 +3037,41 @@ const VideoSourceConfig = ({
|
|||||||
message: confirmMessage,
|
message: confirmMessage,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
await withLoading(`batchSource_${action}`, () => callSourceApi({ action, keys }));
|
await withLoading(`batchSource_${action}`, () =>
|
||||||
showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}了 ${keys.length} 个视频源`, timer: 2000 });
|
callSourceApi({ action, keys })
|
||||||
|
);
|
||||||
|
showAlert({
|
||||||
|
type: 'success',
|
||||||
|
title: `${actionName}成功`,
|
||||||
|
message: `${actionName}了 ${keys.length} 个视频源`,
|
||||||
|
timer: 2000,
|
||||||
|
});
|
||||||
// 重置选择状态
|
// 重置选择状态
|
||||||
setSelectedSources(new Set());
|
setSelectedSources(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败' });
|
showAlert({
|
||||||
|
type: 'error',
|
||||||
|
title: `${actionName}失败`,
|
||||||
|
message: err instanceof Error ? err.message : '操作失败',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
|
setConfirmModal({
|
||||||
}
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2517,28 +3097,48 @@ const VideoSourceConfig = ({
|
|||||||
<div className='flex flex-wrap items-center gap-3 order-2 sm:order-1'>
|
<div className='flex flex-wrap items-center gap-3 order-2 sm:order-1'>
|
||||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
<span className='sm:hidden'>已选 {selectedSources.size}</span>
|
<span className='sm:hidden'>已选 {selectedSources.size}</span>
|
||||||
<span className='hidden sm:inline'>已选择 {selectedSources.size} 个视频源</span>
|
<span className='hidden sm:inline'>
|
||||||
|
已选择 {selectedSources.size} 个视频源
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBatchOperation('batch_enable')}
|
onClick={() => handleBatchOperation('batch_enable')}
|
||||||
disabled={isLoading('batchSource_batch_enable')}
|
disabled={isLoading('batchSource_batch_enable')}
|
||||||
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_enable') ? buttonStyles.disabled : buttonStyles.success}`}
|
className={`px-3 py-1 text-sm ${
|
||||||
|
isLoading('batchSource_batch_enable')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('batchSource_batch_enable') ? '启用中...' : '批量启用'}
|
{isLoading('batchSource_batch_enable')
|
||||||
|
? '启用中...'
|
||||||
|
: '批量启用'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBatchOperation('batch_disable')}
|
onClick={() => handleBatchOperation('batch_disable')}
|
||||||
disabled={isLoading('batchSource_batch_disable')}
|
disabled={isLoading('batchSource_batch_disable')}
|
||||||
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_disable') ? buttonStyles.disabled : buttonStyles.warning}`}
|
className={`px-3 py-1 text-sm ${
|
||||||
|
isLoading('batchSource_batch_disable')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.warning
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('batchSource_batch_disable') ? '禁用中...' : '批量禁用'}
|
{isLoading('batchSource_batch_disable')
|
||||||
|
? '禁用中...'
|
||||||
|
: '批量禁用'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBatchOperation('batch_delete')}
|
onClick={() => handleBatchOperation('batch_delete')}
|
||||||
disabled={isLoading('batchSource_batch_delete')}
|
disabled={isLoading('batchSource_batch_delete')}
|
||||||
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.danger}`}
|
className={`px-3 py-1 text-sm ${
|
||||||
|
isLoading('batchSource_batch_delete')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.danger
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
|
{isLoading('batchSource_batch_delete')
|
||||||
|
? '删除中...'
|
||||||
|
: '批量删除'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div>
|
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div>
|
||||||
@@ -2548,10 +3148,9 @@ const VideoSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowValidationModal(true)}
|
onClick={() => setShowValidationModal(true)}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
className={`px-3 py-1 text-sm rounded-lg transition-colors flex items-center space-x-1 ${isValidating
|
className={`px-3 py-1 text-sm rounded-lg transition-colors flex items-center space-x-1 ${
|
||||||
? buttonStyles.disabled
|
isValidating ? buttonStyles.disabled : buttonStyles.primary
|
||||||
: buttonStyles.primary
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isValidating ? (
|
{isValidating ? (
|
||||||
<>
|
<>
|
||||||
@@ -2564,7 +3163,9 @@ const VideoSourceConfig = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
className={
|
||||||
|
showAddForm ? buttonStyles.secondary : buttonStyles.success
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showAddForm ? '取消' : '添加视频源'}
|
{showAddForm ? '取消' : '添加视频源'}
|
||||||
</button>
|
</button>
|
||||||
@@ -2615,8 +3216,20 @@ const VideoSourceConfig = ({
|
|||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddSource}
|
onClick={handleAddSource}
|
||||||
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
|
disabled={
|
||||||
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
!newSource.name ||
|
||||||
|
!newSource.key ||
|
||||||
|
!newSource.api ||
|
||||||
|
isLoading('addSource')
|
||||||
|
}
|
||||||
|
className={`w-full sm:w-auto px-4 py-2 ${
|
||||||
|
!newSource.name ||
|
||||||
|
!newSource.key ||
|
||||||
|
!newSource.api ||
|
||||||
|
isLoading('addSource')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('addSource') ? '添加中...' : '添加'}
|
{isLoading('addSource') ? '添加中...' : '添加'}
|
||||||
</button>
|
</button>
|
||||||
@@ -2624,10 +3237,11 @@ const VideoSourceConfig = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 视频源表格 */}
|
{/* 视频源表格 */}
|
||||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="source-list">
|
<div
|
||||||
|
className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative'
|
||||||
|
data-table='source-list'
|
||||||
|
>
|
||||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -2690,7 +3304,11 @@ const VideoSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveOrder}
|
onClick={handleSaveOrder}
|
||||||
disabled={isLoading('saveSourceOrder')}
|
disabled={isLoading('saveSourceOrder')}
|
||||||
className={`px-3 py-1.5 text-sm ${isLoading('saveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
|
className={`px-3 py-1.5 text-sm ${
|
||||||
|
isLoading('saveSourceOrder')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
|
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
|
||||||
</button>
|
</button>
|
||||||
@@ -2698,44 +3316,57 @@ const VideoSourceConfig = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 有效性检测弹窗 */}
|
{/* 有效性检测弹窗 */}
|
||||||
{showValidationModal && createPortal(
|
{showValidationModal &&
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50' onClick={() => setShowValidationModal(false)}>
|
createPortal(
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4' onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
|
className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'
|
||||||
视频源有效性检测
|
onClick={() => setShowValidationModal(false)}
|
||||||
</h3>
|
>
|
||||||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
|
<div
|
||||||
请输入检测用的搜索关键词
|
className='bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4'
|
||||||
</p>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<div className='space-y-4'>
|
>
|
||||||
<input
|
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
|
||||||
type='text'
|
视频源有效性检测
|
||||||
placeholder='请输入搜索关键词'
|
</h3>
|
||||||
value={searchKeyword}
|
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
|
||||||
onChange={(e) => setSearchKeyword(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'
|
</p>
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleValidateSources()}
|
<div className='space-y-4'>
|
||||||
/>
|
<input
|
||||||
<div className='flex justify-end space-x-3'>
|
type='text'
|
||||||
<button
|
placeholder='请输入搜索关键词'
|
||||||
onClick={() => setShowValidationModal(false)}
|
value={searchKeyword}
|
||||||
className='px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors'
|
onChange={(e) => setSearchKeyword(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'
|
||||||
取消
|
onKeyPress={(e) =>
|
||||||
</button>
|
e.key === 'Enter' && handleValidateSources()
|
||||||
<button
|
}
|
||||||
onClick={handleValidateSources}
|
/>
|
||||||
disabled={!searchKeyword.trim()}
|
<div className='flex justify-end space-x-3'>
|
||||||
className={`px-4 py-2 ${!searchKeyword.trim() ? buttonStyles.disabled : buttonStyles.primary}`}
|
<button
|
||||||
>
|
onClick={() => setShowValidationModal(false)}
|
||||||
开始检测
|
className='px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors'
|
||||||
</button>
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleValidateSources}
|
||||||
|
disabled={!searchKeyword.trim()}
|
||||||
|
className={`px-4 py-2 ${
|
||||||
|
!searchKeyword.trim()
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
开始检测
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 通用弹窗组件 */}
|
{/* 通用弹窗组件 */}
|
||||||
<AlertModal
|
<AlertModal
|
||||||
@@ -2749,51 +3380,82 @@ const VideoSourceConfig = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 批量操作确认弹窗 */}
|
{/* 批量操作确认弹窗 */}
|
||||||
{confirmModal.isOpen && createPortal(
|
{confirmModal.isOpen &&
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={confirmModal.onCancel}>
|
createPortal(
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full' onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
<div className='p-6'>
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
<div className='flex items-center justify-between mb-4'>
|
onClick={confirmModal.onCancel}
|
||||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
>
|
||||||
{confirmModal.title}
|
<div
|
||||||
</h3>
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full'
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={confirmModal.onCancel}
|
>
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
<div className='p-6'>
|
||||||
>
|
<div className='flex items-center justify-between mb-4'>
|
||||||
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
{confirmModal.title}
|
||||||
</svg>
|
</h3>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={confirmModal.onCancel}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 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='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mb-6'>
|
<div className='mb-6'>
|
||||||
<p className='text-sm text-gray-600 dark:text-gray-400'>
|
<p className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
{confirmModal.message}
|
{confirmModal.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex justify-end space-x-3'>
|
<div className='flex justify-end space-x-3'>
|
||||||
<button
|
<button
|
||||||
onClick={confirmModal.onCancel}
|
onClick={confirmModal.onCancel}
|
||||||
className={`px-4 py-2 text-sm font-medium ${buttonStyles.secondary}`}
|
className={`px-4 py-2 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={confirmModal.onConfirm}
|
onClick={confirmModal.onConfirm}
|
||||||
disabled={isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete')}
|
disabled={
|
||||||
className={`px-4 py-2 text-sm font-medium ${isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.primary}`}
|
isLoading('batchSource_batch_enable') ||
|
||||||
>
|
isLoading('batchSource_batch_disable') ||
|
||||||
{isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? '操作中...' : '确认'}
|
isLoading('batchSource_batch_delete')
|
||||||
</button>
|
}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
isLoading('batchSource_batch_enable') ||
|
||||||
|
isLoading('batchSource_batch_disable') ||
|
||||||
|
isLoading('batchSource_batch_delete')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('batchSource_batch_enable') ||
|
||||||
|
isLoading('batchSource_batch_disable') ||
|
||||||
|
isLoading('batchSource_batch_delete')
|
||||||
|
? '操作中...'
|
||||||
|
: '确认'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2869,13 +3531,17 @@ const CategoryConfig = ({
|
|||||||
const target = categories.find((c) => c.query === query && c.type === type);
|
const target = categories.find((c) => c.query === query && c.type === type);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const action = target.disabled ? 'enable' : 'disable';
|
const action = target.disabled ? 'enable' : 'disable';
|
||||||
withLoading(`toggleCategory_${query}_${type}`, () => callCategoryApi({ action, query, type })).catch(() => {
|
withLoading(`toggleCategory_${query}_${type}`, () =>
|
||||||
|
callCategoryApi({ action, query, type })
|
||||||
|
).catch(() => {
|
||||||
console.error('操作失败', action, query, type);
|
console.error('操作失败', action, query, type);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (query: string, type: 'movie' | 'tv') => {
|
const handleDelete = (query: string, type: 'movie' | 'tv') => {
|
||||||
withLoading(`deleteCategory_${query}_${type}`, () => callCategoryApi({ action: 'delete', query, type })).catch(() => {
|
withLoading(`deleteCategory_${query}_${type}`, () =>
|
||||||
|
callCategoryApi({ action: 'delete', query, type })
|
||||||
|
).catch(() => {
|
||||||
console.error('操作失败', 'delete', query, type);
|
console.error('操作失败', 'delete', query, type);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2917,7 +3583,9 @@ const CategoryConfig = ({
|
|||||||
|
|
||||||
const handleSaveOrder = () => {
|
const handleSaveOrder = () => {
|
||||||
const order = categories.map((c) => `${c.query}:${c.type}`);
|
const order = categories.map((c) => `${c.query}:${c.type}`);
|
||||||
withLoading('saveCategoryOrder', () => callCategoryApi({ action: 'sort', order }))
|
withLoading('saveCategoryOrder', () =>
|
||||||
|
callCategoryApi({ action: 'sort', order })
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setOrderChanged(false);
|
setOrderChanged(false);
|
||||||
})
|
})
|
||||||
@@ -2943,7 +3611,7 @@ const CategoryConfig = ({
|
|||||||
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
className="px-2 py-4 cursor-grab text-gray-400"
|
className='px-2 py-4 cursor-grab text-gray-400'
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
{...{ ...attributes, ...listeners }}
|
{...{ ...attributes, ...listeners }}
|
||||||
>
|
>
|
||||||
@@ -2954,10 +3622,11 @@ const CategoryConfig = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs rounded-full ${category.type === 'movie'
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
|
category.type === 'movie'
|
||||||
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
|
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
|
||||||
}`}
|
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{category.type === 'movie' ? '电影' : '电视剧'}
|
{category.type === 'movie' ? '电影' : '电视剧'}
|
||||||
</span>
|
</span>
|
||||||
@@ -2970,32 +3639,44 @@ const CategoryConfig = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs rounded-full ${!category.disabled
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
!category.disabled
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
? '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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!category.disabled ? '启用中' : '已禁用'}
|
{!category.disabled ? '启用中' : '已禁用'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => handleToggleEnable(category.query, category.type)}
|
||||||
handleToggleEnable(category.query, category.type)
|
disabled={isLoading(
|
||||||
}
|
`toggleCategory_${category.query}_${category.type}`
|
||||||
disabled={isLoading(`toggleCategory_${category.query}_${category.type}`)}
|
)}
|
||||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!category.disabled
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
|
||||||
? buttonStyles.roundedDanger
|
!category.disabled
|
||||||
: buttonStyles.roundedSuccess
|
? buttonStyles.roundedDanger
|
||||||
} transition-colors ${isLoading(`toggleCategory_${category.query}_${category.type}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
: buttonStyles.roundedSuccess
|
||||||
|
} transition-colors ${
|
||||||
|
isLoading(`toggleCategory_${category.query}_${category.type}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!category.disabled ? '禁用' : '启用'}
|
{!category.disabled ? '禁用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
{category.from !== 'config' && (
|
{category.from !== 'config' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(category.query, category.type)}
|
onClick={() => handleDelete(category.query, category.type)}
|
||||||
disabled={isLoading(`deleteCategory_${category.query}_${category.type}`)}
|
disabled={isLoading(
|
||||||
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteCategory_${category.query}_${category.type}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
`deleteCategory_${category.query}_${category.type}`
|
||||||
|
)}
|
||||||
|
className={`${buttonStyles.roundedSecondary} ${
|
||||||
|
isLoading(`deleteCategory_${category.query}_${category.type}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
@@ -3022,7 +3703,9 @@ const CategoryConfig = ({
|
|||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
className={`px-3 py-1 text-sm rounded-lg transition-colors ${showAddForm ? buttonStyles.secondary : buttonStyles.success}`}
|
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
|
||||||
|
showAddForm ? buttonStyles.secondary : buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{showAddForm ? '取消' : '添加分类'}
|
{showAddForm ? '取消' : '添加分类'}
|
||||||
</button>
|
</button>
|
||||||
@@ -3066,8 +3749,18 @@ const CategoryConfig = ({
|
|||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddCategory}
|
onClick={handleAddCategory}
|
||||||
disabled={!newCategory.name || !newCategory.query || isLoading('addCategory')}
|
disabled={
|
||||||
className={`w-full sm:w-auto px-4 py-2 ${!newCategory.name || !newCategory.query || isLoading('addCategory') ? buttonStyles.disabled : buttonStyles.success}`}
|
!newCategory.name ||
|
||||||
|
!newCategory.query ||
|
||||||
|
isLoading('addCategory')
|
||||||
|
}
|
||||||
|
className={`w-full sm:w-auto px-4 py-2 ${
|
||||||
|
!newCategory.name ||
|
||||||
|
!newCategory.query ||
|
||||||
|
isLoading('addCategory')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('addCategory') ? '添加中...' : '添加'}
|
{isLoading('addCategory') ? '添加中...' : '添加'}
|
||||||
</button>
|
</button>
|
||||||
@@ -3128,7 +3821,11 @@ const CategoryConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveOrder}
|
onClick={handleSaveOrder}
|
||||||
disabled={isLoading('saveCategoryOrder')}
|
disabled={isLoading('saveCategoryOrder')}
|
||||||
className={`px-3 py-1.5 text-sm ${isLoading('saveCategoryOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
|
className={`px-3 py-1.5 text-sm ${
|
||||||
|
isLoading('saveCategoryOrder')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('saveCategoryOrder') ? '保存中...' : '保存排序'}
|
{isLoading('saveCategoryOrder') ? '保存中...' : '保存排序'}
|
||||||
</button>
|
</button>
|
||||||
@@ -3150,7 +3847,13 @@ const CategoryConfig = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 新增配置文件组件
|
// 新增配置文件组件
|
||||||
const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
|
const ConfigFileComponent = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
const [configContent, setConfigContent] = useState('');
|
const [configContent, setConfigContent] = useState('');
|
||||||
@@ -3158,8 +3861,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
const [autoUpdate, setAutoUpdate] = useState(false);
|
const [autoUpdate, setAutoUpdate] = useState(false);
|
||||||
const [lastCheckTime, setLastCheckTime] = useState<string>('');
|
const [lastCheckTime, setLastCheckTime] = useState<string>('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.ConfigFile) {
|
if (config?.ConfigFile) {
|
||||||
setConfigContent(config.ConfigFile);
|
setConfigContent(config.ConfigFile);
|
||||||
@@ -3171,8 +3872,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 拉取订阅配置
|
// 拉取订阅配置
|
||||||
const handleFetchConfig = async () => {
|
const handleFetchConfig = async () => {
|
||||||
if (!subscriptionUrl.trim()) {
|
if (!subscriptionUrl.trim()) {
|
||||||
@@ -3211,7 +3910,9 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 处理文件上传
|
// 处理文件上传
|
||||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@@ -3241,7 +3942,9 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据api字段进行去重
|
// 根据api字段进行去重
|
||||||
const existingConfig = configContent ? JSON.parse(configContent) : { api_site: {} };
|
const existingConfig = configContent
|
||||||
|
? JSON.parse(configContent)
|
||||||
|
: { api_site: {} };
|
||||||
const existingApis = new Set();
|
const existingApis = new Set();
|
||||||
|
|
||||||
// 收集现有配置中的所有api
|
// 收集现有配置中的所有api
|
||||||
@@ -3255,30 +3958,35 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
const mergedApiSite = { ...existingConfig.api_site };
|
const mergedApiSite = { ...existingConfig.api_site };
|
||||||
let duplicateCount = 0;
|
let duplicateCount = 0;
|
||||||
|
|
||||||
Object.entries(parsedConfig.api_site || {}).forEach(([key, site]: [string, any]) => {
|
Object.entries(parsedConfig.api_site || {}).forEach(
|
||||||
if (site.api && existingApis.has(site.api)) {
|
([key, site]: [string, any]) => {
|
||||||
duplicateCount++;
|
if (site.api && existingApis.has(site.api)) {
|
||||||
// 跳过重复的api
|
duplicateCount++;
|
||||||
return;
|
// 跳过重复的api
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergedApiSite[key] = site;
|
||||||
}
|
}
|
||||||
mergedApiSite[key] = site;
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const mergedConfig = {
|
const mergedConfig = {
|
||||||
...parsedConfig,
|
...parsedConfig,
|
||||||
api_site: mergedApiSite
|
api_site: mergedApiSite,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新配置内容
|
// 更新配置内容
|
||||||
setConfigContent(JSON.stringify(mergedConfig, null, 2));
|
setConfigContent(JSON.stringify(mergedConfig, null, 2));
|
||||||
|
|
||||||
const message = duplicateCount > 0
|
const message =
|
||||||
? `配置上传成功,跳过了 ${duplicateCount} 个重复的API`
|
duplicateCount > 0
|
||||||
: '配置上传成功';
|
? `配置上传成功,跳过了 ${duplicateCount} 个重复的API`
|
||||||
|
: '配置上传成功';
|
||||||
showSuccess(message, showAlert);
|
showSuccess(message, showAlert);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err instanceof Error ? err.message : '文件上传失败', showAlert);
|
showError(
|
||||||
|
err instanceof Error ? err.message : '文件上传失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3298,7 +4006,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
configFile: configContent,
|
configFile: configContent,
|
||||||
subscriptionUrl,
|
subscriptionUrl,
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
lastCheckTime: lastCheckTime || new Date().toISOString()
|
lastCheckTime: lastCheckTime || new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3316,8 +4024,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||||
@@ -3335,7 +4041,10 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
配置订阅
|
配置订阅
|
||||||
</h3>
|
</h3>
|
||||||
<div className='text-sm text-gray-500 dark:text-gray-400 px-3 py-1.5 rounded-full'>
|
<div className='text-sm text-gray-500 dark:text-gray-400 px-3 py-1.5 rounded-full'>
|
||||||
最后更新: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
|
最后更新:{' '}
|
||||||
|
{lastCheckTime
|
||||||
|
? new Date(lastCheckTime).toLocaleString('zh-CN')
|
||||||
|
: '从未更新'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3363,10 +4072,11 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
<button
|
<button
|
||||||
onClick={handleFetchConfig}
|
onClick={handleFetchConfig}
|
||||||
disabled={isLoading('fetchConfig') || !subscriptionUrl.trim()}
|
disabled={isLoading('fetchConfig') || !subscriptionUrl.trim()}
|
||||||
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${isLoading('fetchConfig') || !subscriptionUrl.trim()
|
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||||
? buttonStyles.disabled
|
isLoading('fetchConfig') || !subscriptionUrl.trim()
|
||||||
: buttonStyles.success
|
? buttonStyles.disabled
|
||||||
}`}
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('fetchConfig') ? (
|
{isLoading('fetchConfig') ? (
|
||||||
<div className='flex items-center justify-center gap-2'>
|
<div className='flex items-center justify-center gap-2'>
|
||||||
@@ -3393,16 +4103,18 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
type='button'
|
type='button'
|
||||||
onClick={() => setAutoUpdate(!autoUpdate)}
|
onClick={() => setAutoUpdate(!autoUpdate)}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
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 ${autoUpdate
|
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 ${
|
||||||
? buttonStyles.toggleOn
|
autoUpdate ? buttonStyles.toggleOn : buttonStyles.toggleOff
|
||||||
: buttonStyles.toggleOff
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${autoUpdate
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
? buttonStyles.toggleThumbOn
|
buttonStyles.toggleThumb
|
||||||
: buttonStyles.toggleThumbOff
|
} transition-transform ${
|
||||||
}`}
|
autoUpdate
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3420,7 +4132,8 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
disabled={false}
|
disabled={false}
|
||||||
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
|
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
||||||
}}
|
}}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-gramm={false}
|
data-gramm={false}
|
||||||
@@ -3458,12 +4171,24 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{isLoading('uploadConfig') ? (
|
{isLoading('uploadConfig') ? (
|
||||||
<>
|
<>
|
||||||
<div className='w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin'></div>
|
<div className='w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin'></div>
|
||||||
<span className='text-sm text-gray-600 dark:text-gray-400'>上传中...</span>
|
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
上传中...
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className='w-5 h-5 text-gray-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
<svg
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12' />
|
className='w-5 h-5 text-gray-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12'
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
点击选择JSON文件或拖拽到此处
|
点击选择JSON文件或拖拽到此处
|
||||||
@@ -3485,10 +4210,11 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading('saveConfig')}
|
disabled={isLoading('saveConfig')}
|
||||||
className={`px-4 py-2 rounded-lg transition-colors ${isLoading('saveConfig')
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
? buttonStyles.disabled
|
isLoading('saveConfig')
|
||||||
: buttonStyles.success
|
? buttonStyles.disabled
|
||||||
}`}
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('saveConfig') ? '保存中…' : '保存'}
|
{isLoading('saveConfig') ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
@@ -3510,7 +4236,13 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 新增站点配置组件
|
// 新增站点配置组件
|
||||||
const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
|
const SiteConfigComponent = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
const [showEnableCommentsModal, setShowEnableCommentsModal] = useState(false);
|
const [showEnableCommentsModal, setShowEnableCommentsModal] = useState(false);
|
||||||
@@ -3583,14 +4315,16 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
if (config?.SiteConfig) {
|
if (config?.SiteConfig) {
|
||||||
setSiteSettings({
|
setSiteSettings({
|
||||||
...config.SiteConfig,
|
...config.SiteConfig,
|
||||||
DoubanProxyType: config.SiteConfig.DoubanProxyType || 'cmliussss-cdn-tencent',
|
DoubanProxyType:
|
||||||
|
config.SiteConfig.DoubanProxyType || 'cmliussss-cdn-tencent',
|
||||||
DoubanProxy: config.SiteConfig.DoubanProxy || '',
|
DoubanProxy: config.SiteConfig.DoubanProxy || '',
|
||||||
DoubanImageProxyType:
|
DoubanImageProxyType:
|
||||||
config.SiteConfig.DoubanImageProxyType || 'cmliussss-cdn-tencent',
|
config.SiteConfig.DoubanImageProxyType || 'cmliussss-cdn-tencent',
|
||||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||||
DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
DanmakuApiBase:
|
||||||
|
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||||
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||||
EnableComments: config.SiteConfig.EnableComments || false,
|
EnableComments: config.SiteConfig.EnableComments || false,
|
||||||
});
|
});
|
||||||
@@ -3707,9 +4441,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
{/* 站点名称 */}
|
{/* 站点名称 */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
站点名称
|
站点名称
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -3718,15 +4450,13 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 站点公告 */}
|
{/* 站点公告 */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
站点公告
|
站点公告
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -3738,16 +4468,14 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 豆瓣数据源设置 */}
|
{/* 豆瓣数据源设置 */}
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
豆瓣数据代理
|
豆瓣数据代理
|
||||||
</label>
|
</label>
|
||||||
<div className='relative' data-dropdown='douban-datasource'>
|
<div className='relative' data-dropdown='douban-datasource'>
|
||||||
@@ -3755,7 +4483,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
|
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
|
||||||
className="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left"
|
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
doubanDataSourceOptions.find(
|
doubanDataSourceOptions.find(
|
||||||
@@ -3767,8 +4495,9 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 下拉箭头 */}
|
{/* 下拉箭头 */}
|
||||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
|
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||||
}`}
|
isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3783,10 +4512,11 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
handleDoubanDataSourceChange(option.value);
|
handleDoubanDataSourceChange(option.value);
|
||||||
setIsDoubanDropdownOpen(false);
|
setIsDoubanDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${siteSettings.DoubanProxyType === option.value
|
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
siteSettings.DoubanProxyType === option.value
|
||||||
: 'text-gray-900 dark:text-gray-100'
|
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||||
}`}
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className='truncate'>{option.label}</span>
|
<span className='truncate'>{option.label}</span>
|
||||||
{siteSettings.DoubanProxyType === option.value && (
|
{siteSettings.DoubanProxyType === option.value && (
|
||||||
@@ -3826,9 +4556,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
|
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
|
||||||
{siteSettings.DoubanProxyType === 'custom' && (
|
{siteSettings.DoubanProxyType === 'custom' && (
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
豆瓣代理地址
|
豆瓣代理地址
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -3841,7 +4569,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
DoubanProxy: e.target.value,
|
DoubanProxy: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500"
|
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
/>
|
/>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
自定义代理服务器地址
|
自定义代理服务器地址
|
||||||
@@ -3853,9 +4581,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 豆瓣图片代理设置 */}
|
{/* 豆瓣图片代理设置 */}
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
豆瓣图片代理
|
豆瓣图片代理
|
||||||
</label>
|
</label>
|
||||||
<div className='relative' data-dropdown='douban-image-proxy'>
|
<div className='relative' data-dropdown='douban-image-proxy'>
|
||||||
@@ -3867,7 +4593,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
!isDoubanImageProxyDropdownOpen
|
!isDoubanImageProxyDropdownOpen
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left"
|
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
doubanImageProxyTypeOptions.find(
|
doubanImageProxyTypeOptions.find(
|
||||||
@@ -3879,8 +4605,9 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 下拉箭头 */}
|
{/* 下拉箭头 */}
|
||||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
|
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||||
}`}
|
isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3895,10 +4622,11 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
handleDoubanImageProxyChange(option.value);
|
handleDoubanImageProxyChange(option.value);
|
||||||
setIsDoubanImageProxyDropdownOpen(false);
|
setIsDoubanImageProxyDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${siteSettings.DoubanImageProxyType === option.value
|
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
siteSettings.DoubanImageProxyType === option.value
|
||||||
: 'text-gray-900 dark:text-gray-100'
|
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||||
}`}
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className='truncate'>{option.label}</span>
|
<span className='truncate'>{option.label}</span>
|
||||||
{siteSettings.DoubanImageProxyType === option.value && (
|
{siteSettings.DoubanImageProxyType === option.value && (
|
||||||
@@ -3938,9 +4666,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
|
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
|
||||||
{siteSettings.DoubanImageProxyType === 'custom' && (
|
{siteSettings.DoubanImageProxyType === 'custom' && (
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
豆瓣图片代理地址
|
豆瓣图片代理地址
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -3953,7 +4679,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
DoubanImageProxy: e.target.value,
|
DoubanImageProxy: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500"
|
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
/>
|
/>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
自定义图片代理服务器地址
|
自定义图片代理服务器地址
|
||||||
@@ -4003,9 +4729,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 禁用黄色过滤器 */}
|
{/* 禁用黄色过滤器 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
禁用黄色过滤器
|
禁用黄色过滤器
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
@@ -4016,16 +4740,20 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
DisableYellowFilter: !prev.DisableYellowFilter,
|
DisableYellowFilter: !prev.DisableYellowFilter,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
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.DisableYellowFilter
|
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 ${
|
||||||
? buttonStyles.toggleOn
|
siteSettings.DisableYellowFilter
|
||||||
: buttonStyles.toggleOff
|
? buttonStyles.toggleOn
|
||||||
}`}
|
: buttonStyles.toggleOff
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.DisableYellowFilter
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
? buttonStyles.toggleThumbOn
|
buttonStyles.toggleThumb
|
||||||
: buttonStyles.toggleThumbOff
|
} transition-transform ${
|
||||||
}`}
|
siteSettings.DisableYellowFilter
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4037,9 +4765,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 流式搜索 */}
|
{/* 流式搜索 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
启用流式搜索
|
启用流式搜索
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
@@ -4050,16 +4776,20 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
FluidSearch: !prev.FluidSearch,
|
FluidSearch: !prev.FluidSearch,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
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.FluidSearch
|
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 ${
|
||||||
? buttonStyles.toggleOn
|
siteSettings.FluidSearch
|
||||||
: buttonStyles.toggleOff
|
? buttonStyles.toggleOn
|
||||||
}`}
|
: buttonStyles.toggleOff
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.FluidSearch
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
? buttonStyles.toggleThumbOn
|
buttonStyles.toggleThumb
|
||||||
: buttonStyles.toggleThumbOff
|
} transition-transform ${
|
||||||
}`}
|
siteSettings.FluidSearch
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4128,22 +4858,26 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
{/* 开启评论 */}
|
{/* 开启评论 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
开启评论
|
开启评论
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => handleCommentsToggle(!siteSettings.EnableComments)}
|
onClick={() => handleCommentsToggle(!siteSettings.EnableComments)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${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 ${
|
||||||
? buttonStyles.toggleOn
|
siteSettings.EnableComments
|
||||||
: buttonStyles.toggleOff
|
? buttonStyles.toggleOn
|
||||||
}`}
|
: buttonStyles.toggleOff
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.EnableComments
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
? buttonStyles.toggleThumbOn
|
buttonStyles.toggleThumb
|
||||||
: buttonStyles.toggleThumbOff
|
} transition-transform ${
|
||||||
}`}
|
siteSettings.EnableComments
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4158,10 +4892,11 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading('saveSiteConfig')}
|
disabled={isLoading('saveSiteConfig')}
|
||||||
className={`px-4 py-2 ${isLoading('saveSiteConfig')
|
className={`px-4 py-2 ${
|
||||||
? buttonStyles.disabled
|
isLoading('saveSiteConfig')
|
||||||
: buttonStyles.success
|
? buttonStyles.disabled
|
||||||
} rounded-lg transition-colors`}
|
: buttonStyles.success
|
||||||
|
} rounded-lg transition-colors`}
|
||||||
>
|
>
|
||||||
{isLoading('saveSiteConfig') ? '保存中…' : '保存'}
|
{isLoading('saveSiteConfig') ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
@@ -4179,58 +4914,317 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 开启评论确认弹窗 */}
|
{/* 开启评论确认弹窗 */}
|
||||||
{showEnableCommentsModal && createPortal(
|
{showEnableCommentsModal &&
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => setShowEnableCommentsModal(false)}>
|
createPortal(
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full' onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
<div className='p-6'>
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
<div className='flex items-center justify-between mb-6'>
|
onClick={() => setShowEnableCommentsModal(false)}
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
>
|
||||||
开启评论功能
|
<div
|
||||||
</h3>
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full'
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={() => setShowEnableCommentsModal(false)}
|
>
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
<div className='p-6'>
|
||||||
>
|
<div className='flex items-center justify-between mb-6'>
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
开启评论功能
|
||||||
</svg>
|
</h3>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={() => setShowEnableCommentsModal(false)}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-6 h-6'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mb-6'>
|
<div className='mb-6'>
|
||||||
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
|
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
<AlertTriangle className='w-5 h-5 text-yellow-600 dark:text-yellow-400' />
|
<AlertTriangle className='w-5 h-5 text-yellow-600 dark:text-yellow-400' />
|
||||||
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
|
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
|
||||||
重要提示
|
重要提示
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-yellow-700 dark:text-yellow-400'>
|
||||||
|
评论功能为逆向抓取豆瓣评论数据,此功能仅供学习,开启后请自行承担相关责任和风险。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-sm text-yellow-700 dark:text-yellow-400'>
|
</div>
|
||||||
评论功能为逆向抓取豆瓣评论数据,此功能仅供学习,开启后请自行承担相关责任和风险。
|
|
||||||
</p>
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEnableCommentsModal(false)}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmEnableComments}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`}
|
||||||
|
>
|
||||||
|
我已知晓,确认开启
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className='flex justify-end space-x-3'>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowEnableCommentsModal(false)}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirmEnableComments}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`}
|
|
||||||
>
|
|
||||||
我已知晓,确认开启
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义去广告配置组件
|
||||||
|
const CustomAdFilterConfig = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
|
const [adFilterCode, setAdFilterCode] = useState('');
|
||||||
|
|
||||||
|
// 默认去广告代码
|
||||||
|
const defaultAdFilterCode = `function filterAdsFromM3U8(type: string, m3u8Content: string): string {
|
||||||
|
if (!m3u8Content) return '';
|
||||||
|
|
||||||
|
// 按行分割M3U8内容
|
||||||
|
const lines = m3u8Content.split('\\n');
|
||||||
|
const filteredLines = [];
|
||||||
|
|
||||||
|
let nextdelete = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// 只过滤#EXT-X-DISCONTINUITY标识
|
||||||
|
if (!line.includes('#EXT-X-DISCONTINUITY')) {
|
||||||
|
filteredLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredLines.join('\\n');
|
||||||
|
}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从数据库配置读取自定义去广告代码
|
||||||
|
if (config?.SiteConfig?.CustomAdFilterCode) {
|
||||||
|
setAdFilterCode(config.SiteConfig.CustomAdFilterCode);
|
||||||
|
} else {
|
||||||
|
// 如果数据库没有保存的代码,使用默认代码
|
||||||
|
setAdFilterCode(defaultAdFilterCode);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 移除 TypeScript 类型注解,转换为纯 JavaScript
|
||||||
|
const removeTypeAnnotations = (code: string): string => {
|
||||||
|
return (
|
||||||
|
code
|
||||||
|
// 移除函数参数的类型注解:name: type
|
||||||
|
.replace(
|
||||||
|
/(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*([,)])/g,
|
||||||
|
'$1$3'
|
||||||
|
)
|
||||||
|
// 移除函数返回值类型注解:): type {
|
||||||
|
.replace(
|
||||||
|
/\)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*\{/g,
|
||||||
|
') {'
|
||||||
|
)
|
||||||
|
// 移除变量声明的类型注解:const name: type =
|
||||||
|
.replace(
|
||||||
|
/(const|let|var)\s+(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*=/g,
|
||||||
|
'$1 $2 ='
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存自定义去广告代码
|
||||||
|
const handleSave = async () => {
|
||||||
|
await withLoading('saveAdFilterCode', async () => {
|
||||||
|
try {
|
||||||
|
// 验证代码语法
|
||||||
|
try {
|
||||||
|
// 移除类型注解后验证
|
||||||
|
const jsCode = removeTypeAnnotations(adFilterCode);
|
||||||
|
// 使用 Function 构造器验证代码是否可以解析
|
||||||
|
new Function(
|
||||||
|
'type',
|
||||||
|
'm3u8Content',
|
||||||
|
jsCode + '\nreturn filterAdsFromM3U8(type, m3u8Content);'
|
||||||
|
);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('代码验证失败:', parseError);
|
||||||
|
showError(
|
||||||
|
'代码语法错误:' +
|
||||||
|
(parseError instanceof Error
|
||||||
|
? parseError.message
|
||||||
|
: '请检查代码格式'),
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置到数据库
|
||||||
|
if (!config) {
|
||||||
|
showError('配置未加载', showAlert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备更新的站点配置,包含自定义去广告代码
|
||||||
|
const updatedSiteConfig = {
|
||||||
|
...config.SiteConfig,
|
||||||
|
CustomAdFilterCode: adFilterCode,
|
||||||
|
CustomAdFilterVersion: Date.now(), // 使用时间戳作为版本号
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/admin/site', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatedSiteConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || '保存配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新配置
|
||||||
|
await refreshConfig();
|
||||||
|
|
||||||
|
showSuccess('去广告代码保存成功,刷新后生效', showAlert);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '保存失败', showAlert);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置为默认代码
|
||||||
|
const handleReset = () => {
|
||||||
|
setAdFilterCode(defaultAdFilterCode);
|
||||||
|
showSuccess('已重置为默认代码', showAlert);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* 说明区域 */}
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-blue-600 dark:text-blue-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
||||||
|
使用说明
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
|
||||||
|
<p>• 此功能用于自定义 M3U8 播放列表的去广告逻辑</p>
|
||||||
|
<p>• 配置保存到数据库,对全平台所有用户生效</p>
|
||||||
|
<p>
|
||||||
|
• 客户端会自动缓存代码,只在版本更新时重新获取,不会频繁请求服务器
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• 函数签名必须为:{' '}
|
||||||
|
<code className='bg-blue-100 dark:bg-blue-900/40 px-1 rounded'>
|
||||||
|
filterAdsFromM3U8(type, m3u8Content)
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p>• type 参数为视频源类型,m3u8Content 为播放列表内容</p>
|
||||||
|
<p>• 函数需要返回处理后的 M3U8 内容</p>
|
||||||
|
<p>• 支持 TypeScript 类型注解,保存时会自动转换为 JavaScript</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 代码编辑区域 */}
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
自定义去广告代码
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className={`${buttonStyles.secondarySmall}`}
|
||||||
|
>
|
||||||
|
重置为默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='relative'>
|
||||||
|
<textarea
|
||||||
|
value={adFilterCode}
|
||||||
|
onChange={(e) => setAdFilterCode(e.target.value)}
|
||||||
|
rows={25}
|
||||||
|
placeholder='请输入自定义去广告代码...'
|
||||||
|
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
data-gramm={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
修改后需保存才能生效,保存前会进行语法验证
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
<button
|
||||||
document.body
|
onClick={handleSave}
|
||||||
)}
|
disabled={isLoading('saveAdFilterCode')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
isLoading('saveAdFilterCode')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('saveAdFilterCode') ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 通用弹窗组件 */}
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.isOpen}
|
||||||
|
onClose={hideAlert}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
timer={alertModal.timer}
|
||||||
|
showConfirm={alertModal.showConfirm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -4247,7 +5241,8 @@ const LiveSourceConfig = ({
|
|||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
|
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [editingLiveSource, setEditingLiveSource] = useState<LiveDataSource | null>(null);
|
const [editingLiveSource, setEditingLiveSource] =
|
||||||
|
useState<LiveDataSource | null>(null);
|
||||||
const [orderChanged, setOrderChanged] = useState(false);
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [newLiveSource, setNewLiveSource] = useState<LiveDataSource>({
|
const [newLiveSource, setNewLiveSource] = useState<LiveDataSource>({
|
||||||
@@ -4310,13 +5305,17 @@ const LiveSourceConfig = ({
|
|||||||
const target = liveSources.find((s) => s.key === key);
|
const target = liveSources.find((s) => s.key === key);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const action = target.disabled ? 'enable' : 'disable';
|
const action = target.disabled ? 'enable' : 'disable';
|
||||||
withLoading(`toggleLiveSource_${key}`, () => callLiveSourceApi({ action, key })).catch(() => {
|
withLoading(`toggleLiveSource_${key}`, () =>
|
||||||
|
callLiveSourceApi({ action, key })
|
||||||
|
).catch(() => {
|
||||||
console.error('操作失败', action, key);
|
console.error('操作失败', action, key);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (key: string) => {
|
const handleDelete = (key: string) => {
|
||||||
withLoading(`deleteLiveSource_${key}`, () => callLiveSourceApi({ action: 'delete', key })).catch(() => {
|
withLoading(`deleteLiveSource_${key}`, () =>
|
||||||
|
callLiveSourceApi({ action: 'delete', key })
|
||||||
|
).catch(() => {
|
||||||
console.error('操作失败', 'delete', key);
|
console.error('操作失败', 'delete', key);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -4340,7 +5339,12 @@ const LiveSourceConfig = ({
|
|||||||
|
|
||||||
// 刷新成功后重新获取配置
|
// 刷新成功后重新获取配置
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
|
showAlert({
|
||||||
|
type: 'success',
|
||||||
|
title: '刷新成功',
|
||||||
|
message: '直播源已刷新',
|
||||||
|
timer: 2000,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
|
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -4377,7 +5381,8 @@ const LiveSourceConfig = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditLiveSource = () => {
|
const handleEditLiveSource = () => {
|
||||||
if (!editingLiveSource || !editingLiveSource.name || !editingLiveSource.url) return;
|
if (!editingLiveSource || !editingLiveSource.name || !editingLiveSource.url)
|
||||||
|
return;
|
||||||
withLoading('editLiveSource', async () => {
|
withLoading('editLiveSource', async () => {
|
||||||
await callLiveSourceApi({
|
await callLiveSourceApi({
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
@@ -4408,7 +5413,9 @@ const LiveSourceConfig = ({
|
|||||||
|
|
||||||
const handleSaveOrder = () => {
|
const handleSaveOrder = () => {
|
||||||
const order = liveSources.map((s) => s.key);
|
const order = liveSources.map((s) => s.key);
|
||||||
withLoading('saveLiveSourceOrder', () => callLiveSourceApi({ action: 'sort', order }))
|
withLoading('saveLiveSourceOrder', () =>
|
||||||
|
callLiveSourceApi({ action: 'sort', order })
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setOrderChanged(false);
|
setOrderChanged(false);
|
||||||
})
|
})
|
||||||
@@ -4466,14 +5473,17 @@ const LiveSourceConfig = ({
|
|||||||
{liveSource.ua || '-'}
|
{liveSource.ua || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-center'>
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-center'>
|
||||||
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
|
{liveSource.channelNumber && liveSource.channelNumber > 0
|
||||||
|
? liveSource.channelNumber
|
||||||
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs rounded-full ${!liveSource.disabled
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
!liveSource.disabled
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
? '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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!liveSource.disabled ? '启用中' : '已禁用'}
|
{!liveSource.disabled ? '启用中' : '已禁用'}
|
||||||
</span>
|
</span>
|
||||||
@@ -4482,10 +5492,15 @@ const LiveSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleEnable(liveSource.key)}
|
onClick={() => handleToggleEnable(liveSource.key)}
|
||||||
disabled={isLoading(`toggleLiveSource_${liveSource.key}`)}
|
disabled={isLoading(`toggleLiveSource_${liveSource.key}`)}
|
||||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
|
||||||
? buttonStyles.roundedDanger
|
!liveSource.disabled
|
||||||
: buttonStyles.roundedSuccess
|
? buttonStyles.roundedDanger
|
||||||
} transition-colors ${isLoading(`toggleLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
: buttonStyles.roundedSuccess
|
||||||
|
} transition-colors ${
|
||||||
|
isLoading(`toggleLiveSource_${liveSource.key}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{!liveSource.disabled ? '禁用' : '启用'}
|
{!liveSource.disabled ? '禁用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
@@ -4494,14 +5509,22 @@ const LiveSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setEditingLiveSource(liveSource)}
|
onClick={() => setEditingLiveSource(liveSource)}
|
||||||
disabled={isLoading(`editLiveSource_${liveSource.key}`)}
|
disabled={isLoading(`editLiveSource_${liveSource.key}`)}
|
||||||
className={`${buttonStyles.roundedPrimary} ${isLoading(`editLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`${buttonStyles.roundedPrimary} ${
|
||||||
|
isLoading(`editLiveSource_${liveSource.key}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(liveSource.key)}
|
onClick={() => handleDelete(liveSource.key)}
|
||||||
disabled={isLoading(`deleteLiveSource_${liveSource.key}`)}
|
disabled={isLoading(`deleteLiveSource_${liveSource.key}`)}
|
||||||
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`${buttonStyles.roundedSecondary} ${
|
||||||
|
isLoading(`deleteLiveSource_${liveSource.key}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
@@ -4531,16 +5554,23 @@ const LiveSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleRefreshLiveSources}
|
onClick={handleRefreshLiveSources}
|
||||||
disabled={isRefreshing || isLoading('refreshLiveSources')}
|
disabled={isRefreshing || isLoading('refreshLiveSources')}
|
||||||
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing || isLoading('refreshLiveSources')
|
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${
|
||||||
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
|
isRefreshing || isLoading('refreshLiveSources')
|
||||||
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
|
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
|
||||||
}`}
|
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span>{isRefreshing || isLoading('refreshLiveSources') ? '刷新中...' : '刷新直播源'}</span>
|
<span>
|
||||||
|
{isRefreshing || isLoading('refreshLiveSources')
|
||||||
|
? '刷新中...'
|
||||||
|
: '刷新直播源'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
className={
|
||||||
|
showAddForm ? buttonStyles.secondary : buttonStyles.success
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showAddForm ? '取消' : '添加直播源'}
|
{showAddForm ? '取消' : '添加直播源'}
|
||||||
</button>
|
</button>
|
||||||
@@ -4595,13 +5625,24 @@ const LiveSourceConfig = ({
|
|||||||
}
|
}
|
||||||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddLiveSource}
|
onClick={handleAddLiveSource}
|
||||||
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource')}
|
disabled={
|
||||||
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
!newLiveSource.name ||
|
||||||
|
!newLiveSource.key ||
|
||||||
|
!newLiveSource.url ||
|
||||||
|
isLoading('addLiveSource')
|
||||||
|
}
|
||||||
|
className={`w-full sm:w-auto px-4 py-2 ${
|
||||||
|
!newLiveSource.name ||
|
||||||
|
!newLiveSource.key ||
|
||||||
|
!newLiveSource.url ||
|
||||||
|
isLoading('addLiveSource')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('addLiveSource') ? '添加中...' : '添加'}
|
{isLoading('addLiveSource') ? '添加中...' : '添加'}
|
||||||
</button>
|
</button>
|
||||||
@@ -4632,7 +5673,9 @@ const LiveSourceConfig = ({
|
|||||||
type='text'
|
type='text'
|
||||||
value={editingLiveSource.name}
|
value={editingLiveSource.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingLiveSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
|
setEditingLiveSource((prev) =>
|
||||||
|
prev ? { ...prev, name: e.target.value } : null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
@@ -4656,7 +5699,9 @@ const LiveSourceConfig = ({
|
|||||||
type='text'
|
type='text'
|
||||||
value={editingLiveSource.url}
|
value={editingLiveSource.url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingLiveSource((prev) => prev ? ({ ...prev, url: e.target.value }) : null)
|
setEditingLiveSource((prev) =>
|
||||||
|
prev ? { ...prev, url: e.target.value } : null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
@@ -4669,7 +5714,9 @@ const LiveSourceConfig = ({
|
|||||||
type='text'
|
type='text'
|
||||||
value={editingLiveSource.epg}
|
value={editingLiveSource.epg}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingLiveSource((prev) => prev ? ({ ...prev, epg: e.target.value }) : null)
|
setEditingLiveSource((prev) =>
|
||||||
|
prev ? { ...prev, epg: e.target.value } : null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
@@ -4682,7 +5729,9 @@ const LiveSourceConfig = ({
|
|||||||
type='text'
|
type='text'
|
||||||
value={editingLiveSource.ua}
|
value={editingLiveSource.ua}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingLiveSource((prev) => prev ? ({ ...prev, ua: e.target.value }) : null)
|
setEditingLiveSource((prev) =>
|
||||||
|
prev ? { ...prev, ua: e.target.value } : null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
/>
|
/>
|
||||||
@@ -4697,8 +5746,18 @@ const LiveSourceConfig = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleEditLiveSource}
|
onClick={handleEditLiveSource}
|
||||||
disabled={!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource')}
|
disabled={
|
||||||
className={`${!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource') ? buttonStyles.disabled : buttonStyles.success}`}
|
!editingLiveSource.name ||
|
||||||
|
!editingLiveSource.url ||
|
||||||
|
isLoading('editLiveSource')
|
||||||
|
}
|
||||||
|
className={`${
|
||||||
|
!editingLiveSource.name ||
|
||||||
|
!editingLiveSource.url ||
|
||||||
|
isLoading('editLiveSource')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('editLiveSource') ? '保存中...' : '保存'}
|
{isLoading('editLiveSource') ? '保存中...' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
@@ -4707,7 +5766,10 @@ const LiveSourceConfig = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 直播源表格 */}
|
{/* 直播源表格 */}
|
||||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="live-source-list">
|
<div
|
||||||
|
className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative'
|
||||||
|
data-table='live-source-list'
|
||||||
|
>
|
||||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -4765,7 +5827,11 @@ const LiveSourceConfig = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveOrder}
|
onClick={handleSaveOrder}
|
||||||
disabled={isLoading('saveLiveSourceOrder')}
|
disabled={isLoading('saveLiveSourceOrder')}
|
||||||
className={`px-3 py-1.5 text-sm ${isLoading('saveLiveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
|
className={`px-3 py-1.5 text-sm ${
|
||||||
|
isLoading('saveLiveSourceOrder')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.primary
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading('saveLiveSourceOrder') ? '保存中...' : '保存排序'}
|
{isLoading('saveLiveSourceOrder') ? '保存中...' : '保存排序'}
|
||||||
</button>
|
</button>
|
||||||
@@ -4782,8 +5848,6 @@ const LiveSourceConfig = ({
|
|||||||
timer={alertModal.timer}
|
timer={alertModal.timer}
|
||||||
showConfirm={alertModal.showConfirm}
|
showConfirm={alertModal.showConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -4804,6 +5868,7 @@ function AdminPageClient() {
|
|||||||
categoryConfig: false,
|
categoryConfig: false,
|
||||||
configFile: false,
|
configFile: false,
|
||||||
dataMigration: false,
|
dataMigration: false,
|
||||||
|
customAdFilter: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取管理员配置
|
// 获取管理员配置
|
||||||
@@ -4929,7 +5994,10 @@ function AdminPageClient() {
|
|||||||
isExpanded={expandedTabs.configFile}
|
isExpanded={expandedTabs.configFile}
|
||||||
onToggle={() => toggleTab('configFile')}
|
onToggle={() => toggleTab('configFile')}
|
||||||
>
|
>
|
||||||
<ConfigFileComponent config={config} refreshConfig={fetchConfig} />
|
<ConfigFileComponent
|
||||||
|
config={config}
|
||||||
|
refreshConfig={fetchConfig}
|
||||||
|
/>
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -5004,6 +6072,34 @@ function AdminPageClient() {
|
|||||||
<CategoryConfig config={config} refreshConfig={fetchConfig} />
|
<CategoryConfig config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
{/* 自定义去广告标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='自定义去广告'
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
width='20'
|
||||||
|
height='20'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='text-gray-600 dark:text-gray-400'
|
||||||
|
>
|
||||||
|
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z' />
|
||||||
|
<path d='M8 12h8' />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.customAdFilter}
|
||||||
|
onToggle={() => toggleTab('customAdFilter')}
|
||||||
|
>
|
||||||
|
<CustomAdFilterConfig
|
||||||
|
config={config}
|
||||||
|
refreshConfig={fetchConfig}
|
||||||
|
/>
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* 数据迁移标签 - 仅站长可见 */}
|
{/* 数据迁移标签 - 仅站长可见 */}
|
||||||
{role === 'owner' && (
|
{role === 'owner' && (
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
@@ -5036,61 +6132,92 @@ function AdminPageClient() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 重置配置确认弹窗 */}
|
{/* 重置配置确认弹窗 */}
|
||||||
{showResetConfigModal && createPortal(
|
{showResetConfigModal &&
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => setShowResetConfigModal(false)}>
|
createPortal(
|
||||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
<div className='p-6'>
|
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||||
<div className='flex items-center justify-between mb-6'>
|
onClick={() => setShowResetConfigModal(false)}
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
>
|
||||||
确认重置配置
|
<div
|
||||||
</h3>
|
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full'
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={() => setShowResetConfigModal(false)}
|
>
|
||||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
<div className='p-6'>
|
||||||
>
|
<div className='flex items-center justify-between mb-6'>
|
||||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
确认重置配置
|
||||||
</svg>
|
</h3>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={() => setShowResetConfigModal(false)}
|
||||||
|
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||||
<div className='mb-6'>
|
>
|
||||||
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4'>
|
<svg
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
className='w-6 h-6'
|
||||||
<svg className='w-5 h-5 text-yellow-600 dark:text-yellow-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
fill='none'
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M6 18L18 6M6 6l12 12'
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
|
</button>
|
||||||
⚠️ 危险操作警告
|
</div>
|
||||||
</span>
|
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4'>
|
||||||
|
<div className='flex items-center space-x-2 mb-2'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-yellow-600 dark:text-yellow-400'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
|
||||||
|
⚠️ 危险操作警告
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-yellow-700 dark:text-yellow-400'>
|
||||||
|
此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-sm text-yellow-700 dark:text-yellow-400'>
|
</div>
|
||||||
此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?
|
|
||||||
</p>
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end space-x-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfigModal(false)}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmResetConfig}
|
||||||
|
disabled={isLoading('resetConfig')}
|
||||||
|
className={`px-6 py-2.5 text-sm font-medium ${
|
||||||
|
isLoading('resetConfig')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.danger
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('resetConfig') ? '重置中...' : '确认重置'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className='flex justify-end space-x-3'>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowResetConfigModal(false)}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirmResetConfig}
|
|
||||||
disabled={isLoading('resetConfig')}
|
|
||||||
className={`px-6 py-2.5 text-sm font-medium ${isLoading('resetConfig') ? buttonStyles.disabled : buttonStyles.danger}`}
|
|
||||||
>
|
|
||||||
{isLoading('resetConfig') ? '重置中...' : '确认重置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ad-filter
|
||||||
|
* 获取自定义去广告代码配置(公开接口,无需认证)
|
||||||
|
* 返回代码内容和版本号,客户端通过版本号判断是否需要更新缓存
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
// 返回自定义去广告代码和版本号
|
||||||
|
return NextResponse.json({
|
||||||
|
code: config.SiteConfig?.CustomAdFilterCode || '',
|
||||||
|
version: config.SiteConfig?.CustomAdFilterVersion || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取去广告代码配置失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取配置失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
|
CustomAdFilterCode,
|
||||||
|
CustomAdFilterVersion,
|
||||||
} = body as {
|
} = body as {
|
||||||
SiteName: string;
|
SiteName: string;
|
||||||
Announcement: string;
|
Announcement: string;
|
||||||
@@ -56,6 +58,8 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
|
CustomAdFilterCode?: string;
|
||||||
|
CustomAdFilterVersion?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验
|
||||||
@@ -72,7 +76,9 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof FluidSearch !== 'boolean' ||
|
typeof FluidSearch !== 'boolean' ||
|
||||||
typeof DanmakuApiBase !== 'string' ||
|
typeof DanmakuApiBase !== 'string' ||
|
||||||
typeof DanmakuApiToken !== 'string' ||
|
typeof DanmakuApiToken !== 'string' ||
|
||||||
typeof EnableComments !== 'boolean'
|
typeof EnableComments !== 'boolean' ||
|
||||||
|
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||||
|
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number')
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -105,6 +111,8 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
|
CustomAdFilterCode,
|
||||||
|
CustomAdFilterVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
@@ -112,6 +112,57 @@ function PlayPageClient() {
|
|||||||
blockAdEnabledRef.current = blockAdEnabled;
|
blockAdEnabledRef.current = blockAdEnabled;
|
||||||
}, [blockAdEnabled]);
|
}, [blockAdEnabled]);
|
||||||
|
|
||||||
|
// 自定义去广告代码(从服务器获取并缓存)
|
||||||
|
const customAdFilterCodeRef = useRef<string>('');
|
||||||
|
|
||||||
|
// 初始化时获取自定义去广告代码
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAdFilterCode = async () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先从 localStorage 获取缓存的代码,立即可用
|
||||||
|
const cachedCode = localStorage.getItem('custom_ad_filter_code_cache');
|
||||||
|
const cachedVersion = localStorage.getItem('custom_ad_filter_version_cache');
|
||||||
|
|
||||||
|
if (cachedCode) {
|
||||||
|
customAdFilterCodeRef.current = cachedCode;
|
||||||
|
console.log('使用缓存的去广告代码');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步从服务器获取最新版本号,检查是否需要更新
|
||||||
|
const response = await fetch('/api/ad-filter');
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('获取去广告代码配置失败,使用缓存');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, version } = await response.json();
|
||||||
|
|
||||||
|
// 如果版本号不一致或没有缓存,更新缓存
|
||||||
|
if (!cachedVersion || parseInt(cachedVersion) !== version) {
|
||||||
|
console.log('检测到去广告代码更新,更新本地缓存');
|
||||||
|
if (code) {
|
||||||
|
localStorage.setItem('custom_ad_filter_code_cache', code);
|
||||||
|
localStorage.setItem('custom_ad_filter_version_cache', version.toString());
|
||||||
|
customAdFilterCodeRef.current = code;
|
||||||
|
} else if (!cachedCode) {
|
||||||
|
// 如果服务器没有代码且本地也没有缓存,清空缓存
|
||||||
|
localStorage.removeItem('custom_ad_filter_code_cache');
|
||||||
|
localStorage.removeItem('custom_ad_filter_version_cache');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('去广告代码已是最新版本');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取去广告代码配置失败:', error);
|
||||||
|
// 失败时已经使用了缓存,无需额外处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAdFilterCode();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Anime4K超分相关状态
|
// Anime4K超分相关状态
|
||||||
const [webGPUSupported, setWebGPUSupported] = useState<boolean>(false);
|
const [webGPUSupported, setWebGPUSupported] = useState<boolean>(false);
|
||||||
const [anime4kEnabled, setAnime4kEnabled] = useState<boolean>(false);
|
const [anime4kEnabled, setAnime4kEnabled] = useState<boolean>(false);
|
||||||
@@ -1067,6 +1118,30 @@ function PlayPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function filterAdsFromM3U8(type: string, m3u8Content: string): string {
|
function filterAdsFromM3U8(type: string, m3u8Content: string): string {
|
||||||
|
// 尝试使用缓存的自定义去广告代码
|
||||||
|
if (customAdFilterCodeRef.current && customAdFilterCodeRef.current.trim()) {
|
||||||
|
try {
|
||||||
|
// 移除 TypeScript 类型注解,转换为纯 JavaScript
|
||||||
|
const jsCode = customAdFilterCodeRef.current
|
||||||
|
// 移除函数参数的类型注解:name: type
|
||||||
|
.replace(/(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*([,)])/g, '$1$3')
|
||||||
|
// 移除函数返回值类型注解:): type {
|
||||||
|
.replace(/\)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*\{/g, ') {')
|
||||||
|
// 移除变量声明的类型注解:const name: type =
|
||||||
|
.replace(/(const|let|var)\s+(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*=/g, '$1 $2 =');
|
||||||
|
|
||||||
|
// 创建并执行自定义函数
|
||||||
|
const customFunction = new Function('type', 'm3u8Content',
|
||||||
|
jsCode + '\nreturn filterAdsFromM3U8(type, m3u8Content);'
|
||||||
|
);
|
||||||
|
return customFunction(type, m3u8Content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('执行自定义去广告代码失败,使用默认规则:', err);
|
||||||
|
// 如果自定义代码执行失败,继续使用默认规则
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认去广告规则
|
||||||
if (!m3u8Content) return '';
|
if (!m3u8Content) return '';
|
||||||
|
|
||||||
// 按行分割M3U8内容
|
// 按行分割M3U8内容
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface AdminConfig {
|
|||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
|
// 自定义去广告代码
|
||||||
|
CustomAdFilterCode?: string;
|
||||||
|
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
|
||||||
};
|
};
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: {
|
Users: {
|
||||||
|
|||||||
Reference in New Issue
Block a user