diff --git a/cmd/webdemo/main.go b/cmd/webdemo/main.go index 62b6c15..5042f28 100644 --- a/cmd/webdemo/main.go +++ b/cmd/webdemo/main.go @@ -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()) } diff --git a/docs/dev.md b/docs/dev.md index 6423c49..9c16fbd 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -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 ` 显式挂载本地副本;省略该参数时继续使用生成数据。 开发按依赖从少到多推进:先确定数据和规则,再实现存储与服务,最后接入外部协议和界面。核心逻辑不依赖框架类型,协议转换集中在边界层。涉及持久化时,先定义迁移和历史数据语义 diff --git a/docs/modules/admin-console.md b/docs/modules/admin-console.md index 12ade7f..4b85cc7 100644 --- a/docs/modules/admin-console.md +++ b/docs/modules/admin-console.md @@ -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` 时直接使用副本中的用户、用量、账目和价格;省略时继续使用原来的生成数据。界面修改会写入该副本;保留压缩快照可用于恢复初始数据。 ## 管理接口 diff --git a/internal/collection/query.go b/internal/collection/query.go index c56fe9b..c615227 100644 --- a/internal/collection/query.go +++ b/internal/collection/query.go @@ -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 } diff --git a/internal/plugin/management.go b/internal/plugin/management.go index 40ad475..4b94746 100644 --- a/internal/plugin/management.go +++ b/internal/plugin/management.go @@ -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}) } diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 6531f2a..629def2 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -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{``, `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{``, `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{`

Day

`, `

Week

`} { + 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) } diff --git a/internal/repository/sqlite_query_test.go b/internal/repository/sqlite_query_test.go index ab9c0fa..42a3e03 100644 --- a/internal/repository/sqlite_query_test.go +++ b/internal/repository/sqlite_query_test.go @@ -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) + } } diff --git a/internal/repository/sqlite_summary.go b/internal/repository/sqlite_summary.go index ed9d656..6fae840 100644 --- a/internal/repository/sqlite_summary.go +++ b/internal/repository/sqlite_summary.go @@ -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 } diff --git a/internal/web/app/core/runtime.js b/internal/web/app/core/runtime.js index 2d040c0..78fd063 100644 --- a/internal/web/app/core/runtime.js +++ b/internal/web/app/core/runtime.js @@ -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); diff --git a/internal/web/app/core/shared.js b/internal/web/app/core/shared.js index 2d8de5a..ff94459 100644 --- a/internal/web/app/core/shared.js +++ b/internal/web/app/core/shared.js @@ -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); diff --git a/internal/web/app/features/keys.js b/internal/web/app/features/keys.js index a182f14..44a749d 100644 --- a/internal/web/app/features/keys.js +++ b/internal/web/app/features/keys.js @@ -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() { diff --git a/internal/web/app/main.js b/internal/web/app/main.js index 398bab3..8eb6e75 100644 --- a/internal/web/app/main.js +++ b/internal/web/app/main.js @@ -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(); diff --git a/internal/web/styles/keys.css b/internal/web/styles/keys.css index 1825769..cd37c94 100644 --- a/internal/web/styles/keys.css +++ b/internal/web/styles/keys.css @@ -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; } diff --git a/internal/web/styles/layout.css b/internal/web/styles/layout.css index db650bc..50f5375 100644 --- a/internal/web/styles/layout.css +++ b/internal/web/styles/layout.css @@ -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); } diff --git a/internal/web/styles/responsive.css b/internal/web/styles/responsive.css index f9b6e71..23841a7 100644 --- a/internal/web/styles/responsive.css +++ b/internal/web/styles/responsive.css @@ -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; } } diff --git a/internal/web/ui.html b/internal/web/ui.html index 0a2a27b..89fbf28 100644 --- a/internal/web/ui.html +++ b/internal/web/ui.html @@ -3,7 +3,7 @@ - Billing 管理台 + CPA @@ -13,23 +13,27 @@
-
-
B
Billing · 管理控制台
-
+
+ +
+ +
+
-
-
-
今日请求自然日内实际请求
-
今日 Token输入与输出合计
-
今日成本按已配置价格计算
+
+
+
-

今日用户用量

近 7 日 Token

用户Key状态余额 / 额度并发路由允许模型查看
@@ -75,6 +79,10 @@
+ +
+
暂无日志
+
diff --git a/internal/webdemo/database_handler.go b/internal/webdemo/database_handler.go new file mode 100644 index 0000000..e0875ad --- /dev/null +++ b/internal/webdemo/database_handler.go @@ -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) +} diff --git a/internal/webdemo/fake_handler.go b/internal/webdemo/fake_handler.go index 4f5349d..5119d8c 100644 --- a/internal/webdemo/fake_handler.go +++ b/internal/webdemo/fake_handler.go @@ -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} } diff --git a/internal/webdemo/server.go b/internal/webdemo/server.go index 00f9802..b576ad6 100644 --- a/internal/webdemo/server.go +++ b/internal/webdemo/server.go @@ -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) diff --git a/internal/webdemo/server_test.go b/internal/webdemo/server_test.go index e32a02b..844697d 100644 --- a/internal/webdemo/server_test.go +++ b/internal/webdemo/server_test.go @@ -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)) + } +}