feat(webdemo): add database-backed usage dashboard

This commit is contained in:
2026-08-19 18:24:49 +08:00
parent f0f8abbbb6
commit faabe563e1
20 changed files with 382 additions and 91 deletions
+14 -3
View File
@@ -12,15 +12,26 @@ import (
func main() {
listen := flag.String("listen", "127.0.0.1:8320", "HTTP listen address")
input := flag.String("input", "", "data input; supported value: fake")
inputName := flag.String("input", "", "demo input; supported value: fake")
databasePath := flag.String("database", "", "optional SQLite data source for the fake demo")
flag.Parse()
if *input != "fake" {
if *inputName != "fake" {
fmt.Fprintln(os.Stderr, "missing supported -input value: fake")
os.Exit(2)
}
server := &http.Server{Addr: *listen, Handler: webdemo.NewServer(webdemo.NewFakeInput())}
var input webdemo.Input = webdemo.NewFakeInput()
if *databasePath != "" {
databaseInput, err := webdemo.NewDatabaseInput(*databasePath)
if err != nil {
log.Fatal(err)
}
input = databaseInput
defer databaseInput.Close()
}
server := &http.Server{Addr: *listen, Handler: webdemo.NewServer(input)}
log.Printf("billing web demo: http://%s/ui", *listen)
log.Fatal(server.ListenAndServe())
}
+1 -1
View File
@@ -10,7 +10,7 @@ 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 数据库。
管理台界面开发使用 `go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320`。该命令用独立 Fake 输入端启动正式页面资源,不依赖本地 CPA 和 billing 数据库。需要查看真实数据分布时,增加 `-database <SQLite 副本路径>` 显式挂载本地副本;省略该参数时继续使用生成数据。
开发按依赖从少到多推进:先确定数据和规则,再实现存储与服务,最后接入外部协议和界面。核心逻辑不依赖框架类型,协议转换集中在边界层。涉及持久化时,先定义迁移和历史数据语义
+9 -1
View File
@@ -110,7 +110,15 @@ Demo 使用独立 Fake 输入端。页面资源与正式插件相同,Fake 数
go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320
```
启动后访问 `http://127.0.0.1:8320/ui`。Fake 输入端提供用户、额度、请求分页、筛选、账目、价格和目录接口。进程重启后恢复初始数据。
启动后访问 `http://127.0.0.1:8320/ui`。Fake 输入端提供用户、额度、请求分页、筛选、账目、价格和目录接口。页面右上角提供“管理员示教”和“普通用户视角”,后者使用页面已有的只读权限状态。进程重启后恢复初始数据。
也可以显式挂载独立的 billing SQLite 副本:
```powershell
go run ./cmd/webdemo -input fake -database .runtime/demo/billing.db -listen 127.0.0.1:8320
```
指定 `-database` 时直接使用副本中的用户、用量、账目和价格;省略时继续使用原来的生成数据。界面修改会写入该副本;保留压缩快照可用于恢复初始数据。
## 管理接口
+2
View File
@@ -53,10 +53,12 @@ type UserUsageSummary struct {
KeyID string
KeyAlias string
Today UsageSummary
Days []DailyUsageSummary
LastUsedAt *time.Time
}
type DailyUsageSummary struct {
Date time.Time
TotalTokens int64
CostMicros int64
}
+6 -2
View File
@@ -322,11 +322,15 @@ func (a *App) usageDashboardResponse() ManagementResponse {
}
users := make([]map[string]any, 0, len(dashboard.Users))
for _, user := range dashboard.Users {
users = append(users, map[string]any{"key_id": user.KeyID, "key_alias": user.KeyAlias, "today": usageSummaryItem(user.Today), "last_used_at": user.LastUsedAt})
userDays := make([]map[string]any, 0, len(user.Days))
for _, day := range user.Days {
userDays = append(userDays, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens, "cost_usd": float64(day.CostMicros) / 1_000_000})
}
users = append(users, map[string]any{"key_id": user.KeyID, "key_alias": user.KeyAlias, "today": usageSummaryItem(user.Today), "days": userDays, "last_used_at": user.LastUsedAt})
}
days := make([]map[string]any, 0, len(dashboard.Days))
for _, day := range dashboard.Days {
days = append(days, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens})
days = append(days, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens, "cost_usd": float64(day.CostMicros) / 1_000_000})
}
return jsonManagementResponse(http.StatusOK, map[string]any{"today": usageSummaryItem(dashboard.Today), "users": users, "days": days})
}
+8 -3
View File
@@ -562,14 +562,19 @@ func TestUsageManagementResponseContainsDisplayFields(t *testing.T) {
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, "请求明细") {
if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, `data-view="logs">日志`) {
t.Fatalf("unexpected UI response: status=%d", pageResponse.StatusCode)
}
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"`} {
for _, feature := range []string{`<body class="read-only">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `id="quota-chart"`, `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 HTML feature %q", feature)
}
}
for _, removed := range []string{`<h2>Day</h2>`, `<h2>Week</h2>`} {
if strings.Contains(page, removed) {
t.Fatalf("UI still contains removed card title %q", removed)
}
}
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
t.Fatal("UI pagination controls are not above the usage table")
}
@@ -589,7 +594,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
}
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} 个已关联价格发生变化`} {
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)
}
+9 -2
View File
@@ -141,14 +141,21 @@ func TestSQLiteUsageDashboardUsesAllIndexedHistory(t *testing.T) {
defer store.Close()
location, _ := time.LoadLocation("Asia/Shanghai")
today := time.Date(2026, 8, 15, 12, 0, 0, 0, location)
if err := store.Insert(context.Background(), collection.Record{ManagedKeyID: "key-a", RequestedAt: today.Add(-time.Hour), Model: "model", InputTokens: 10, OutputTokens: 2, TotalTokens: 12, CostMicros: nil}); err != nil {
cost := int64(125_000)
if err := store.Insert(context.Background(), collection.Record{RequestedAt: today.Add(-time.Hour), Model: "model", InputTokens: 10, OutputTokens: 2, TotalTokens: 12, CostMicros: &cost}); err != nil {
t.Fatal(err)
}
dashboard, err := store.UsageDashboard(context.Background(), today, 7)
if err != nil {
t.Fatal(err)
}
if dashboard.Today.Requests != 1 || dashboard.Today.TotalTokens != 12 || len(dashboard.Users) != 1 || len(dashboard.Days) != 7 {
if dashboard.Today.Requests != 1 || dashboard.Today.TotalTokens != 12 || len(dashboard.Users) != 1 || len(dashboard.Days) != 7 || len(dashboard.Users[0].Days) != 7 {
t.Fatalf("unexpected dashboard: %+v", dashboard)
}
if dashboard.Days[6].CostMicros != cost {
t.Fatalf("today cost = %d, want %d", dashboard.Days[6].CostMicros, cost)
}
if dashboard.Users[0].Days[6].CostMicros != cost {
t.Fatalf("user today cost = %d, want %d", dashboard.Users[0].Days[6].CostMicros, cost)
}
}
+40 -4
View File
@@ -73,26 +73,62 @@ WHERE d.requested_at>=?`, formatTime(start)).Scan(&dashboard.Today.Requests, &da
dailyStart := start.AddDate(0, 0, -(days - 1))
rows, err = r.readDB.QueryContext(ctx, `
SELECT DATE(d.requested_at, '+8 hours'), COALESCE(SUM(u.total_tokens),0)
SELECT DATE(d.requested_at, '+8 hours'), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost_micros),0)
FROM request_detail_index d LEFT JOIN usage_records u ON u.id=d.usage_id
WHERE d.requested_at>=? GROUP BY DATE(d.requested_at, '+8 hours') ORDER BY 1`, formatTime(dailyStart))
if err != nil {
return dashboard, fmt.Errorf("汇总每日用量: %w", err)
}
byDate := make(map[string]int64)
costByDate := make(map[string]int64)
for rows.Next() {
var date string
var tokens int64
if err := rows.Scan(&date, &tokens); err != nil {
var tokens, costMicros int64
if err := rows.Scan(&date, &tokens, &costMicros); err != nil {
_ = rows.Close()
return dashboard, fmt.Errorf("读取每日用量: %w", err)
}
byDate[date] = tokens
costByDate[date] = costMicros
}
_ = rows.Close()
for index := 0; index < days; index++ {
date := dailyStart.AddDate(0, 0, index)
dashboard.Days = append(dashboard.Days, collection.DailyUsageSummary{Date: date, TotalTokens: byDate[date.Format("2006-01-02")]})
key := date.Format("2006-01-02")
dashboard.Days = append(dashboard.Days, collection.DailyUsageSummary{Date: date, TotalTokens: byDate[key], CostMicros: costByDate[key]})
}
rows, err = r.readDB.QueryContext(ctx, `
SELECT d.managed_key_id, DATE(d.requested_at, '+8 hours'),
COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost_micros),0)
FROM request_detail_index d LEFT JOIN usage_records u ON u.id=d.usage_id
WHERE d.requested_at>=?
GROUP BY d.managed_key_id, DATE(d.requested_at, '+8 hours')`, formatTime(dailyStart))
if err != nil {
return dashboard, fmt.Errorf("汇总用户每日用量: %w", err)
}
userDays := make(map[string]map[string]collection.DailyUsageSummary)
for rows.Next() {
var keyID, date string
var tokens, costMicros int64
if err := rows.Scan(&keyID, &date, &tokens, &costMicros); err != nil {
_ = rows.Close()
return dashboard, fmt.Errorf("读取用户每日用量: %w", err)
}
if userDays[keyID] == nil {
userDays[keyID] = make(map[string]collection.DailyUsageSummary)
}
userDays[keyID][date] = collection.DailyUsageSummary{TotalTokens: tokens, CostMicros: costMicros}
}
_ = rows.Close()
for index := range dashboard.Users {
for dayIndex := 0; dayIndex < days; dayIndex++ {
date := dailyStart.AddDate(0, 0, dayIndex)
key := date.Format("2006-01-02")
item := userDays[dashboard.Users[index].KeyID][key]
item.Date = date
dashboard.Users[index].Days = append(dashboard.Users[index].Days, item)
}
}
return dashboard, nil
}
+34
View File
@@ -23,8 +23,11 @@ export const keyInput = document.querySelector("#key");
const readOnlyBase = config.readOnlyBase || location.pathname;
const accessListeners = new Set();
let managementAuthorized = false;
let managementAuthenticated = false;
let demoPerspective = "admin";
export function initializeRuntime(reload) {
document.body.classList.toggle("demo-mode", Boolean(config.demoPerspectives));
keyInput.value = config.managementKey || storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
keyInput.disabled = Boolean(config.lockManagementKey);
document.querySelector(".auth-box").classList.toggle("hidden", Boolean(config.hideManagementKey));
@@ -40,6 +43,7 @@ export function initializeRuntime(reload) {
keyInput.blur();
}
});
initializeDemoPerspective(reload);
setAccessState(false);
}
@@ -54,6 +58,36 @@ export function isManagementAuthorized() {
}
export function setAccessState(authorized) {
managementAuthenticated = authorized;
applyAccessState();
}
function initializeDemoPerspective(reload) {
const switcher = document.querySelector("#demo-perspective");
if (!config.demoPerspectives) return;
switcher.classList.remove("hidden");
switcher.querySelectorAll("[data-perspective]").forEach(button => {
button.addEventListener("click", () => {
if (button.dataset.perspective === demoPerspective) return;
demoPerspective = button.dataset.perspective;
syncDemoPerspective(switcher);
applyAccessState();
reload();
});
});
syncDemoPerspective(switcher);
}
function syncDemoPerspective(switcher) {
switcher.querySelectorAll("[data-perspective]").forEach(button => {
const selected = button.dataset.perspective === demoPerspective;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
}
function applyAccessState() {
const authorized = managementAuthenticated && (!config.demoPerspectives || demoPerspective === "admin");
if (managementAuthorized === authorized) return;
managementAuthorized = authorized;
document.body.classList.toggle("read-only", !authorized);
-4
View File
@@ -3,10 +3,6 @@ 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);
+53 -29
View File
@@ -1,5 +1,5 @@
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
import { compactNumber, localDateTimeValue, money, number, option, result } from "../core/shared.js";
import { localDateTimeValue, money, number, option, result } from "../core/shared.js";
const keyRowsNode = document.querySelector("#key-rows");
const keyStatusNode = document.querySelector("#key-status");
@@ -191,29 +191,30 @@ function renderManagedKeys() {
});
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 }]));
const byUser = new Map(managedKeys.map(key => [key.name, {
name: key.name, requests: 0, tokens: 0, cost: 0,
balance: key.billing ? Number(key.billing.balance_usd || 0) : null,
days: [], 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 });
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, balance: null, days: [], 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.days = user.days || [];
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 => {
["用户", "今日请求", "今日 Token", "今日成本", "剩余额度", "最近使用"].forEach(label => {
const cell = document.createElement("span");
cell.textContent = label;
header.append(cell);
@@ -221,33 +222,56 @@ function renderUserCharts() {
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 => {
[item.name, number(item.requests), number(item.tokens), money(item.cost), item.balance === null ? "" : money(item.balance), 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;
}));
const days = [...(usageDashboard.days || [])].reverse().map(item => ({ date: new Date(item.date + "T00:00:00"), cost: Number(item.cost_usd || 0) }));
renderQuotaMatrix(days, [...byUser.values()]);
}
function renderQuotaMatrix(days, users) {
const dates = days.map(day => dateKey(day.date));
const rows = users.map(user => {
const costs = new Map(user.days.map(day => [day.date, Number(day.cost_usd || 0)]));
const values = dates.map(date => costs.get(date) || 0);
return { name: user.name, values, total: values.reduce((sum, value) => sum + value, 0) };
}).sort((left, right) => right.total - left.total || left.name.localeCompare(right.name));
const maximum = Math.max(0.000001, ...rows.flatMap(row => row.values));
const matrix = document.createElement("div");
matrix.className = "quota-matrix";
matrix.style.gridTemplateColumns = `minmax(100px, 1.25fr) repeat(${days.length}, minmax(74px, 1fr)) minmax(82px, 1fr)`;
matrix.append(quotaCell("用户", "header user"), ...days.map(day => quotaCell(`${day.date.getMonth() + 1}/${day.date.getDate()}`, "header")), quotaCell("7 日合计", "header total"));
rows.forEach(row => {
matrix.append(quotaCell(row.name, "user"));
row.values.forEach((value, index) => {
const cell = quotaCell(value ? money(value) : "—", value ? "usage" : "");
cell.style.setProperty("--quota-intensity", `${12 + value / maximum * 58}%`);
cell.title = `${row.name} · ${dates[index]} · ${money(value)}`;
matrix.append(cell);
});
matrix.append(quotaCell(money(row.total), "total"));
});
const weekTotal = days.reduce((sum, day) => sum + day.cost, 0);
matrix.append(quotaCell("每日合计", "header user"), ...days.map(day => quotaCell(money(day.cost), "total")), quotaCell(money(weekTotal), "total"));
document.querySelector("#quota-chart").replaceChildren(matrix);
}
function quotaCell(text, classes = "") {
const cell = document.createElement("div");
cell.className = "quota-cell" + (classes ? " " + classes : "");
cell.textContent = text;
return cell;
}
function dateKey(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
async function createManagedKey() {
+1 -1
View File
@@ -3,7 +3,7 @@ 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 });
const loaders = Object.freeze({ keys: loadKeys, usage: activateUsage, pricing: loadPricing, logs: () => {} });
initializeKeys();
initializeUsage();
+9 -16
View File
@@ -1,24 +1,17 @@
.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-grid { display: grid; grid-template-columns: minmax(0, 1fr); 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; }
.quota-matrix-wrap { overflow-x: auto; }
.quota-matrix { display: grid; min-width: 720px; border-top: 1px solid var(--border-soft); border-left: 1px solid var(--border-soft); font-size: 9px; }
.quota-cell { display: grid; min-width: 0; min-height: 32px; padding: 7px 6px; place-items: center; overflow: hidden; border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); color: var(--text-secondary); text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.quota-cell.header { min-height: 27px; background: var(--bg-primary); color: var(--muted); }
.quota-cell.user { text-align: center; }
.quota-cell.total { background: var(--bg-tertiary); color: var(--text); font-weight: 600; }
.quota-cell.usage { background: color-mix(in srgb, var(--primary-active) var(--quota-intensity), var(--bg-primary)); color: var(--text); }
.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 { display: grid; grid-template-columns: minmax(120px, 1.25fr) repeat(4, minmax(100px, 1fr)) minmax(150px, 1.4fr); align-items: center; gap: 10px; min-width: 790px; 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; }
+8 -6
View File
@@ -1,13 +1,15 @@
.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; }
.workspace-bar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; min-height: 50px; padding-right: 222px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
body.demo-mode .workspace-bar { padding-right: 22px; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.demo-perspective { display: flex; align-items: center; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary); }
.demo-perspective button { border: 0; border-radius: 5px; padding: 6px 10px; background: transparent; color: var(--muted); font-size: 11px; }
.demo-perspective button:hover { border: 0; background: var(--bg-hover); color: var(--text); }
.demo-perspective button.active { background: var(--bg-tertiary); color: var(--text); box-shadow: inset 0 0 0 1px var(--border-primary); }
.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; }
.workspace-nav { display: flex; flex: 1 1 auto; align-items: stretch; gap: 3px; min-width: 0; height: 50px; padding: 0 22px; 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); }
+2 -2
View File
@@ -1,3 +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; } }
@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)); } .chart-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .workspace-bar, body.demo-mode .workspace-bar { align-items: stretch; flex-wrap: wrap; padding: 0 12px; } .workspace-nav { order: 2; width: 100%; padding: 0; } .topbar-actions { order: 1; width: 100%; justify-content: flex-end; padding-top: 8px; } .demo-perspective button { padding: 6px 8px; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } 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; } .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; } }
+22 -14
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Billing 管理台</title>
<title>CPA</title>
<link rel="stylesheet" href="./styles/base.css">
<link rel="stylesheet" href="./styles/layout.css">
<link rel="stylesheet" href="./styles/keys.css">
@@ -13,23 +13,27 @@
</head>
<body class="read-only">
<div class="app-shell">
<header class="topbar">
<div class="brand"><div class="brand-mark">B</div><div><strong>Billing</strong><span> · 管理控制台</span></div></div>
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
<header class="workspace-bar">
<nav class="workspace-nav" aria-label="工作区">
<button class="nav-button active" type="button" data-view="keys">用户</button>
<button class="nav-button" type="button" data-view="usage">请求</button>
<button class="nav-button" type="button" data-view="pricing">模型</button>
<button class="nav-button" type="button" data-view="logs">日志</button>
</nav>
<div class="topbar-actions">
<div id="demo-perspective" class="demo-perspective hidden" aria-label="演示视角">
<button type="button" data-perspective="admin">管理员示教</button>
<button type="button" data-perspective="user">普通用户视角</button>
</div>
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
</div>
</header>
<nav class="workspace-nav" aria-label="工作区">
<button class="nav-button active" type="button" data-view="keys">用户 Key</button>
<button class="nav-button" type="button" data-view="usage">请求明细</button>
<button class="nav-button" type="button" data-view="pricing">价格配置</button>
</nav>
<main>
<section id="view-keys" class="view active">
<div class="summary-grid">
<article class="summary-card"><span>今日请求</span><strong id="metric-requests"></strong><small>自然日内实际请求</small></article>
<article class="summary-card"><span>今日 Token</span><strong id="metric-tokens"></strong><small>输入与输出合计</small></article>
<article class="summary-card"><span>今日成本</span><strong id="metric-cost"></strong><small>按已配置价格计算</small></article>
<div class="chart-grid">
<article class="chart-card"><div id="user-usage" class="user-usage"></div></article>
<article class="chart-card"><div id="quota-chart" class="quota-matrix-wrap"></div></article>
</div>
<div class="chart-grid"><article class="chart-card"><header><div><h2>今日用户用量</h2></div></header><div id="user-usage" class="user-usage"></div></article><article class="chart-card"><header><div><h2>近 7 日 Token</h2></div></header><div id="daily-chart" class="bar-chart"></div></article></div>
<div id="key-management" class="surface admin-only">
<div class="surface-toolbar"><span id="key-status" class="status"></span><button id="create-key" class="primary" type="button">创建 Key</button></div>
<div class="table-wrap"><table class="key-table"><thead><tr><th class="left">用户</th><th class="left">Key</th><th class="left">状态</th><th class="left">余额 / 额度</th><th class="left">并发</th><th class="left">路由</th><th class="left">允许模型</th><th class="left">查看</th></tr></thead><tbody id="key-rows"></tbody></table></div>
@@ -75,6 +79,10 @@
</section>
</div>
</section>
<section id="view-logs" class="view">
<div class="surface"><div class="empty">暂无日志</div></div>
</section>
</main>
</div>
+84
View File
@@ -0,0 +1,84 @@
package webdemo
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"billing/internal/plugin"
)
// DatabaseInput exposes the billing management API against a standalone SQLite copy.
type DatabaseInput struct {
app *plugin.App
}
func NewDatabaseInput(databasePath string) (*DatabaseInput, error) {
absolutePath, err := filepath.Abs(databasePath)
if err != nil {
return nil, fmt.Errorf("resolve demo database path: %w", err)
}
app := plugin.NewApp()
config := fmt.Sprintf("database_path: %q\nenabled: true\ncodex_only: false\n", absolutePath)
lifecycle, err := json.Marshal(plugin.LifecycleRequest{ConfigYAML: []byte(config), SchemaVersion: plugin.SchemaVersion})
if err != nil {
return nil, fmt.Errorf("encode demo lifecycle request: %w", err)
}
if _, err := app.HandleMethod(plugin.MethodPluginRegister, lifecycle); err != nil {
app.Shutdown()
return nil, fmt.Errorf("open demo database: %w", err)
}
return &DatabaseInput{app: app}, nil
}
func (input *DatabaseInput) Close() error {
input.app.Shutdown()
return nil
}
func (input *DatabaseInput) ServeHTTP(response http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
writeJSON(response, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "request_body_error", "message": err.Error()}})
return
}
wireRequest, err := json.Marshal(plugin.ManagementRequest{
Method: request.Method, Path: request.URL.Path, Headers: request.Header.Clone(), Query: request.URL.Query(), Body: body,
})
if err != nil {
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "request_encode_error", "message": err.Error()}})
return
}
raw, err := input.app.HandleMethod(plugin.MethodManagementHandle, wireRequest)
if err != nil {
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "management_error", "message": err.Error()}})
return
}
var envelope plugin.Envelope
if err := json.Unmarshal(raw, &envelope); err != nil || !envelope.OK {
message := "invalid management response"
if envelope.Error != nil {
message = envelope.Error.Message
}
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "management_envelope_error", "message": message}})
return
}
var result plugin.ManagementResponse
if err := json.Unmarshal(envelope.Result, &result); err != nil {
writeJSON(response, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "response_decode_error", "message": err.Error()}})
return
}
for name, values := range result.Headers {
for _, value := range values {
response.Header().Add(name, value)
}
}
status := result.StatusCode
if status == 0 {
status = http.StatusOK
}
response.WriteHeader(status)
_, _ = response.Write(result.Body)
}
+12 -2
View File
@@ -280,11 +280,20 @@ func (input *fakeInput) dashboard() map[string]any {
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) })
userDays := make([]map[string]any, 0, 7)
for offset := 6; offset >= 0; offset-- {
start := today.AddDate(0, 0, -offset)
end := start.AddDate(0, 0, 1)
daySummary := summarize(filterUsage(records, func(record fakeUsage) bool {
return !record.RequestedAt.Before(start) && record.RequestedAt.Before(end)
}))
userDays = append(userDays, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": daySummary["total_tokens"], "cost_usd": daySummary["cost_usd"]})
}
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})
users = append(users, map[string]any{"key_id": key.ID, "key_alias": key.Name, "today": summarize(current), "days": userDays, "last_used_at": last})
}
days := make([]map[string]any, 0, 7)
for offset := 6; offset >= 0; offset-- {
@@ -293,7 +302,8 @@ func (input *fakeInput) dashboard() map[string]any {
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"]})
summary := summarize(records)
days = append(days, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": summary["total_tokens"], "cost_usd": summary["cost_usd"]})
}
return map[string]any{"today": summarize(todayRecords), "users": users, "days": days}
}
+1 -1
View File
@@ -18,7 +18,7 @@ func NewServer(input Input) http.Handler {
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 /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,demoPerspectives:true});`)))
mux.HandleFunc("GET /styles/{name}", embeddedAsset("styles/"))
mux.HandleFunc("GET /app/{path...}", embeddedAsset("app/"))
mux.Handle(managementBase+"/", input)
+67
View File
@@ -2,6 +2,7 @@ package webdemo
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -24,6 +25,19 @@ func TestServerServesSplitUIAndFakeInput(t *testing.T) {
response.Body.Close()
}
configResponse, err := http.Get(server.URL + "/ui-config.js")
if err != nil {
t.Fatal(err)
}
defer configResponse.Body.Close()
configBody, err := io.ReadAll(configResponse.Body)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(configBody), "demoPerspectives:true") {
t.Fatalf("demo config does not enable perspective switch: %s", configBody)
}
response, err := http.Get(server.URL + managementBase + "/usage?page=2&page_size=100&model=deepseek")
if err != nil {
t.Fatal(err)
@@ -86,3 +100,56 @@ func TestFakeInputSupportsKeyMutation(t *testing.T) {
t.Fatalf("unexpected created key: status=%d key=%+v", response.StatusCode, key)
}
}
func TestFakeDashboardIncludesSevenDailyCosts(t *testing.T) {
server := httptest.NewServer(NewServer(NewFakeInput()))
defer server.Close()
response, err := http.Get(server.URL + managementBase + "/usage-summary")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var dashboard struct {
Days []struct {
Date string `json:"date"`
CostUSD float64 `json:"cost_usd"`
} `json:"days"`
Users []struct {
Days []struct {
CostUSD float64 `json:"cost_usd"`
} `json:"days"`
} `json:"users"`
}
if err := json.NewDecoder(response.Body).Decode(&dashboard); err != nil {
t.Fatal(err)
}
if len(dashboard.Days) != 7 || dashboard.Days[6].CostUSD <= 0 || len(dashboard.Users) == 0 || len(dashboard.Users[0].Days) != 7 {
t.Fatalf("unexpected daily costs: %+v", dashboard.Days)
}
}
func TestDatabaseInputServesStandaloneBillingDatabase(t *testing.T) {
input, err := NewDatabaseInput(t.TempDir() + "/billing.db")
if err != nil {
t.Fatal(err)
}
defer input.Close()
server := httptest.NewServer(NewServer(input))
defer server.Close()
response, err := http.Get(server.URL + managementBase + "/keys")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var payload struct {
Keys []fakeKey `json:"keys"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || len(payload.Keys) != 1 {
t.Fatalf("database input keys: status=%d keys=%d", response.StatusCode, len(payload.Keys))
}
}