feat: 拆分web
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"billing/internal/webdemo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8320", "HTTP listen address")
|
||||
input := flag.String("input", "", "data input; supported value: fake")
|
||||
flag.Parse()
|
||||
|
||||
if *input != "fake" {
|
||||
fmt.Fprintln(os.Stderr, "missing supported -input value: fake")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
server := &http.Server{Addr: *listen, Handler: webdemo.NewServer(webdemo.NewFakeInput())}
|
||||
log.Printf("billing web demo: http://%s/ui", *listen)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
@@ -10,6 +10,8 @@ git submodule update --init --recursive
|
||||
|
||||
升级 submodule、构建宿主、本地替换、验收和远端发布使用 [`operations.md`](operations.md)。该文档同时记录已确认的上游测试失败及免复测条件。
|
||||
|
||||
管理台界面开发使用 `go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320`。该命令用独立 Fake 输入端启动正式页面资源,不依赖本地 CPA 和 billing 数据库。
|
||||
|
||||
开发按依赖从少到多推进:先确定数据和规则,再实现存储与服务,最后接入外部协议和界面。核心逻辑不依赖框架类型,协议转换集中在边界层。涉及持久化时,先定义迁移和历史数据语义
|
||||
|
||||
开发的核心在于每一步的可预见性与可测试性
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
管理台是 billing 各业务模块的统一操作和查看入口。它不单独保存业务事实,而是通过 CLIProxyAPI 受保护的 Management API 读取或修改 SQLite 中的真实配置与记录。
|
||||
|
||||
当前管理台采用单页、紧凑布局,直接作为插件资源嵌入,不依赖 CDN、外部前端框架或独立构建服务。
|
||||
当前管理台采用单页、紧凑布局。HTML、CSS、页面逻辑和运行配置分别作为插件资源嵌入,不依赖 CDN、外部前端框架或独立构建服务。
|
||||
|
||||
## 页面结构
|
||||
|
||||
@@ -100,6 +100,18 @@ Key 列表展示:
|
||||
- 所有修改仍由 CLIProxyAPI 管理认证和 billing 服务端规则校验;
|
||||
- 密钥无效时回退匿名只读数据,不额外显示状态标签。
|
||||
|
||||
## Demo 开发服务器
|
||||
|
||||
Demo 使用独立 Fake 输入端。页面资源与正式插件相同,Fake 数据不写入 HTML、页面脚本或 billing 数据库。
|
||||
|
||||
只有需要界面开发时才使用启动参数:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320
|
||||
```
|
||||
|
||||
启动后访问 `http://127.0.0.1:8320/ui`。Fake 输入端提供用户、额度、请求分页、筛选、账目、价格和目录接口。进程重启后恢复初始数据。
|
||||
|
||||
## 管理接口
|
||||
|
||||
管理接口统一挂载在:
|
||||
|
||||
@@ -33,8 +33,24 @@ const (
|
||||
resourceUI = "/ui"
|
||||
)
|
||||
|
||||
var resourceAssets = []string{
|
||||
"/ui-config.js",
|
||||
"/app/main.js",
|
||||
"/app/core/runtime.js",
|
||||
"/app/core/shared.js",
|
||||
"/app/features/keys.js",
|
||||
"/app/features/pricing.js",
|
||||
"/app/features/usage.js",
|
||||
"/styles/base.css",
|
||||
"/styles/keys.css",
|
||||
"/styles/layout.css",
|
||||
"/styles/pricing.css",
|
||||
"/styles/responsive.css",
|
||||
"/styles/usage.css",
|
||||
}
|
||||
|
||||
func managementRegistration() ManagementRegistrationResponse {
|
||||
return ManagementRegistrationResponse{
|
||||
registration := ManagementRegistrationResponse{
|
||||
Routes: []ManagementRoute{
|
||||
{Method: http.MethodGet, Path: managementBase + routeUsage, Description: "查看最近的用量记录。"},
|
||||
{Method: http.MethodGet, Path: managementBase + routeUsageSummary, Description: "查看用户与每日用量汇总。"},
|
||||
@@ -59,6 +75,10 @@ func managementRegistration() ManagementRegistrationResponse {
|
||||
{Path: resourceBase + resourceUI, Menu: "用量记录", Description: "查看 CPA 最近收到的请求用量。"},
|
||||
},
|
||||
}
|
||||
for _, path := range resourceAssets {
|
||||
registration.Resources = append(registration.Resources, ResourceRoute{Path: resourceBase + path})
|
||||
}
|
||||
return registration
|
||||
}
|
||||
|
||||
func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
@@ -80,6 +100,12 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
Body: web.UI(),
|
||||
})
|
||||
}
|
||||
if req.Method == http.MethodGet {
|
||||
name := strings.TrimPrefix(path, resourceBase+"/")
|
||||
if body, contentType, found := web.Asset(name); found {
|
||||
return OKEnvelope(staticResourceResponse(contentType, body))
|
||||
}
|
||||
}
|
||||
if req.Method == http.MethodGet && path == managementBase+routeUsage {
|
||||
return OKEnvelope(a.usageResponse(req.Query))
|
||||
}
|
||||
@@ -143,6 +169,17 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
}))
|
||||
}
|
||||
|
||||
func staticResourceResponse(contentType string, body []byte) ManagementResponse {
|
||||
return ManagementResponse{
|
||||
StatusCode: http.StatusOK,
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{contentType},
|
||||
"Cache-Control": []string{"no-store"},
|
||||
},
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
type usageListResponse struct {
|
||||
Records []usageListItem `json:"records"`
|
||||
Pagination usagePaginationResult `json:"pagination"`
|
||||
|
||||
@@ -57,9 +57,14 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
|
||||
if len(registration.Routes) != 18 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices {
|
||||
t.Fatalf("unexpected management routes: %+v", registration.Routes)
|
||||
}
|
||||
if len(registration.Resources) != 1 || registration.Resources[0].Path != resourceBase+resourceUI {
|
||||
if len(registration.Resources) != 1+len(resourceAssets) || registration.Resources[0].Path != resourceBase+resourceUI {
|
||||
t.Fatalf("unexpected resource routes: %+v", registration.Resources)
|
||||
}
|
||||
for index, path := range resourceAssets {
|
||||
if registration.Resources[index+1].Path != resourceBase+path {
|
||||
t.Fatalf("resource %d = %q, want %q", index+1, registration.Resources[index+1].Path, resourceBase+path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyResourceReturnsRealDataWithoutSecrets(t *testing.T) {
|
||||
@@ -554,52 +559,54 @@ func TestUsageManagementResponseContainsDisplayFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageResourceServesTablePage(t *testing.T) {
|
||||
response := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI)
|
||||
page := string(response.Body)
|
||||
if response.StatusCode != http.StatusOK || !strings.Contains(page, "请求明细") {
|
||||
t.Fatalf("unexpected UI response: status=%d", response.StatusCode)
|
||||
func TestUsageResourceServesFeatureModules(t *testing.T) {
|
||||
pageResponse := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI)
|
||||
page := string(pageResponse.Body)
|
||||
if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, "请求明细") {
|
||||
t.Fatalf("unexpected UI response: status=%d", pageResponse.StatusCode)
|
||||
}
|
||||
for _, column := range []string{"Key / 别名", "推理强度", "生成速度", "缓存写入", "总成本", "客户端 IP"} {
|
||||
if !strings.Contains(page, column) {
|
||||
t.Fatalf("UI does not contain column %q", column)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(page, `return "compact"`) || !strings.Contains(page, "isCompactEndpoint(record.endpoint)") {
|
||||
t.Fatal("UI does not contain compact display rules")
|
||||
}
|
||||
for _, feature := range []string{"创建 Key", "指定账号", "允许模型", "永久归档"} {
|
||||
for _, feature := range []string{`<body class="read-only">`, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="surface admin-only"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain managed access feature %q", feature)
|
||||
}
|
||||
}
|
||||
for _, feature := range []string{`<body class="read-only">`, `<input id="key" type="password" autocomplete="off" aria-label="管理密钥">`, `id="page-buttons"`, "const PAGE_SIZE = 100", `id="user-usage"`, `id="daily-chart"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="usage-from"`, `id="usage-request-id"`, `id="apply-usage-filters"`, "SUMMARY_API", "READONLY_API", "dataFetch", "managementAuthorized", `overflow-y: hidden`, `id="editor-quota"`, `id="editor-reset-period"`, `id="billing-ledger"`, "deepseek-*", "请求结束后按实际费用扣款", `id="key-management" class="surface admin-only"`, `class="price-section admin-only"`, `id="price-editor-content" class="hidden"`, `row.setAttribute("role", "button")`, `id="long-price-section"`, "syncLongSectionVisibility"} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain workspace feature %q", feature)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "<script src=") || strings.Contains(page, "<link rel=\"stylesheet\" href=") {
|
||||
t.Fatal("UI unexpectedly depends on external assets")
|
||||
}
|
||||
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1–100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格", "点击“管理”修改访问、路由与额度", "由 CPA 调度", `copy.textContent = "复制"`, "测试模式", `data-mode="demo"`, "DEMO_STORE_KEY", "demoState", "mode-button", "测试目录 · 不访问 models.dev", `id="access-mode"`, `placeholder="留空时只读"`, `label for="key"`, "密钥无效 · 只读", `id="include-archived"`, `id="reload-keys"`} {
|
||||
if strings.Contains(page, removed) {
|
||||
t.Fatalf("UI still contains removed description %q", removed)
|
||||
}
|
||||
}
|
||||
for _, feature := range []string{`minlength="6"`, `maskedSecret(key.secret)`, `key.masked_secret`, `className = "credential-copy"`, `return "自由选择"`, `body.classList.toggle("read-only"`} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain compact Key display feature %q", feature)
|
||||
t.Fatalf("UI does not contain HTML feature %q", feature)
|
||||
}
|
||||
}
|
||||
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
|
||||
t.Fatal("UI pagination controls are not above the usage table")
|
||||
}
|
||||
for _, feature := range []string{`class="surface price-panel price-layout"`, `id="new-price"`, "基础价格 · $ / 1M Token", "长上下文价格", "Fast 价格", "删除 ${model} 的真实价格配置", `id="catalog-query"`, `id="refresh-catalog"`, "PRICE_IMPORT_API", "发现 ${changed.length} 个已关联价格发生变化"} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain redesigned pricing feature %q", feature)
|
||||
|
||||
for _, path := range []string{"/styles/base.css", "/styles/layout.css", "/styles/keys.css", "/styles/usage.css", "/styles/pricing.css", "/styles/responsive.css"} {
|
||||
response := managementCall(t, NewApp(), http.MethodGet, resourceBase+path)
|
||||
if response.StatusCode != http.StatusOK || response.Headers.Get("Content-Type") != "text/css; charset=utf-8" || len(response.Body) == 0 {
|
||||
t.Fatalf("unexpected CSS resource %s: status=%d headers=%v", path, response.StatusCode, response.Headers)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "scrollIntoView") {
|
||||
t.Fatal("UI pagination still changes the document scroll position")
|
||||
|
||||
var javascript strings.Builder
|
||||
for _, path := range []string{"/app/main.js", "/app/core/runtime.js", "/app/core/shared.js", "/app/features/keys.js", "/app/features/usage.js", "/app/features/pricing.js"} {
|
||||
response := managementCall(t, NewApp(), http.MethodGet, resourceBase+path)
|
||||
if response.StatusCode != http.StatusOK || response.Headers.Get("Content-Type") != "text/javascript; charset=utf-8" {
|
||||
t.Fatalf("unexpected JS resource %s: status=%d headers=%v", path, response.StatusCode, response.Headers)
|
||||
}
|
||||
javascript.Write(response.Body)
|
||||
}
|
||||
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "pageSize = 100", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", "maskedSecret(key.secret)", `return "自由选择"`, `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
|
||||
if !strings.Contains(javascript.String(), feature) {
|
||||
t.Fatalf("UI modules do not contain feature %q", feature)
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"DEMO_STORE_KEY", "demoState", "mode-button", `data-mode="demo"`, "scrollIntoView"} {
|
||||
if strings.Contains(javascript.String(), removed) {
|
||||
t.Fatalf("UI modules still contain removed feature %q", removed)
|
||||
}
|
||||
}
|
||||
usageResponse := managementCall(t, NewApp(), http.MethodGet, resourceBase+"/app/features/usage.js")
|
||||
usageModule := string(usageResponse.Body)
|
||||
if strings.Index(usageModule, "const columns =") > strings.Index(usageModule, "let visibleColumns = loadVisibleColumns()") {
|
||||
t.Fatal("usage module initializes visible columns before defining the column schema")
|
||||
}
|
||||
|
||||
configResponse := managementCall(t, NewApp(), http.MethodGet, resourceBase+"/ui-config.js")
|
||||
if configResponse.StatusCode != http.StatusOK || !strings.Contains(string(configResponse.Body), "BILLING_UI_CONFIG") {
|
||||
t.Fatalf("unexpected UI config resource: status=%d", configResponse.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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"
|
||||
});
|
||||
|
||||
export const keyInput = document.querySelector("#key");
|
||||
|
||||
const readOnlyBase = config.readOnlyBase || location.pathname;
|
||||
const accessListeners = new Set();
|
||||
let managementAuthorized = false;
|
||||
|
||||
export function initializeRuntime(reload) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
setAccessState(false);
|
||||
}
|
||||
|
||||
export function onAccessChange(listener) {
|
||||
accessListeners.add(listener);
|
||||
listener(managementAuthorized);
|
||||
return () => accessListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function isManagementAuthorized() {
|
||||
return managementAuthorized;
|
||||
}
|
||||
|
||||
export function setAccessState(authorized) {
|
||||
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 = 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"]
|
||||
]);
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared presentation helpers have no feature state.
|
||||
export function number(value) {
|
||||
return Number.isFinite(value) ? value.toLocaleString() : "-";
|
||||
}
|
||||
|
||||
export function compactNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
|
||||
}
|
||||
|
||||
export function money(value) {
|
||||
const amount = Number(value || 0);
|
||||
return "$" + (Number.isFinite(amount) ? amount : 0).toFixed(4);
|
||||
}
|
||||
|
||||
export function localDateTimeValue(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
export 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");
|
||||
}
|
||||
|
||||
export 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\//, "") || "-";
|
||||
}
|
||||
|
||||
export 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 "成功";
|
||||
}
|
||||
|
||||
export function option(value, label, selected = false) {
|
||||
const node = document.createElement("option");
|
||||
node.value = value;
|
||||
node.textContent = label;
|
||||
node.selected = selected;
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
|
||||
import { compactNumber, localDateTimeValue, money, number, option, result } from "../core/shared.js";
|
||||
|
||||
const keyRowsNode = document.querySelector("#key-rows");
|
||||
const keyStatusNode = document.querySelector("#key-status");
|
||||
const keyStatsNode = document.querySelector("#key-stats");
|
||||
const drawerNode = document.querySelector("#key-drawer");
|
||||
const drawerOverlayNode = document.querySelector("#drawer-overlay");
|
||||
const editorNode = document.querySelector("#key-editor");
|
||||
const managedFieldsNode = document.querySelector("#managed-fields");
|
||||
const editorFooterNode = document.querySelector("#editor-footer");
|
||||
const statsGridNode = document.querySelector("#stats-grid");
|
||||
const statsRecentNode = document.querySelector("#stats-recent");
|
||||
|
||||
let managedKeys = [];
|
||||
let upstreamAccounts = [];
|
||||
let modelSuggestions = [];
|
||||
let usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
|
||||
let editingKey = null;
|
||||
|
||||
export function initializeKeys() {
|
||||
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
|
||||
document.querySelector("#close-drawer").addEventListener("click", closeKeyDrawer);
|
||||
document.querySelector("#cancel-editor").addEventListener("click", closeKeyDrawer);
|
||||
drawerOverlayNode.addEventListener("click", closeKeyDrawer);
|
||||
document.querySelector("#save-key").addEventListener("click", saveKeyEditor);
|
||||
document.querySelector("#archive-key").addEventListener("click", () => editingKey && archiveManagedKey(editingKey));
|
||||
document.querySelector("#editor-route").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#editor-all-models").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#editor-reset-period").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#reset-billing").addEventListener("click", resetManagedBilling);
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") closeKeyDrawer();
|
||||
});
|
||||
onAccessChange(authorized => {
|
||||
if (!authorized) closeKeyDrawer();
|
||||
if (managedKeys.length) renderManagedKeys();
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadKeys() {
|
||||
keyStatusNode.textContent = "正在读取";
|
||||
try {
|
||||
const [upstreams, models, summary] = await Promise.all([
|
||||
dataFetch(routes.upstreams),
|
||||
dataFetch(routes.models),
|
||||
dataFetch(routes.usageSummary)
|
||||
]);
|
||||
upstreamAccounts = upstreams.accounts || [];
|
||||
modelSuggestions = models.models || [];
|
||||
usageDashboard = summary;
|
||||
const keys = await dataFetch(routes.keys);
|
||||
managedKeys = keys.keys || [];
|
||||
renderManagedKeys();
|
||||
keyStatusNode.textContent = isManagementAuthorized() ? `共 ${managedKeys.length} 个 Key,${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}` : "";
|
||||
} catch (error) {
|
||||
keyStatusNode.textContent = "读取失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
export function closeKeyDrawer() {
|
||||
drawerNode.classList.add("hidden");
|
||||
drawerOverlayNode.classList.add("hidden");
|
||||
drawerNode.setAttribute("aria-hidden", "true");
|
||||
editingKey = null;
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
drawerNode.classList.remove("hidden");
|
||||
drawerOverlayNode.classList.remove("hidden");
|
||||
drawerNode.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function openKeyEditor(key = null) {
|
||||
editingKey = key;
|
||||
const creating = !key;
|
||||
document.querySelector("#drawer-title").textContent = creating ? "创建 Key" : `管理 ${key.name}`;
|
||||
document.querySelector("#drawer-subtitle").textContent = creating ? "配置访问、模型、路由与初始额度" : "管理访问、模型、路由与额度";
|
||||
editorNode.classList.remove("hidden");
|
||||
keyStatsNode.classList.add("hidden");
|
||||
editorFooterNode.classList.remove("hidden");
|
||||
managedFieldsNode.classList.remove("hidden");
|
||||
document.querySelector("#editor-name").value = key?.name || "";
|
||||
const secret = document.querySelector("#editor-secret");
|
||||
secret.value = key?.secret || "";
|
||||
secret.readOnly = !creating;
|
||||
document.querySelector("#editor-status").value = key?.status || "active";
|
||||
document.querySelector("#editor-route").value = key?.route_mode || "auto";
|
||||
const upstream = document.querySelector("#editor-upstream");
|
||||
upstream.replaceChildren(option("", "未指定", !key?.upstream_account_id), ...upstreamAccounts.map(account => option(account.id, `${upstreamLabel(account)}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key?.upstream_account_id)));
|
||||
document.querySelector("#editor-all-models").checked = key?.all_models ?? true;
|
||||
const models = document.querySelector("#editor-models");
|
||||
models.value = (key?.models || []).join(", ");
|
||||
models.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol";
|
||||
const billing = key?.billing || { quota_usd: "0", spent_usd: "0", balance_usd: "0", reset_period: "none", max_concurrency: 4, active_requests: 0 };
|
||||
document.querySelector("#editor-quota").value = billing.quota_usd || "0";
|
||||
document.querySelector("#editor-concurrency").value = billing.max_concurrency || 4;
|
||||
document.querySelector("#editor-reset-period").value = billing.reset_period || "none";
|
||||
document.querySelector("#editor-next-reset").value = localDateTimeValue(billing.next_reset_at);
|
||||
document.querySelector("#billing-quota-view").textContent = money(billing.quota_usd);
|
||||
document.querySelector("#billing-spent-view").textContent = money(billing.spent_usd);
|
||||
document.querySelector("#billing-balance-view").textContent = money(billing.balance_usd);
|
||||
document.querySelector("#billing-status").textContent = creating ? "新用户默认额度为 $0" : `${billing.active_requests || 0} 个请求正在执行`;
|
||||
document.querySelector("#billing-preview").classList.toggle("hidden", creating);
|
||||
document.querySelector("#ledger-section").classList.toggle("hidden", creating);
|
||||
document.querySelector("#reset-billing").classList.toggle("hidden", creating || key?.status === "archived");
|
||||
document.querySelector("#archive-key").classList.toggle("hidden", creating || key.status === "archived");
|
||||
document.querySelector("#save-key").classList.toggle("hidden", key?.status === "archived");
|
||||
document.querySelector("#save-key").textContent = creating ? "创建 Key" : "保存修改";
|
||||
["editor-name", "editor-status", "editor-route", "editor-all-models", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
|
||||
syncEditorRoute();
|
||||
openDrawer();
|
||||
if (!creating) loadBillingLedger(key);
|
||||
}
|
||||
|
||||
function renderManagedKeys() {
|
||||
keyRowsNode.replaceChildren(...managedKeys.map(key => {
|
||||
const row = document.createElement("tr");
|
||||
const archived = key.status === "archived";
|
||||
const identity = document.createElement("div");
|
||||
identity.className = "key-identity";
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "avatar";
|
||||
avatar.textContent = (key.name || "K").slice(0, 2).toUpperCase();
|
||||
const identityText = document.createElement("div");
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = key.name;
|
||||
const id = document.createElement("small");
|
||||
id.textContent = key.id;
|
||||
identityText.append(name, id);
|
||||
identity.append(avatar, identityText);
|
||||
const credential = document.createElement("div");
|
||||
credential.className = "credential";
|
||||
const secret = document.createElement("code");
|
||||
secret.className = "credential-copy";
|
||||
secret.textContent = isManagementAuthorized() ? maskedSecret(key.secret) : (key.masked_secret || "******");
|
||||
if (isManagementAuthorized() && key.secret) {
|
||||
secret.tabIndex = 0;
|
||||
secret.setAttribute("role", "button");
|
||||
secret.setAttribute("aria-label", "复制完整 Key");
|
||||
secret.title = "点击复制完整 Key";
|
||||
secret.addEventListener("click", () => copyText(key.secret, secret));
|
||||
secret.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
copyText(key.secret, secret);
|
||||
}
|
||||
});
|
||||
}
|
||||
credential.append(secret);
|
||||
const status = document.createElement("span");
|
||||
status.className = `badge ${key.status}`;
|
||||
status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
|
||||
const billing = key.billing || {};
|
||||
const balance = document.createElement("div");
|
||||
const balanceMain = document.createElement("div");
|
||||
balanceMain.className = "route-main";
|
||||
balanceMain.textContent = money(billing.balance_usd);
|
||||
const balanceSub = document.createElement("div");
|
||||
balanceSub.className = "route-sub";
|
||||
balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`;
|
||||
balance.append(balanceMain, balanceSub);
|
||||
const concurrency = document.createElement("span");
|
||||
concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
|
||||
const routeNode = document.createElement("span");
|
||||
routeNode.className = "route-main";
|
||||
routeNode.textContent = routeLabel(key);
|
||||
const models = document.createElement("span");
|
||||
models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "key-actions";
|
||||
const stats = document.createElement("button");
|
||||
stats.type = "button";
|
||||
stats.className = "small";
|
||||
stats.textContent = "统计";
|
||||
stats.addEventListener("click", () => loadKeyStats(key));
|
||||
actions.append(stats);
|
||||
if (isManagementAuthorized()) {
|
||||
const manage = document.createElement("button");
|
||||
manage.type = "button";
|
||||
manage.className = "small admin-only";
|
||||
manage.textContent = archived ? "查看" : "管理";
|
||||
manage.addEventListener("click", () => openKeyEditor(key));
|
||||
actions.append(manage);
|
||||
}
|
||||
[identity, credential, status, balance, concurrency, routeNode, models, actions].forEach(node => {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "left";
|
||||
cell.append(node);
|
||||
row.append(cell);
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
const today = usageDashboard.today || {};
|
||||
document.querySelector("#metric-requests").textContent = number(today.requests || 0);
|
||||
document.querySelector("#metric-tokens").textContent = compactNumber(today.total_tokens || 0);
|
||||
document.querySelector("#metric-cost").textContent = "$" + Number(today.cost_usd || 0).toFixed(4);
|
||||
renderUserCharts();
|
||||
}
|
||||
|
||||
function renderUserCharts() {
|
||||
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
|
||||
(usageDashboard.users || []).forEach(user => {
|
||||
const name = user.key_alias || "未识别";
|
||||
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
|
||||
const value = byUser.get(name);
|
||||
const today = user.today || {};
|
||||
value.requests = today.requests || 0;
|
||||
value.tokens = today.total_tokens || 0;
|
||||
value.cost = today.cost_usd || 0;
|
||||
value.last = user.last_used_at ? new Date(user.last_used_at) : null;
|
||||
});
|
||||
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
|
||||
const header = document.createElement("div");
|
||||
header.className = "user-usage-row header";
|
||||
["用户", "今日请求", "今日 Token", "今日成本", "最近使用"].forEach(label => {
|
||||
const cell = document.createElement("span");
|
||||
cell.textContent = label;
|
||||
header.append(cell);
|
||||
});
|
||||
document.querySelector("#user-usage").replaceChildren(header, ...userRows.map(item => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "user-usage-row";
|
||||
[item.name, number(item.requests), number(item.tokens), "$" + item.cost.toFixed(4), item.last ? item.last.toLocaleString() : "从未"].forEach(value => {
|
||||
const cell = document.createElement("span");
|
||||
cell.textContent = value;
|
||||
row.append(cell);
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
const days = (usageDashboard.days || []).map(item => ({ date: new Date(item.date + "T00:00:00"), tokens: item.total_tokens || 0 }));
|
||||
const maximum = Math.max(1, ...days.map(day => day.tokens));
|
||||
document.querySelector("#daily-chart").replaceChildren(...days.map(day => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "bar-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "bar-label";
|
||||
label.textContent = `${day.date.getMonth() + 1}/${day.date.getDate()}`;
|
||||
const track = document.createElement("span");
|
||||
track.className = "bar-track";
|
||||
const value = document.createElement("span");
|
||||
value.className = "bar-value";
|
||||
value.style.width = `${day.tokens / maximum * 100}%`;
|
||||
track.append(value);
|
||||
const count = document.createElement("span");
|
||||
count.className = "bar-count";
|
||||
count.textContent = compactNumber(day.tokens);
|
||||
row.append(label, track, count);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function createManagedKey() {
|
||||
const route = document.querySelector("#editor-route").value;
|
||||
const allModels = document.querySelector("#editor-all-models").checked;
|
||||
const payload = {
|
||||
name: document.querySelector("#editor-name").value.trim(),
|
||||
secret: document.querySelector("#editor-secret").value.trim(),
|
||||
route_mode: route,
|
||||
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
|
||||
all_models: allModels,
|
||||
models: modelRules(),
|
||||
billing: billingPayload()
|
||||
};
|
||||
try {
|
||||
const created = await managedFetch(routes.keys, { method: "POST", body: JSON.stringify(payload) });
|
||||
keyStatusNode.textContent = `已创建 ${created.name}:${created.secret}`;
|
||||
closeKeyDrawer();
|
||||
await loadKeys();
|
||||
} catch (error) {
|
||||
keyStatusNode.textContent = "创建失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveKeyEditor() {
|
||||
if (!editingKey) return createManagedKey();
|
||||
const route = document.querySelector("#editor-route").value;
|
||||
try {
|
||||
await managedFetch(routes.keys, { method: "PATCH", body: JSON.stringify({
|
||||
id: editingKey.id,
|
||||
name: document.querySelector("#editor-name").value.trim(),
|
||||
status: document.querySelector("#editor-status").value,
|
||||
route_mode: route,
|
||||
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
|
||||
all_models: document.querySelector("#editor-all-models").checked,
|
||||
models: modelRules(),
|
||||
billing: billingPayload()
|
||||
}) });
|
||||
closeKeyDrawer();
|
||||
await loadKeys();
|
||||
} catch (error) {
|
||||
keyStatusNode.textContent = "保存失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBillingLedger(key) {
|
||||
const node = document.querySelector("#billing-ledger");
|
||||
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "正在读取" }));
|
||||
try {
|
||||
const entries = (await managedFetch(routes.billingLedger + "?id=" + encodeURIComponent(key.id) + "&limit=50")).entries || [];
|
||||
node.replaceChildren(...(entries.length ? entries.map(entry => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ledger-item";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = ({ charge: "请求扣费", quota_change: "调整额度", cycle_reset: "额度重置" }[entry.kind] || entry.kind);
|
||||
const amount = document.createElement("strong");
|
||||
const numeric = Number(entry.amount_usd || 0);
|
||||
amount.className = numeric < 0 ? "negative" : "positive";
|
||||
amount.textContent = (numeric > 0 ? "+" : "") + money(numeric);
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = `${new Date(entry.occurred_at).toLocaleString()} · 余额 ${money(entry.balance_after_usd)}${entry.model ? " · " + entry.model : ""}${entry.request_id ? " · " + entry.request_id : ""}`;
|
||||
row.append(label, amount, detail);
|
||||
return row;
|
||||
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无账目" })]));
|
||||
} catch (error) {
|
||||
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "读取失败:" + error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetManagedBilling() {
|
||||
if (!editingKey || !confirm(`立即重置 ${editingKey.name} 的额度?当前剩余余额不会结转。`)) return;
|
||||
try {
|
||||
await managedFetch(routes.billingReset, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
|
||||
await loadKeys();
|
||||
const refreshed = managedKeys.find(item => item.id === editingKey.id);
|
||||
if (refreshed) openKeyEditor(refreshed);
|
||||
} catch (error) {
|
||||
document.querySelector("#billing-status").textContent = "重置失败:" + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveManagedKey(key) {
|
||||
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
|
||||
try {
|
||||
await managedFetch(routes.keys, { method: "DELETE", body: JSON.stringify({ id: key.id }) });
|
||||
closeKeyDrawer();
|
||||
await loadKeys();
|
||||
} catch (error) {
|
||||
keyStatusNode.textContent = "归档失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadKeyStats(key) {
|
||||
try {
|
||||
const payload = await dataFetch(routes.keyStats + "?id=" + encodeURIComponent(key.id));
|
||||
const total = payload.stats.total;
|
||||
const today = payload.stats.today;
|
||||
document.querySelector("#drawer-title").textContent = `${key.name} · 统计`;
|
||||
document.querySelector("#drawer-subtitle").textContent = "历史统计永久保留";
|
||||
editorNode.classList.add("hidden");
|
||||
editorFooterNode.classList.add("hidden");
|
||||
keyStatsNode.classList.remove("hidden");
|
||||
statsGridNode.replaceChildren(...[
|
||||
["今日请求", number(today.requests)], ["今日 Token", number(today.total_tokens)],
|
||||
["累计请求", number(total.requests)], ["累计成本", "$" + (total.cost_micros / 1000000).toFixed(4)]
|
||||
].map(([label, value]) => {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = label;
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = value;
|
||||
tile.append(name, strong);
|
||||
return tile;
|
||||
}));
|
||||
const recent = (payload.recent || []).slice(0, 10);
|
||||
statsRecentNode.replaceChildren(...(recent.length ? recent.map(item => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "recent-item";
|
||||
const model = document.createElement("strong");
|
||||
model.textContent = `${item.model || "-"} · ${result(item)}`;
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = `${new Date(item.requested_at).toLocaleString()} · ${number(item.total_tokens)} Token · ${item.auth_id || "自动选择"}`;
|
||||
row.append(model, detail);
|
||||
return row;
|
||||
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无请求" })]));
|
||||
openDrawer();
|
||||
} catch (error) {
|
||||
keyStatusNode.textContent = "读取统计失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function syncEditorRoute() {
|
||||
const archived = editingKey?.status === "archived";
|
||||
const strict = document.querySelector("#editor-route").value === "strict";
|
||||
document.querySelector("#editor-upstream").disabled = archived || !strict;
|
||||
document.querySelector("#editor-models").disabled = archived || document.querySelector("#editor-all-models").checked;
|
||||
const period = document.querySelector("#editor-reset-period").value;
|
||||
const nextReset = document.querySelector("#editor-next-reset");
|
||||
nextReset.disabled = archived || period === "none";
|
||||
if (period !== "none" && !nextReset.value) {
|
||||
const next = new Date();
|
||||
if (period === "daily") next.setDate(next.getDate() + 1);
|
||||
else if (period === "weekly") next.setDate(next.getDate() + 7);
|
||||
else next.setMonth(next.getMonth() + 1);
|
||||
nextReset.value = localDateTimeValue(next);
|
||||
}
|
||||
}
|
||||
|
||||
function billingPayload() {
|
||||
const period = document.querySelector("#editor-reset-period").value;
|
||||
const nextValue = document.querySelector("#editor-next-reset").value;
|
||||
return {
|
||||
quota_usd: document.querySelector("#editor-quota").value.trim() || "0",
|
||||
reset_period: period,
|
||||
next_reset_at: period === "none" || !nextValue ? null : new Date(nextValue).toISOString(),
|
||||
max_concurrency: Number(document.querySelector("#editor-concurrency").value || 4)
|
||||
};
|
||||
}
|
||||
|
||||
function modelRules() {
|
||||
return document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function upstreamLabel(account) {
|
||||
return `${account.provider || "unknown"} · ${account.display_name || account.cpa_auth_id || account.id}`;
|
||||
}
|
||||
|
||||
function routeLabel(key) {
|
||||
if (key.route_mode !== "strict") return "自由选择";
|
||||
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
|
||||
return account ? account.display_name || account.cpa_auth_id || account.id : "未指定";
|
||||
}
|
||||
|
||||
function maskedSecret(value) {
|
||||
const secret = String(value || "");
|
||||
return secret.slice(0, 2) + "******" + secret.slice(-4);
|
||||
}
|
||||
|
||||
function copyText(value, button) {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
const previous = button.textContent;
|
||||
button.textContent = "已复制";
|
||||
setTimeout(() => { button.textContent = previous; }, 1200);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
|
||||
|
||||
const statusNode = document.querySelector("#price-status");
|
||||
const listNode = document.querySelector("#price-list");
|
||||
const editorNode = document.querySelector("#price-editor-content");
|
||||
const sourceNode = document.querySelector("#price-source");
|
||||
const catalogSummaryNode = document.querySelector("#catalog-summary");
|
||||
const catalogResultsNode = document.querySelector("#catalog-results");
|
||||
|
||||
let currentPrices = [];
|
||||
let selectedPriceModel = "";
|
||||
let currentCatalog = null;
|
||||
|
||||
export function initializePricing() {
|
||||
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.querySelector("#new-price").addEventListener("click", clearPriceForm);
|
||||
document.querySelector("#delete-price").addEventListener("click", () => selectedPriceModel && deletePrice(selectedPriceModel));
|
||||
document.querySelector("#search-catalog").addEventListener("click", searchCatalog);
|
||||
document.querySelector("#catalog-query").addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
searchCatalog();
|
||||
}
|
||||
});
|
||||
document.querySelector("#refresh-catalog").addEventListener("click", refreshCatalog);
|
||||
onAccessChange(updateAccessState);
|
||||
}
|
||||
|
||||
export async function loadPricing() {
|
||||
try {
|
||||
const payload = await dataFetch(routes.prices);
|
||||
currentPrices = payload.prices || [];
|
||||
renderPrices();
|
||||
const selected = currentPrices.find(price => price.model === selectedPriceModel);
|
||||
if (selected) fillPriceForm(selected);
|
||||
else if (selectedPriceModel) clearPriceForm(false);
|
||||
else showPriceEditor(false);
|
||||
statusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
|
||||
if (isManagementAuthorized()) await loadCatalogStatus();
|
||||
else {
|
||||
currentCatalog = null;
|
||||
catalogSummaryNode.textContent = "";
|
||||
renderCatalogResults([]);
|
||||
}
|
||||
} catch (error) {
|
||||
statusNode.textContent = "读取价格失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccessState(authorized) {
|
||||
document.querySelectorAll("#view-pricing .price-editor input, #view-pricing .price-editor select").forEach(node => {
|
||||
if (node.id !== "catalog-query") node.disabled = !authorized;
|
||||
});
|
||||
syncLongSectionVisibility();
|
||||
}
|
||||
|
||||
function showPriceEditor(show) {
|
||||
editorNode.classList.toggle("hidden", !show);
|
||||
document.querySelector("#delete-price").classList.toggle("hidden", !selectedPriceModel);
|
||||
}
|
||||
|
||||
function clearPriceForm(showEditor = true) {
|
||||
selectedPriceModel = "";
|
||||
["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";
|
||||
showPriceSource({ kind: "manual" });
|
||||
setLongFields(false);
|
||||
syncLongSectionVisibility(false);
|
||||
showPriceEditor(showEditor);
|
||||
renderPrices();
|
||||
}
|
||||
|
||||
function fillPriceForm(price) {
|
||||
selectedPriceModel = price.model;
|
||||
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";
|
||||
showPriceSource(price.source || { kind: "manual" });
|
||||
setLongFields(Boolean(long));
|
||||
syncLongSectionVisibility(Boolean(long));
|
||||
showPriceEditor(true);
|
||||
document.querySelectorAll(".price-row").forEach(row => row.classList.toggle("selected", row.dataset.model === selectedPriceModel));
|
||||
}
|
||||
|
||||
function renderPrices() {
|
||||
if (!currentPrices.length) {
|
||||
listNode.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "尚未配置模型" }));
|
||||
return;
|
||||
}
|
||||
listNode.replaceChildren(...currentPrices.map(price => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "price-row" + (price.model === selectedPriceModel ? " selected" : "");
|
||||
row.dataset.model = price.model;
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
const text = document.createElement("div");
|
||||
const model = document.createElement("div");
|
||||
model.className = "price-name";
|
||||
model.textContent = price.model;
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""} · ${priceSourceText(price.source)}`;
|
||||
text.append(model, detail);
|
||||
row.addEventListener("click", () => fillPriceForm(price));
|
||||
row.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
fillPriceForm(price);
|
||||
}
|
||||
});
|
||||
row.append(text);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function savePrice() {
|
||||
try {
|
||||
const payload = pricePayload();
|
||||
selectedPriceModel = payload.model;
|
||||
await managedFetch(routes.prices, { method: "PUT", body: JSON.stringify(payload) });
|
||||
statusNode.textContent = "价格已保存";
|
||||
await loadPricing();
|
||||
showPriceSource({ kind: "manual" });
|
||||
} catch (error) {
|
||||
statusNode.textContent = "保存失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePrice(model) {
|
||||
if (!confirm(`删除 ${model} 的真实价格配置?`)) return;
|
||||
try {
|
||||
await managedFetch(routes.prices, { method: "DELETE", body: JSON.stringify({ model }) });
|
||||
if (selectedPriceModel === model) clearPriceForm(false);
|
||||
await loadPricing();
|
||||
} catch (error) {
|
||||
statusNode.textContent = "删除失败: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
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 renderCatalogResults(models) {
|
||||
if (!models.length) {
|
||||
catalogResultsNode.replaceChildren();
|
||||
return;
|
||||
}
|
||||
catalogResultsNode.replaceChildren(...models.map(entry => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "catalog-result";
|
||||
const text = document.createElement("div");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `${entry.provider_name || entry.provider} · ${entry.model_name || entry.model}`;
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = `${entry.id} · 输入 ${entry.base.input_per_1m} / 输出 ${entry.base.output_per_1m}${entry.long_context ? " · 长上下文" : ""}`;
|
||||
text.append(title, detail);
|
||||
row.append(text);
|
||||
if (isManagementAuthorized()) {
|
||||
const use = document.createElement("button");
|
||||
use.type = "button";
|
||||
use.className = "small admin-only";
|
||||
use.textContent = "使用";
|
||||
use.addEventListener("click", () => importCatalogPrice(entry));
|
||||
row.append(use);
|
||||
}
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchCatalog() {
|
||||
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
|
||||
try {
|
||||
const payload = await dataFetch(routes.catalog + "?q=" + encodeURIComponent(query));
|
||||
catalogSummaryNode.textContent = catalogSummary(payload);
|
||||
renderCatalogResults(payload.models || []);
|
||||
} catch (error) {
|
||||
catalogSummaryNode.textContent = "读取参考价格失败:" + error.message;
|
||||
renderCatalogResults([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalogStatus() {
|
||||
await searchCatalog();
|
||||
}
|
||||
|
||||
async function importCatalogPrice(entry) {
|
||||
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
|
||||
if (!localModel) {
|
||||
statusNode.textContent = "请先输入本地模型名称";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const imported = await managedFetch(routes.priceImport, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
|
||||
await loadPricing();
|
||||
fillPriceForm(imported);
|
||||
statusNode.textContent = `${localModel} 已采用 ${entry.provider_name || entry.provider} / ${entry.model} 参考价`;
|
||||
} catch (error) {
|
||||
statusNode.textContent = "导入参考价格失败:" + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCatalog() {
|
||||
try {
|
||||
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
|
||||
const preview = await managedFetch(routes.catalogRefresh, { method: "POST" });
|
||||
currentCatalog = preview.catalog;
|
||||
const changed = (preview.changes || []).filter(change => change.status === "changed");
|
||||
const missing = (preview.changes || []).filter(change => change.status === "missing");
|
||||
if (!changed.length) {
|
||||
catalogSummaryNode.textContent = `目录已更新 · 没有价格变化${missing.length ? ` · ${missing.length} 个来源已下架` : ""}`;
|
||||
await searchCatalog();
|
||||
return;
|
||||
}
|
||||
const lines = changed.slice(0, 8).map(change => `${change.model}: 输入 ${change.before.base.input_per_1m} → ${change.after.base.input_per_1m},输出 ${change.before.base.output_per_1m} → ${change.after.base.output_per_1m}`);
|
||||
const confirmed = confirm(`发现 ${changed.length} 个已关联价格发生变化:\n\n${lines.join("\n")}${changed.length > lines.length ? "\n…" : ""}\n\n确认更新这些本地价格?`);
|
||||
if (!confirmed) {
|
||||
catalogSummaryNode.textContent = `目录已更新 · ${changed.length} 个变化尚未应用`;
|
||||
return;
|
||||
}
|
||||
const applied = await managedFetch(routes.catalogApply, { method: "POST", body: JSON.stringify({ revision: preview.catalog.revision, models: changed.map(change => change.model) }) });
|
||||
catalogSummaryNode.textContent = `已确认更新 ${applied.updated} 个本地价格`;
|
||||
await loadPricing();
|
||||
} catch (error) {
|
||||
catalogSummaryNode.textContent = "更新目录失败:" + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function catalogSummary(payload) {
|
||||
currentCatalog = payload?.catalog || null;
|
||||
if (!payload?.loaded) return payload?.last_error ? `尚无可用目录 · ${payload.last_error}` : "尚未下载目录";
|
||||
const updated = currentCatalog?.fetched_at ? new Date(currentCatalog.fetched_at).toLocaleString() : "-";
|
||||
return `${currentCatalog?.models || 0} 条参考价格 · ${updated}`;
|
||||
}
|
||||
|
||||
function priceSourceText(source) {
|
||||
if (source?.kind === "models.dev") return `models.dev · ${source.provider || "-"} / ${source.model || source.catalog_id || "-"}`;
|
||||
return "手动配置";
|
||||
}
|
||||
|
||||
function showPriceSource(source) {
|
||||
sourceNode.textContent = priceSourceText(source);
|
||||
sourceNode.dataset.kind = source?.kind || "manual";
|
||||
}
|
||||
|
||||
function setLongFields(enabled) {
|
||||
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
|
||||
}
|
||||
|
||||
function syncLongSectionVisibility(hasLong = document.querySelector("#long-enabled").checked) {
|
||||
document.querySelector("#long-price-section").classList.toggle("hidden", !isManagementAuthorized() && !hasLong);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { dataFetch, routes } 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 pageSize = 100;
|
||||
|
||||
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 Promise.all([loadUsageFilterOptions(), loadUsage(true, 1)]);
|
||||
}
|
||||
|
||||
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 params = usageFilterQuery();
|
||||
params.set("page", String(page || 1));
|
||||
params.set("page_size", String(pageSize));
|
||||
if (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() {
|
||||
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: pageSize, 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) : "-";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { initializeRuntime } from "./core/runtime.js";
|
||||
import { closeKeyDrawer, initializeKeys, loadKeys } from "./features/keys.js";
|
||||
import { initializePricing, loadPricing } from "./features/pricing.js";
|
||||
import { activateUsage, initializeUsage, refreshVisibleUsage, resetUsage } from "./features/usage.js";
|
||||
|
||||
const loaders = Object.freeze({ keys: loadKeys, usage: activateUsage, pricing: loadPricing });
|
||||
|
||||
initializeKeys();
|
||||
initializeUsage();
|
||||
initializePricing();
|
||||
initializeRuntime(reloadCurrentView);
|
||||
|
||||
document.querySelectorAll(".nav-button").forEach(button => {
|
||||
button.addEventListener("click", () => setView(button.dataset.view));
|
||||
});
|
||||
document.addEventListener("visibilitychange", refreshVisibleUsage);
|
||||
setInterval(refreshVisibleUsage, 3000);
|
||||
|
||||
setView(sessionStorage.getItem("billing:active-view") || "keys");
|
||||
|
||||
function setView(view) {
|
||||
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
|
||||
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
|
||||
try {
|
||||
sessionStorage.setItem("billing:active-view", view);
|
||||
} catch (_) {}
|
||||
loaders[view]?.();
|
||||
}
|
||||
|
||||
function reloadCurrentView() {
|
||||
closeKeyDrawer();
|
||||
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
|
||||
if (active === "usage") resetUsage();
|
||||
else loaders[active]?.();
|
||||
}
|
||||
+25
-4
@@ -1,12 +1,33 @@
|
||||
// Package web 提供插件管理页面的静态资源。
|
||||
package web
|
||||
|
||||
import _ "embed"
|
||||
import "embed"
|
||||
|
||||
//go:embed ui.html
|
||||
var uiHTML []byte
|
||||
import "path/filepath"
|
||||
|
||||
//go:embed ui.html ui-config.js app/*.js app/core/*.js app/features/*.js styles/*.css
|
||||
var assets embed.FS
|
||||
|
||||
// UI 返回只读的管理页面内容。
|
||||
func UI() []byte {
|
||||
return uiHTML
|
||||
value, _, _ := Asset("ui.html")
|
||||
return value
|
||||
}
|
||||
|
||||
// Asset 返回嵌入的管理页面资源。
|
||||
func Asset(name string) ([]byte, string, bool) {
|
||||
value, err := assets.ReadFile(name)
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
contentType := "application/octet-stream"
|
||||
switch filepath.Ext(name) {
|
||||
case ".html":
|
||||
contentType = "text/html; charset=utf-8"
|
||||
case ".css":
|
||||
contentType = "text/css; charset=utf-8"
|
||||
case ".js":
|
||||
contentType = "text/javascript; charset=utf-8"
|
||||
}
|
||||
return value, contentType, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
/* Keep these tokens aligned with CLIProxyAPI Manager's dark theme. */
|
||||
--bg-primary: #1d1b18; --bg-secondary: #151412; --bg-tertiary: #262320; --bg-hover: #2e2a26; --bg-quinary: #191714; --floating-surface: #2a2723;
|
||||
--border-color: #3a3530; --border-primary: #4a453f; --border-hover: #5a544d;
|
||||
--text-primary: #f6f4f1; --text-secondary: #c9c3bb; --text-tertiary: #9c958d; --text-quaternary: #6f6962;
|
||||
--primary-color: #8b8680; --primary-hover: #9a948e; --primary-active: #a6a099;
|
||||
--success-color: #10b981; --error-color: #c65746; --warning-color: #ffd862; --amber-color: #f59e0b;
|
||||
--bg: var(--bg-primary); --panel: var(--bg-secondary); --panel-raised: var(--floating-surface); --panel-soft: var(--bg-quinary);
|
||||
--border: var(--border-color); --border-soft: var(--border-color); --text: var(--text-primary); --muted: var(--text-tertiary);
|
||||
--accent: var(--primary-active); --accent-strong: var(--primary-color); --accent-soft: var(--bg-tertiary);
|
||||
--success: var(--success-color); --danger: var(--error-color); --warning: var(--warning-color);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
body.read-only .admin-only { display: none !important; }
|
||||
input, select { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; outline: none; background: var(--bg-tertiary); color: inherit; transition: border-color .15s, box-shadow .15s; }
|
||||
input:focus, select:focus { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
|
||||
input:disabled, select:disabled { cursor: not-allowed; opacity: .55; }
|
||||
input[type="checkbox"] { width: 15px; height: 15px; padding: 0; accent-color: var(--accent-strong); }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 4px; font-size: 19px; letter-spacing: -.01em; }
|
||||
h2 { margin-bottom: 10px; font-size: 15px; }
|
||||
.section-heading p, .subtle { margin-bottom: 0; color: var(--muted); font-size: 12px; }
|
||||
.tools { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
|
||||
button, summary { border: 1px solid var(--border); border-radius: 7px; padding: 8px 11px; background: var(--bg-tertiary); color: var(--text); font-size: 12px; }
|
||||
button:hover, summary:hover { border-color: var(--border-hover); background: var(--bg-hover); }
|
||||
button.primary { border-color: var(--primary-color); background: var(--primary-color); color: white; }
|
||||
button.primary:hover { border-color: var(--primary-hover); background: var(--primary-hover); }
|
||||
button.danger { color: #f1b0a6; }
|
||||
button.small { padding: 5px 8px; }
|
||||
button.icon-button { width: 32px; height: 32px; padding: 0; font-size: 17px; }
|
||||
.surface { border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.surface-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 45px; padding: 8px 10px 8px 13px; border-bottom: 1px solid var(--border-soft); }
|
||||
.surface-toolbar .status { min-height: 0; }
|
||||
.inline-check { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
|
||||
.hidden { display: none !important; }
|
||||
.empty { padding: 28px; color: var(--muted); text-align: center; }
|
||||
@@ -0,0 +1,72 @@
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; margin-bottom: 12px; }
|
||||
.summary-card { min-height: 78px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.summary-card span { color: var(--muted); font-size: 11px; }
|
||||
.summary-card strong { display: block; margin-top: 5px; font-size: 22px; font-weight: 650; letter-spacing: -.03em; }
|
||||
.summary-card small { display: block; margin-top: 2px; color: var(--text-quaternary); font-size: 11px; }
|
||||
.surface { border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.surface-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 45px; padding: 8px 10px 8px 13px; border-bottom: 1px solid var(--border-soft); }
|
||||
.surface-toolbar .status { min-height: 0; }
|
||||
.chart-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 9px; margin-bottom: 12px; }
|
||||
.chart-card { min-height: 180px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.chart-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 13px; }
|
||||
.chart-card h2 { margin-bottom: 2px; font-size: 12px; }
|
||||
.chart-card p { margin-bottom: 0; color: var(--muted); font-size: 10px; }
|
||||
.bar-chart { display: grid; gap: 9px; }
|
||||
.bar-row { display: grid; grid-template-columns: minmax(70px, 110px) minmax(100px, 1fr) 42px; align-items: center; gap: 9px; font-size: 10px; }
|
||||
.bar-label { overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: var(--bg-tertiary); }
|
||||
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--primary-active); }
|
||||
.bar-count { color: var(--muted); text-align: right; }
|
||||
.user-usage { display: grid; overflow-x: auto; }
|
||||
.user-usage-row { display: grid; grid-template-columns: minmax(90px, 1.2fr) 68px minmax(90px, .8fr) 78px minmax(120px, 1fr); align-items: center; gap: 10px; min-width: 570px; min-height: 31px; border-bottom: 1px solid var(--border-soft); font-size: 10px; }
|
||||
.user-usage-row:last-child { border-bottom: 0; }
|
||||
.user-usage-row.header { min-height: 26px; color: var(--muted); font-size: 9px; }
|
||||
.user-usage-row span:not(:first-child) { text-align: right; }
|
||||
.key-identity { display: flex; align-items: center; gap: 9px; }
|
||||
.avatar { display: grid; flex: 0 0 auto; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--accent-soft); color: var(--text-secondary); font-size: 11px; font-weight: 700; }
|
||||
.key-identity strong { display: block; font-size: 12px; }
|
||||
.key-identity small { display: block; max-width: 160px; overflow: hidden; color: var(--text-quaternary); font-size: 10px; text-overflow: ellipsis; }
|
||||
.credential { display: flex; align-items: center; }
|
||||
.credential-copy { cursor: copy; border-radius: 4px; padding: 3px 4px; }
|
||||
.credential-copy:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.credential-copy:focus-visible { outline: 2px solid var(--primary-active); outline-offset: 2px; }
|
||||
code { color: var(--text-secondary); font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 10px; }
|
||||
.badge::before { width: 5px; height: 5px; border-radius: 50%; background: currentColor; content: ""; }
|
||||
.badge.active { border-color: #059669; background: #064e3b4d; color: #6ee7b7; }
|
||||
.badge.disabled, .badge.archived { color: var(--text-tertiary); }
|
||||
.route-main { font-size: 12px; }
|
||||
.route-sub { margin-top: 2px; max-width: 220px; overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; }
|
||||
.key-actions { display: flex; gap: 6px; }
|
||||
.drawer-overlay { position: fixed; z-index: 40; inset: 0; background: #000000a6; backdrop-filter: blur(2px); }
|
||||
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(540px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: var(--bg-primary); box-shadow: -20px 0 60px #0008; }
|
||||
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 17px 18px 13px; border-bottom: 1px solid var(--border-soft); }
|
||||
.drawer-header h2 { margin-bottom: 3px; }
|
||||
.drawer-header p { margin-bottom: 0; color: var(--muted); font-size: 11px; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px; }
|
||||
.drawer-form { display: grid; gap: 13px; }
|
||||
.drawer-section { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--bg-secondary); }
|
||||
.drawer-section h3 { margin: 0; font-size: 11px; }
|
||||
.field-hint { margin: -2px 0 0; color: var(--text-quaternary); font-size: 9px; }
|
||||
.billing-preview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
||||
.billing-preview div { padding: 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--bg-primary); }
|
||||
.billing-preview span { display: block; color: var(--muted); font-size: 9px; }
|
||||
.billing-preview strong { display: block; margin-top: 3px; font-size: 14px; }
|
||||
.billing-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.ledger-list { display: grid; max-height: 210px; overflow: auto; gap: 5px; }
|
||||
.ledger-item { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; padding: 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--bg-primary); }
|
||||
.ledger-item strong { font-size: 10px; }
|
||||
.ledger-item .positive { color: var(--success); }
|
||||
.ledger-item .negative { color: var(--danger); }
|
||||
.ledger-item small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.drawer-footer { display: flex; justify-content: space-between; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border-soft); }
|
||||
.drawer-footer div { display: flex; gap: 7px; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; }
|
||||
.stats-tile { padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--panel-soft); }
|
||||
.stats-tile span { color: var(--muted); font-size: 10px; }
|
||||
.stats-tile strong { display: block; margin-top: 5px; font-size: 17px; }
|
||||
.recent-list { display: grid; gap: 6px; }
|
||||
.recent-item { padding: 9px 10px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--panel-soft); }
|
||||
.recent-item strong { display: block; margin-bottom: 3px; font-size: 11px; }
|
||||
.recent-item span { color: var(--muted); font-size: 10px; }
|
||||
@@ -0,0 +1,19 @@
|
||||
.app-shell { min-height: 100vh; padding-top: 18px; }
|
||||
/* CPA Manager overlays its own controls on the iframe's top-right corner. */
|
||||
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 58px; padding: 0 222px 0 22px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.brand-mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
|
||||
.brand strong { font-size: 14px; letter-spacing: .02em; }
|
||||
.brand span { color: var(--muted); font-size: 12px; }
|
||||
.auth-box { display: flex; align-items: center; gap: 8px; }
|
||||
#key { width: 188px; }
|
||||
.workspace-nav { display: flex; align-items: stretch; gap: 3px; height: 50px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: var(--bg-secondary); overflow-x: auto; overflow-y: hidden; }
|
||||
.nav-button { position: relative; display: flex; flex: 0 0 auto; align-items: center; height: 49px; border: 0; padding: 0 13px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; }
|
||||
.nav-button:hover { color: var(--text); }
|
||||
.nav-button.active { color: var(--text); }
|
||||
.nav-button.active::after { position: absolute; right: 10px; bottom: -1px; left: 10px; height: 2px; border-radius: 2px 2px 0 0; background: var(--accent-strong); content: ""; }
|
||||
main { padding: 18px 22px 28px; }
|
||||
.view { display: none; }
|
||||
.view.active { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-heading.actions-only { justify-content: flex-end; }
|
||||
@@ -0,0 +1,36 @@
|
||||
.price-panel { padding: 0; }
|
||||
.price-layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); min-height: 520px; }
|
||||
.price-sidebar { padding: 13px; border-right: 1px solid var(--border-soft); background: var(--panel-soft); }
|
||||
.price-sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
||||
.price-sidebar-header h2 { margin: 0; font-size: 12px; }
|
||||
.price-list { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 6px; }
|
||||
.price-row { display: block; min-width: 0; overflow: hidden; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); cursor: pointer; outline: none; }
|
||||
.price-row > div { min-width: 0; overflow: hidden; }
|
||||
.price-row:hover { border-color: var(--border-hover); background: var(--bg-hover); }
|
||||
.price-row:focus-visible { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
|
||||
.price-row.selected { border-color: var(--border-primary); background: var(--bg-tertiary); }
|
||||
.price-row .price-name { overflow: hidden; font-size: 11px; font-weight: 600; text-overflow: ellipsis; }
|
||||
.price-row small { display: block; margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.price-editor { padding: 16px; }
|
||||
.price-editor-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.price-editor-header h2 { margin: 0; }
|
||||
.price-section { margin-bottom: 10px; padding: 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--bg-quinary); }
|
||||
.price-section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 11px; }
|
||||
.price-section-header h3 { margin: 0; font-size: 11px; }
|
||||
.price-section-header .inline-check { color: var(--text); }
|
||||
.price-grid { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; }
|
||||
.price-grid.long-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
.field, .price-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 10px; }
|
||||
.price-model-field { max-width: 480px; }
|
||||
.catalog-toolbar { display: flex; align-items: center; gap: 7px; }
|
||||
.catalog-toolbar input { flex: 1 1 260px; }
|
||||
.catalog-summary { min-height: 17px; margin-bottom: 8px; color: var(--muted); font-size: 10px; }
|
||||
.catalog-results { display: grid; gap: 6px; max-height: 250px; margin-top: 8px; overflow-y: auto; }
|
||||
.catalog-result { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); }
|
||||
.catalog-result strong { display: block; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.catalog-result small { display: block; margin-top: 3px; color: var(--muted); font-size: 9px; }
|
||||
.price-source { min-height: 18px; margin-top: 8px; color: var(--text-tertiary); font-size: 10px; }
|
||||
.price-source.catalog-linked { color: var(--text-secondary); }
|
||||
.fast-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(130px, 220px); align-items: end; gap: 16px; }
|
||||
.fast-row .inline-check { align-self: center; color: var(--text); }
|
||||
.price-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 3px; }
|
||||
@@ -0,0 +1,3 @@
|
||||
@media (max-width: 1100px) { .usage-filters { grid-template-columns: repeat(3, minmax(140px, 1fr)); } }
|
||||
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .summary-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); overflow-x: auto; } .chart-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .topbar { align-items: flex-start; min-height: 0; padding: 11px 12px; } .brand span { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
|
||||
@@ -0,0 +1,30 @@
|
||||
details { position: relative; }
|
||||
summary { list-style: none; cursor: pointer; }
|
||||
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 var(--border); border-radius: 8px; background: var(--panel-raised); box-shadow: 0 16px 40px #0009; }
|
||||
.column-options label { display: flex; align-items: center; gap: 6px; }
|
||||
.status { min-height: 20px; color: var(--muted); font-size: 12px; }
|
||||
.table-wrap { overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; }
|
||||
th, td { padding: 8px 10px; border-bottom: 1px solid var(--border-soft); text-align: right; }
|
||||
th { position: sticky; top: 0; z-index: 1; background: var(--bg-quinary); color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .03em; text-transform: uppercase; }
|
||||
th.left, td.left { text-align: left; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: color-mix(in srgb, var(--text-primary) 3%, transparent); }
|
||||
.ok { color: var(--success-color); }
|
||||
.failed { color: var(--error-color); }
|
||||
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 0; }
|
||||
.page-buttons { display: flex; align-items: center; gap: 4px; }
|
||||
.page-buttons button { min-width: 29px; height: 27px; padding: 3px 7px; }
|
||||
.page-buttons button.active { border-color: var(--primary-color); background: var(--primary-color); color: white; }
|
||||
.page-buttons button:disabled { cursor: default; opacity: .4; }
|
||||
.page-ellipsis { width: 22px; color: var(--muted); text-align: center; font-size: 10px; }
|
||||
.usage-filters { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: 9px; margin-bottom: 10px; padding: 11px; }
|
||||
.section-heading .tools { position: relative; }
|
||||
.usage-filter-panel { position: static; margin: 0; }
|
||||
.usage-filter-panel > summary { display: inline-flex; align-items: center; }
|
||||
.usage-filter-panel .usage-filters { position: absolute; z-index: 15; top: calc(100% + 8px); right: 0; width: min(960px, calc(100vw - 44px)); margin-bottom: 0; box-shadow: 0 16px 40px #0009; }
|
||||
.usage-filter { display: grid; gap: 5px; min-width: 0; color: var(--muted); font-size: 9px; }
|
||||
.usage-filter input, .usage-filter select { height: 32px; padding: 6px 8px; font-size: 11px; }
|
||||
.usage-filter.request-filter { grid-column: span 2; }
|
||||
.usage-filter-actions { display: flex; align-items: end; justify-content: flex-end; gap: 7px; grid-column: span 2; }
|
||||
@@ -0,0 +1 @@
|
||||
window.BILLING_UI_CONFIG = Object.freeze({});
|
||||
+8
-1054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
package webdemo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeInput struct {
|
||||
mu sync.Mutex
|
||||
keys []fakeKey
|
||||
upstreams []fakeUpstream
|
||||
models []string
|
||||
usage []fakeUsage
|
||||
prices []map[string]any
|
||||
catalog []map[string]any
|
||||
ledger []fakeLedger
|
||||
now time.Time
|
||||
}
|
||||
|
||||
const fakeUsageCount = 50_000
|
||||
|
||||
type fakeKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
MaskedSecret string `json:"masked_secret"`
|
||||
Status string `json:"status"`
|
||||
RouteMode string `json:"route_mode"`
|
||||
UpstreamAccountID string `json:"upstream_account_id"`
|
||||
AllModels bool `json:"all_models"`
|
||||
Models []string `json:"models"`
|
||||
Billing map[string]any `json:"billing"`
|
||||
}
|
||||
|
||||
type fakeUpstream struct {
|
||||
ID string `json:"id"`
|
||||
CPAAuthID string `json:"cpa_auth_id"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Priority int `json:"priority"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Unavailable bool `json:"unavailable"`
|
||||
}
|
||||
|
||||
type fakeUsage struct {
|
||||
RequestID string `json:"request_id"`
|
||||
TraceID string `json:"trace_id"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
ManagedKeyID string `json:"managed_key_id"`
|
||||
KeyAlias string `json:"key_alias"`
|
||||
APIKey string `json:"api_key"`
|
||||
AuthID string `json:"auth_id"`
|
||||
AuthIndex string `json:"auth_index"`
|
||||
AuthType string `json:"auth_type"`
|
||||
Model string `json:"model"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
Speed string `json:"speed"`
|
||||
Failed bool `json:"failed"`
|
||||
Outcome string `json:"outcome"`
|
||||
StatusCode int `json:"status_code"`
|
||||
RequestType string `json:"request_type"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
TTFTMilliseconds int64 `json:"ttft_ms"`
|
||||
SpeedTPS float64 `json:"speed_tps"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
||||
CacheRate float64 `json:"cache_rate"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CostAvailable bool `json:"cost_available"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
}
|
||||
|
||||
type fakeLedger struct {
|
||||
ID int64 `json:"id"`
|
||||
KeyID string `json:"key_id"`
|
||||
Kind string `json:"kind"`
|
||||
AmountUSD string `json:"amount_usd"`
|
||||
BalanceAfterUSD string `json:"balance_after_usd"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
func NewFakeInput() Input {
|
||||
now := time.Now()
|
||||
input := &fakeInput{now: now}
|
||||
input.upstreams = []fakeUpstream{
|
||||
{ID: "upstream-primary", CPAAuthID: "codex:deepseek:primary", Provider: "codex", DisplayName: "DeepSeek 主账号", Priority: 10},
|
||||
{ID: "upstream-backup", CPAAuthID: "codex:deepseek:backup", Provider: "codex", DisplayName: "DeepSeek 备用账号", Priority: 20},
|
||||
{ID: "upstream-paused", CPAAuthID: "codex:openai:paused", Provider: "codex", DisplayName: "OpenAI 暂停账号", Priority: 30, Disabled: true},
|
||||
}
|
||||
input.models = []string{"deepseek-v4-flash", "deepseek-reasoner", "gpt-5.6-sol"}
|
||||
input.keys = fakeKeys(now)
|
||||
input.usage = fakeUsageRecords(now, input.keys, input.upstreams, input.models)
|
||||
input.prices = fakePrices()
|
||||
input.catalog = fakeCatalog()
|
||||
input.ledger = fakeLedgerRecords(now, input.keys, input.models)
|
||||
return input
|
||||
}
|
||||
|
||||
func fakeKeys(now time.Time) []fakeKey {
|
||||
monthlyReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location())
|
||||
monthlyStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
return []fakeKey{
|
||||
{ID: "key_default", Name: "默认用户", Secret: "demo-default-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "auto", AllModels: true, Billing: fakeBilling("50", "18.427631", "31.572369", "monthly", monthlyReset, 8, 1, monthlyStart)},
|
||||
{ID: "key_alice", Name: "Alice", Secret: "demo-alice-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-primary", Models: []string{"deepseek-*", "gpt-5.6-sol"}, Billing: fakeBilling("20", "6.983214", "13.016786", "weekly", now.Add(4*24*time.Hour), 4, 0, now.Add(-3*24*time.Hour))},
|
||||
{ID: "key_bob", Name: "Bob", Secret: "demo-bob-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-backup", Models: []string{"deepseek-v4-flash"}, Billing: fakeBilling("10", "9.764502", "0.235498", "none", time.Time{}, 2, 2, now.Add(-18*24*time.Hour))},
|
||||
{ID: "key_carol", Name: "Carol", Secret: "demo-carol-000000", MaskedSecret: "de******0000", Status: "disabled", RouteMode: "auto", Models: []string{"gpt-5.6-sol"}, Billing: fakeBilling("15", "2.154800", "12.845200", "monthly", monthlyReset, 4, 0, monthlyStart)},
|
||||
}
|
||||
}
|
||||
|
||||
func fakeBilling(quota, spent, balance, period string, next time.Time, concurrency, active int, started time.Time) map[string]any {
|
||||
var nextValue any
|
||||
if !next.IsZero() {
|
||||
nextValue = next
|
||||
}
|
||||
return map[string]any{"quota_usd": quota, "spent_usd": spent, "balance_usd": balance, "reset_period": period, "next_reset_at": nextValue, "max_concurrency": concurrency, "active_requests": active, "cycle_started_at": started}
|
||||
}
|
||||
|
||||
func fakeUsageRecords(now time.Time, keys []fakeKey, upstreams []fakeUpstream, models []string) []fakeUsage {
|
||||
records := make([]fakeUsage, fakeUsageCount)
|
||||
for index := range records {
|
||||
key := keys[index%len(keys)]
|
||||
inputTokens := int64(1200 + (index*977)%48000)
|
||||
outputTokens := int64(180 + (index*113)%6200)
|
||||
reasoningTokens := int64(0)
|
||||
if index%3 == 0 {
|
||||
reasoningTokens = int64(80 + (index*31)%1600)
|
||||
}
|
||||
cacheReadTokens := int64(0)
|
||||
if index%4 == 0 {
|
||||
cacheReadTokens = inputTokens * 62 / 100
|
||||
} else if index%4 == 1 {
|
||||
cacheReadTokens = inputTokens * 18 / 100
|
||||
}
|
||||
outcome, statusCode := "succeeded", httpStatusOK
|
||||
if index%31 == 0 {
|
||||
outcome, statusCode = "failed", 502
|
||||
} else if index%43 == 0 {
|
||||
outcome, statusCode = "canceled", 499
|
||||
} else if index%47 == 0 {
|
||||
outcome, statusCode = "rejected", 429
|
||||
}
|
||||
endpoint, requestType := "/v1/responses", "SSE"
|
||||
if index%17 == 0 {
|
||||
endpoint, requestType = "/v1/responses/compact", "JSON"
|
||||
} else if index%11 == 0 {
|
||||
endpoint = "/v1/chat/completions"
|
||||
}
|
||||
clientIP := ""
|
||||
if index%5 == 0 {
|
||||
clientIP = fmt.Sprintf("10.0.0.%d", index%20+10)
|
||||
}
|
||||
totalTokens := inputTokens + outputTokens
|
||||
records[index] = fakeUsage{
|
||||
RequestID: fmt.Sprintf("req_demo_%05d", index+1), TraceID: fmt.Sprintf("trace_demo_%05d", index+1), RequestedAt: now.Add(-time.Duration(index) * 19 * time.Minute), ManagedKeyID: key.ID, KeyAlias: key.Name, APIKey: key.MaskedSecret,
|
||||
AuthID: upstreams[index%2].CPAAuthID, AuthIndex: fmt.Sprint(index%2 + 1), AuthType: "codex", Model: models[index%len(models)], ReasoningEffort: []string{"high", "medium", "low"}[index%3], ServiceTier: map[bool]string{true: "priority", false: "auto"}[index%8 == 0], Speed: map[bool]string{true: "fast", false: ""}[index%8 == 0],
|
||||
Failed: outcome == "failed", Outcome: outcome, StatusCode: statusCode, RequestType: requestType, Endpoint: endpoint, TTFTMilliseconds: int64(180 + index%720), SpeedTPS: 24 + float64(index%55)*0.7,
|
||||
InputTokens: inputTokens, OutputTokens: outputTokens, ReasoningTokens: reasoningTokens, CacheReadTokens: cacheReadTokens, CacheWriteTokens: map[bool]int64{true: 240, false: 0}[index%13 == 0], CacheRate: float64(cacheReadTokens) / float64(inputTokens) * 100, TotalTokens: totalTokens, CostAvailable: true, CostUSD: float64(inputTokens)*0.00000014 + float64(outputTokens)*0.00000028, ClientIP: clientIP,
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
const httpStatusOK = 200
|
||||
|
||||
func fakePrices() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"model": "deepseek-v4-flash", "base": map[string]any{"input_per_1m": "0.14", "cache_read_per_1m": "0.014", "cache_write_per_1m": "0.14", "output_per_1m": "0.28"}, "fast_pricing_enabled": false, "fast_multiplier": "2.5", "source": map[string]any{"kind": "models.dev", "provider": "deepseek", "model": "deepseek-v4-flash", "catalog_id": "deepseek/deepseek-v4-flash"}},
|
||||
{"model": "gpt-5.6-sol", "base": map[string]any{"input_per_1m": "5", "cache_read_per_1m": "0.5", "cache_write_per_1m": "6.25", "output_per_1m": "30"}, "long_context": map[string]any{"threshold_input_tokens": 272000, "comparison": "gt", "input_per_1m": "10", "cache_read_per_1m": "1", "cache_write_per_1m": "12.5", "output_per_1m": "45"}, "fast_pricing_enabled": true, "fast_multiplier": "2.5", "source": map[string]any{"kind": "manual"}},
|
||||
}
|
||||
}
|
||||
|
||||
func fakeCatalog() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"id": "deepseek/deepseek-v4-flash", "provider": "deepseek", "provider_name": "DeepSeek", "model": "deepseek-v4-flash", "model_name": "DeepSeek V4 Flash", "base": map[string]any{"input_per_1m": "0.14", "cache_read_per_1m": "0.014", "cache_write_per_1m": "0.14", "output_per_1m": "0.28"}},
|
||||
{"id": "openai/gpt-5.6-sol", "provider": "openai", "provider_name": "OpenAI", "model": "gpt-5.6-sol", "model_name": "GPT-5.6 Sol", "base": map[string]any{"input_per_1m": "5", "cache_read_per_1m": "0.5", "cache_write_per_1m": "6.25", "output_per_1m": "30"}, "long_context": map[string]any{"threshold_input_tokens": 272000, "comparison": "gt", "input_per_1m": "10", "cache_read_per_1m": "1", "cache_write_per_1m": "12.5", "output_per_1m": "45"}},
|
||||
}
|
||||
}
|
||||
|
||||
func fakeLedgerRecords(now time.Time, keys []fakeKey, models []string) []fakeLedger {
|
||||
entries := make([]fakeLedger, 0, len(keys)*8)
|
||||
for keyIndex, key := range keys {
|
||||
for index := 0; index < 8; index++ {
|
||||
entry := fakeLedger{ID: int64(keyIndex*10 + index + 1), KeyID: key.ID, Kind: "charge", AmountUSD: fmt.Sprintf("-%.6f", 0.08+float64(index)*0.017), BalanceAfterUSD: fmt.Sprintf("%.6f", toFloat(key.Billing["balance_usd"])+float64(index)*0.097), RequestID: fmt.Sprintf("req_demo_%05d", keyIndex+index*len(keys)+1), Model: models[index%len(models)], OccurredAt: now.Add(-time.Duration(index) * 5 * time.Hour)}
|
||||
if index == 7 {
|
||||
entry.Kind, entry.AmountUSD, entry.RequestID, entry.Model = "quota_change", fmt.Sprint(key.Billing["quota_usd"]), "", ""
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func toFloat(value any) float64 {
|
||||
var result float64
|
||||
_, _ = fmt.Sscan(fmt.Sprint(value), &result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package webdemo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (input *fakeInput) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
input.mu.Lock()
|
||||
defer input.mu.Unlock()
|
||||
|
||||
response.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
path := strings.TrimPrefix(request.URL.Path, managementBase)
|
||||
var payload any
|
||||
var err error
|
||||
switch {
|
||||
case request.Method == http.MethodGet && path == "/usage":
|
||||
payload = input.usagePage(request)
|
||||
case request.Method == http.MethodGet && path == "/usage-summary":
|
||||
payload = input.dashboard()
|
||||
case request.Method == http.MethodGet && path == "/keys":
|
||||
payload = input.listKeys(request.URL.Query().Get("include_archived") == "1")
|
||||
case request.Method == http.MethodPost && path == "/keys":
|
||||
payload, err = input.createKey(request)
|
||||
case request.Method == http.MethodPatch && path == "/keys":
|
||||
payload, err = input.updateKey(request)
|
||||
case request.Method == http.MethodDelete && path == "/keys":
|
||||
payload, err = input.archiveKey(request)
|
||||
case request.Method == http.MethodGet && path == "/key-stats":
|
||||
payload = input.keyStats(request.URL.Query().Get("id"))
|
||||
case request.Method == http.MethodGet && path == "/upstreams":
|
||||
payload = map[string]any{"accounts": input.upstreams}
|
||||
case request.Method == http.MethodGet && path == "/model-suggestions":
|
||||
payload = map[string]any{"models": input.models}
|
||||
case request.Method == http.MethodGet && path == "/billing-ledger":
|
||||
payload = input.billingLedger(request)
|
||||
case request.Method == http.MethodPost && path == "/billing-reset":
|
||||
payload, err = input.resetBilling(request)
|
||||
case request.Method == http.MethodGet && path == "/prices":
|
||||
payload = map[string]any{"prices": input.prices}
|
||||
case request.Method == http.MethodPut && path == "/prices":
|
||||
payload, err = input.putPrice(request)
|
||||
case request.Method == http.MethodDelete && path == "/prices":
|
||||
payload, err = input.deletePrice(request)
|
||||
case request.Method == http.MethodGet && path == "/price-catalog":
|
||||
payload = input.searchCatalog(request.URL.Query().Get("q"))
|
||||
case request.Method == http.MethodPost && path == "/prices/import":
|
||||
payload, err = input.importPrice(request)
|
||||
case request.Method == http.MethodPost && path == "/price-catalog/refresh":
|
||||
payload = map[string]any{"catalog": input.catalogInfo(), "changes": []any{}}
|
||||
case request.Method == http.MethodPost && path == "/price-catalog/apply":
|
||||
payload = map[string]any{"updated": 0}
|
||||
default:
|
||||
writeJSON(response, http.StatusNotFound, map[string]any{"error": map[string]string{"code": "not_found", "message": request.Method + " " + path}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(response, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "fake_input_error", "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
writeJSON(response, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func writeJSON(response http.ResponseWriter, status int, payload any) {
|
||||
response.WriteHeader(status)
|
||||
_ = json.NewEncoder(response).Encode(payload)
|
||||
}
|
||||
|
||||
func decodeBody(request *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.UseNumber()
|
||||
return decoder.Decode(target)
|
||||
}
|
||||
|
||||
func (input *fakeInput) listKeys(includeArchived bool) map[string]any {
|
||||
keys := make([]fakeKey, 0, len(input.keys))
|
||||
for _, key := range input.keys {
|
||||
if includeArchived || key.Status != "archived" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return map[string]any{"keys": keys}
|
||||
}
|
||||
|
||||
func (input *fakeInput) createKey(request *http.Request) (fakeKey, error) {
|
||||
var value struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
RouteMode string `json:"route_mode"`
|
||||
UpstreamAccountID string `json:"upstream_account_id"`
|
||||
AllModels bool `json:"all_models"`
|
||||
Models []string `json:"models"`
|
||||
Billing map[string]any `json:"billing"`
|
||||
}
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return fakeKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(value.Name) == "" {
|
||||
return fakeKey{}, fmt.Errorf("name is required")
|
||||
}
|
||||
if value.Secret == "" {
|
||||
value.Secret = fmt.Sprintf("demo-generated-%06d", len(input.keys)+1)
|
||||
}
|
||||
key := fakeKey{ID: fmt.Sprintf("key_demo_%d", len(input.keys)+1), Name: value.Name, Secret: value.Secret, MaskedSecret: maskFakeSecret(value.Secret), Status: "active", RouteMode: value.RouteMode, UpstreamAccountID: value.UpstreamAccountID, AllModels: value.AllModels, Models: value.Models, Billing: normalizeFakeBilling(value.Billing, nil)}
|
||||
input.keys = append(input.keys, key)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (input *fakeInput) updateKey(request *http.Request) (fakeKey, error) {
|
||||
var value map[string]any
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return fakeKey{}, err
|
||||
}
|
||||
index := input.keyIndex(fmt.Sprint(value["id"]))
|
||||
if index < 0 {
|
||||
return fakeKey{}, fmt.Errorf("key not found")
|
||||
}
|
||||
key := input.keys[index]
|
||||
if name := strings.TrimSpace(fmt.Sprint(value["name"])); name != "" {
|
||||
key.Name = name
|
||||
}
|
||||
if status := strings.TrimSpace(fmt.Sprint(value["status"])); status != "" {
|
||||
key.Status = status
|
||||
}
|
||||
if routeMode := strings.TrimSpace(fmt.Sprint(value["route_mode"])); routeMode != "" {
|
||||
key.RouteMode = routeMode
|
||||
}
|
||||
key.UpstreamAccountID = stringValue(value["upstream_account_id"])
|
||||
if allModels, ok := value["all_models"].(bool); ok {
|
||||
key.AllModels = allModels
|
||||
}
|
||||
if models, ok := value["models"].([]any); ok {
|
||||
key.Models = make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
key.Models = append(key.Models, fmt.Sprint(model))
|
||||
}
|
||||
}
|
||||
if billing, ok := value["billing"].(map[string]any); ok {
|
||||
key.Billing = normalizeFakeBilling(billing, key.Billing)
|
||||
}
|
||||
input.keys[index] = key
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (input *fakeInput) archiveKey(request *http.Request) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := input.keyIndex(fmt.Sprint(value["id"]))
|
||||
if index < 0 {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
}
|
||||
input.keys[index].Status = "archived"
|
||||
return map[string]any{"archived": true}, nil
|
||||
}
|
||||
|
||||
func (input *fakeInput) keyIndex(id string) int {
|
||||
for index := range input.keys {
|
||||
if input.keys[index].ID == id {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func maskFakeSecret(value string) string {
|
||||
if len(value) < 6 {
|
||||
return "******"
|
||||
}
|
||||
return value[:2] + "******" + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func normalizeFakeBilling(value, previous map[string]any) map[string]any {
|
||||
result := map[string]any{"quota_usd": "0", "spent_usd": "0", "balance_usd": "0", "reset_period": "none", "next_reset_at": nil, "max_concurrency": 4, "active_requests": 0, "cycle_started_at": time.Now()}
|
||||
for key, item := range previous {
|
||||
result[key] = item
|
||||
}
|
||||
for key, item := range value {
|
||||
result[key] = item
|
||||
}
|
||||
quota := toFloat(result["quota_usd"])
|
||||
spent := toFloat(result["spent_usd"])
|
||||
result["quota_usd"] = fmt.Sprint(result["quota_usd"])
|
||||
result["balance_usd"] = fmt.Sprintf("%.6f", quota-spent)
|
||||
return result
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func (input *fakeInput) usagePage(request *http.Request) map[string]any {
|
||||
query := request.URL.Query()
|
||||
records := make([]fakeUsage, 0, len(input.usage))
|
||||
for _, record := range input.usage {
|
||||
if query.Get("key_id") != "" && query.Get("key_id") != record.ManagedKeyID || query.Get("model") != "" && !strings.Contains(strings.ToLower(record.Model), strings.ToLower(query.Get("model"))) || query.Get("result") != "" && query.Get("result") != usageResult(record) || query.Get("auth_id") != "" && query.Get("auth_id") != record.AuthID || query.Get("endpoint") != "" && query.Get("endpoint") != endpointName(record.Endpoint) || query.Get("request_id") != "" && query.Get("request_id") != record.RequestID {
|
||||
continue
|
||||
}
|
||||
if from := parseTime(query.Get("from")); !from.IsZero() && record.RequestedAt.Before(from) {
|
||||
continue
|
||||
}
|
||||
if to := parseTime(query.Get("to")); !to.IsZero() && !record.RequestedAt.Before(to) {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
pageSize := boundedInt(query.Get("page_size"), 100, 1, 100)
|
||||
totalPages := int(math.Ceil(float64(len(records)) / float64(pageSize)))
|
||||
page := boundedInt(query.Get("page"), 1, 1, max(1, totalPages))
|
||||
start := min((page-1)*pageSize, len(records))
|
||||
end := min(start+pageSize, len(records))
|
||||
previous, next := "", ""
|
||||
if page > 1 {
|
||||
previous = "fake-previous"
|
||||
}
|
||||
if page < totalPages {
|
||||
next = "fake-next"
|
||||
}
|
||||
return map[string]any{"records": records[start:end], "pagination": map[string]any{"page": page, "page_size": pageSize, "total": len(records), "total_pages": totalPages, "previous_cursor": previous, "next_cursor": next}}
|
||||
}
|
||||
|
||||
func usageResult(record fakeUsage) string {
|
||||
if record.Outcome == "canceled" || record.Outcome == "rejected" || record.Outcome == "failed" {
|
||||
return record.Outcome
|
||||
}
|
||||
return "succeeded"
|
||||
}
|
||||
|
||||
func endpointName(value string) string {
|
||||
if strings.HasSuffix(value, "/responses/compact") {
|
||||
return "compact"
|
||||
}
|
||||
if strings.HasSuffix(value, "/chat/completions") {
|
||||
return "chat"
|
||||
}
|
||||
return "responses"
|
||||
}
|
||||
|
||||
func parseTime(value string) time.Time {
|
||||
parsed, _ := time.Parse(time.RFC3339, value)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func boundedInt(value string, fallback, minimum, maximum int) int {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return min(max(parsed, minimum), maximum)
|
||||
}
|
||||
|
||||
func summarize(records []fakeUsage) map[string]any {
|
||||
var inputTokens, outputTokens, totalTokens int64
|
||||
var cost float64
|
||||
for _, record := range records {
|
||||
inputTokens += record.InputTokens
|
||||
outputTokens += record.OutputTokens
|
||||
totalTokens += record.TotalTokens
|
||||
if record.CostAvailable {
|
||||
cost += record.CostUSD
|
||||
}
|
||||
}
|
||||
return map[string]any{"requests": len(records), "input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": totalTokens, "cost_usd": cost, "cost_micros": int64(math.Round(cost * 1_000_000))}
|
||||
}
|
||||
|
||||
func (input *fakeInput) dashboard() map[string]any {
|
||||
today := startOfDay(input.now)
|
||||
todayRecords := filterUsage(input.usage, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) })
|
||||
users := make([]map[string]any, 0, len(input.keys))
|
||||
for _, key := range input.keys {
|
||||
records := filterUsage(input.usage, func(record fakeUsage) bool { return record.ManagedKeyID == key.ID })
|
||||
current := filterUsage(records, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) })
|
||||
var last any
|
||||
if len(records) > 0 {
|
||||
last = records[0].RequestedAt
|
||||
}
|
||||
users = append(users, map[string]any{"key_id": key.ID, "key_alias": key.Name, "today": summarize(current), "last_used_at": last})
|
||||
}
|
||||
days := make([]map[string]any, 0, 7)
|
||||
for offset := 6; offset >= 0; offset-- {
|
||||
start := today.AddDate(0, 0, -offset)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
records := filterUsage(input.usage, func(record fakeUsage) bool {
|
||||
return !record.RequestedAt.Before(start) && record.RequestedAt.Before(end)
|
||||
})
|
||||
days = append(days, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": summarize(records)["total_tokens"]})
|
||||
}
|
||||
return map[string]any{"today": summarize(todayRecords), "users": users, "days": days}
|
||||
}
|
||||
|
||||
func startOfDay(value time.Time) time.Time {
|
||||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location())
|
||||
}
|
||||
|
||||
func filterUsage(records []fakeUsage, keep func(fakeUsage) bool) []fakeUsage {
|
||||
result := make([]fakeUsage, 0, len(records))
|
||||
for _, record := range records {
|
||||
if keep(record) {
|
||||
result = append(result, record)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (input *fakeInput) keyStats(id string) map[string]any {
|
||||
records := filterUsage(input.usage, func(record fakeUsage) bool { return record.ManagedKeyID == id })
|
||||
today := startOfDay(input.now)
|
||||
current := filterUsage(records, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) })
|
||||
return map[string]any{"stats": map[string]any{"total": summarize(records), "today": summarize(current)}, "recent": records[:min(10, len(records))]}
|
||||
}
|
||||
|
||||
func (input *fakeInput) billingLedger(request *http.Request) map[string]any {
|
||||
id := request.URL.Query().Get("id")
|
||||
limit := boundedInt(request.URL.Query().Get("limit"), 50, 1, 100)
|
||||
entries := make([]fakeLedger, 0, limit)
|
||||
for _, entry := range input.ledger {
|
||||
if entry.KeyID == id && len(entries) < limit {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return map[string]any{"entries": entries}
|
||||
}
|
||||
|
||||
func (input *fakeInput) resetBilling(request *http.Request) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := input.keyIndex(fmt.Sprint(value["id"]))
|
||||
if index < 0 {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
}
|
||||
input.keys[index].Billing["spent_usd"] = "0"
|
||||
input.keys[index].Billing["balance_usd"] = fmt.Sprint(input.keys[index].Billing["quota_usd"])
|
||||
input.keys[index].Billing["cycle_started_at"] = time.Now()
|
||||
return input.keys[index].Billing, nil
|
||||
}
|
||||
|
||||
func (input *fakeInput) putPrice(request *http.Request) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model := strings.TrimSpace(fmt.Sprint(value["model"]))
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
value["source"] = map[string]any{"kind": "manual"}
|
||||
input.storePrice(model, value)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (input *fakeInput) deletePrice(request *http.Request) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model := fmt.Sprint(value["model"])
|
||||
filtered := input.prices[:0]
|
||||
for _, price := range input.prices {
|
||||
if fmt.Sprint(price["model"]) != model {
|
||||
filtered = append(filtered, price)
|
||||
}
|
||||
}
|
||||
input.prices = filtered
|
||||
return map[string]any{"deleted": model}, nil
|
||||
}
|
||||
|
||||
func (input *fakeInput) storePrice(model string, value map[string]any) {
|
||||
for index := range input.prices {
|
||||
if fmt.Sprint(input.prices[index]["model"]) == model {
|
||||
input.prices[index] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
input.prices = append(input.prices, value)
|
||||
sort.Slice(input.prices, func(left, right int) bool {
|
||||
return fmt.Sprint(input.prices[left]["model"]) < fmt.Sprint(input.prices[right]["model"])
|
||||
})
|
||||
}
|
||||
|
||||
func (input *fakeInput) catalogInfo() map[string]any {
|
||||
return map[string]any{"revision": "fake-2026-08-19", "models": len(input.catalog), "fetched_at": input.now}
|
||||
}
|
||||
|
||||
func (input *fakeInput) searchCatalog(query string) map[string]any {
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
models := make([]map[string]any, 0, len(input.catalog))
|
||||
for _, entry := range input.catalog {
|
||||
values := []any{entry["id"], entry["provider"], entry["provider_name"], entry["model"], entry["model_name"]}
|
||||
for _, value := range values {
|
||||
if normalized == "" || strings.Contains(strings.ToLower(fmt.Sprint(value)), normalized) {
|
||||
models = append(models, entry)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{"loaded": true, "catalog": input.catalogInfo(), "models": models}
|
||||
}
|
||||
|
||||
func (input *fakeInput) importPrice(request *http.Request) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := decodeBody(request, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model, catalogID := fmt.Sprint(value["model"]), fmt.Sprint(value["catalog_id"])
|
||||
for _, entry := range input.catalog {
|
||||
if fmt.Sprint(entry["id"]) != catalogID {
|
||||
continue
|
||||
}
|
||||
price := map[string]any{"model": model, "base": entry["base"], "fast_pricing_enabled": false, "fast_multiplier": "2.5", "source": map[string]any{"kind": "models.dev", "provider": entry["provider"], "model": entry["model"], "catalog_id": catalogID, "revision": "fake-2026-08-19"}}
|
||||
if longContext, ok := entry["long_context"]; ok {
|
||||
price["long_context"] = longContext
|
||||
}
|
||||
input.storePrice(model, price)
|
||||
return price, nil
|
||||
}
|
||||
return nil, fmt.Errorf("catalog entry not found")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package webdemo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"billing/internal/web"
|
||||
)
|
||||
|
||||
const managementBase = "/v0/management/plugins/billing"
|
||||
|
||||
type Input interface {
|
||||
ServeHTTP(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
func NewServer(input Input) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(response, request, "/ui", http.StatusTemporaryRedirect)
|
||||
})
|
||||
mux.HandleFunc("GET /ui", asset("text/html; charset=utf-8", web.UI()))
|
||||
mux.HandleFunc("GET /ui-config.js", asset("text/javascript; charset=utf-8", []byte(`window.BILLING_UI_CONFIG = Object.freeze({managementBase:"/v0/management/plugins/billing",managementKey:"demo",lockManagementKey:true,hideManagementKey:true});`)))
|
||||
mux.HandleFunc("GET /styles/{name}", embeddedAsset("styles/"))
|
||||
mux.HandleFunc("GET /app/{path...}", embeddedAsset("app/"))
|
||||
mux.Handle(managementBase+"/", input)
|
||||
return mux
|
||||
}
|
||||
|
||||
func embeddedAsset(prefix string) http.HandlerFunc {
|
||||
return func(response http.ResponseWriter, request *http.Request) {
|
||||
name := request.PathValue("name")
|
||||
if path := request.PathValue("path"); path != "" {
|
||||
name = path
|
||||
}
|
||||
body, contentType, found := web.Asset(prefix + name)
|
||||
if !found {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
asset(contentType, body)(response, request)
|
||||
}
|
||||
}
|
||||
|
||||
func asset(contentType string, body []byte) http.HandlerFunc {
|
||||
return func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.Header().Set("Content-Type", contentType)
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
response.WriteHeader(http.StatusOK)
|
||||
_, _ = response.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package webdemo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerServesSplitUIAndFakeInput(t *testing.T) {
|
||||
server := httptest.NewServer(NewServer(NewFakeInput()))
|
||||
defer server.Close()
|
||||
|
||||
for _, path := range []string{"/ui", "/ui-config.js", "/styles/base.css", "/styles/keys.css", "/styles/usage.css", "/styles/pricing.css", "/app/main.js", "/app/core/runtime.js", "/app/features/keys.js", "/app/features/usage.js", "/app/features/pricing.js"} {
|
||||
response, err := http.Get(server.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
response.Body.Close()
|
||||
t.Fatalf("GET %s status = %d", path, response.StatusCode)
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
|
||||
response, err := http.Get(server.URL + managementBase + "/usage?page=2&page_size=100&model=deepseek")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var usage struct {
|
||||
Records []fakeUsage `json:"records"`
|
||||
Page map[string]any `json:"pagination"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&usage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || len(usage.Records) == 0 || usage.Page["page"] != float64(2) {
|
||||
t.Fatalf("unexpected fake usage response: status=%d records=%d page=%v", response.StatusCode, len(usage.Records), usage.Page)
|
||||
}
|
||||
for _, record := range usage.Records {
|
||||
if !strings.Contains(record.Model, "deepseek") {
|
||||
t.Fatalf("unexpected filtered model %q", record.Model)
|
||||
}
|
||||
}
|
||||
|
||||
response, err = http.Get(server.URL + managementBase + "/usage?page=1&page_size=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var allUsage struct {
|
||||
Page struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&allUsage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allUsage.Page.Total != fakeUsageCount {
|
||||
t.Fatalf("fake usage total = %d, want %d", allUsage.Page.Total, fakeUsageCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeInputSupportsKeyMutation(t *testing.T) {
|
||||
server := httptest.NewServer(NewServer(NewFakeInput()))
|
||||
defer server.Close()
|
||||
|
||||
body := `{"name":"Demo User","route_mode":"auto","all_models":true,"billing":{"quota_usd":"25","reset_period":"none","max_concurrency":4}}`
|
||||
request, err := http.NewRequest(http.MethodPost, server.URL+managementBase+"/keys", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var key fakeKey
|
||||
if err := json.NewDecoder(response.Body).Decode(&key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || key.Name != "Demo User" || key.Billing["balance_usd"] != "25.000000" {
|
||||
t.Fatalf("unexpected created key: status=%d key=%+v", response.StatusCode, key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user