Files
cpa-plugin/internal/web/app/core/runtime.js
T

183 lines
6.5 KiB
JavaScript

// 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 "";
}
}