Files
cpa-plugin/internal/web/ui.html
T
chuan 6a461bf1f3 feat: 添加持久化用量计费与管理面板
- 使用 SQLite 保存关联后的请求生命周期和用量记录
- 支持长上下文阶梯价格和可配置的 Fast 计费倍率
- 添加管理接口和可配置列的用量面板
- 保留失败、取消、重试和 compact 请求,便于计费核对
2026-08-14 21:08:02 +08:00

387 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>