Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
21 KiB
21 KiB
GHCS v0.2 · keys/page.tsx · API密钥管理前端
部署路径:~/guanghu-dev/ghcs-mono/frontend/src/app/console/keys/page.tsx
"use client";
import { useState, useEffect, useCallback } from "react";
import {
KeyIcon,
PlusIcon,
TrashIcon,
EyeIcon,
EyeSlashIcon,
PencilIcon,
ShieldCheckIcon,
XMarkIcon,
CheckIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
const SUPPLIER_COLORS: Record<string, string> = {
deepseek: "#4F46E5",
qwen: "#0EA5E9",
glm: "#8B5CF6",
moonshot: "#10B981",
other: "#6B7280",
};
type Supplier = {
key: string;
name: string;
color: string;
key_count: number;
active_count: number;
};
type ApiKey = {
id: string;
supplier: string;
supplier_name: string;
key_name: string;
api_key_masked: string;
is_active: boolean;
monthly_budget: number | null;
created_at: string;
updated_at: string;
};
type UsageItem = {
id: string;
supplier: string;
key_name: string;
total_calls: number;
total_tokens: number;
estimated_cost: number;
monthly_budget: number | null;
budget_used_percent: number | null;
is_active: boolean;
};
type Alert = {
level: "red" | "yellow";
key_id: string;
key_name: string;
supplier: string;
message: string;
};
export default function ApiKeysPage() {
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [usage, setUsage] = useState<UsageItem[]>([]);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
const [form, setForm] = useState({
supplier: "deepseek",
key_name: "",
api_key: "",
monthly_budget: "",
});
const [showKey, setShowKey] = useState(false);
const [activeTab, setActiveTab] = useState<"keys" | "usage">("keys");
const API_BASE = process.env.NEXT_PUBLIC_CONSOLE_API || "";
const fetchAll = useCallback(async () => {
try {
const [supRes, keyRes, usageRes, healthRes] = await Promise.all([
fetch(`${API_BASE}/api/keys/suppliers`),
fetch(`${API_BASE}/api/keys/list`),
fetch(`${API_BASE}/api/keys/usage`),
fetch(`${API_BASE}/api/keys/health-check`),
]);
if (supRes.ok) setSuppliers((await supRes.json()).suppliers);
if (keyRes.ok) setKeys((await keyRes.json()).keys);
if (usageRes.ok) setUsage((await usageRes.json()).items);
if (healthRes.ok) setAlerts((await healthRes.json()).alerts);
} catch (e) {
console.error("加载失败", e);
} finally {
setLoading(false);
}
}, [API_BASE]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const openAdd = (supplier: string) => {
setEditingKey(null);
setForm({ supplier, key_name: "", api_key: "", monthly_budget: "" });
setShowKey(false);
setShowModal(true);
};
const openEdit = (key: ApiKey) => {
setEditingKey(key);
setForm({
supplier: key.supplier,
key_name: key.key_name,
api_key: "",
monthly_budget: key.monthly_budget?.toString() || "",
});
setShowKey(false);
setShowModal(true);
};
const handleSubmit = async () => {
if (!form.key_name) return alert("请输入密钥名称");
const url = editingKey
? `${API_BASE}/api/keys/${editingKey.id}`
: `${API_BASE}/api/keys/add`;
const method = editingKey ? "PUT" : "POST";
const body: Record<string, unknown> = {
supplier: form.supplier,
key_name: form.key_name,
};
if (form.api_key) body.api_key = form.api_key;
if (form.monthly_budget) body.monthly_budget = parseFloat(form.monthly_budget);
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
setShowModal(false);
fetchAll();
} else {
const err = await res.json();
alert("操作失败: " + (err.detail || "未知错误"));
}
};
const handleDelete = async (keyId: string, keyName: string) => {
if (!confirm(`确认删除密钥 "${keyName}"?此操作不可恢复。`)) return;
const res = await fetch(`${API_BASE}/api/keys/${keyId}`, { method: "DELETE" });
if (res.ok) fetchAll();
};
const handleToggleActive = async (key: ApiKey) => {
const res = await fetch(`${API_BASE}/api/keys/${key.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !key.is_active }),
});
if (res.ok) fetchAll();
};
const totalCost = usage.reduce((s, u) => s + u.estimated_cost, 0);
const totalCalls = usage.reduce((s, u) => s + u.total_calls, 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-100 flex items-center gap-3">
<KeyIcon className="w-7 h-7 text-indigo-400" />
API 密钥管理
</h1>
<p className="text-gray-400 mt-1">
管理商业API供应商密钥 · 用量监控 · 费用告警
</p>
</div>
{alerts.length > 0 && (
<div className="space-y-2">
{alerts.map((a, i) => (
<div
key={i}
className={`p-3 rounded-lg border flex items-start gap-3 ${
a.level === "red"
? "bg-red-900/30 border-red-700/50 text-red-300"
: "bg-yellow-900/30 border-yellow-700/50 text-yellow-300"
}`}
>
<ShieldCheckIcon className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div>
<span className="font-semibold">{a.key_name}</span>{" "}
<span className="opacity-75">({a.supplier})</span>
<p className="text-sm mt-0.5">{a.message}</p>
</div>
</div>
))}
</div>
)}
<div className="flex gap-1 bg-gray-800/50 rounded-lg p-1 w-fit">
<button
onClick={() => setActiveTab("keys")}
className={`px-4 py-2 rounded-md text-sm font-medium transition ${
activeTab === "keys"
? "bg-gray-700 text-white"
: "text-gray-400 hover:text-gray-200"
}`}
>
密钥列表
</button>
<button
onClick={() => setActiveTab("usage")}
className={`px-4 py-2 rounded-md text-sm font-medium transition ${
activeTab === "usage"
? "bg-gray-700 text-white"
: "text-gray-400 hover:text-gray-200"
}`}
>
用量统计
</button>
</div>
{activeTab === "keys" ? (
<div className="space-y-6">
{loading ? (
<div className="text-gray-400 py-8 text-center">
<ArrowPathIcon className="w-6 h-6 animate-spin inline" /> 加载中...
</div>
) : (
suppliers.map((sup) => {
const supKeys = keys.filter((k) => k.supplier === sup.key);
return (
<div
key={sup.key}
className="bg-gray-800/50 rounded-xl border border-gray-700/50 overflow-hidden"
>
<div className="p-4 flex items-center justify-between border-b border-gray-700/30">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style= backgroundColor: sup.color
/>
<h3 className="text-lg font-semibold text-gray-100">
{sup.name}
</h3>
<span className="text-sm text-gray-500">
{sup.active_count}/{sup.key_count} 活跃
</span>
</div>
<button
onClick={() => openAdd(sup.key)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg transition"
>
<PlusIcon className="w-4 h-4" />
添加密钥
</button>
</div>
{supKeys.length === 0 ? (
<div className="p-6 text-center text-gray-500 text-sm">
暂无密钥,点击上方按钮添加
</div>
) : (
<div className="divide-y divide-gray-700/20">
{supKeys.map((key) => (
<div
key={key.id}
className="p-4 flex items-center justify-between hover:bg-gray-700/20 transition"
>
<div className="flex items-center gap-4">
<button
onClick={() => handleToggleActive(key)}
className={`relative w-10 h-5 rounded-full transition ${
key.is_active
? "bg-green-500"
: "bg-gray-600"
}`}
>
<div
className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition ${
key.is_active ? "left-5" : "left-0.5"
}`}
/>
</button>
<div>
<div className="flex items-center gap-2">
<span className="text-gray-100 font-medium">
{key.key_name}
</span>
<span className="text-gray-500 text-xs font-mono bg-gray-700/50 px-2 py-0.5 rounded">
{key.api_key_masked}
</span>
</div>
<div className="text-xs text-gray-500 mt-1">
创建: {key.created_at.slice(0, 10)}
{key.updated_at !== key.created_at &&
` · 更新: ${key.updated_at.slice(0, 10)}`}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(key)}
className="p-1.5 text-gray-400 hover:text-gray-200 transition"
title="编辑"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(key.id, key.key_name)}
className="p-1.5 text-gray-400 hover:text-red-400 transition"
title="删除"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})
)}
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-xl border border-gray-700/50 p-4">
<div className="text-gray-400 text-sm">总调用次数</div>
<div className="text-2xl font-bold text-gray-100">
{totalCalls.toLocaleString()}
</div>
</div>
<div className="bg-gray-800/50 rounded-xl border border-gray-700/50 p-4">
<div className="text-gray-400 text-sm">预估月费</div>
<div className="text-2xl font-bold text-indigo-400">
¥{totalCost.toFixed(2)}
</div>
</div>
<div className="bg-gray-800/50 rounded-xl border border-gray-700/50 p-4">
<div className="text-gray-400 text-sm">活跃密钥</div>
<div className="text-2xl font-bold text-green-400">
{usage.filter((u) => u.is_active).length}
</div>
</div>
</div>
{usage.length === 0 ? (
<div className="text-gray-500 text-center py-8">暂无调用记录</div>
) : (
<div className="bg-gray-800/50 rounded-xl border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700/30 text-gray-400">
<td className="p-3">密钥</td>
<td className="p-3">供应商</td>
<td className="p-3 text-right">调用次数</td>
<td className="p-3 text-right">Token 用量</td>
<td className="p-3 text-right">预估费用</td>
<td className="p-3 text-right">预算</td>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/20">
{usage.map((u) => (
<tr key={u.id} className="hover:bg-gray-700/20">
<td className="p-3">
<span className="text-gray-100">{u.key_name}</span>
</td>
<td className="p-3">
<span
className="inline-flex items-center gap-1.5"
style= color: SUPPLIER_COLORS[u.supplier] || "#6B7280"
>
<span
className="w-2 h-2 rounded-full inline-block"
style=
backgroundColor: SUPPLIER_COLORS[u.supplier] || "#6B7280",
/>
{u.supplier}
</span>
</td>
<td className="p-3 text-right text-gray-300">
{u.total_calls.toLocaleString()}
</td>
<td className="p-3 text-right text-gray-300">
{u.total_tokens.toLocaleString()}
</td>
<td className="p-3 text-right text-indigo-400 font-mono">
¥{u.estimated_cost.toFixed(2)}
</td>
<td className="p-3 text-right">
{u.monthly_budget ? (
<div className="flex items-center justify-end gap-2">
<div className="w-16 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition ${
(u.budget_used_percent || 0) >= 100
? "bg-red-500"
: (u.budget_used_percent || 0) >= 80
? "bg-yellow-500"
: "bg-green-500"
}`}
style={{
width: `${Math.min(u.budget_used_percent || 0, 100)}%`,
}}
/>
</div>
<span className="text-gray-400 text-xs">
{u.budget_used_percent}%
</span>
</div>
) : (
<span className="text-gray-600">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{showModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-100">
{editingKey ? "编辑密钥" : "添加密钥"}
</h3>
<button
onClick={() => setShowModal(false)}
className="p-1 text-gray-400 hover:text-gray-200"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">供应商</label>
<select
value={form.supplier}
onChange={(e) => setForm({ ...form, supplier: e.target.value })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-indigo-500"
>
{Object.keys(SUPPLIER_COLORS).map((k) => (
<option key={k} value={k}>{k}</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
密钥名称
</label>
<input
type="text"
placeholder="例如: 个人开发Key"
value={form.key_name}
onChange={(e) => setForm({ ...form, key_name: e.target.value })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
API Key {editingKey && "(留空则不修改)"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
placeholder={editingKey ? "留空保持原密钥" : "sk-..."}
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 pr-10 text-gray-100 text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-2 text-gray-400 hover:text-gray-200"
>
{showKey ? (
<EyeSlashIcon className="w-4 h-4" />
) : (
<EyeIcon className="w-4 h-4" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
月度预算(元,可选)
</label>
<input
type="number"
placeholder="超出80%时会告警"
value={form.monthly_budget}
onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg text-sm transition"
>
取消
</button>
<button
onClick={handleSubmit}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm flex items-center justify-center gap-2 transition"
>
<CheckIcon className="w-4 h-4" />
{editingKey ? "保存" : "添加"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
📁 部署
下载这个文件,然后拖到 VS Code 里替换:
keys-page.tsx
或者终端里执行:
base64 -d << 'B64END' > ~/guanghu-dev/ghcs-mono/frontend/src/app/console/keys/page.tsx
base64_data
B64END