7434e5195e
Anonymous callers get a free cumulative quota (1000 calls per IP); once exhausted they get 403 quota_exhausted and must register. Public users can self-register (email+password) to obtain a higher-quota API key, view usage, and regenerate the key. Quota counters live in Redis; the public API stays read-only except for the registration writes. - migration 0011: app_user table + api_key.quota_total + 'registered' tier - ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters - middleware: enforce cumulative quota + X-Quota-* headers - store: RegisterUser/Authenticate/RegenerateKey (bcrypt) - handlers: POST /api/v1/register, /account, /account/regenerate - admin: quota_total column + registered tier - public: 'API 密钥' account page + API docs quota section Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { api, ApiError } from "../api";
|
||
import type { ApiKey } from "../types";
|
||
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
|
||
|
||
const TIERS = [
|
||
{ key: "free", label: "免费 (free)", rate: 120, quota: 1000 },
|
||
{ key: "registered", label: "注册用户 (registered)", rate: 300, quota: 100000 },
|
||
{ key: "partner", label: "合作方 (partner)", rate: 600, quota: 0 },
|
||
{ key: "internal", label: "内部 (internal)", rate: 6000, quota: 0 },
|
||
];
|
||
|
||
function tierLabel(tier: string): string {
|
||
return TIERS.find((t) => t.key === tier)?.label ?? tier;
|
||
}
|
||
|
||
export default function ApiKeysPage() {
|
||
const [rows, setRows] = useState<ApiKey[]>([]);
|
||
const [error, setError] = useState("");
|
||
const [creating, setCreating] = useState(false);
|
||
const [newKey, setNewKey] = useState<string | null>(null);
|
||
|
||
async function load() {
|
||
setError("");
|
||
try {
|
||
const res = await api.listApiKeys();
|
||
setRows(res.items);
|
||
} catch (e) {
|
||
setError(e instanceof ApiError ? e.message : "加载失败");
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
async function revoke(id: string, name: string) {
|
||
if (!confirm(`确认吊销密钥「${name}」?使用该密钥的请求将立即被拒绝。`)) return;
|
||
try {
|
||
await api.revokeApiKey(id);
|
||
await load();
|
||
} catch (e) {
|
||
setError(e instanceof ApiError ? e.message : "操作失败");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto max-w-4xl">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||
<KeyRound className="h-5 w-5 text-emerald-600" /> API 密钥
|
||
</h2>
|
||
<p className="text-sm text-gray-500 mt-1">
|
||
公开 API 免费只读,匿名也可调用(低额度)。签发密钥可获得更高速率上限并归属用量。
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setCreating(true)}
|
||
className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 flex items-center gap-1.5"
|
||
>
|
||
<Plus className="h-4 w-4" /> 签发密钥
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>
|
||
)}
|
||
|
||
{newKey && (
|
||
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||
<div className="text-sm font-medium text-amber-800 mb-1">
|
||
密钥已生成,请立即复制保存——它只显示这一次。
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 bg-white border rounded px-3 py-2 text-sm break-all">
|
||
{newKey}
|
||
</code>
|
||
<button
|
||
onClick={() => navigator.clipboard?.writeText(newKey)}
|
||
className="px-3 py-2 rounded border text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-1"
|
||
>
|
||
<Copy className="h-4 w-4" /> 复制
|
||
</button>
|
||
<button
|
||
onClick={() => setNewKey(null)}
|
||
className="px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100"
|
||
>
|
||
我已保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{creating && (
|
||
<CreateKeyForm
|
||
onClose={() => setCreating(false)}
|
||
onCreated={(plaintext) => {
|
||
setCreating(false);
|
||
setNewKey(plaintext);
|
||
load();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<div className="bg-white border rounded-lg overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||
<tr>
|
||
<th className="px-4 py-2 font-medium">名称</th>
|
||
<th className="px-4 py-2 font-medium">前缀</th>
|
||
<th className="px-4 py-2 font-medium">级别</th>
|
||
<th className="px-4 py-2 font-medium">速率/分钟</th>
|
||
<th className="px-4 py-2 font-medium">用量(今日/累计)</th>
|
||
<th className="px-4 py-2 font-medium">累计配额</th>
|
||
<th className="px-4 py-2 font-medium">状态</th>
|
||
<th className="px-4 py-2 font-medium"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y">
|
||
{rows.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||
暂无密钥
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
rows.map((k) => (
|
||
<tr key={k.id} className={k.revoked_at ? "opacity-50" : ""}>
|
||
<td className="px-4 py-2 text-gray-800">
|
||
{k.name}
|
||
{k.owner_email && (
|
||
<span className="block text-xs text-gray-400">{k.owner_email}</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-500">
|
||
<code>{k.key_prefix}…</code>
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-600">{tierLabel(k.tier)}</td>
|
||
<td className="px-4 py-2 text-gray-600">{k.rate_limit_per_min}</td>
|
||
<td className="px-4 py-2 text-gray-600">
|
||
{k.usage.today} / {k.usage.total}
|
||
</td>
|
||
<td className="px-4 py-2 text-gray-600">
|
||
{k.quota_total > 0 ? (
|
||
<span className={k.usage.total >= k.quota_total ? "text-red-600" : ""}>
|
||
{k.usage.total.toLocaleString()} / {k.quota_total.toLocaleString()}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400">不限</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2">
|
||
{k.revoked_at ? (
|
||
<span className="text-xs rounded px-2 py-0.5 bg-red-50 text-red-700">
|
||
已吊销
|
||
</span>
|
||
) : (
|
||
<span className="text-xs rounded px-2 py-0.5 bg-emerald-50 text-emerald-700">
|
||
启用中
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2 text-right">
|
||
{!k.revoked_at && (
|
||
<button
|
||
onClick={() => revoke(k.id, k.name)}
|
||
className="text-gray-400 hover:text-red-600"
|
||
title="吊销"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CreateKeyForm({
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
onClose: () => void;
|
||
onCreated: (plaintext: string) => void;
|
||
}) {
|
||
const [name, setName] = useState("");
|
||
const [ownerEmail, setOwnerEmail] = useState("");
|
||
const [tier, setTier] = useState("free");
|
||
const [rate, setRate] = useState(120);
|
||
const [quota, setQuota] = useState(1000);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
function pickTier(t: string) {
|
||
setTier(t);
|
||
const def = TIERS.find((x) => x.key === t);
|
||
if (def) {
|
||
setRate(def.rate);
|
||
setQuota(def.quota);
|
||
}
|
||
}
|
||
|
||
async function submit() {
|
||
if (!name.trim()) {
|
||
setError("名称不能为空");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const res = await api.createApiKey({
|
||
name: name.trim(),
|
||
owner_email: ownerEmail.trim() || undefined,
|
||
tier,
|
||
rate_limit_per_min: rate,
|
||
quota_total: quota,
|
||
});
|
||
onCreated(res.key);
|
||
} catch (e) {
|
||
setError(e instanceof ApiError ? e.message : "创建失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="mb-4 bg-white border rounded-lg p-5">
|
||
<h3 className="font-medium text-gray-700 mb-3">签发新密钥</h3>
|
||
{error && <div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-3 py-2">{error}</div>}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<label className="block">
|
||
<span className="text-xs text-gray-500">名称 *</span>
|
||
<input
|
||
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="例如:我的 App / 合作方 X"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-xs text-gray-500">负责人邮箱(可选)</span>
|
||
<input
|
||
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
|
||
value={ownerEmail}
|
||
onChange={(e) => setOwnerEmail(e.target.value)}
|
||
placeholder="owner@example.com"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-xs text-gray-500">级别</span>
|
||
<select
|
||
className="w-full border rounded-md px-3 py-2 text-sm mt-1 bg-white"
|
||
value={tier}
|
||
onChange={(e) => pickTier(e.target.value)}
|
||
>
|
||
{TIERS.map((t) => (
|
||
<option key={t.key} value={t.key}>
|
||
{t.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-xs text-gray-500">速率上限(次/分钟)</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
|
||
value={rate}
|
||
onChange={(e) => setRate(Math.max(1, parseInt(e.target.value || "1", 10)))}
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-xs text-gray-500">累计配额(总调用次数,0=不限)</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
|
||
value={quota}
|
||
onChange={(e) => setQuota(Math.max(0, parseInt(e.target.value || "0", 10)))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="mt-4 flex gap-2">
|
||
<button
|
||
onClick={submit}
|
||
disabled={busy}
|
||
className="px-4 py-2 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700 disabled:opacity-60"
|
||
>
|
||
生成
|
||
</button>
|
||
<button onClick={onClose} className="px-4 py-2 rounded border text-sm text-gray-600">
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|