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