// Runtime owns configuration, authentication state, and API transport. const config = window.BILLING_UI_CONFIG || {}; const managementBase = config.managementBase || "/v0/management/plugins/billing"; export const routes = Object.freeze({ usage: managementBase + "/usage", usageSummary: managementBase + "/usage-summary", prices: managementBase + "/prices", priceImport: managementBase + "/prices/import", catalog: managementBase + "/price-catalog", catalogRefresh: managementBase + "/price-catalog/refresh", catalogApply: managementBase + "/price-catalog/apply", keys: managementBase + "/keys", keyStats: managementBase + "/key-stats", upstreams: managementBase + "/upstreams", models: managementBase + "/model-suggestions", billingReset: managementBase + "/billing-reset", billingLedger: managementBase + "/billing-ledger", events: managementBase + "/events" }); export const keyInput = document.querySelector("#key"); const readOnlyBase = config.readOnlyBase || location.pathname; const accessListeners = new Set(); let managementAuthorized = false; let managementAuthenticated = false; let demoPerspective = "admin"; export function initializeRuntime(reload) { document.body.classList.toggle("demo-mode", Boolean(config.demoPerspectives)); keyInput.value = config.managementKey || storedPanelKey() || sessionStorage.getItem("billing:management-key") || ""; keyInput.disabled = Boolean(config.lockManagementKey); document.querySelector(".auth-box").classList.toggle("hidden", Boolean(config.hideManagementKey)); keyInput.addEventListener("change", () => { if (config.lockManagementKey) return; sessionStorage.setItem("billing:management-key", keyInput.value.trim()); setAccessState(false); reload(); }); keyInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); keyInput.blur(); } }); initializeDemoPerspective(reload); setAccessState(false); } export function onAccessChange(listener) { accessListeners.add(listener); listener(managementAuthorized); return () => accessListeners.delete(listener); } export function isManagementAuthorized() { return managementAuthorized; } export function usesReadOnlyData() { return !keyInput.value.trim() || (config.demoPerspectives && demoPerspective === "user"); } export function setAccessState(authorized) { managementAuthenticated = authorized; applyAccessState(); } function initializeDemoPerspective(reload) { const switcher = document.querySelector("#demo-perspective"); if (!config.demoPerspectives) return; switcher.classList.remove("hidden"); switcher.querySelectorAll("[data-perspective]").forEach(button => { button.addEventListener("click", () => { if (button.dataset.perspective === demoPerspective) return; demoPerspective = button.dataset.perspective; syncDemoPerspective(switcher); applyAccessState(); reload(); }); }); syncDemoPerspective(switcher); } function syncDemoPerspective(switcher) { switcher.querySelectorAll("[data-perspective]").forEach(button => { const selected = button.dataset.perspective === demoPerspective; button.classList.toggle("active", selected); button.setAttribute("aria-pressed", String(selected)); }); } function applyAccessState() { const authorized = managementAuthenticated && (!config.demoPerspectives || demoPerspective === "admin"); if (managementAuthorized === authorized) return; managementAuthorized = authorized; document.body.classList.toggle("read-only", !authorized); accessListeners.forEach(listener => listener(authorized)); } export async function dataFetch(url) { const key = usesReadOnlyData() ? "" : keyInput.value.trim(); if (key) { const response = await fetch(url, { headers: authHeaders() }); const payload = await responsePayload(response); if (response.ok) { setAccessState(true); return payload; } if (response.status !== 401 && response.status !== 403) throw new Error(payload?.error?.message || "HTTP " + response.status); setAccessState(false); } else { setAccessState(false); } const response = await fetch(readOnlyURL(url), { headers: { Accept: "application/json" } }); const payload = await responsePayload(response); if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status); return payload; } export async function managedFetch(url, options = {}) { if (!keyInput.value.trim()) { setAccessState(false); throw new Error("请输入管理密钥"); } const headers = authHeaders(Boolean(options.body)); const response = await fetch(url, { ...options, headers: { ...headers, ...(options.headers || {}) } }); const payload = await responsePayload(response); if (!response.ok) { if (response.status === 401 || response.status === 403) setAccessState(false); throw new Error(payload?.error?.message || "HTTP " + response.status); } setAccessState(true); return payload; } function authHeaders(json = false) { const headers = { Authorization: "Bearer " + keyInput.value.trim() }; if (json) headers["Content-Type"] = "application/json"; return headers; } function readOnlyURL(managementURL) { const source = new URL(managementURL, location.origin); const views = new Map([ [routes.usage, "usage"], [routes.usageSummary, "usage-summary"], [routes.prices, "prices"], [routes.catalog, "price-catalog"], [routes.keys, "keys"], [routes.keyStats, "key-stats"], [routes.upstreams, "upstreams"], [routes.models, "model-suggestions"], [routes.events, "events"] ]); const view = views.get(source.pathname); if (!view) throw new Error("该数据不支持匿名读取"); source.searchParams.set("view", view); return readOnlyBase + "?" + source.searchParams.toString(); } async function responsePayload(response) { try { return await response.json(); } catch (_) { return {}; } } 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 index = 0; index < encoded.length; index++) bytes[index] = encoded.charCodeAt(index) ^ secret[index % secret.length]; raw = new TextDecoder().decode(bytes); } try { return JSON.parse(raw)?.state?.managementKey || ""; } catch (_) { return ""; } }