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

24 KiB
Raw Permalink Blame History

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


完整代码(直接全选复制)

"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

验收口径

  • NetworkPOST /api/login → 200
  • localStorageghcs_access_token 有值
  • 启停:PUT /api/keys/{id} → 200不再 401
  • 新增:POST /api/keys/add → 200
  • 删除:DELETE /api/keys/{id} → 200