548 lines
26 KiB
JavaScript
548 lines
26 KiB
JavaScript
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 resetAllBillingNode = document.querySelector("#reset-all-billing");
|
||
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());
|
||
resetAllBillingNode.addEventListener("click", resetAllManagedBilling);
|
||
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();
|
||
resetAllBillingNode.disabled = !managedKeys.length;
|
||
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;
|
||
document.querySelector("#editor-show-stats").checked = key?.show_in_stats ?? 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-show-stats", "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 card = document.createElement("article");
|
||
card.className = "user-card";
|
||
const archived = key.status === "archived";
|
||
const identity = document.createElement("button");
|
||
identity.type = "button";
|
||
identity.className = "key-identity key-copy";
|
||
identity.disabled = !key.secret;
|
||
identity.title = "点击复制 Key";
|
||
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;
|
||
identityText.append(name);
|
||
identity.append(avatar, identityText);
|
||
identity.addEventListener("click", () => copyText(key.secret, name));
|
||
const header = document.createElement("header");
|
||
header.className = "user-card-header";
|
||
const billing = key.billing || {};
|
||
const resetPeriod = billing.reset_period && billing.reset_period !== "none" ? ({ daily: "每天重置", weekly: "每周重置", monthly: "每月重置" }[billing.reset_period] || billing.reset_period) : "不重置";
|
||
const details = document.createElement("div");
|
||
details.className = "user-card-details";
|
||
details.append(
|
||
userCardDetail("剩余额度", money(billing.balance_usd), resetPeriod),
|
||
userCardDetail("总额度", money(billing.quota_usd), `累计消耗 ${money(billing.lifetime_spent_usd)}`),
|
||
userCardDetail("并发", `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`, "执行中 / 上限"),
|
||
userCardDetail("路由", routeLabel(key), "")
|
||
);
|
||
const modelBlock = document.createElement("div");
|
||
modelBlock.className = "user-card-line";
|
||
const models = document.createElement("strong");
|
||
models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
|
||
modelBlock.append(Object.assign(document.createElement("span"), { textContent: "允许模型" }), models);
|
||
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);
|
||
}
|
||
header.append(identity, actions);
|
||
card.append(header, details, modelBlock);
|
||
return card;
|
||
}));
|
||
renderUserCharts();
|
||
}
|
||
|
||
function userCardDetail(label, value, hint) {
|
||
const item = document.createElement("div");
|
||
item.className = "user-card-detail";
|
||
const content = document.createElement("div");
|
||
content.append(Object.assign(document.createElement("strong"), { textContent: value }));
|
||
if (hint) content.append(Object.assign(document.createElement("small"), { textContent: hint }));
|
||
item.append(
|
||
Object.assign(document.createElement("span"), { textContent: label }),
|
||
content
|
||
);
|
||
return item;
|
||
}
|
||
|
||
function renderUserCharts() {
|
||
const visibleKeys = managedKeys.filter(key => key.show_in_stats !== false);
|
||
const byUser = new Map(visibleKeys.map(key => [key.name, {
|
||
name: key.name, requests: 0, tokens: 0, cost: 0,
|
||
balance: key.billing ? Number(key.billing.balance_usd || 0) : null,
|
||
resetPeriod: key.billing?.reset_period || null,
|
||
nextResetAt: key.billing?.next_reset_at ? new Date(key.billing.next_reset_at) : 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, resetPeriod: null, nextResetAt: 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), quotaAmount(item.cost), quotaAmount(item.balance), item.last ? item.last.toLocaleString() : "从未", resetTime(item)].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()]);
|
||
renderStatsInsights(visibleKeys);
|
||
}
|
||
|
||
function quotaAmount(value) {
|
||
if (value === null) return "—";
|
||
const amount = Number(value);
|
||
return Number.isFinite(amount) ? amount.toFixed(2) : "—";
|
||
}
|
||
|
||
function resetTime(item) {
|
||
if (item.resetPeriod === "none") return "永不";
|
||
if (!item.nextResetAt || !Number.isFinite(item.nextResetAt.getTime())) return "—";
|
||
const value = item.nextResetAt;
|
||
const parts = [
|
||
value.getFullYear(),
|
||
String(value.getMonth() + 1).padStart(2, "0"),
|
||
String(value.getDate()).padStart(2, "0")
|
||
];
|
||
return `${parts.join("-")} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function renderStatsInsights(visibleKeys) {
|
||
renderQuotaHealth(visibleKeys);
|
||
}
|
||
|
||
function renderQuotaHealth(keys) {
|
||
const items = keys.map(key => {
|
||
const billing = key.billing || {};
|
||
const quota = Number(billing.quota_usd || 0);
|
||
const balance = Number(billing.balance_usd || 0);
|
||
const ratio = quota > 0 ? balance / quota : null;
|
||
return { key, billing, quota, balance, ratio };
|
||
}).sort((left, right) => {
|
||
if (left.ratio === null) return right.ratio === null ? left.key.name.localeCompare(right.key.name) : 1;
|
||
if (right.ratio === null) return -1;
|
||
return left.ratio - right.ratio || left.key.name.localeCompare(right.key.name);
|
||
});
|
||
const node = document.querySelector("#quota-health");
|
||
if (!items.length) {
|
||
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无展示中的用户" }));
|
||
return;
|
||
}
|
||
node.replaceChildren(...items.map(item => {
|
||
const row = document.createElement("div");
|
||
row.className = "quota-health-row";
|
||
const heading = document.createElement("div");
|
||
heading.className = "quota-health-heading";
|
||
const identity = document.createElement("strong");
|
||
identity.textContent = item.key.name;
|
||
const percent = item.ratio === null ? null : Math.round(item.ratio * 100);
|
||
const detail = document.createElement("span");
|
||
const active = Number(item.billing.active_requests || 0);
|
||
const maximum = Number(item.billing.max_concurrency || 4);
|
||
detail.textContent = item.ratio === null
|
||
? `未配置周期额度 · 当前并发 ${active} / ${maximum}`
|
||
: `${money(item.balance)} / ${money(item.quota)} · 剩余 ${percent}% · 当前并发 ${active} / ${maximum}`;
|
||
detail.className = item.ratio !== null && item.ratio <= 0.2 ? "danger" : "";
|
||
heading.append(identity, detail);
|
||
const track = document.createElement("div");
|
||
track.className = "quota-health-track";
|
||
const bar = document.createElement("div");
|
||
bar.className = "quota-health-bar";
|
||
if (item.ratio !== null && item.ratio <= 0.2) bar.classList.add("danger");
|
||
bar.style.width = `${item.ratio === null ? 0 : Math.max(0, Math.min(100, percent))}%`;
|
||
track.append(bar);
|
||
row.append(heading, track);
|
||
return row;
|
||
}));
|
||
}
|
||
|
||
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,
|
||
show_in_stats: document.querySelector("#editor-show-stats").checked,
|
||
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,
|
||
show_in_stats: document.querySelector("#editor-show-stats").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 resetAllManagedBilling() {
|
||
const targets = managedKeys.filter(key => key.status !== "archived");
|
||
if (!targets.length || !confirm(`重置全部 ${targets.length} 个用户的额度?当前剩余余额不会结转。`)) return;
|
||
resetAllBillingNode.disabled = true;
|
||
keyStatusNode.textContent = `正在重置 ${targets.length} 个用户`;
|
||
try {
|
||
const result = await managedFetch(routes.billingReset, { method: "POST", body: JSON.stringify({ all: true }) });
|
||
await loadKeys();
|
||
keyStatusNode.textContent = `已重置全部 ${result.reset_count || 0} 个用户的额度`;
|
||
} catch (error) {
|
||
keyStatusNode.textContent = "重置失败:" + error.message;
|
||
} finally {
|
||
resetAllBillingNode.disabled = false;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
const provider = account.provider || "unknown";
|
||
const name = account.display_name || account.cpa_auth_id || account.id;
|
||
const duplicated = upstreamAccounts.some(item => item !== account &&
|
||
(item.provider || "unknown") === provider &&
|
||
(item.display_name || item.cpa_auth_id || item.id) === name);
|
||
if (!duplicated) return `${provider} · ${name}`;
|
||
const identity = (account.cpa_auth_index || account.cpa_auth_id || account.id).slice(0, 8);
|
||
return `${provider} · ${name} · ${identity}`;
|
||
}
|
||
|
||
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 copyText(value, button) {
|
||
if (!navigator.clipboard) return;
|
||
navigator.clipboard.writeText(value).then(() => {
|
||
const previous = button.textContent;
|
||
button.textContent = "已复制";
|
||
setTimeout(() => { button.textContent = previous; }, 1200);
|
||
});
|
||
}
|