import { dataFetch, isManagementAuthorized, routes, usesReadOnlyData } from "../core/runtime.js"; import { endpoint, isCompactEndpoint, number, option, result } from "../core/shared.js"; const statusNode = document.querySelector("#status"); const rowsNode = document.querySelector("#rows"); const headersNode = document.querySelector("#headers"); const columnOptionsNode = document.querySelector("#column-options"); const pageButtonsNode = document.querySelector("#page-buttons"); const columnStoreKey = "billing:usage-columns"; const adminPageSize = 100; const userPageSize = 50; let currentRecords = []; let currentPage = 1; let currentPagination = emptyPagination(); let loading = false; 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: "upstream", label: "上游账号", align: "left", value: record => record.auth_id || record.auth_index || record.auth_type || "-" }, { 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: result }, { 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) ? "-" : tokenSpeed(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 || "-" } ]; let visibleColumns = loadVisibleColumns(); export function initializeUsage() { renderColumnControls(); document.querySelector("#refresh").addEventListener("click", () => loadUsage(true, currentPage)); document.querySelector("#apply-usage-filters").addEventListener("click", () => { currentPage = 1; loadUsage(true, 1); }); document.querySelector("#clear-usage-filters").addEventListener("click", clearFilters); document.querySelector("#usage-request-id").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); currentPage = 1; loadUsage(true, 1); } }); } export async function activateUsage() { await loadUsage(true, 1); if (isManagementAuthorized()) await loadUsageFilterOptions(); } export function refreshVisibleUsage() { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) loadUsage(false, 1); } export function resetUsage() { currentPage = 1; currentPagination = emptyPagination(); return activateUsage(); } async function loadUsage(manual = false, page = currentPage, cursor = "") { if (loading) return; loading = true; if (manual) statusNode.textContent = "正在读取"; try { const readOnly = usesReadOnlyData(); const params = readOnly ? new URLSearchParams() : usageFilterQuery(); params.set("page", String(readOnly ? 1 : page || 1)); params.set("page_size", String(readOnly ? userPageSize : adminPageSize)); if (!readOnly && cursor) params.set("cursor", cursor); const payload = await dataFetch(routes.usage + "?" + params.toString()); currentPagination = payload.pagination || emptyPagination(); render(payload.records || []); statusNode.textContent = ""; } catch (error) { statusNode.textContent = "读取失败: " + error.message; } finally { loading = false; } } async function loadUsageFilterOptions() { try { const [keys, upstreams, models] = await Promise.all([ dataFetch(routes.keys + "?include_archived=1"), dataFetch(routes.upstreams), dataFetch(routes.models) ]); populateUsageFilterOptions(keys.keys || [], upstreams.accounts || [], models.models || []); } catch (error) { statusNode.textContent = "读取筛选项失败: " + error.message; } } 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; })); renderPagination(); } 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 renderPagination() { if (!isManagementAuthorized()) { currentPage = 1; pageButtonsNode.replaceChildren(); return; } const pageCount = currentPagination.total_pages; currentPage = currentPagination.page || 1; const controls = []; const button = (label, page, disabled = false, active = false, cursor = "") => { const node = document.createElement("button"); node.type = "button"; node.textContent = label; node.disabled = disabled; node.classList.toggle("active", active); if (!disabled && !active) node.addEventListener("click", () => loadUsage(true, page, cursor)); return node; }; controls.push(button("上一页", currentPage - 1, currentPage <= 1, false, currentPagination.previous_cursor || "")); pageItems(currentPage, Math.max(1, pageCount)).forEach(item => { if (typeof item === "string") { const ellipsis = document.createElement("span"); ellipsis.className = "page-ellipsis"; ellipsis.textContent = "…"; controls.push(ellipsis); } else { controls.push(button(String(item), item, false, item === currentPage)); } }); controls.push(button("下一页", currentPage + 1, pageCount === 0 || currentPage >= pageCount, false, currentPagination.next_cursor || "")); pageButtonsNode.replaceChildren(...controls); } function usageFilterQuery() { const params = new URLSearchParams(); const from = document.querySelector("#usage-from").value; const to = document.querySelector("#usage-to").value; if (from) params.set("from", new Date(from).toISOString()); if (to) params.set("to", new Date(to).toISOString()); [["key_id", "#usage-key"], ["model", "#usage-model"], ["result", "#usage-result"], ["auth_id", "#usage-auth"], ["endpoint", "#usage-endpoint"], ["request_id", "#usage-request-id"]].forEach(([name, selector]) => { const value = document.querySelector(selector).value.trim(); if (value) params.set(name, value); }); return params; } function populateUsageFilterOptions(keys, upstreams, models) { const keySelect = document.querySelector("#usage-key"); const authSelect = document.querySelector("#usage-auth"); const selectedKey = keySelect.value; const selectedAuth = authSelect.value; keySelect.replaceChildren(option("", "全部用户"), ...keys.map(key => option(key.id, key.name, key.id === selectedKey))); authSelect.replaceChildren(option("", "全部上游"), ...upstreams.map(account => option(account.cpa_auth_id, account.display_name || account.cpa_auth_id, account.cpa_auth_id === selectedAuth))); document.querySelector("#usage-model-options").replaceChildren(...models.map(model => option(model, model))); } function clearFilters() { ["usage-from", "usage-to", "usage-model", "usage-request-id"].forEach(id => document.querySelector("#" + id).value = ""); ["usage-key", "usage-result", "usage-auth", "usage-endpoint"].forEach(id => document.querySelector("#" + id).value = ""); currentPage = 1; loadUsage(true, 1); } 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)); } function pageItems(page, count) { if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1); const values = new Set([1, count, page - 1, page, page + 1]); const sorted = [...values].filter(value => value >= 1 && value <= count).sort((left, right) => left - right); const items = []; sorted.forEach((value, index) => { if (index && value - sorted[index - 1] > 1) items.push("ellipsis-" + value); items.push(value); }); return items; } function emptyPagination() { return { page: 1, page_size: usesReadOnlyData() ? userPageSize : adminPageSize, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; } function duration(value) { return value > 0 ? value.toLocaleString() + " ms" : "-"; } function percent(value) { return Number.isFinite(value) ? value.toFixed(2) + "%" : "-"; } function tokenSpeed(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) : "-"; }