496 lines
19 KiB
TypeScript
496 lines
19 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { parseFetchError } from '@/lib/error-handler'
|
||
|
||
interface SuperAdmin {
|
||
id: string
|
||
email: string
|
||
username: string
|
||
realName: string | null
|
||
department: string
|
||
role: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface ApiKey {
|
||
id: string
|
||
name: string
|
||
key: string
|
||
isActive: boolean
|
||
createdBy: string
|
||
lastUsedAt: string | null
|
||
createdAt: string
|
||
}
|
||
|
||
interface SuperAdminsTabProps {
|
||
showToast: (message: string, type?: 'success' | 'error') => void
|
||
}
|
||
|
||
export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||
const [superAdmins, setSuperAdmins] = useState<SuperAdmin[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [count, setCount] = useState(0)
|
||
const [canAdd, setCanAdd] = useState(false)
|
||
const [addingUserId, setAddingUserId] = useState('')
|
||
const [adding, setAdding] = useState(false)
|
||
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
|
||
|
||
// API Keys 相关状态
|
||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||
const [apiKeysLoading, setApiKeysLoading] = useState(true)
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [newKeyName, setNewKeyName] = useState('')
|
||
const [createdKey, setCreatedKey] = useState<string | null>(null)
|
||
|
||
// 加载超级管理员列表
|
||
const loadSuperAdmins = async () => {
|
||
try {
|
||
const response = await fetch('/api/admin/super-admins')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setSuperAdmins(data.superAdmins)
|
||
setCount(data.count)
|
||
setCanAdd(data.canAdd)
|
||
} else {
|
||
showToast('加载超级管理员列表失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Load super admins error:', error)
|
||
showToast('加载超级管理员列表失败', 'error')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadSuperAdmins()
|
||
fetchApiKeys()
|
||
}, [])
|
||
|
||
// 添加超级管理员
|
||
const handleAddSuperAdmin = async () => {
|
||
if (!addingUserId.trim()) {
|
||
showToast('请输入用户ID', 'error')
|
||
return
|
||
}
|
||
|
||
setAdding(true)
|
||
try {
|
||
const response = await fetch('/api/admin/super-admins', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ userId: addingUserId.trim() }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
showToast('超级管理员添加成功', 'success')
|
||
setAddingUserId('')
|
||
await loadSuperAdmins()
|
||
} else {
|
||
const data = await response.json()
|
||
showToast(data.error || '添加失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Add super admin error:', error)
|
||
showToast('添加失败', 'error')
|
||
} finally {
|
||
setAdding(false)
|
||
}
|
||
}
|
||
|
||
// 移除超级管理员
|
||
const handleRemoveSuperAdmin = async (userId: string) => {
|
||
if (!confirm('确定要移除该超级管理员权限吗?')) {
|
||
return
|
||
}
|
||
|
||
setRemovingUserId(userId)
|
||
try {
|
||
const response = await fetch(`/api/admin/super-admins?userId=${userId}`, {
|
||
method: 'DELETE',
|
||
})
|
||
|
||
if (response.ok) {
|
||
showToast('超级管理员权限已移除', 'success')
|
||
await loadSuperAdmins()
|
||
} else {
|
||
const data = await response.json()
|
||
showToast(data.error || '移除失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Remove super admin error:', error)
|
||
showToast('移除失败', 'error')
|
||
} finally {
|
||
setRemovingUserId(null)
|
||
}
|
||
}
|
||
|
||
// 加载 API Keys
|
||
const fetchApiKeys = async () => {
|
||
try {
|
||
setApiKeysLoading(true)
|
||
const response = await fetch('/api/admin/api-keys')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setApiKeys(data.data || [])
|
||
} else {
|
||
const errorData = await parseFetchError(response)
|
||
showToast(errorData.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('加载 API Keys 失败:', error)
|
||
const errorMessage = error instanceof Error ? error.message : '加载失败'
|
||
showToast(errorMessage, 'error')
|
||
} finally {
|
||
setApiKeysLoading(false)
|
||
}
|
||
}
|
||
|
||
// 创建 API Key
|
||
const handleCreateApiKey = async () => {
|
||
if (!newKeyName.trim()) {
|
||
showToast('请输入密钥名称', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/api-keys', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: newKeyName }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCreatedKey(data.data.key)
|
||
setNewKeyName('')
|
||
showToast('API Key 创建成功', 'success')
|
||
fetchApiKeys()
|
||
} else {
|
||
const errorData = await parseFetchError(response)
|
||
showToast(errorData.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('创建失败:', error)
|
||
const errorMessage = error instanceof Error ? error.message : '创建失败'
|
||
showToast(errorMessage, 'error')
|
||
}
|
||
}
|
||
|
||
// 切换 API Key 状态
|
||
const handleToggleApiKeyActive = async (id: string, isActive: boolean) => {
|
||
try {
|
||
const response = await fetch(`/api/admin/api-keys/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ isActive: !isActive }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
showToast(isActive ? 'API Key 已禁用' : 'API Key 已启用', 'success')
|
||
fetchApiKeys()
|
||
} else {
|
||
const errorData = await parseFetchError(response)
|
||
showToast(errorData.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('更新失败:', error)
|
||
const errorMessage = error instanceof Error ? error.message : '更新失败'
|
||
showToast(errorMessage, 'error')
|
||
}
|
||
}
|
||
|
||
// 删除 API Key
|
||
const handleDeleteApiKey = async (id: string) => {
|
||
if (!confirm('确定要删除此 API Key 吗?删除后无法恢复。')) return
|
||
|
||
try {
|
||
const response = await fetch(`/api/admin/api-keys/${id}`, {
|
||
method: 'DELETE',
|
||
})
|
||
|
||
if (response.ok) {
|
||
showToast('API Key 已删除', 'success')
|
||
fetchApiKeys()
|
||
} else {
|
||
const errorData = await parseFetchError(response)
|
||
showToast(errorData.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('删除失败:', error)
|
||
const errorMessage = error instanceof Error ? error.message : '删除失败'
|
||
showToast(errorMessage, 'error')
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="card-apple">
|
||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||
加载中...
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="card-apple">
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||
超级管理员管理
|
||
</h2>
|
||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
当前:{count} / 4 个超级管理员
|
||
{!canAdd && <span className="ml-2 text-orange-600 dark:text-orange-400">(已达上限)</span>}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-6">
|
||
{/* 添加超级管理员 */}
|
||
{canAdd && (
|
||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||
添加超级管理员
|
||
</h3>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={addingUserId}
|
||
onChange={(e) => setAddingUserId(e.target.value)}
|
||
placeholder="输入用户ID"
|
||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button
|
||
onClick={handleAddSuperAdmin}
|
||
disabled={adding || !addingUserId.trim()}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{adding ? '添加中...' : '添加'}
|
||
</button>
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
提示:只能添加管理员角色的用户,且最多4个超级管理员
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 超级管理员列表 */}
|
||
<div>
|
||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||
超级管理员列表
|
||
</h3>
|
||
{superAdmins.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
暂无超级管理员
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{superAdmins.map((admin) => (
|
||
<div
|
||
key={admin.id}
|
||
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||
>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{admin.realName || admin.username}
|
||
</span>
|
||
<span className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">
|
||
超级管理员
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
<span>{admin.email}</span>
|
||
{admin.department && (
|
||
<span className="ml-2">· {admin.department}</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||
创建时间:{new Date(admin.createdAt).toLocaleString('zh-CN')}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => handleRemoveSuperAdmin(admin.id)}
|
||
disabled={removingUserId === admin.id}
|
||
className="ml-4 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 bg-red-50 dark:bg-red-900/20 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
|
||
>
|
||
{removingUserId === admin.id ? '移除中...' : '移除'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* API Key 管理卡片 */}
|
||
<div className="mt-8">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||
API Key 管理
|
||
</h3>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||
>
|
||
创建 API Key
|
||
</button>
|
||
</div>
|
||
|
||
{apiKeysLoading ? (
|
||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||
加载中...
|
||
</div>
|
||
) : apiKeys.length === 0 ? (
|
||
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<p>暂无 API Key</p>
|
||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||
点击上方按钮创建第一个 API Key
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
<thead className="bg-gray-100 dark:bg-gray-700">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
名称
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
密钥
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
状态
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
最后使用
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
创建时间
|
||
</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
操作
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||
{apiKeys.map((key) => (
|
||
<tr key={key.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||
{key.name}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500 dark:text-gray-400">
|
||
{key.key.substring(0, 16)}...
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
className={`px-2 py-1 text-xs rounded ${
|
||
key.isActive
|
||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
{key.isActive ? '启用' : '禁用'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||
{key.lastUsedAt
|
||
? new Date(key.lastUsedAt).toLocaleString('zh-CN')
|
||
: '从未使用'}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||
{new Date(key.createdAt).toLocaleString('zh-CN')}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
|
||
<button
|
||
onClick={() => handleToggleApiKeyActive(key.id, key.isActive)}
|
||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||
>
|
||
{key.isActive ? '禁用' : '启用'}
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteApiKey(key.id)}
|
||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 创建 API Key 模态框 */}
|
||
{showCreateModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||
创建 API Key
|
||
</h2>
|
||
|
||
{createdKey ? (
|
||
<div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||
API Key 已创建,请妥善保存:
|
||
</p>
|
||
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded font-mono text-sm break-all mb-4 text-gray-900 dark:text-white">
|
||
{createdKey}
|
||
</div>
|
||
<p className="text-sm text-red-600 dark:text-red-400 mb-4">
|
||
注意:此密钥只显示一次,关闭后将无法再次查看
|
||
</p>
|
||
<button
|
||
onClick={() => {
|
||
setCreatedKey(null)
|
||
setShowCreateModal(false)
|
||
}}
|
||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
||
密钥名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newKeyName}
|
||
onChange={(e) => setNewKeyName(e.target.value)}
|
||
placeholder="例如:任务系统"
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-4 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter') {
|
||
handleCreateApiKey()
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex space-x-3">
|
||
<button
|
||
onClick={() => setShowCreateModal(false)}
|
||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleCreateApiKey}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
创建
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|