- 使用 SQLite 保存关联后的请求生命周期和用量记录 - 支持长上下文阶梯价格和可配置的 Fast 计费倍率 - 添加管理接口和可配置列的用量面板 - 保留失败、取消、重试和 compact 请求,便于计费核对
387 lines
20 KiB
HTML
387 lines
20 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>CPA 用量记录</title>
|
||
<style>
|
||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||
body { margin: 0; padding: 24px; background: #111827; color: #e5e7eb; }
|
||
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||
h1 { margin: 0; font-size: 20px; }
|
||
.tools { display: flex; align-items: center; gap: 8px; }
|
||
input, select, button { border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; background: #1f2937; color: inherit; }
|
||
button { cursor: pointer; }
|
||
details { position: relative; }
|
||
summary { list-style: none; cursor: pointer; border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; background: #1f2937; }
|
||
summary::-webkit-details-marker { display: none; }
|
||
.column-options { position: absolute; z-index: 10; right: 0; top: calc(100% + 6px); display: grid; grid-template-columns: repeat(2, max-content); gap: 8px 18px; padding: 12px; border: 1px solid #374151; border-radius: 6px; background: #1f2937; box-shadow: 0 12px 30px #0008; }
|
||
.column-options label { display: flex; align-items: center; gap: 6px; }
|
||
.status { min-height: 24px; color: #9ca3af; }
|
||
.pricing { position: static; margin-bottom: 16px; }
|
||
.pricing > summary { display: inline-block; }
|
||
.price-panel { margin-top: 10px; padding: 14px; border: 1px solid #374151; border-radius: 8px; background: #172033; }
|
||
.price-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); gap: 10px; }
|
||
.price-grid label { display: grid; gap: 5px; color: #9ca3af; font-size: 12px; }
|
||
.price-grid .check { display: flex; align-items: center; gap: 7px; color: #e5e7eb; }
|
||
.price-grid .check input { width: auto; }
|
||
.price-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||
.price-list { display: grid; gap: 6px; margin-top: 14px; }
|
||
.price-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 1px solid #374151; border-radius: 6px; }
|
||
.price-row small { color: #9ca3af; }
|
||
.hidden { display: none; }
|
||
.table-wrap { overflow: auto; border: 1px solid #374151; border-radius: 8px; }
|
||
table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; }
|
||
th, td { padding: 9px 10px; border-bottom: 1px solid #273244; text-align: right; }
|
||
th { position: sticky; top: 0; background: #1f2937; color: #9ca3af; font-weight: 600; }
|
||
th.left, td.left { text-align: left; }
|
||
tr:last-child td { border-bottom: 0; }
|
||
.ok { color: #34d399; }
|
||
.failed { color: #f87171; }
|
||
@media (max-width: 900px) { .price-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } }
|
||
@media (max-width: 720px) { body { padding: 12px; } header { align-items: stretch; flex-direction: column; } .price-grid { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>最近用量记录</h1>
|
||
<div class="tools">
|
||
<input id="key" type="password" autocomplete="off" placeholder="管理密钥">
|
||
<details>
|
||
<summary>选择列</summary>
|
||
<div id="column-options" class="column-options"></div>
|
||
</details>
|
||
<button id="refresh" type="button">刷新</button>
|
||
</div>
|
||
</header>
|
||
<details class="pricing">
|
||
<summary>价格设置</summary>
|
||
<div class="price-panel">
|
||
<div class="price-grid">
|
||
<label>模型<input id="price-model" placeholder="gpt-5.6-sol"></label>
|
||
<label>输入 ($/1M)<input id="price-input" inputmode="decimal"></label>
|
||
<label>缓存读取 ($/1M)<input id="price-cache-read" inputmode="decimal"></label>
|
||
<label>缓存写入 ($/1M)<input id="price-cache-write" inputmode="decimal"></label>
|
||
<label>输出 ($/1M)<input id="price-output" inputmode="decimal"></label>
|
||
<label class="check"><input id="long-enabled" type="checkbox">启用长上下文价格</label>
|
||
<label class="long-field hidden">输入门槛<input id="long-threshold" type="number" min="0" step="1"></label>
|
||
<label class="long-field hidden">门槛比较<select id="long-comparison"><option value="gt">大于</option><option value="gte">大于等于</option></select></label>
|
||
<label class="long-field hidden">长上下文输入<input id="long-input" inputmode="decimal"></label>
|
||
<label class="long-field hidden">长上下文缓存读取<input id="long-cache-read" inputmode="decimal"></label>
|
||
<label class="long-field hidden">长上下文缓存写入<input id="long-cache-write" inputmode="decimal"></label>
|
||
<label class="long-field hidden">长上下文输出<input id="long-output" inputmode="decimal"></label>
|
||
<label class="check"><input id="fast-pricing-enabled" type="checkbox">Fast 使用倍率计价</label>
|
||
<label>Fast 倍率<input id="fast-multiplier" inputmode="decimal" value="2.5"></label>
|
||
</div>
|
||
<div class="price-actions"><button id="save-price" type="button">保存价格</button><button id="clear-price" type="button">清空表单</button></div>
|
||
<div id="price-status" class="status"></div>
|
||
<div id="price-list" class="price-list"></div>
|
||
</div>
|
||
</details>
|
||
<div id="status" class="status"></div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr id="headers"></tr></thead>
|
||
<tbody id="rows"></tbody>
|
||
</table>
|
||
</div>
|
||
<script>
|
||
const API = "/v0/management/plugins/cpa-ext/usage";
|
||
const PRICE_API = "/v0/management/plugins/cpa-ext/prices";
|
||
const keyInput = document.querySelector("#key");
|
||
const statusNode = document.querySelector("#status");
|
||
const rowsNode = document.querySelector("#rows");
|
||
const headersNode = document.querySelector("#headers");
|
||
const columnOptionsNode = document.querySelector("#column-options");
|
||
const columnStoreKey = "cpa-ext:usage-columns";
|
||
const priceStatusNode = document.querySelector("#price-status");
|
||
const priceListNode = document.querySelector("#price-list");
|
||
let currentRecords = [];
|
||
let lastSignature = "";
|
||
let loading = false;
|
||
let currentPrices = [];
|
||
|
||
function storedPanelKey() {
|
||
const prefix = "enc::v1::";
|
||
const salt = "cli-proxy-api-webui::secure-storage";
|
||
let raw = localStorage.getItem("cli-proxy-auth");
|
||
if (!raw) return "";
|
||
if (raw.startsWith(prefix)) {
|
||
const secret = new TextEncoder().encode(salt + "|" + location.host + "|" + navigator.userAgent);
|
||
const encoded = atob(raw.slice(prefix.length));
|
||
const bytes = new Uint8Array(encoded.length);
|
||
for (let i = 0; i < encoded.length; i++) bytes[i] = encoded.charCodeAt(i) ^ secret[i % secret.length];
|
||
raw = new TextDecoder().decode(bytes);
|
||
}
|
||
try {
|
||
const value = JSON.parse(raw);
|
||
return value?.state?.managementKey || "";
|
||
} catch (_) { return ""; }
|
||
}
|
||
|
||
function duration(value) {
|
||
return value > 0 ? value.toLocaleString() + " ms" : "-";
|
||
}
|
||
|
||
function number(value) {
|
||
return Number.isFinite(value) ? value.toLocaleString() : "-";
|
||
}
|
||
|
||
function percent(value) {
|
||
return Number.isFinite(value) ? value.toFixed(2) + "%" : "-";
|
||
}
|
||
|
||
function speed(value) {
|
||
return Number.isFinite(value) ? value.toFixed(1) + " tok/s" : "-";
|
||
}
|
||
|
||
function cost(record) {
|
||
return record.cost_available && Number.isFinite(record.cost_usd) ? "$" + record.cost_usd.toFixed(4) : "-";
|
||
}
|
||
|
||
function isCompactEndpoint(value) {
|
||
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "").split("?", 1)[0].replace(/\/+$/, "");
|
||
return path.endsWith("/responses/compact");
|
||
}
|
||
|
||
function endpoint(value) {
|
||
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "");
|
||
if (isCompactEndpoint(path)) return "compact";
|
||
if (path.endsWith("/chat/completions")) return "chat";
|
||
if (path.endsWith("/responses")) return "responses";
|
||
return path.replace(/^\/v1\//, "") || "-";
|
||
}
|
||
|
||
function result(record) {
|
||
if (record.outcome === "canceled") return "已取消";
|
||
if (record.outcome === "rejected") return "已拒绝";
|
||
if (record.failed || record.outcome === "failed") return record.status_code ? `失败 (${record.status_code})` : "失败";
|
||
return "成功";
|
||
}
|
||
|
||
const columns = [
|
||
{ id: "time", label: "时间", align: "left", value: record => new Date(record.requested_at).toLocaleString() },
|
||
{ id: "key", label: "Key / 别名", align: "left", value: record => record.key_alias || record.api_key || "-" },
|
||
{ id: "model", label: "模型", align: "left", value: record => record.model || "-" },
|
||
{ id: "reasoning_effort", label: "推理强度", align: "left", value: record => record.reasoning_effort || "-" },
|
||
{ id: "service_tier", label: "模式", align: "left", value: record => String(record.speed || "").toLowerCase() === "fast" ? "fast" : (record.service_tier || "-") },
|
||
{ id: "result", label: "结果", align: "left", value: record => result(record) },
|
||
{ id: "request_type", label: "类型", align: "left", value: record => record.request_type || "-" },
|
||
{ id: "endpoint", label: "端点", align: "left", value: record => endpoint(record.endpoint) },
|
||
{ id: "ttft", label: "首字延迟", value: record => isCompactEndpoint(record.endpoint) ? "-" : duration(record.ttft_ms) },
|
||
{ id: "speed", label: "生成速度", value: record => isCompactEndpoint(record.endpoint) ? "-" : speed(record.speed_tps) },
|
||
{ id: "input", label: "输入", value: record => number(record.input_tokens) },
|
||
{ id: "output", label: "输出", value: record => number(record.output_tokens) },
|
||
{ id: "reasoning", label: "推理", value: record => number(record.reasoning_tokens) },
|
||
{ id: "cache_read", label: "缓存读取", value: record => number(record.cache_read_tokens) },
|
||
{ id: "cache_write", label: "缓存写入", value: record => number(record.cache_write_tokens) },
|
||
{ id: "cache_rate", label: "缓存率", value: record => percent(record.cache_rate) },
|
||
{ id: "total", label: "Token 总数", value: record => number(record.total_tokens) },
|
||
{ id: "cost", label: "总成本", value: record => cost(record) },
|
||
{ id: "client_ip", label: "客户端 IP", align: "left", value: record => record.client_ip || "-" }
|
||
];
|
||
|
||
function loadVisibleColumns() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(columnStoreKey));
|
||
if (Array.isArray(saved)) {
|
||
const valid = saved.filter(id => columns.some(column => column.id === id));
|
||
if (valid.length) return new Set(valid);
|
||
}
|
||
} catch (_) {}
|
||
return new Set(columns.map(column => column.id));
|
||
}
|
||
|
||
let visibleColumns = loadVisibleColumns();
|
||
|
||
function renderColumnControls() {
|
||
headersNode.replaceChildren(...columns.filter(column => visibleColumns.has(column.id)).map(column => {
|
||
const header = document.createElement("th");
|
||
header.textContent = column.label;
|
||
if (column.align) header.className = column.align;
|
||
return header;
|
||
}));
|
||
columnOptionsNode.replaceChildren(...columns.map(column => {
|
||
const label = document.createElement("label");
|
||
const input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.checked = visibleColumns.has(column.id);
|
||
input.addEventListener("change", () => {
|
||
if (!input.checked && visibleColumns.size === 1) {
|
||
input.checked = true;
|
||
return;
|
||
}
|
||
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
|
||
try { localStorage.setItem(columnStoreKey, JSON.stringify(columns.filter(item => visibleColumns.has(item.id)).map(item => item.id))); } catch (_) {}
|
||
renderColumnControls();
|
||
render(currentRecords);
|
||
});
|
||
label.append(input, column.label);
|
||
return label;
|
||
}));
|
||
}
|
||
|
||
function render(records) {
|
||
currentRecords = records;
|
||
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
|
||
rowsNode.replaceChildren(...records.map(record => {
|
||
const row = document.createElement("tr");
|
||
activeColumns.forEach(column => {
|
||
const cell = document.createElement("td");
|
||
cell.textContent = column.value(record);
|
||
if (column.align) cell.classList.add(column.align);
|
||
if (column.id === "result") cell.classList.add(record.failed ? "failed" : "ok");
|
||
row.appendChild(cell);
|
||
});
|
||
return row;
|
||
}));
|
||
}
|
||
|
||
function authHeaders(json = false) {
|
||
const headers = { Authorization: "Bearer " + keyInput.value.trim() };
|
||
if (json) headers["Content-Type"] = "application/json";
|
||
return headers;
|
||
}
|
||
|
||
function setLongFields(enabled) {
|
||
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
|
||
}
|
||
|
||
function clearPriceForm() {
|
||
["price-model", "price-input", "price-cache-read", "price-cache-write", "price-output", "long-threshold", "long-input", "long-cache-read", "long-cache-write", "long-output"].forEach(id => document.querySelector("#" + id).value = "");
|
||
document.querySelector("#long-enabled").checked = false;
|
||
document.querySelector("#long-comparison").value = "gt";
|
||
document.querySelector("#fast-pricing-enabled").checked = false;
|
||
document.querySelector("#fast-multiplier").value = "2.5";
|
||
setLongFields(false);
|
||
}
|
||
|
||
function fillPriceForm(price) {
|
||
document.querySelector("#price-model").value = price.model;
|
||
document.querySelector("#price-input").value = price.base.input_per_1m;
|
||
document.querySelector("#price-cache-read").value = price.base.cache_read_per_1m;
|
||
document.querySelector("#price-cache-write").value = price.base.cache_write_per_1m;
|
||
document.querySelector("#price-output").value = price.base.output_per_1m;
|
||
const long = price.long_context;
|
||
document.querySelector("#long-enabled").checked = Boolean(long);
|
||
document.querySelector("#long-threshold").value = long?.threshold_input_tokens ?? "";
|
||
document.querySelector("#long-comparison").value = long?.comparison || "gt";
|
||
document.querySelector("#long-input").value = long?.input_per_1m ?? "";
|
||
document.querySelector("#long-cache-read").value = long?.cache_read_per_1m ?? "";
|
||
document.querySelector("#long-cache-write").value = long?.cache_write_per_1m ?? "";
|
||
document.querySelector("#long-output").value = long?.output_per_1m ?? "";
|
||
document.querySelector("#fast-pricing-enabled").checked = price.fast_pricing_enabled;
|
||
document.querySelector("#fast-multiplier").value = price.fast_multiplier || "2.5";
|
||
setLongFields(Boolean(long));
|
||
}
|
||
|
||
function pricePayload() {
|
||
const value = id => document.querySelector("#" + id).value.trim();
|
||
const payload = {
|
||
model: value("price-model"),
|
||
base: { input_per_1m: value("price-input"), cache_read_per_1m: value("price-cache-read"), cache_write_per_1m: value("price-cache-write"), output_per_1m: value("price-output") },
|
||
fast_pricing_enabled: document.querySelector("#fast-pricing-enabled").checked,
|
||
fast_multiplier: value("fast-multiplier")
|
||
};
|
||
if (document.querySelector("#long-enabled").checked) {
|
||
payload.long_context = {
|
||
threshold_input_tokens: Number(value("long-threshold")), comparison: value("long-comparison"),
|
||
input_per_1m: value("long-input"), cache_read_per_1m: value("long-cache-read"), cache_write_per_1m: value("long-cache-write"), output_per_1m: value("long-output")
|
||
};
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function renderPrices() {
|
||
priceListNode.replaceChildren(...currentPrices.map(price => {
|
||
const row = document.createElement("div");
|
||
row.className = "price-row";
|
||
const text = document.createElement("div");
|
||
const model = document.createElement("div");
|
||
model.textContent = price.model;
|
||
const detail = document.createElement("small");
|
||
detail.textContent = `输入 $${price.base.input_per_1m},缓存读 $${price.base.cache_read_per_1m},缓存写 $${price.base.cache_write_per_1m},输出 $${price.base.output_per_1m}${price.long_context ? ",含长上下文" : ""}${price.fast_pricing_enabled ? ",Fast ×" + price.fast_multiplier : ""}`;
|
||
text.append(model, detail);
|
||
const actions = document.createElement("div");
|
||
const edit = document.createElement("button");
|
||
edit.type = "button"; edit.textContent = "编辑"; edit.addEventListener("click", () => fillPriceForm(price));
|
||
const remove = document.createElement("button");
|
||
remove.type = "button"; remove.textContent = "删除"; remove.addEventListener("click", () => deletePrice(price.model));
|
||
actions.append(edit, remove); row.append(text, actions); return row;
|
||
}));
|
||
}
|
||
|
||
async function loadPrices() {
|
||
if (!keyInput.value.trim()) return;
|
||
try {
|
||
const response = await fetch(PRICE_API, { headers: authHeaders() });
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
currentPrices = payload.prices || [];
|
||
renderPrices();
|
||
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
|
||
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
|
||
}
|
||
|
||
async function savePrice() {
|
||
try {
|
||
const response = await fetch(PRICE_API, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(pricePayload()) });
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
priceStatusNode.textContent = "价格已保存";
|
||
await loadPrices();
|
||
} catch (error) { priceStatusNode.textContent = "保存失败: " + error.message; }
|
||
}
|
||
|
||
async function deletePrice(model) {
|
||
try {
|
||
const response = await fetch(PRICE_API, { method: "DELETE", headers: authHeaders(true), body: JSON.stringify({ model }) });
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
if (document.querySelector("#price-model").value.trim() === model) clearPriceForm();
|
||
await loadPrices();
|
||
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
|
||
}
|
||
|
||
async function load(manual = false) {
|
||
if (loading) return;
|
||
const key = keyInput.value.trim();
|
||
if (!key) {
|
||
statusNode.textContent = "请输入管理密钥";
|
||
return;
|
||
}
|
||
loading = true;
|
||
if (manual) statusNode.textContent = "正在读取";
|
||
try {
|
||
const response = await fetch(API, { headers: authHeaders() });
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
const records = payload.records || [];
|
||
const signature = JSON.stringify(records);
|
||
if (signature !== lastSignature) {
|
||
lastSignature = signature;
|
||
render(records);
|
||
}
|
||
statusNode.textContent = `显示 ${payload.records?.length || 0} 条,最多展示 ${payload.retained} 条`;
|
||
} catch (error) {
|
||
statusNode.textContent = "读取失败: " + error.message;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
keyInput.value = storedPanelKey() || sessionStorage.getItem("cpa-ext:management-key") || "";
|
||
keyInput.addEventListener("change", () => sessionStorage.setItem("cpa-ext:management-key", keyInput.value.trim()));
|
||
document.querySelector("#refresh").addEventListener("click", () => load(true));
|
||
document.querySelector("#long-enabled").addEventListener("change", event => setLongFields(event.target.checked));
|
||
document.querySelector("#save-price").addEventListener("click", savePrice);
|
||
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
|
||
document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); });
|
||
renderColumnControls();
|
||
load();
|
||
loadPrices();
|
||
setInterval(() => { if (!document.hidden) load(); }, 3000);
|
||
</script>
|
||
</body>
|
||
</html>
|