Files
cpa-plugin/internal/web/app/features/keys.js
T

462 lines
22 KiB
JavaScript
Raw 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.
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
import { localDateTimeValue, money, number, option, result } from "../core/shared.js";
const keyRowsNode = document.querySelector("#key-rows");
const keyStatusNode = document.querySelector("#key-status");
const keyStatsNode = document.querySelector("#key-stats");
const drawerNode = document.querySelector("#key-drawer");
const drawerOverlayNode = document.querySelector("#drawer-overlay");
const editorNode = document.querySelector("#key-editor");
const managedFieldsNode = document.querySelector("#managed-fields");
const editorFooterNode = document.querySelector("#editor-footer");
const statsGridNode = document.querySelector("#stats-grid");
const statsRecentNode = document.querySelector("#stats-recent");
let managedKeys = [];
let upstreamAccounts = [];
let modelSuggestions = [];
let usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
let editingKey = null;
export function initializeKeys() {
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
document.querySelector("#close-drawer").addEventListener("click", closeKeyDrawer);
document.querySelector("#cancel-editor").addEventListener("click", closeKeyDrawer);
drawerOverlayNode.addEventListener("click", closeKeyDrawer);
document.querySelector("#save-key").addEventListener("click", saveKeyEditor);
document.querySelector("#archive-key").addEventListener("click", () => editingKey && archiveManagedKey(editingKey));
document.querySelector("#editor-route").addEventListener("change", syncEditorRoute);
document.querySelector("#editor-all-models").addEventListener("change", syncEditorRoute);
document.querySelector("#editor-reset-period").addEventListener("change", syncEditorRoute);
document.querySelector("#reset-billing").addEventListener("click", resetManagedBilling);
document.addEventListener("keydown", event => {
if (event.key === "Escape") closeKeyDrawer();
});
onAccessChange(authorized => {
if (!authorized) closeKeyDrawer();
if (managedKeys.length) renderManagedKeys();
});
}
export async function loadKeys() {
keyStatusNode.textContent = "正在读取";
try {
const [upstreams, models, summary] = await Promise.all([
dataFetch(routes.upstreams),
dataFetch(routes.models),
dataFetch(routes.usageSummary)
]);
upstreamAccounts = upstreams.accounts || [];
modelSuggestions = models.models || [];
usageDashboard = summary;
const keys = await dataFetch(routes.keys);
managedKeys = keys.keys || [];
renderManagedKeys();
keyStatusNode.textContent = isManagementAuthorized() ? `共 ${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}` : "";
} catch (error) {
keyStatusNode.textContent = "读取失败: " + error.message;
}
}
export function closeKeyDrawer() {
drawerNode.classList.add("hidden");
drawerOverlayNode.classList.add("hidden");
drawerNode.setAttribute("aria-hidden", "true");
editingKey = null;
}
function openDrawer() {
drawerNode.classList.remove("hidden");
drawerOverlayNode.classList.remove("hidden");
drawerNode.setAttribute("aria-hidden", "false");
}
function openKeyEditor(key = null) {
editingKey = key;
const creating = !key;
document.querySelector("#drawer-title").textContent = creating ? "创建 Key" : `管理 ${key.name}`;
document.querySelector("#drawer-subtitle").textContent = creating ? "配置访问、模型、路由与初始额度" : "管理访问、模型、路由与额度";
editorNode.classList.remove("hidden");
keyStatsNode.classList.add("hidden");
editorFooterNode.classList.remove("hidden");
managedFieldsNode.classList.remove("hidden");
document.querySelector("#editor-name").value = key?.name || "";
const secret = document.querySelector("#editor-secret");
secret.value = key?.secret || "";
secret.readOnly = !creating;
document.querySelector("#editor-status").value = key?.status || "active";
document.querySelector("#editor-route").value = key?.route_mode || "auto";
const upstream = document.querySelector("#editor-upstream");
upstream.replaceChildren(option("", "未指定", !key?.upstream_account_id), ...upstreamAccounts.map(account => option(account.id, `${upstreamLabel(account)}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key?.upstream_account_id)));
document.querySelector("#editor-all-models").checked = key?.all_models ?? true;
const models = document.querySelector("#editor-models");
models.value = (key?.models || []).join(", ");
models.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol";
const billing = key?.billing || { quota_usd: "0", spent_usd: "0", balance_usd: "0", reset_period: "none", max_concurrency: 4, active_requests: 0 };
document.querySelector("#editor-quota").value = billing.quota_usd || "0";
document.querySelector("#editor-concurrency").value = billing.max_concurrency || 4;
document.querySelector("#editor-reset-period").value = billing.reset_period || "none";
document.querySelector("#editor-next-reset").value = localDateTimeValue(billing.next_reset_at);
document.querySelector("#billing-quota-view").textContent = money(billing.quota_usd);
document.querySelector("#billing-spent-view").textContent = money(billing.spent_usd);
document.querySelector("#billing-balance-view").textContent = money(billing.balance_usd);
document.querySelector("#billing-status").textContent = creating ? "新用户默认额度为 $0" : `${billing.active_requests || 0} 个请求正在执行`;
document.querySelector("#billing-preview").classList.toggle("hidden", creating);
document.querySelector("#ledger-section").classList.toggle("hidden", creating);
document.querySelector("#reset-billing").classList.toggle("hidden", creating || key?.status === "archived");
document.querySelector("#archive-key").classList.toggle("hidden", creating || key.status === "archived");
document.querySelector("#save-key").classList.toggle("hidden", key?.status === "archived");
document.querySelector("#save-key").textContent = creating ? "创建 Key" : "保存修改";
["editor-name", "editor-status", "editor-route", "editor-all-models", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
syncEditorRoute();
openDrawer();
if (!creating) loadBillingLedger(key);
}
function renderManagedKeys() {
keyRowsNode.replaceChildren(...managedKeys.map(key => {
const row = document.createElement("tr");
const archived = key.status === "archived";
const identity = document.createElement("div");
identity.className = "key-identity";
const avatar = document.createElement("span");
avatar.className = "avatar";
avatar.textContent = (key.name || "K").slice(0, 2).toUpperCase();
const identityText = document.createElement("div");
const name = document.createElement("strong");
name.textContent = key.name;
const id = document.createElement("small");
id.textContent = key.id;
identityText.append(name, id);
identity.append(avatar, identityText);
const credential = document.createElement("div");
credential.className = "credential";
const secret = document.createElement("code");
secret.className = "credential-copy";
secret.textContent = isManagementAuthorized() ? maskedSecret(key.secret) : (key.masked_secret || "******");
if (isManagementAuthorized() && key.secret) {
secret.tabIndex = 0;
secret.setAttribute("role", "button");
secret.setAttribute("aria-label", "复制完整 Key");
secret.title = "点击复制完整 Key";
secret.addEventListener("click", () => copyText(key.secret, secret));
secret.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copyText(key.secret, secret);
}
});
}
credential.append(secret);
const status = document.createElement("span");
status.className = `badge ${key.status}`;
status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
const billing = key.billing || {};
const balance = document.createElement("div");
const balanceMain = document.createElement("div");
balanceMain.className = "route-main";
balanceMain.textContent = money(billing.balance_usd);
const balanceSub = document.createElement("div");
balanceSub.className = "route-sub";
balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`;
balance.append(balanceMain, balanceSub);
const concurrency = document.createElement("span");
concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
const routeNode = document.createElement("span");
routeNode.className = "route-main";
routeNode.textContent = routeLabel(key);
const models = document.createElement("span");
models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
const actions = document.createElement("div");
actions.className = "key-actions";
const stats = document.createElement("button");
stats.type = "button";
stats.className = "small";
stats.textContent = "统计";
stats.addEventListener("click", () => loadKeyStats(key));
actions.append(stats);
if (isManagementAuthorized()) {
const manage = document.createElement("button");
manage.type = "button";
manage.className = "small admin-only";
manage.textContent = archived ? "查看" : "管理";
manage.addEventListener("click", () => openKeyEditor(key));
actions.append(manage);
}
[identity, credential, status, balance, concurrency, routeNode, models, actions].forEach(node => {
const cell = document.createElement("td");
cell.className = "left";
cell.append(node);
row.append(cell);
});
return row;
}));
renderUserCharts();
}
function renderUserCharts() {
const byUser = new Map(managedKeys.map(key => [key.name, {
name: key.name, requests: 0, tokens: 0, cost: 0,
balance: key.billing ? Number(key.billing.balance_usd || 0) : null,
days: [], last: null
}]));
(usageDashboard.users || []).forEach(user => {
const name = user.key_alias || "未识别";
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, balance: null, days: [], last: null });
const value = byUser.get(name);
const today = user.today || {};
value.requests = today.requests || 0;
value.tokens = today.total_tokens || 0;
value.cost = today.cost_usd || 0;
value.days = user.days || [];
value.last = user.last_used_at ? new Date(user.last_used_at) : null;
});
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
const header = document.createElement("div");
header.className = "user-usage-row header";
["用户", "今日请求", "今日 Token", "今日成本", "剩余额度", "最近使用"].forEach(label => {
const cell = document.createElement("span");
cell.textContent = label;
header.append(cell);
});
document.querySelector("#user-usage").replaceChildren(header, ...userRows.map(item => {
const row = document.createElement("div");
row.className = "user-usage-row";
[item.name, number(item.requests), number(item.tokens), money(item.cost), item.balance === null ? "—" : money(item.balance), item.last ? item.last.toLocaleString() : "从未"].forEach(value => {
const cell = document.createElement("span");
cell.textContent = value;
row.append(cell);
});
return row;
}));
const days = [...(usageDashboard.days || [])].reverse().map(item => ({ date: new Date(item.date + "T00:00:00"), cost: Number(item.cost_usd || 0) }));
renderQuotaMatrix(days, [...byUser.values()]);
}
function renderQuotaMatrix(days, users) {
const dates = days.map(day => dateKey(day.date));
const rows = users.map(user => {
const costs = new Map(user.days.map(day => [day.date, Number(day.cost_usd || 0)]));
const values = dates.map(date => costs.get(date) || 0);
return { name: user.name, values, total: values.reduce((sum, value) => sum + value, 0) };
}).sort((left, right) => right.total - left.total || left.name.localeCompare(right.name));
const maximum = Math.max(0.000001, ...rows.flatMap(row => row.values));
const matrix = document.createElement("div");
matrix.className = "quota-matrix";
matrix.style.gridTemplateColumns = `minmax(100px, 1.25fr) repeat(${days.length}, minmax(74px, 1fr)) minmax(82px, 1fr)`;
matrix.append(quotaCell("用户", "header user"), ...days.map(day => quotaCell(`${day.date.getMonth() + 1}/${day.date.getDate()}`, "header")), quotaCell("7 日合计", "header total"));
rows.forEach(row => {
matrix.append(quotaCell(row.name, "user"));
row.values.forEach((value, index) => {
const cell = quotaCell(value ? money(value) : "—", value ? "usage" : "");
cell.style.setProperty("--quota-intensity", `${12 + value / maximum * 58}%`);
cell.title = `${row.name} · ${dates[index]} · ${money(value)}`;
matrix.append(cell);
});
matrix.append(quotaCell(money(row.total), "total"));
});
const weekTotal = days.reduce((sum, day) => sum + day.cost, 0);
matrix.append(quotaCell("每日合计", "header user"), ...days.map(day => quotaCell(money(day.cost), "total")), quotaCell(money(weekTotal), "total"));
document.querySelector("#quota-chart").replaceChildren(matrix);
}
function quotaCell(text, classes = "") {
const cell = document.createElement("div");
cell.className = "quota-cell" + (classes ? " " + classes : "");
cell.textContent = text;
return cell;
}
function dateKey(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
async function createManagedKey() {
const route = document.querySelector("#editor-route").value;
const allModels = document.querySelector("#editor-all-models").checked;
const payload = {
name: document.querySelector("#editor-name").value.trim(),
secret: document.querySelector("#editor-secret").value.trim(),
route_mode: route,
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
all_models: allModels,
models: modelRules(),
billing: billingPayload()
};
try {
const created = await managedFetch(routes.keys, { method: "POST", body: JSON.stringify(payload) });
keyStatusNode.textContent = `已创建 ${created.name}${created.secret}`;
closeKeyDrawer();
await loadKeys();
} catch (error) {
keyStatusNode.textContent = "创建失败: " + error.message;
}
}
async function saveKeyEditor() {
if (!editingKey) return createManagedKey();
const route = document.querySelector("#editor-route").value;
try {
await managedFetch(routes.keys, { method: "PATCH", body: JSON.stringify({
id: editingKey.id,
name: document.querySelector("#editor-name").value.trim(),
status: document.querySelector("#editor-status").value,
route_mode: route,
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
all_models: document.querySelector("#editor-all-models").checked,
models: modelRules(),
billing: billingPayload()
}) });
closeKeyDrawer();
await loadKeys();
} catch (error) {
keyStatusNode.textContent = "保存失败: " + error.message;
}
}
async function loadBillingLedger(key) {
const node = document.querySelector("#billing-ledger");
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "正在读取" }));
try {
const entries = (await managedFetch(routes.billingLedger + "?id=" + encodeURIComponent(key.id) + "&limit=50")).entries || [];
node.replaceChildren(...(entries.length ? entries.map(entry => {
const row = document.createElement("div");
row.className = "ledger-item";
const label = document.createElement("strong");
label.textContent = ({ charge: "请求扣费", quota_change: "调整额度", cycle_reset: "额度重置" }[entry.kind] || entry.kind);
const amount = document.createElement("strong");
const numeric = Number(entry.amount_usd || 0);
amount.className = numeric < 0 ? "negative" : "positive";
amount.textContent = (numeric > 0 ? "+" : "") + money(numeric);
const detail = document.createElement("small");
detail.textContent = `${new Date(entry.occurred_at).toLocaleString()} · 余额 ${money(entry.balance_after_usd)}${entry.model ? " · " + entry.model : ""}${entry.request_id ? " · " + entry.request_id : ""}`;
row.append(label, amount, detail);
return row;
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无账目" })]));
} catch (error) {
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "读取失败:" + error.message }));
}
}
async function resetManagedBilling() {
if (!editingKey || !confirm(`立即重置 ${editingKey.name} 的额度?当前剩余余额不会结转。`)) return;
try {
await managedFetch(routes.billingReset, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
await loadKeys();
const refreshed = managedKeys.find(item => item.id === editingKey.id);
if (refreshed) openKeyEditor(refreshed);
} catch (error) {
document.querySelector("#billing-status").textContent = "重置失败:" + error.message;
}
}
async function archiveManagedKey(key) {
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
try {
await managedFetch(routes.keys, { method: "DELETE", body: JSON.stringify({ id: key.id }) });
closeKeyDrawer();
await loadKeys();
} catch (error) {
keyStatusNode.textContent = "归档失败: " + error.message;
}
}
async function loadKeyStats(key) {
try {
const payload = await dataFetch(routes.keyStats + "?id=" + encodeURIComponent(key.id));
const total = payload.stats.total;
const today = payload.stats.today;
document.querySelector("#drawer-title").textContent = `${key.name} · 统计`;
document.querySelector("#drawer-subtitle").textContent = "历史统计永久保留";
editorNode.classList.add("hidden");
editorFooterNode.classList.add("hidden");
keyStatsNode.classList.remove("hidden");
statsGridNode.replaceChildren(...[
["今日请求", number(today.requests)], ["今日 Token", number(today.total_tokens)],
["累计请求", number(total.requests)], ["累计成本", "$" + (total.cost_micros / 1000000).toFixed(4)]
].map(([label, value]) => {
const tile = document.createElement("div");
tile.className = "stats-tile";
const name = document.createElement("span");
name.textContent = label;
const strong = document.createElement("strong");
strong.textContent = value;
tile.append(name, strong);
return tile;
}));
const recent = (payload.recent || []).slice(0, 10);
statsRecentNode.replaceChildren(...(recent.length ? recent.map(item => {
const row = document.createElement("div");
row.className = "recent-item";
const model = document.createElement("strong");
model.textContent = `${item.model || "-"} · ${result(item)}`;
const detail = document.createElement("span");
detail.textContent = `${new Date(item.requested_at).toLocaleString()} · ${number(item.total_tokens)} Token · ${item.auth_id || "自动选择"}`;
row.append(model, detail);
return row;
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无请求" })]));
openDrawer();
} catch (error) {
keyStatusNode.textContent = "读取统计失败: " + error.message;
}
}
function syncEditorRoute() {
const archived = editingKey?.status === "archived";
const strict = document.querySelector("#editor-route").value === "strict";
document.querySelector("#editor-upstream").disabled = archived || !strict;
document.querySelector("#editor-models").disabled = archived || document.querySelector("#editor-all-models").checked;
const period = document.querySelector("#editor-reset-period").value;
const nextReset = document.querySelector("#editor-next-reset");
nextReset.disabled = archived || period === "none";
if (period !== "none" && !nextReset.value) {
const next = new Date();
if (period === "daily") next.setDate(next.getDate() + 1);
else if (period === "weekly") next.setDate(next.getDate() + 7);
else next.setMonth(next.getMonth() + 1);
nextReset.value = localDateTimeValue(next);
}
}
function billingPayload() {
const period = document.querySelector("#editor-reset-period").value;
const nextValue = document.querySelector("#editor-next-reset").value;
return {
quota_usd: document.querySelector("#editor-quota").value.trim() || "0",
reset_period: period,
next_reset_at: period === "none" || !nextValue ? null : new Date(nextValue).toISOString(),
max_concurrency: Number(document.querySelector("#editor-concurrency").value || 4)
};
}
function modelRules() {
return document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean);
}
function upstreamLabel(account) {
return `${account.provider || "unknown"} · ${account.display_name || account.cpa_auth_id || account.id}`;
}
function routeLabel(key) {
if (key.route_mode !== "strict") return "自由选择";
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
return account ? account.display_name || account.cpa_auth_id || account.id : "未指定";
}
function maskedSecret(value) {
const secret = String(value || "");
return secret.slice(0, 2) + "******" + secret.slice(-4);
}
function copyText(value, button) {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(value).then(() => {
const previous = button.textContent;
button.textContent = "已复制";
setTimeout(() => { button.textContent = previous; }, 1200);
});
}