669 lines
24 KiB
Markdown
669 lines
24 KiB
Markdown
|
|
# GHCS v0.2 · keys/page.tsx 完整可替换版(含登录入口+鉴权)
|
|||
|
|
|
|||
|
|
本页提供一份 **可直接全量替换** 的 `page.tsx` 代码:包含
|
|||
|
|
|
|||
|
|
- ✅ 登录入口(POST /api/login → localStorage.ghcs_access_token)
|
|||
|
|
- ✅ 写操作自动带 Bearer(新增/编辑/启停/删除都不再 401)
|
|||
|
|
- ✅ 去掉 `any`(满足 `@typescript-eslint/no-explicit-any`)
|
|||
|
|
- ✅ token 初始化不使用 `setState in effect`(用 lazy initializer)
|
|||
|
|
|
|||
|
|
> 替换路径:`~/guanghu-dev/ghcs-mono/frontend/src/app/console/keys/page.tsx`
|
|||
|
|
>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ✅ 完整代码(直接全选复制)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useCallback } from "react";
|
|||
|
|
import {
|
|||
|
|
KeyIcon,
|
|||
|
|
PlusIcon,
|
|||
|
|
TrashIcon,
|
|||
|
|
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;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type ApiKeyForm = {
|
|||
|
|
supplier: string;
|
|||
|
|
key_name: string;
|
|||
|
|
api_key: string;
|
|||
|
|
monthly_budget: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function ApiKeysPage() {
|
|||
|
|
const API_BASE = (process.env.NEXT_PUBLIC_CONSOLE_API as string) || "";
|
|||
|
|
|
|||
|
|
// ===== Auth =====
|
|||
|
|
const [token, setToken] = useState<string>(() => {
|
|||
|
|
if (typeof window === "undefined") return "";
|
|||
|
|
return localStorage.getItem("ghcs_access_token") || "";
|
|||
|
|
});
|
|||
|
|
const [loginForm, setLoginForm] = useState({ username: "awen", password: "" });
|
|||
|
|
const [authError, setAuthError] = useState<string>("");
|
|||
|
|
|
|||
|
|
const authHeaders = () => {
|
|||
|
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleLogin = async () => {
|
|||
|
|
setAuthError("");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${API_BASE}/api/login`, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
username: loginForm.username,
|
|||
|
|
password: loginForm.password,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const errText = await res.text();
|
|||
|
|
setAuthError("登录失败: " + errText);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = (await res.json()) as { access_token?: string };
|
|||
|
|
const accessToken = data.access_token;
|
|||
|
|
|
|||
|
|
if (!accessToken) {
|
|||
|
|
setAuthError("登录失败:未返回 access_token");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
localStorage.setItem("ghcs_access_token", accessToken);
|
|||
|
|
setToken(accessToken);
|
|||
|
|
setLoginForm((f) => ({ ...f, password: "" }));
|
|||
|
|
} catch (e: unknown) {
|
|||
|
|
setAuthError("登录异常: " + String(e));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleLogout = () => {
|
|||
|
|
localStorage.removeItem("ghcs_access_token");
|
|||
|
|
setToken("");
|
|||
|
|
setAuthError("");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ===== Data state =====
|
|||
|
|
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<ApiKeyForm>({
|
|||
|
|
supplier: "deepseek",
|
|||
|
|
key_name: "",
|
|||
|
|
api_key: "",
|
|||
|
|
monthly_budget: "",
|
|||
|
|
});
|
|||
|
|
const [activeTab, setActiveTab] = useState<"keys" | "usage">("keys");
|
|||
|
|
|
|||
|
|
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 as Supplier[]);
|
|||
|
|
if (keyRes.ok) setKeys((await keyRes.json()).keys as ApiKey[]);
|
|||
|
|
if (usageRes.ok) setUsage((await usageRes.json()).items as UsageItem[]);
|
|||
|
|
if (healthRes.ok) setAlerts((await healthRes.json()).alerts as Alert[]);
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [API_BASE]);
|
|||
|
|
|
|||
|
|
// NOTE: 你们仓库当前 eslint 规则会对 effect 内触发 setState 很敏感。
|
|||
|
|
// 这里允许一次性初始化拉取。若你未来要“lint 全绿”,可以统一调整 eslint 规则或改为路由加载器。
|
|||
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchAll();
|
|||
|
|
}, [fetchAll]);
|
|||
|
|
|
|||
|
|
const openAdd = (supplier: string) => {
|
|||
|
|
setEditingKey(null);
|
|||
|
|
setForm({ supplier, key_name: "", api_key: "", monthly_budget: "" });
|
|||
|
|
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() || "",
|
|||
|
|
});
|
|||
|
|
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",
|
|||
|
|
...authHeaders(),
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify(body),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const errText = await res.text();
|
|||
|
|
alert("操作失败: " + errText);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setShowModal(false);
|
|||
|
|
await fetchAll();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (keyId: string, keyName: string) => {
|
|||
|
|
if (!confirm(`确认删除密钥 "${keyName}"?此操作不可恢复。`)) return;
|
|||
|
|
|
|||
|
|
const res = await fetch(`${API_BASE}/api/keys/${keyId}`, {
|
|||
|
|
method: "DELETE",
|
|||
|
|
headers: {
|
|||
|
|
...authHeaders(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const errText = await res.text();
|
|||
|
|
alert("删除失败: " + errText);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await fetchAll();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleToggleActive = async (key: ApiKey) => {
|
|||
|
|
const res = await fetch(`${API_BASE}/api/keys/${key.id}`, {
|
|||
|
|
method: "PUT",
|
|||
|
|
headers: {
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
...authHeaders(),
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({ is_active: !key.is_active }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const errText = await res.text();
|
|||
|
|
alert("启停失败: " + errText);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await 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>
|
|||
|
|
|
|||
|
|
{/* Login bar */}
|
|||
|
|
<div className="bg-gray-800/50 rounded-xl border border-gray-700/50 p-4">
|
|||
|
|
{token ? (
|
|||
|
|
<div className="flex items-center justify-between gap-4">
|
|||
|
|
<div className="text-sm text-gray-300">
|
|||
|
|
已登录(Bearer token 已加载)
|
|||
|
|
<span className="text-gray-500 ml-2 font-mono">
|
|||
|
|
{token.slice(0, 12)}…{token.slice(-8)}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={handleLogout}
|
|||
|
|
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
退出登录
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="text-sm text-yellow-300">
|
|||
|
|
未登录:写操作(新增/启停/删除)会 401。请先登录获取 token。
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-wrap items-center gap-2">
|
|||
|
|
<input
|
|||
|
|
value={loginForm.username}
|
|||
|
|
onChange={(e) => setLoginForm((f) => ({ ...f, username: e.target.value }))}
|
|||
|
|
placeholder="username"
|
|||
|
|
className="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 text-sm"
|
|||
|
|
/>
|
|||
|
|
<input
|
|||
|
|
type="password"
|
|||
|
|
value={loginForm.password}
|
|||
|
|
onChange={(e) => setLoginForm((f) => ({ ...f, password: e.target.value }))}
|
|||
|
|
placeholder="password"
|
|||
|
|
className="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 text-sm"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
onClick={handleLogin}
|
|||
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm"
|
|||
|
|
>
|
|||
|
|
登录
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{authError && <div className="text-sm text-red-300">{authError}</div>}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</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>
|
|||
|
|
<input
|
|||
|
|
type="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 text-gray-100 text-sm font-mono focus:outline-none focus:border-indigo-500"
|
|||
|
|
/>
|
|||
|
|
</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>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 使用说明(复制替换步骤)
|
|||
|
|
|
|||
|
|
1. VS Code 打开文件:`frontend/src/app/console/keys/page.tsx`
|
|||
|
|
2. `Cmd+A` 全选 → 粘贴本页代码 → `Cmd+S` 保存
|
|||
|
|
3. 前端重启:`Ctrl+C` 停掉 `npm run dev` → 再 `npm run dev`
|
|||
|
|
4. 打开:`http://localhost:3000/console/keys`
|
|||
|
|
5. 登录:`awen / guanghu2026`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 验收口径
|
|||
|
|
|
|||
|
|
- Network:`POST /api/login` → 200
|
|||
|
|
- localStorage:`ghcs_access_token` 有值
|
|||
|
|
- 启停:`PUT /api/keys/{id}` → 200(不再 401)
|
|||
|
|
- 新增:`POST /api/keys/add` → 200
|
|||
|
|
- 删除:`DELETE /api/keys/{id}` → 200
|