183 lines
6.5 KiB
JavaScript
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 "";
|
|
}
|
|
}
|