feat: add batch source and user operates
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion,react-hooks/exhaustive-deps */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
||||
|
||||
'use client';
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||
@@ -301,6 +301,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
tags?: string[];
|
||||
} | null>(null);
|
||||
const [selectedUserGroups, setSelectedUserGroups] = useState<string[]>([]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
||||
const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false);
|
||||
const [selectedUserGroup, setSelectedUserGroup] = useState<string>('');
|
||||
const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false);
|
||||
const [deletingUserGroup, setDeletingUserGroup] = useState<{
|
||||
name: string;
|
||||
@@ -312,6 +315,17 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
// 当前登录用户名
|
||||
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
|
||||
|
||||
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
|
||||
const selectAllUsers = useMemo(() => {
|
||||
const selectableUserCount = config?.UserConfig?.Users?.filter(user =>
|
||||
(role === 'owner' ||
|
||||
(role === 'admin' &&
|
||||
(user.role === 'user' ||
|
||||
user.username === currentUsername)))
|
||||
).length || 0;
|
||||
return selectedUsers.size === selectableUserCount && selectedUsers.size > 0;
|
||||
}, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]);
|
||||
|
||||
// 获取用户组列表
|
||||
const userGroups = config?.UserConfig?.Tags || [];
|
||||
|
||||
@@ -498,6 +512,69 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理用户选择
|
||||
const handleSelectUser = useCallback((username: string, checked: boolean) => {
|
||||
setSelectedUsers(prev => {
|
||||
const newSelectedUsers = new Set(prev);
|
||||
if (checked) {
|
||||
newSelectedUsers.add(username);
|
||||
} else {
|
||||
newSelectedUsers.delete(username);
|
||||
}
|
||||
return newSelectedUsers;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectAllUsers = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
// 只选择自己有权限操作的用户
|
||||
const selectableUsernames = config?.UserConfig?.Users?.filter(user =>
|
||||
(role === 'owner' ||
|
||||
(role === 'admin' &&
|
||||
(user.role === 'user' ||
|
||||
user.username === currentUsername)))
|
||||
).map(u => u.username) || [];
|
||||
setSelectedUsers(new Set(selectableUsernames));
|
||||
} else {
|
||||
setSelectedUsers(new Set());
|
||||
}
|
||||
}, [config?.UserConfig?.Users, role, currentUsername]);
|
||||
|
||||
// 批量设置用户组
|
||||
const handleBatchSetUserGroup = async (userGroup: string) => {
|
||||
if (selectedUsers.size === 0) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/user', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'batchUpdateUserGroups',
|
||||
usernames: Array.from(selectedUsers),
|
||||
userGroups: userGroup === '' ? [] : [userGroup],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `操作失败: ${res.status}`);
|
||||
}
|
||||
|
||||
const userCount = selectedUsers.size;
|
||||
setSelectedUsers(new Set());
|
||||
setShowBatchUserGroupModal(false);
|
||||
setSelectedUserGroup('');
|
||||
showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert);
|
||||
|
||||
// 刷新配置
|
||||
await refreshConfig();
|
||||
} catch (err) {
|
||||
showError('批量设置用户组失败', showAlert);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 提取URL域名的辅助函数
|
||||
const extractDomain = (url: string): string => {
|
||||
try {
|
||||
@@ -636,9 +713,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
</div>
|
||||
|
||||
{/* 用户组列表 */}
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[20rem] overflow-y-auto overflow-x-auto'>
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[20rem] overflow-y-auto overflow-x-auto relative'>
|
||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
用户组名称
|
||||
@@ -700,18 +777,37 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
用户列表
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddUserForm(!showAddUserForm);
|
||||
if (showChangePasswordForm) {
|
||||
setShowChangePasswordForm(false);
|
||||
setChangePasswordUser({ username: '', password: '' });
|
||||
}
|
||||
}}
|
||||
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
|
||||
>
|
||||
{showAddUserForm ? '取消' : '添加用户'}
|
||||
</button>
|
||||
<div className='flex items-center space-x-2'>
|
||||
{/* 批量操作按钮 */}
|
||||
{selectedUsers.size > 0 && (
|
||||
<>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已选择 {selectedUsers.size} 个用户
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowBatchUserGroupModal(true)}
|
||||
className='px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
|
||||
>
|
||||
批量设置用户组
|
||||
</button>
|
||||
</div>
|
||||
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddUserForm(!showAddUserForm);
|
||||
if (showChangePasswordForm) {
|
||||
setShowChangePasswordForm(false);
|
||||
setChangePasswordUser({ username: '', password: '' });
|
||||
}
|
||||
}}
|
||||
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
|
||||
>
|
||||
{showAddUserForm ? '取消' : '添加用户'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加用户表单 */}
|
||||
@@ -817,10 +913,33 @@ 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'>
|
||||
<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'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className='w-4' />
|
||||
<th className='w-10 px-1 py-3 text-center'>
|
||||
{(() => {
|
||||
// 检查是否有权限操作任何用户
|
||||
const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
|
||||
(role === 'owner' ||
|
||||
(role === 'admin' &&
|
||||
(user.role === 'user' ||
|
||||
user.username === currentUsername)))
|
||||
);
|
||||
|
||||
return hasAnyPermission ? (
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={selectAllUsers}
|
||||
onChange={(e) => handleSelectAllUsers(e.target.checked)}
|
||||
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-4 h-4' />
|
||||
);
|
||||
})()}
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
|
||||
@@ -898,6 +1017,22 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
key={user.username}
|
||||
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
|
||||
>
|
||||
<td className='w-4' />
|
||||
<td className='w-10 px-1 py-3 text-center'>
|
||||
{(role === 'owner' ||
|
||||
(role === 'admin' &&
|
||||
(user.role === 'user' ||
|
||||
user.username === currentUsername))) ? (
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={selectedUsers.has(user.username)}
|
||||
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'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-4 h-4' />
|
||||
)}
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
{user.username}
|
||||
</td>
|
||||
@@ -1669,6 +1804,92 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
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 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBatchSetUserGroup(selectedUserGroup)}
|
||||
className='px-6 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors'
|
||||
>
|
||||
确认设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 通用弹窗组件 */}
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
@@ -1706,6 +1927,29 @@ const VideoSourceConfig = ({
|
||||
from: 'config',
|
||||
});
|
||||
|
||||
// 批量操作相关状态
|
||||
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
|
||||
|
||||
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
|
||||
const selectAll = useMemo(() => {
|
||||
return selectedSources.size === sources.length && selectedSources.size > 0;
|
||||
}, [selectedSources.size, sources.length]);
|
||||
|
||||
// 确认弹窗状态
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => { },
|
||||
onCancel: () => { }
|
||||
});
|
||||
|
||||
// 有效性检测相关状态
|
||||
const [showValidationModal, setShowValidationModal] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
@@ -1739,6 +1983,8 @@ const VideoSourceConfig = ({
|
||||
setSources(config.SourceConfig);
|
||||
// 进入时重置 orderChanged
|
||||
setOrderChanged(false);
|
||||
// 重置选择状态
|
||||
setSelectedSources(new Set());
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -1981,6 +2227,14 @@ const VideoSourceConfig = ({
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</td>
|
||||
<td className='px-2 py-4 text-center'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={selectedSources.has(source.key)}
|
||||
onChange={(e) => handleSelectSource(source.key, 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'
|
||||
/>
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||
{source.name}
|
||||
</td>
|
||||
@@ -2049,6 +2303,77 @@ const VideoSourceConfig = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
const allKeys = sources.map(s => s.key);
|
||||
setSelectedSources(new Set(allKeys));
|
||||
} else {
|
||||
setSelectedSources(new Set());
|
||||
}
|
||||
}, [sources]);
|
||||
|
||||
// 单个选择
|
||||
const handleSelectSource = useCallback((key: string, checked: boolean) => {
|
||||
setSelectedSources(prev => {
|
||||
const newSelected = new Set(prev);
|
||||
if (checked) {
|
||||
newSelected.add(key);
|
||||
} else {
|
||||
newSelected.delete(key);
|
||||
}
|
||||
return newSelected;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 批量操作
|
||||
const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
|
||||
if (selectedSources.size === 0) {
|
||||
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' });
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Array.from(selectedSources);
|
||||
let confirmMessage = '';
|
||||
let actionName = '';
|
||||
|
||||
switch (action) {
|
||||
case 'batch_enable':
|
||||
confirmMessage = `确定要启用选中的 ${keys.length} 个视频源吗?`;
|
||||
actionName = '批量启用';
|
||||
break;
|
||||
case 'batch_disable':
|
||||
confirmMessage = `确定要禁用选中的 ${keys.length} 个视频源吗?`;
|
||||
actionName = '批量禁用';
|
||||
break;
|
||||
case 'batch_delete':
|
||||
confirmMessage = `确定要删除选中的 ${keys.length} 个视频源吗?此操作不可恢复!`;
|
||||
actionName = '批量删除';
|
||||
break;
|
||||
}
|
||||
|
||||
// 显示确认弹窗
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: '确认操作',
|
||||
message: confirmMessage,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await callSourceApi({ action, keys });
|
||||
showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}了 ${keys.length} 个视频源`, timer: 2000 });
|
||||
// 重置选择状态
|
||||
setSelectedSources(new Set());
|
||||
} catch (err) {
|
||||
showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败' });
|
||||
}
|
||||
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
|
||||
},
|
||||
onCancel: () => {
|
||||
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||
@@ -2065,6 +2390,35 @@ const VideoSourceConfig = ({
|
||||
视频源列表
|
||||
</h4>
|
||||
<div className='flex items-center space-x-2'>
|
||||
{/* 批量操作按钮 */}
|
||||
{selectedSources.size > 0 && (
|
||||
<>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已选择 {selectedSources.size} 个视频源
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleBatchOperation('batch_enable')}
|
||||
className='px-3 py-1 text-sm bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors'
|
||||
>
|
||||
批量启用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBatchOperation('batch_disable')}
|
||||
className='px-3 py-1 text-sm bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors'
|
||||
>
|
||||
批量禁用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBatchOperation('batch_delete')}
|
||||
className='px-3 py-1 text-sm bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors'
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowValidationModal(true)}
|
||||
disabled={isValidating}
|
||||
@@ -2143,12 +2497,22 @@ const VideoSourceConfig = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* 视频源表格 */}
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
|
||||
<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'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className='w-8' />
|
||||
<th className='w-12 px-2 py-3 text-center'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={selectAll}
|
||||
onChange={(e) => handleSelectAll(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'
|
||||
/>
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
名称
|
||||
</th>
|
||||
@@ -2255,6 +2619,52 @@ const VideoSourceConfig = ({
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
/>
|
||||
|
||||
{/* 批量操作确认弹窗 */}
|
||||
{confirmModal.isOpen && createPortal(
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={confirmModal.onCancel}>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full' onClick={(e) => e.stopPropagation()}>
|
||||
<div className='p-6'>
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{confirmModal.title}
|
||||
</h3>
|
||||
<button
|
||||
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'>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
{confirmModal.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end space-x-3'>
|
||||
<button
|
||||
onClick={confirmModal.onCancel}
|
||||
className='px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmModal.onConfirm}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors'
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2535,9 +2945,9 @@ const CategoryConfig = ({
|
||||
)}
|
||||
|
||||
{/* 分类表格 */}
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative'>
|
||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className='w-8' />
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { db } from '@/lib/db';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -111,6 +111,88 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
|
||||
}
|
||||
adminConfig.SourceConfig.splice(idx, 1);
|
||||
|
||||
// 检查并清理用户组和用户的权限数组
|
||||
// 清理用户组权限
|
||||
if (adminConfig.UserConfig.Tags) {
|
||||
adminConfig.UserConfig.Tags.forEach(tag => {
|
||||
if (tag.enabledApis) {
|
||||
tag.enabledApis = tag.enabledApis.filter(api => api !== key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清理用户权限
|
||||
adminConfig.UserConfig.Users.forEach(user => {
|
||||
if (user.enabledApis) {
|
||||
user.enabledApis = user.enabledApis.filter(api => api !== key);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'batch_disable': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
keys.forEach(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'batch_enable': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
keys.forEach(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (entry) {
|
||||
entry.disabled = false;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'batch_delete': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
// 过滤掉 from=config 的源,但不报错
|
||||
const keysToDelete = keys.filter(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
return entry && entry.from !== 'config';
|
||||
});
|
||||
|
||||
// 批量删除
|
||||
keysToDelete.forEach(key => {
|
||||
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
|
||||
if (idx !== -1) {
|
||||
adminConfig.SourceConfig.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 检查并清理用户组和用户的权限数组
|
||||
if (keysToDelete.length > 0) {
|
||||
// 清理用户组权限
|
||||
if (adminConfig.UserConfig.Tags) {
|
||||
adminConfig.UserConfig.Tags.forEach(tag => {
|
||||
if (tag.enabledApis) {
|
||||
tag.enabledApis = tag.enabledApis.filter(api => !keysToDelete.includes(api));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清理用户权限
|
||||
adminConfig.UserConfig.Users.forEach(user => {
|
||||
if (user.enabledApis) {
|
||||
user.enabledApis = user.enabledApis.filter(api => !keysToDelete.includes(api));
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sort': {
|
||||
|
||||
@@ -20,6 +20,7 @@ const ACTIONS = [
|
||||
'updateUserApis',
|
||||
'userGroup',
|
||||
'updateUserGroups',
|
||||
'batchUpdateUserGroups',
|
||||
] as const;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -56,8 +57,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 用户组操作不需要targetUsername
|
||||
if (!targetUsername && action !== 'userGroup') {
|
||||
// 用户组操作和批量操作不需要targetUsername
|
||||
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
|
||||
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ export async function POST(request: NextRequest) {
|
||||
action !== 'updateUserApis' &&
|
||||
action !== 'userGroup' &&
|
||||
action !== 'updateUserGroups' &&
|
||||
action !== 'batchUpdateUserGroups' &&
|
||||
username === targetUsername
|
||||
) {
|
||||
return NextResponse.json(
|
||||
@@ -92,11 +94,11 @@ export async function POST(request: NextRequest) {
|
||||
operatorRole = 'admin';
|
||||
}
|
||||
|
||||
// 查找目标用户条目(用户组操作不需要)
|
||||
// 查找目标用户条目(用户组操作和批量操作不需要)
|
||||
let targetEntry: any = null;
|
||||
let isTargetAdmin = false;
|
||||
|
||||
if (action !== 'userGroup' && targetUsername) {
|
||||
if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
|
||||
targetEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === targetUsername
|
||||
);
|
||||
@@ -419,6 +421,38 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
break;
|
||||
}
|
||||
case 'batchUpdateUserGroups': {
|
||||
const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
|
||||
|
||||
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
|
||||
return NextResponse.json({ error: '缺少用户名列表' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||
if (operatorRole !== 'owner') {
|
||||
for (const targetUsername of usernames) {
|
||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量更新用户组
|
||||
for (const targetUsername of usernames) {
|
||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||
if (targetUser) {
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
targetUser.tags = userGroups;
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
||||
delete targetUser.tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -25,12 +25,15 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -76,12 +79,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -144,12 +150,15 @@ export async function DELETE(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,15 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -48,12 +51,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -111,12 +117,15 @@ export async function DELETE(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q')?.trim();
|
||||
|
||||
|
||||
@@ -24,12 +24,15 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -58,12 +61,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -107,12 +113,15 @@ export async function DELETE(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -57,12 +60,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (adminConfig.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -107,12 +113,15 @@ export async function DELETE(request: NextRequest) {
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (adminConfig.UserConfig.Users) {
|
||||
// 检查用户是否被封禁
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (user && user.banned) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user