Guanghu Domestic Migration a27e87cb99 chore: import sanitized domestic snapshot for REPO-007
Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc

[SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
2026-07-17 15:59:55 +08:00

532 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}