467 lines
23 KiB
JavaScript
467 lines
23 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;
|
||
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 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 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 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) {
|
||
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 copyText(value, button) {
|
||
if (!navigator.clipboard) return;
|
||
navigator.clipboard.writeText(value).then(() => {
|
||
const previous = button.textContent;
|
||
button.textContent = "已复制";
|
||
setTimeout(() => { button.textContent = previous; }, 1200);
|
||
});
|
||
}
|