290 lines
9.1 KiB
TypeScript
290 lines
9.1 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { useParams, useRouter } from 'next/navigation';
|
||
|
||
interface ApiKeyItem {
|
||
id: string;
|
||
name: string;
|
||
isActive: boolean;
|
||
lastUsedAt: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface AdminInfo {
|
||
id: string;
|
||
username: string;
|
||
isSuperAdmin: boolean;
|
||
}
|
||
|
||
export default function ProjectApiKeysPage() {
|
||
const router = useRouter();
|
||
const params = useParams();
|
||
const projectCode = params.projectCode as string;
|
||
|
||
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
||
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showModal, setShowModal] = useState(false);
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const adminRes = await fetch('/api/admin/me');
|
||
if (adminRes.status === 401) {
|
||
router.push('/admin/login');
|
||
return;
|
||
}
|
||
|
||
const adminData = await adminRes.json();
|
||
if (!adminData.isSuperAdmin) {
|
||
router.push(`/admin/${projectCode}`);
|
||
return;
|
||
}
|
||
setAdmin(adminData);
|
||
|
||
const apiKeyRes = await fetch(`/api/admin/${projectCode}/api-keys`);
|
||
if (apiKeyRes.status === 403 || apiKeyRes.status === 404) {
|
||
router.push('/admin/select-project');
|
||
return;
|
||
}
|
||
|
||
const data = await apiKeyRes.json();
|
||
setApiKeys(data.apiKeys || []);
|
||
} catch (error) {
|
||
console.error('获取项目 API Key 失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
void loadData();
|
||
}, [projectCode]);
|
||
|
||
const handleDisable = async (id: string) => {
|
||
if (!confirm('确定要禁用这个 API Key 吗?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/admin/${projectCode}/api-keys?id=${id}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (response.ok) {
|
||
await loadData();
|
||
}
|
||
} catch (error) {
|
||
console.error('禁用项目 API Key 失败:', error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-100">
|
||
<nav className="bg-white shadow">
|
||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||
<div className="flex items-center gap-4">
|
||
<Link href={`/admin/${projectCode}`} className="text-gray-600 hover:text-gray-900">
|
||
← 返回
|
||
</Link>
|
||
<h1 className="text-xl font-bold">项目 API Key 管理</h1>
|
||
</div>
|
||
{admin?.isSuperAdmin ? (
|
||
<button
|
||
onClick={() => setShowModal(true)}
|
||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||
>
|
||
创建 API Key
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</nav>
|
||
|
||
<main className="mx-auto max-w-5xl px-4 py-8">
|
||
<div className="rounded-lg bg-white shadow">
|
||
{loading ? (
|
||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||
) : (
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||
名称
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||
状态
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||
最近使用
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||
创建时间
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||
操作
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{apiKeys.map((item) => (
|
||
<tr key={item.id}>
|
||
<td className="px-6 py-4 font-medium">{item.name}</td>
|
||
<td className="px-6 py-4">
|
||
<span
|
||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||
item.isActive
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-gray-100 text-gray-500'
|
||
}`}
|
||
>
|
||
{item.isActive ? '启用中' : '已禁用'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-gray-600">
|
||
{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString() : '未使用'}
|
||
</td>
|
||
<td className="px-6 py-4 text-gray-600">
|
||
{new Date(item.createdAt).toLocaleString()}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
{item.isActive ? (
|
||
<button
|
||
onClick={() => void handleDisable(item.id)}
|
||
className="text-sm text-red-600 hover:underline"
|
||
>
|
||
禁用
|
||
</button>
|
||
) : (
|
||
<span className="text-sm text-gray-400">不可恢复</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!apiKeys.length ? (
|
||
<tr>
|
||
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
||
暂无 API Key
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
{showModal ? (
|
||
<ApiKeyModal
|
||
projectCode={projectCode}
|
||
onClose={() => setShowModal(false)}
|
||
onSuccess={() => {
|
||
setShowModal(false);
|
||
void loadData();
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ApiKeyModal({
|
||
projectCode,
|
||
onClose,
|
||
onSuccess,
|
||
}: {
|
||
projectCode: string;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}) {
|
||
const [name, setName] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [generatedKey, setGeneratedKey] = useState('');
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
try {
|
||
const response = await fetch(`/api/admin/${projectCode}/api-keys`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
setGeneratedKey(data.key);
|
||
} else {
|
||
setError(data.error || '创建失败');
|
||
}
|
||
} catch {
|
||
setError('网络错误');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (generatedKey) {
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
|
||
<h2 className="mb-4 text-lg font-medium">API Key 已创建</h2>
|
||
<div className="mb-4 rounded-lg bg-gray-100 p-4">
|
||
<p className="mb-2 text-sm text-gray-500">请立即保存,只会显示一次:</p>
|
||
<p className="break-all font-mono text-sm font-bold">{generatedKey}</p>
|
||
</div>
|
||
<button
|
||
onClick={onSuccess}
|
||
className="w-full rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700"
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="w-full max-w-md rounded-lg bg-white shadow-xl">
|
||
<div className="border-b p-6">
|
||
<h2 className="text-lg font-medium">创建项目 API Key</h2>
|
||
</div>
|
||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-700">名称 *</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
className="w-full rounded-lg border px-3 py-2"
|
||
placeholder="例如:官网发证"
|
||
required
|
||
/>
|
||
</div>
|
||
{error ? <div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div> : null}
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 rounded-lg border py-2 hover:bg-gray-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{loading ? '创建中...' : '创建'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|