feat: 完善匿名只读管理台

This commit is contained in:
chuan
2026-08-16 00:04:01 +08:00
parent 6775b64f1a
commit daf7e4ce61
9 changed files with 370 additions and 357 deletions
+2
View File
@@ -127,6 +127,8 @@ CGO_ENABLED=1 go test ./...
- 查看当前额度、已用、余额以及最近扣费账目;
- 累计、今日和最近请求统计。
管理页面资源可以直接反向代理。未填写 Management Key 时展示脱敏后的真实只读数据并隐藏全部修改入口;输入有效 Management Key 后才显示完整管理操作。匿名只读接口不会返回完整下游 Key,历史 Usage 的旧 `api_key` 字段也会被清空。只读展示仅需代理 `/v0/resource/plugins/billing/ui`(数据请求复用同一路径的查询参数);若要在外部页面执行管理操作,还需同源代理 `/v0/management/plugins/billing/*`
严格绑定的账号不可用或不支持目标模型时请求直接失败,不回退到其他账号。新 Key 在创建时复制 `default` 当时的路由与模型规则,之后独立维护。
现有和新建 Key 的初始额度都是 `$0`,默认不自动重置、并发上限为 4。管理员分配额度后才能发起模型请求。金额以微美元整数保存;修改额度不会清空本周期已用金额,手动或自动重置不会结转旧余额。允许模型没有价格配置时,请求会在触达上游前以 503 拒绝。
+1 -1
View File
@@ -2,7 +2,7 @@
- [x] 持久化用量、价格与管理面板
- [x] 一个 Key 对应一个用户的认证、模型权限与上游路由
- [x] 测试模式与真实数据隔离的紧凑管理台
- [x] 匿名只读与密钥管理分离的紧凑管理台
- [x] 美元额度、周期重置、并发限制与不可变账目
- [x] 请求明细的服务端分页与筛选
- [x] models.dev 供应商参考价目录、显式导入与确认更新
+30 -28
View File
@@ -14,7 +14,7 @@
| 请求明细 | 分页查看请求结果、上游、Token、性能和成本,执行组合筛选 |
| 价格配置 | 维护本地价格,并搜索、导入和确认更新 models.dev 参考价格 |
页面顶部统一提供实时/测试模式切换和 CPA 管理密钥输入
页面顶部保留 CPA 管理密钥输入。留空时页面进入匿名只读模式;输入有效密钥后进入管理模式
## 用户 Key 页面
@@ -29,7 +29,7 @@ Key 列表展示:
- 自动或严格上游路由;
- 允许的模型规则。
管理员可以创建 Key、显示已归档项、刷新列表,并通过侧边抽屉完成以下操作:
管理员可以创建 Key,并通过侧边抽屉完成以下操作。列表默认不显示已归档项
- 修改名称和状态;
- 选择自动路由或指定上游账号;
@@ -44,7 +44,7 @@ Key 列表展示:
## 请求明细页面
请求明细默认每页显示 100 条,页码、上一页、下一页、选择列和刷新操作位于同一顶部工具区。
请求明细默认每页显示 100 条,页码、上一页、下一页、查询过滤、列和刷新操作位于同一顶部工具区。查询过滤默认折叠,按需展开。
当前筛选项包括:
@@ -56,13 +56,13 @@ Key 列表展示:
- Responses、Compact 或 Chat 端点;
- 完整 Request ID。
筛选和分页由服务端执行。应用新筛选会返回第一页;刷新保持当前页和筛选条件。只有页面可见、位于请求明细且处于第一页时,实时模式才每三秒自动刷新,避免用户查看历史页时列表自行跳动。
筛选和分页由服务端执行。应用新筛选会返回第一页;刷新保持当前页和筛选条件。只有页面可见、位于请求明细且处于第一页时才每三秒自动刷新,避免用户查看历史页时列表自行跳动。
选择列”用于控制明细表可见字段,不改变服务端保存的数据。
“列”用于控制明细表可见字段,不改变服务端保存的数据。
## 价格配置页面
价格页面采用左侧模型列表、右侧编辑区:
价格页面采用左侧模型列表、右侧编辑区。未选择模型时右侧留空,点击左侧任意模型项后加载价格详情
- 新增或选择一个精确模型名称;
- 设置输入、缓存读取、缓存写入和输出的基础价格;
@@ -79,30 +79,26 @@ Key 列表展示:
点击普通“保存价格”始终把该模型切换为手动配置。目录更新或下载失败不会改变本地价格,也不会影响用户请求。
## 实时模式
## 匿名只读模式
实时模式连接当前 CLIProxyAPI 实例:
直接打开资源 URL 且不填写管理密钥时,页面读取当前 CLIProxyAPI 实例的真实数据
- 需要输入 CPA Management Key
- 所有读取来自真实管理接口
- 创建、编辑、归档、重置和价格保存会修改真实数据
- 请求明细和汇总反映真实请求事实。
- 可以查看汇总、请求明细和已配置价格;完整用户 Key 管理表不展示
- 可以执行分页、筛选、刷新和选择显示列
- Key 只由服务端返回 `00******0000` 形式的脱敏值,不能复制完整 Key
- 历史 Usage 中可能存在的旧 `api_key` 值在匿名响应中统一清空;
- 创建、管理、保存、删除、归档、重置、目录更新和参考价导入入口不显示。
管理台资源本身可以被 CPA 加载,但敏感管理接口仍由 CLIProxyAPI 的管理认证保护
匿名读取使用单独的 GET-only 插件资源,不复用受保护的完整 Key 接口,也不提供任何修改方法
## 测试模式
## 管理模式
测试模式用于管理员熟悉页面和验证交互,与真实数据完全隔离
输入有效 CPA Management Key 后,页面切换到受保护的 Management API
- 不需要管理密钥
- 不访问任何真实 billing 管理接口
- 使用浏览器本地的模拟 Key、上游、请求、价格和账目
- 使用浏览器本地的参考价格样例测试搜索和导入,不访问 models.dev;
- 创建、编辑、归档、重置和删除只修改本地模拟状态;
- 提供放大的请求记录量,便于测试分页、筛选和表格布局;
- 可以一键恢复初始测试数据。
测试数据保存在浏览器本地存储中,当前选择的实时/测试模式保存在当前会话。切换模式不会把测试数据导入真实数据库,也不会用真实数据覆盖测试样例。
- 读取完整管理数据,脱敏 Key 可以点击复制完整值
- 显示创建、管理、归档、额度重置、价格保存和目录更新等操作
- 所有修改仍由 CLIProxyAPI 管理认证和 billing 服务端规则校验
- 密钥无效时回退匿名只读数据,不额外显示状态标签。
## 管理接口
@@ -134,6 +130,12 @@ Key 列表展示:
/v0/resource/plugins/billing/ui
```
匿名只读 JSON 复用同一个 GET-only 页面资源,页面通过 `view` 查询参数选择数据:
```text
/v0/resource/plugins/billing/ui?view=keys
```
管理接口返回结构化 JSON 和明确的 HTTP 状态码;未知路径返回 `not_found`,数据库不可用、请求字段错误等情况返回对应错误码和消息。
## 界面与数据原则
@@ -141,7 +143,7 @@ Key 列表展示:
- 管理台不直接操作 SQLite,只调用公开管理接口。
- 统计卡片和图表使用服务端汇总,不从当前明细页推算。
- 业务校验由服务端完成,前端校验只用于尽早提示。
- 完整 Key 仅由受管理认证保护的 Key 接口返回;页面列表脱敏显示,点击后复制完整值。
- 完整 Key 仅由受管理认证保护的 Key 接口返回;匿名接口只返回服务端生成的脱敏值。
- 上游 Token、Cookie 和原始凭证不进入管理台。
- 界面保持单个嵌入资源,避免运行时依赖外部 CDN。
- 窄屏下表单和工具栏自动重排,明细表保留横向滚动能力。
@@ -152,13 +154,13 @@ Key 列表展示:
- 提供统一管理页面;
- 调用并呈现 billing Management API
- 管理实时和测试数据模式
- 提供匿名只读和密钥管理两种访问状态
- 提供紧凑、可筛选、可分页的操作界面。
本模块不负责:
- 绕过 CLIProxyAPI 管理认证
- 允许匿名调用任何修改接口
- 在浏览器中直接执行业务扣费或访问控制;
- 保存真实业务数据;
- 提供普通用户自助页面
- 提供普通用户自助修改能力
- 提供登录、角色和多管理员权限体系。
+2 -2
View File
@@ -36,7 +36,7 @@
| 上游账号 | 严格路由时绑定的 CPA 上游账号 |
| 模型规则 | 允许全部模型,或一组模型匹配规则 |
Key 值允许 1–256 个非空白、非控制字符。未手动填写时,系统生成以 `cpa_` 开头的随机值。
Key 值允许 6–256 个非空白、非控制字符。未手动填写时,系统生成以 `cpa_` 开头的随机值。
## 状态规则
@@ -125,7 +125,7 @@ Key 值允许 1–256 个非空白、非控制字符。未手动填写时,系
- 根据历史请求和价格配置获得模型名称建议;
- 查看该 Key 的累计、今日和最近使用记录。
管理操作通过受 CLIProxyAPI Management Key 保护的管理接口完成。测试模式使用独立模拟数据,不读取或修改这里描述的真实 Key 和路由配置
管理操作通过受 CLIProxyAPI Management Key 保护的管理接口完成。匿名页面只能读取服务端脱敏后的真实数据,不返回完整 Key,也不能创建、修改、重置或归档
## 模块边界
+1 -6
View File
@@ -280,12 +280,7 @@ func billingSettings(request billingSettingsRequest) (managedaccess.BillingSetti
}
func managedKeyResponse(key managedaccess.ManagedKey, state managedaccess.BillingState) managedKeyDTO {
return managedKeyDTO{ManagedKey: key, Billing: billingStateDTO{
QuotaUSD: formatMicros(state.QuotaMicros), SpentUSD: formatMicros(state.SpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
NextResetAt: state.NextResetAt, MaxConcurrency: state.MaxConcurrency,
ActiveRequests: state.ActiveRequests, CycleStartedAt: state.CycleStartedAt,
}}
return managedKeyDTO{ManagedKey: key, Billing: billingStateResponse(state)}
}
func (a *App) resetManagedKeyBilling(body []byte) ManagementResponse {
+3
View File
@@ -68,6 +68,9 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
}
path := strings.TrimRight(req.Path, "/")
if req.Method == http.MethodGet && path == resourceBase+resourceUI {
if strings.TrimSpace(req.Query.Get("view")) != "" {
return OKEnvelope(a.readOnlyResponse(req.Query))
}
return OKEnvelope(ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{
+45 -4
View File
@@ -62,6 +62,47 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
}
}
func TestReadOnlyResourceReturnsRealDataWithoutSecrets(t *testing.T) {
app := NewApp()
defer app.Shutdown()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
keys := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"keys"}},
})
if keys.StatusCode != http.StatusOK || !strings.Contains(string(keys.Body), `"masked_secret":"00******0000"`) {
t.Fatalf("unexpected read-only keys: status=%d body=%s", keys.StatusCode, keys.Body)
}
if strings.Contains(string(keys.Body), `"secret":`) {
t.Fatalf("read-only response exposed a complete secret: %s", keys.Body)
}
usageRaw, _ := json.Marshal(UsageRecord{Provider: "openai", Model: "gpt-test", APIKey: "000000", RequestedAt: time.Now(), Detail: UsageDetail{TotalTokens: 1}})
if _, err := app.HandleMethod(MethodUsageHandle, usageRaw); err != nil {
t.Fatal(err)
}
usage := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"usage"}},
})
if usage.StatusCode != http.StatusOK || strings.Contains(string(usage.Body), `"api_key":"000000"`) {
t.Fatalf("read-only usage exposed a historical key: status=%d body=%s", usage.StatusCode, usage.Body)
}
invalid := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"missing"}},
})
if invalid.StatusCode != http.StatusBadRequest || !strings.Contains(string(invalid.Body), `"code":"invalid_view"`) {
t.Fatalf("unexpected invalid view response: status=%d body=%s", invalid.StatusCode, invalid.Body)
}
}
func TestModelsDevCatalogRequiresExplicitImportAndConfirmedRefresh(t *testing.T) {
catalogBody := `{"providers":{"openai":{"id":"openai","name":"OpenAI","models":{"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","cost":{"input":5,"output":30,"cache_read":0.5,"cache_write":6.25}}}},"free":{"id":"free","name":"Free","models":{"placeholder":{"id":"placeholder","cost":{"input":0,"output":0}}}}}}`
serverStatus := http.StatusOK
@@ -532,7 +573,7 @@ func TestUsageResourceServesTablePage(t *testing.T) {
t.Fatalf("UI does not contain managed access feature %q", feature)
}
}
for _, feature := range []string{"测试模式", `data-mode="demo"`, `id="page-buttons"`, "const PAGE_SIZE = 100", "length: 2370", `id="user-usage"`, `id="daily-chart"`, `id="usage-from"`, `id="usage-request-id"`, `id="apply-usage-filters"`, "SUMMARY_API", `overflow-y: hidden`, "测试模式禁止访问真实管理接口", `id="editor-quota"`, `id="editor-reset-period"`, `id="billing-ledger"`, "deepseek-*", "请求结束后按实际费用扣款"} {
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)
}
@@ -540,12 +581,12 @@ func TestUsageResourceServesTablePage(t *testing.T) {
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 条", "最多展示", "1100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格", "点击“管理”修改访问、路由与额度", "由 CPA 调度", `copy.textContent = "复制"`} {
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1100 /", "一个 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)`, `className = "credential-copy"`, `return "自由选择"`} {
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)
}
@@ -553,7 +594,7 @@ func TestUsageResourceServesTablePage(t *testing.T) {
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} 个已关联价格发生变化", "测试目录 · 不访问 models.dev"} {
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)
}
+132
View File
@@ -0,0 +1,132 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
managedaccess "billing/internal/access"
)
type readOnlyManagedKeyDTO struct {
ID string `json:"id"`
Name string `json:"name"`
MaskedSecret string `json:"masked_secret"`
Status string `json:"status"`
RouteMode string `json:"route_mode"`
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
AllModels bool `json:"all_models"`
Models []string `json:"models"`
Billing billingStateDTO `json:"billing"`
}
func (a *App) readOnlyResponse(query url.Values) ManagementResponse {
switch strings.TrimSpace(query.Get("view")) {
case "keys":
return a.readOnlyManagedKeys(query.Get("include_archived") == "1")
case "usage":
return a.readOnlyUsageResponse(query)
case "usage-summary":
return a.usageDashboardResponse()
case "prices":
return a.listPrices()
case "price-catalog":
return a.searchPriceCatalog(query)
case "upstreams":
return a.readOnlyUpstreamAccounts()
case "model-suggestions":
return a.modelSuggestions()
case "key-stats":
return a.readOnlyManagedKeyStats(strings.TrimSpace(query.Get("id")))
default:
return managementError(http.StatusBadRequest, "invalid_view", "只读资源类型不存在")
}
}
func (a *App) readOnlyUsageResponse(query url.Values) ManagementResponse {
response := a.usageResponse(query)
if response.StatusCode != http.StatusOK {
return response
}
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
return managementError(http.StatusInternalServerError, "response_error", "无法生成只读请求明细")
}
redactUsageItems(payload.Records)
return jsonManagementResponse(http.StatusOK, payload)
}
func (a *App) readOnlyManagedKeyStats(id string) ManagementResponse {
response := a.managedKeyStats(id)
if response.StatusCode != http.StatusOK {
return response
}
var payload managedKeyStatsResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
return managementError(http.StatusInternalServerError, "response_error", "无法生成只读 Key 统计")
}
redactUsageItems(payload.Recent)
return jsonManagementResponse(http.StatusOK, payload)
}
func redactUsageItems(items []usageListItem) {
for index := range items {
items[index].APIKey = ""
}
}
func (a *App) readOnlyManagedKeys(includeArchived bool) ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
keys, err := store.ListManagedKeys(context.Background(), includeArchived)
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
items := make([]readOnlyManagedKeyDTO, 0, len(keys))
for _, key := range keys {
state, stateErr := store.BillingState(context.Background(), key.ID, time.Now().UTC())
if stateErr != nil {
return managementError(http.StatusInternalServerError, "database_error", stateErr.Error())
}
items = append(items, readOnlyManagedKeyDTO{
ID: key.ID, Name: key.Name, MaskedSecret: maskManagedSecret(key.Secret), Status: key.Status,
RouteMode: key.RouteMode, UpstreamAccountID: key.UpstreamAccountID,
AllModels: key.AllModels, Models: key.Models, Billing: billingStateResponse(state),
})
}
return jsonManagementResponse(http.StatusOK, map[string]any{"keys": items})
}
func (a *App) readOnlyUpstreamAccounts() ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "上游账号数据库尚未初始化")
}
accounts, err := store.ListUpstreamAccounts(context.Background())
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
return jsonManagementResponse(http.StatusOK, map[string]any{"accounts": accounts})
}
func maskManagedSecret(secret string) string {
characters := []rune(secret)
if len(characters) < 6 {
return "******"
}
return string(characters[:2]) + "******" + string(characters[len(characters)-4:])
}
func billingStateResponse(state managedaccess.BillingState) billingStateDTO {
return billingStateDTO{
QuotaUSD: formatMicros(state.QuotaMicros), SpentUSD: formatMicros(state.SpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
NextResetAt: state.NextResetAt, MaxConcurrency: state.MaxConcurrency,
ActiveRequests: state.ActiveRequests, CycleStartedAt: state.CycleStartedAt,
}
}
+154 -316
View File
@@ -31,14 +31,7 @@
.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; }
.auth-box label { color: var(--muted); font-size: 12px; }
.mode-switch { display: flex; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary); }
.mode-button { border: 0; padding: 5px 9px; background: transparent; color: var(--muted); }
.mode-button:hover { border-color: transparent; background: var(--bg-hover); }
.mode-button.active { background: var(--bg-tertiary); color: var(--text); }
.demo-mode .mode-button[data-mode="demo"] { background: color-mix(in srgb, var(--amber-color) 38%, transparent); color: #fcd34d; }
.demo-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 22px; border-bottom: 1px solid color-mix(in srgb, var(--amber-color) 45%, transparent); background: color-mix(in srgb, var(--amber-color) 14%, var(--bg-secondary)); color: #fcd34d; font-size: 11px; }
.demo-banner button { border-color: color-mix(in srgb, var(--amber-color) 45%, transparent); background: color-mix(in srgb, var(--amber-color) 24%, transparent); color: #fcd34d; }
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; }
@@ -102,11 +95,12 @@
.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; gap: 6px; }
.price-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 9px 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); }
.price-row { display: block; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); cursor: pointer; outline: none; }
.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-row .key-actions { flex-wrap: 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; }
@@ -197,22 +191,25 @@
.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; }
@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, .auth-box label { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .demo-banner { padding-right: 12px; padding-left: 12px; } .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: 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; } }
</style>
</head>
<body>
<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"><div class="mode-switch" aria-label="数据模式"><button class="mode-button active" type="button" data-mode="live">实时</button><button class="mode-button" type="button" data-mode="demo">测试</button></div><label for="key">管理密钥</label><input id="key" type="password" autocomplete="off" placeholder="输入管理密钥"></div>
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
</header>
<div id="demo-banner" class="demo-banner hidden"><span><strong>测试模式</strong> · 页面使用浏览器本地样例数据,任何创建、编辑、归档和价格操作都不会访问 CPA。</span><button id="reset-demo" type="button">重置测试数据</button></div>
<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>
@@ -220,51 +217,54 @@
</nav>
<main>
<section id="view-keys" class="view active">
<div class="section-heading actions-only"><div class="tools"><label class="inline-check"><input id="include-archived" type="checkbox">显示已归档</label><button id="reload-keys" type="button">刷新</button><button id="create-key" class="primary" type="button">创建 Key</button></div></div>
<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>
<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 class="surface">
<div class="surface-toolbar"><span id="key-status" class="status"></span></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>
<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>
</div>
</section>
<section id="view-usage" class="view">
<div class="section-heading"><div><div id="page-buttons" class="page-buttons"></div><span id="status" class="status"></span></div><div class="tools"><details><summary>选择列</summary><div id="column-options" class="column-options"></div></details><button id="refresh" type="button">刷新</button></div></div>
<div class="surface usage-filters">
<label class="usage-filter">开始时间<input id="usage-from" type="datetime-local" step="1"></label>
<label class="usage-filter">结束时间<input id="usage-to" type="datetime-local" step="1"></label>
<label class="usage-filter">用户<select id="usage-key"><option value="">全部用户</option></select></label>
<label class="usage-filter">模型<input id="usage-model" list="usage-model-options" placeholder="全部模型"><datalist id="usage-model-options"></datalist></label>
<label class="usage-filter">结果<select id="usage-result"><option value="">全部结果</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="rejected">已拒绝</option><option value="canceled">已取消</option></select></label>
<label class="usage-filter">上游<select id="usage-auth"><option value="">全部上游</option></select></label>
<label class="usage-filter">端点<select id="usage-endpoint"><option value="">全部端点</option><option value="responses">responses</option><option value="compact">compact</option><option value="chat">chat</option></select></label>
<label class="usage-filter request-filter">请求 ID<input id="usage-request-id" placeholder="粘贴完整 Request ID"></label>
<div class="usage-filter-actions"><button id="clear-usage-filters" type="button">清空</button><button id="apply-usage-filters" class="primary" type="button">查询</button></div>
</div>
<div class="section-heading"><div><div id="page-buttons" class="page-buttons"></div><span id="status" class="status"></span></div><div class="tools">
<details id="usage-filter-panel" class="usage-filter-panel"><summary>查询过滤</summary><div class="surface usage-filters">
<label class="usage-filter">开始时间<input id="usage-from" type="datetime-local" step="1"></label>
<label class="usage-filter">结束时间<input id="usage-to" type="datetime-local" step="1"></label>
<label class="usage-filter">用户<select id="usage-key"><option value="">全部用户</option></select></label>
<label class="usage-filter">模型<input id="usage-model" list="usage-model-options" placeholder="全部模型"><datalist id="usage-model-options"></datalist></label>
<label class="usage-filter">结果<select id="usage-result"><option value="">全部结果</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="rejected">已拒绝</option><option value="canceled">已取消</option></select></label>
<label class="usage-filter">上游<select id="usage-auth"><option value="">全部上游</option></select></label>
<label class="usage-filter">端点<select id="usage-endpoint"><option value="">全部端点</option><option value="responses">responses</option><option value="compact">compact</option><option value="chat">chat</option></select></label>
<label class="usage-filter request-filter">请求 ID<input id="usage-request-id" placeholder="粘贴完整 Request ID"></label>
<div class="usage-filter-actions"><button id="clear-usage-filters" type="button">清空</button><button id="apply-usage-filters" class="primary" type="button">查询</button></div>
</div></details>
<details><summary></summary><div id="column-options" class="column-options"></div></details><button id="refresh" type="button">刷新</button>
</div></div>
<div class="surface"><div class="table-wrap"><table><thead><tr id="headers"></tr></thead><tbody id="rows"></tbody></table></div></div>
</section>
<section id="view-pricing" class="view">
<div class="section-heading"><div><h1>价格配置</h1></div><div id="price-status" class="status"></div></div>
<div class="surface price-panel price-layout">
<aside class="price-sidebar"><div class="price-sidebar-header"><h2>已配置模型</h2><button id="new-price" class="small" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
<aside class="price-sidebar"><div class="price-sidebar-header"><h2>已配置模型</h2><button id="new-price" class="small admin-only" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
<section class="price-editor">
<div id="price-editor-content" class="hidden">
<div class="price-editor-header"><h2>模型价格</h2></div>
<div class="price-section"><label class="field price-model-field">模型名称<input id="price-model" placeholder="gpt-5.6-sol"></label><div id="price-source" class="price-source">手动配置</div></div>
<div class="price-section"><div class="price-section-header"><h3>models.dev 参考价格</h3><button id="refresh-catalog" class="small" type="button">更新目录</button></div><div id="catalog-summary" class="catalog-summary"></div><div class="catalog-toolbar"><input id="catalog-query" placeholder="搜索模型或供应商"><button id="search-catalog" class="small" type="button">搜索</button></div><div id="catalog-results" class="catalog-results"></div></div>
<div class="price-section admin-only"><div class="price-section-header"><h3>models.dev 参考价格</h3><button id="refresh-catalog" class="small" type="button">更新目录</button></div><div id="catalog-summary" class="catalog-summary"></div><div class="catalog-toolbar"><input id="catalog-query" placeholder="搜索模型或供应商"><button id="search-catalog" class="small" type="button">搜索</button></div><div id="catalog-results" class="catalog-results"></div></div>
<div class="price-section"><div class="price-section-header"><h3>基础价格 · $ / 1M Token</h3></div><div class="price-grid">
<label>输入<input id="price-input" inputmode="decimal"></label><label>缓存读取<input id="price-cache-read" inputmode="decimal"></label><label>缓存写入<input id="price-cache-write" inputmode="decimal"></label><label>输出<input id="price-output" inputmode="decimal"></label>
</div></div>
<div class="price-section"><div class="price-section-header"><h3>长上下文价格</h3><label class="inline-check"><input id="long-enabled" type="checkbox">启用</label></div><div class="price-grid long-grid">
<div id="long-price-section" class="price-section hidden"><div class="price-section-header"><h3>长上下文价格</h3><label class="inline-check"><input id="long-enabled" type="checkbox">启用</label></div><div class="price-grid long-grid">
<label class="long-field hidden">输入门槛<input id="long-threshold" type="number" min="0" step="1"></label><label class="long-field hidden">门槛比较<select id="long-comparison"><option value="gt">大于</option><option value="gte">大于等于</option></select></label><label class="long-field hidden">输入<input id="long-input" inputmode="decimal"></label><label class="long-field hidden">缓存读取<input id="long-cache-read" inputmode="decimal"></label><label class="long-field hidden">缓存写入<input id="long-cache-write" inputmode="decimal"></label><label class="long-field hidden">输出<input id="long-output" inputmode="decimal"></label>
</div></div>
<div class="price-section"><div class="price-section-header"><h3>Fast 价格</h3></div><div class="fast-row"><label class="inline-check"><input id="fast-pricing-enabled" type="checkbox">使用倍率计价</label><label class="field">倍率<input id="fast-multiplier" inputmode="decimal" value="2.5"></label></div></div>
<div class="price-actions"><button id="clear-price" type="button">清空</button><button id="save-price" class="primary" type="button">保存价格</button></div>
<div class="price-actions admin-only"><button id="delete-price" class="danger hidden" type="button">删除</button><button id="clear-price" type="button">清空</button><button id="save-price" class="primary" type="button">保存价格</button></div>
</div>
</section>
</div>
</section>
@@ -313,6 +313,7 @@
const MODELS_API = MANAGEMENT_API + "/model-suggestions";
const BILLING_RESET_API = MANAGEMENT_API + "/billing-reset";
const BILLING_LEDGER_API = MANAGEMENT_API + "/billing-ledger";
const READONLY_API = location.pathname;
const keyInput = document.querySelector("#key");
const statusNode = document.querySelector("#status");
const rowsNode = document.querySelector("#rows");
@@ -321,6 +322,7 @@
const columnStoreKey = "billing:usage-columns";
const priceStatusNode = document.querySelector("#price-status");
const priceListNode = document.querySelector("#price-list");
const priceEditorContentNode = document.querySelector("#price-editor-content");
const priceSourceNode = document.querySelector("#price-source");
const catalogSummaryNode = document.querySelector("#catalog-summary");
const catalogResultsNode = document.querySelector("#catalog-results");
@@ -336,92 +338,19 @@
const statsRecentNode = document.querySelector("#stats-recent");
const pageButtonsNode = document.querySelector("#page-buttons");
const PAGE_SIZE = 100;
const DEMO_STORE_KEY = "billing:demo-state:v4";
let currentRecords = [];
let currentPage = 1;
let currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
let usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
let lastSignature = "";
let loading = false;
let currentPrices = [];
let selectedPriceModel = "";
let currentCatalog = null;
let managedKeys = [];
let upstreamAccounts = [];
let modelSuggestions = [];
let editingKey = null;
let testMode = false;
let demoState = loadDemoState();
function demoPrice() {
return {
model: "deepseek-v4-flash",
base: { input_per_1m: "2.5", cache_read_per_1m: "0.25", cache_write_per_1m: "3.125", output_per_1m: "15" },
long_context: { threshold_input_tokens: 272000, comparison: "gt", input_per_1m: "5", cache_read_per_1m: "0.5", cache_write_per_1m: "6.25", output_per_1m: "22.5" },
fast_pricing_enabled: true, fast_multiplier: "2.5",
source: { kind: "manual" }
};
}
function demoCatalog() {
return [
{ id: "openai/gpt-5.6-sol", provider: "openai", provider_name: "OpenAI", model: "gpt-5.6-sol", model_name: "GPT-5.6 Sol", base: { input_per_1m: "5", cache_read_per_1m: "0.5", cache_write_per_1m: "6.25", output_per_1m: "30" }, long_context: { 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" } },
{ id: "deepseek/deepseek-v4-flash", provider: "deepseek", provider_name: "DeepSeek", model: "deepseek-v4-flash", model_name: "DeepSeek V4 Flash", base: { input_per_1m: "0.14", cache_read_per_1m: "0.014", cache_write_per_1m: "0.14", output_per_1m: "0.28" } },
{ id: "google/gemini-3.7-flash", provider: "google", provider_name: "Google", model: "gemini-3.7-flash", model_name: "Gemini 3.7 Flash", base: { input_per_1m: "0.38", cache_read_per_1m: "0.038", cache_write_per_1m: "0.38", output_per_1m: "1.88" } }
];
}
function freshDemoState() {
const upstreams = ["A", "B", "C"].map((name, index) => ({
id: "demo-upstream-" + name.toLowerCase(), cpa_auth_id: "codex:demo:" + name.toLowerCase(),
provider: "codex", display_name: "DeepSeek 测试源 " + name, priority: 10,
disabled: false, unavailable: false
}));
const keys = [
{ id: "demo-key-alice", name: "alice", secret: "alice-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: false, models: ["deepseek-*"] },
{ id: "demo-key-bob", name: "bob", secret: "bob-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: true, models: [] },
{ id: "demo-key-carol", name: "carol", secret: "carol-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[1].id, all_models: false, models: ["deepseek-source-b", "deepseek-v4-flash"] },
{ id: "demo-key-eve", name: "eve", secret: "eve-000000", status: "disabled", route_mode: "auto", upstream_account_id: "", all_models: false, models: ["deepseek-source-c"] }
];
const now = Date.now();
const usage = Array.from({ length: 2370 }, (_, index) => {
const key = keys[index % keys.length];
const failed = index % 13 === 0;
const input = 900 + (index * 137) % 18000;
const output = 24 + (index * 17) % 420;
return {
request_id: "demo-request-" + String(index + 1).padStart(4, "0"), execution_id: "demo-exec-" + index,
requested_at: new Date(now - index * 187000).toISOString(), managed_key_id: key.id, key_alias: key.name, api_key: key.secret,
auth_id: upstreams[index % upstreams.length].cpa_auth_id, auth_index: "demo-" + (index % upstreams.length + 1),
model: index % 7 === 0 ? "deepseek-source-c" : "deepseek-v4-flash", reasoning_effort: "-", service_tier: "auto", speed: "",
failed, outcome: failed ? "failed" : "succeeded", status_code: failed ? 503 : 200,
request_type: "SSE", endpoint: "/v1/responses", ttft_ms: 120 + index % 360, speed_tps: 32 + index % 28,
input_tokens: input, output_tokens: output, reasoning_tokens: 0, cache_read_tokens: Math.floor(input * .72), cache_write_tokens: 0,
cache_rate: 72, total_tokens: input + output, cost_usd: (input * 2.5 + output * 15) / 1000000,
cost_available: true, client_ip: "127.0.0.1"
};
});
const ledger = [];
keys.forEach((key, keyIndex) => {
const records = usage.filter(item => item.key_alias === key.name);
const spent = records.reduce((sum, item) => sum + (item.cost_usd || 0), 0);
const quota = 250 + keyIndex * 50;
key.billing = { quota_usd: String(quota), spent_usd: spent.toFixed(6), balance_usd: (quota - spent).toFixed(6), reset_period: keyIndex % 2 ? "monthly" : "none", next_reset_at: keyIndex % 2 ? new Date(now + 86400000 * 20).toISOString() : null, max_concurrency: 4, active_requests: keyIndex % 3, cycle_started_at: new Date(now - 86400000 * 10).toISOString() };
records.slice(0, 12).forEach((record, index) => ledger.push({ id: keyIndex * 100 + index + 1, key_id: key.id, kind: "charge", amount_usd: (-record.cost_usd).toFixed(6), balance_after_usd: (quota - spent + index * record.cost_usd).toFixed(6), request_id: record.request_id, model: record.model, occurred_at: record.requested_at }));
});
return { keys, upstreams, usage, prices: [demoPrice()], ledger };
}
function loadDemoState() {
try {
const value = JSON.parse(localStorage.getItem(DEMO_STORE_KEY));
if (value && Array.isArray(value.keys) && Array.isArray(value.usage) && Array.isArray(value.prices) && Array.isArray(value.ledger)) return value;
} catch (_) {}
return freshDemoState();
}
function saveDemoState() {
localStorage.setItem(DEMO_STORE_KEY, JSON.stringify(demoState));
}
let managementAuthorized = false;
function storedPanelKey() {
const prefix = "enc::v1::";
@@ -627,44 +556,6 @@
return params;
}
function demoUsagePage(page) {
const params = usageFilterQuery();
const from = params.get("from") ? new Date(params.get("from")) : null;
const to = params.get("to") ? new Date(params.get("to")) : null;
const resultValue = record => record.outcome === "canceled" ? "canceled" : record.outcome === "rejected" ? "rejected" : (record.failed || record.outcome === "failed") ? "failed" : "succeeded";
const endpointValue = record => endpoint(record.endpoint);
const records = demoState.usage.filter(record => {
const requestedAt = new Date(record.requested_at);
return (!from || requestedAt >= from) && (!to || requestedAt < to) &&
(!params.get("key_id") || record.managed_key_id === params.get("key_id")) &&
(!params.get("model") || String(record.model || "").toLowerCase() === params.get("model").toLowerCase()) &&
(!params.get("result") || resultValue(record) === params.get("result")) &&
(!params.get("auth_id") || record.auth_id === params.get("auth_id")) &&
(!params.get("endpoint") || endpointValue(record) === params.get("endpoint")) &&
(!params.get("request_id") || record.request_id === params.get("request_id"));
});
const totalPages = Math.ceil(records.length / PAGE_SIZE);
const safePage = Math.max(1, Math.min(page || 1, Math.max(1, totalPages)));
const start = (safePage - 1) * PAGE_SIZE;
return { records: records.slice(start, start + PAGE_SIZE), pagination: { page: safePage, page_size: PAGE_SIZE, total: records.length, total_pages: totalPages, previous_cursor: safePage > 1 ? "demo-previous" : "", next_cursor: safePage < totalPages ? "demo-next" : "" } };
}
function demoUsageDashboard() {
const today = startOfDay(new Date());
const summarize = records => ({ requests: records.length, input_tokens: records.reduce((sum, item) => sum + (item.input_tokens || 0), 0), output_tokens: records.reduce((sum, item) => sum + (item.output_tokens || 0), 0), total_tokens: records.reduce((sum, item) => sum + (item.total_tokens || 0), 0), cost_usd: records.reduce((sum, item) => sum + (item.cost_available ? item.cost_usd || 0 : 0), 0) });
const todayRecords = demoState.usage.filter(record => new Date(record.requested_at) >= today);
const users = demoState.keys.map(key => {
const records = demoState.usage.filter(record => record.managed_key_id === key.id);
return { key_id: key.id, key_alias: key.name, today: summarize(records.filter(record => new Date(record.requested_at) >= today)), last_used_at: records[0]?.requested_at || null };
});
const days = Array.from({ length: 7 }, (_, index) => {
const date = startOfDay(new Date()); date.setDate(date.getDate() - (6 - index));
const end = new Date(date); end.setDate(end.getDate() + 1);
return { date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, total_tokens: demoState.usage.filter(record => { const value = new Date(record.requested_at); return value >= date && value < end; }).reduce((sum, record) => sum + (record.total_tokens || 0), 0) };
});
return { today: summarize(todayRecords), users, days };
}
function populateUsageFilterOptions(keys, upstreams, models) {
const keySelect = document.querySelector("#usage-key");
const authSelect = document.querySelector("#usage-auth");
@@ -675,13 +566,8 @@
}
async function loadUsageFilterOptions() {
if (testMode) {
populateUsageFilterOptions(demoState.keys, demoState.upstreams, [...new Set(demoState.usage.map(record => record.model).filter(Boolean))]);
return;
}
if (!keyInput.value.trim()) return;
try {
const [keys, upstreams, models] = await Promise.all([managedFetch(KEYS_API + "?include_archived=1"), managedFetch(UPSTREAMS_API), managedFetch(MODELS_API)]);
const [keys, upstreams, models] = await Promise.all([dataFetch(KEYS_API + "?include_archived=1"), dataFetch(UPSTREAMS_API), dataFetch(MODELS_API)]);
populateUsageFilterOptions(keys.keys || [], upstreams.accounts || [], models.models || []);
} catch (error) { statusNode.textContent = "读取筛选项失败: " + error.message; }
}
@@ -692,12 +578,60 @@
return headers;
}
function setAccessState(authorized) {
managementAuthorized = authorized;
document.body.classList.toggle("read-only", !authorized);
document.querySelectorAll("#view-pricing .price-editor input, #view-pricing .price-editor select").forEach(node => {
if (node.id !== "catalog-query") node.disabled = !authorized;
});
syncLongSectionVisibility();
if (!authorized) closeDrawer();
}
function readOnlyURL(managementURL) {
const source = new URL(managementURL, location.origin);
const views = {
[API]: "usage", [SUMMARY_API]: "usage-summary", [PRICE_API]: "prices",
[CATALOG_API]: "price-catalog", [KEYS_API]: "keys", [KEY_STATS_API]: "key-stats",
[UPSTREAMS_API]: "upstreams", [MODELS_API]: "model-suggestions"
};
const view = views[source.pathname];
if (!view) throw new Error("该数据不支持匿名读取");
source.searchParams.set("view", view);
return READONLY_API + "?" + source.searchParams.toString();
}
async function responsePayload(response) {
try { return await response.json(); } catch (_) { return {}; }
}
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;
}
async function managedFetch(url, options = {}) {
if (testMode) throw new Error("测试模式禁止访问真实管理接口");
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 response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
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;
}
@@ -803,8 +737,9 @@
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 = maskedSecret(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 credential = document.createElement("div"); credential.className = "credential"; const secret = document.createElement("code"); secret.className = "credential-copy"; secret.textContent = managementAuthorized ? maskedSecret(key.secret) : (key.masked_secret || "******");
if (managementAuthorized && 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}`;
@@ -812,7 +747,8 @@
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));
const manage = document.createElement("button"); manage.type = "button"; manage.className = "small"; manage.textContent = archived ? "查看" : "管理"; manage.addEventListener("click", () => openKeyEditor(key)); actions.append(stats, manage);
actions.append(stats);
if (managementAuthorized) { 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;
}));
@@ -823,10 +759,6 @@
renderUserCharts();
}
function startOfDay(value) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function compactNumber(value) {
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
}
@@ -851,27 +783,16 @@
}
async function loadKeyManagement() {
if (testMode) {
managedKeys = demoState.keys.filter(key => document.querySelector("#include-archived").checked || key.status !== "archived");
upstreamAccounts = demoState.upstreams;
modelSuggestions = ["deepseek-v4-flash", "deepseek-source-a", "deepseek-source-b", "deepseek-source-c"];
usageDashboard = demoUsageDashboard();
renderManagedKeys();
keyStatusNode.textContent = `测试数据 · ${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号`;
return;
}
if (!keyInput.value.trim()) return;
keyStatusNode.textContent = "正在同步";
keyStatusNode.textContent = "正在读取";
try {
const [upstreams, models, summary] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(SUMMARY_API)]);
const [upstreams, models, summary] = await Promise.all([dataFetch(UPSTREAMS_API), dataFetch(MODELS_API), dataFetch(SUMMARY_API)]);
upstreamAccounts = upstreams.accounts || [];
modelSuggestions = models.models || [];
usageDashboard = summary;
const archived = document.querySelector("#include-archived").checked ? "?include_archived=1" : "";
const keys = await managedFetch(KEYS_API + archived);
const keys = await dataFetch(KEYS_API);
managedKeys = keys.keys || [];
renderManagedKeys();
keyStatusNode.textContent = `${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}`;
keyStatusNode.textContent = managementAuthorized ? `${managedKeys.length} 个 Key${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}` : "";
} catch (error) { keyStatusNode.textContent = "读取失败: " + error.message; }
}
@@ -882,15 +803,6 @@
const allModels = document.querySelector("#editor-all-models").checked;
const payload = { name, secret, route_mode: route, upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "", all_models: allModels, models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean), billing: billingPayload() };
try {
if (testMode) {
if (!name) throw new Error("请输入名称");
if (secret && secret.length < 6) throw new Error("Key 至少需要 6 个字符");
if (demoState.keys.some(key => key.name.toLowerCase() === name.toLowerCase())) throw new Error("名称已经存在");
const created = { id: "demo-key-" + Date.now(), ...payload, secret: secret || `demo-${Date.now()}-000000`, status: "active", billing: { ...payload.billing, spent_usd: "0", balance_usd: payload.billing.quota_usd, active_requests: 0, cycle_started_at: new Date().toISOString() } };
demoState.keys.push(created); saveDemoState(); closeDrawer(); await loadKeyManagement();
keyStatusNode.textContent = `测试数据已创建 ${created.name}${created.secret}`;
return;
}
const created = await managedFetch(KEYS_API, { method: "POST", body: JSON.stringify(payload) });
keyStatusNode.textContent = `已创建 ${created.name}${created.secret}`;
closeDrawer(); await loadKeyManagement();
@@ -899,15 +811,6 @@
async function updateManagedKey(payload) {
try {
if (testMode) {
const index = demoState.keys.findIndex(key => key.id === payload.id);
if (index < 0) throw new Error("测试 Key 不存在");
const previous = demoState.keys[index]; const spent = Number(previous.billing?.spent_usd || 0); const previousQuota = Number(previous.billing?.quota_usd || 0); const nextQuota = Number(payload.billing?.quota_usd || 0);
const billing = { ...previous.billing, ...payload.billing, spent_usd: String(spent), balance_usd: String(nextQuota - spent), active_requests: previous.billing?.active_requests || 0 };
demoState.keys[index] = { ...previous, ...payload, billing };
if (previousQuota !== nextQuota) demoState.ledger.push({ id: Date.now(), key_id: previous.id, kind: "quota_change", amount_usd: String(nextQuota - previousQuota), balance_after_usd: String(nextQuota - spent), occurred_at: new Date().toISOString() });
saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
}
await managedFetch(KEYS_API, { method: "PATCH", body: JSON.stringify(payload) }); closeDrawer(); await loadKeyManagement();
}
catch (error) { keyStatusNode.textContent = "保存失败: " + error.message; }
@@ -933,7 +836,7 @@
const node = document.querySelector("#billing-ledger");
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "正在读取" }));
try {
const entries = testMode ? demoState.ledger.filter(item => item.key_id === key.id).sort((a, b) => b.id - a.id).slice(0, 50) : (await managedFetch(BILLING_LEDGER_API + "?id=" + encodeURIComponent(key.id) + "&limit=50")).entries || [];
const entries = (await managedFetch(BILLING_LEDGER_API + "?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);
@@ -947,14 +850,7 @@
async function resetManagedBilling() {
if (!editingKey || !confirm(`立即重置 ${editingKey.name} 的额度?当前剩余余额不会结转。`)) return;
try {
if (testMode) {
const key = demoState.keys.find(item => item.id === editingKey.id); const quota = Number(key.billing.quota_usd || 0); const now = new Date();
key.billing.spent_usd = "0"; key.billing.balance_usd = String(quota); key.billing.cycle_started_at = now.toISOString();
if (key.billing.reset_period !== "none") { const next = new Date(now); if (key.billing.reset_period === "daily") next.setDate(next.getDate() + 1); else if (key.billing.reset_period === "weekly") next.setDate(next.getDate() + 7); else next.setMonth(next.getMonth() + 1); key.billing.next_reset_at = next.toISOString(); }
demoState.ledger.push({ id: Date.now(), key_id: key.id, kind: "cycle_reset", amount_usd: String(quota), balance_after_usd: String(quota), occurred_at: now.toISOString() }); saveDemoState(); editingKey = key;
} else {
await managedFetch(BILLING_RESET_API, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
}
await managedFetch(BILLING_RESET_API, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
await loadKeyManagement(); const refreshed = managedKeys.find(item => item.id === editingKey.id); if (refreshed) openKeyEditor(refreshed);
} catch (error) { document.querySelector("#billing-status").textContent = "重置失败:" + error.message; }
}
@@ -962,9 +858,6 @@
async function archiveManagedKey(key) {
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
try {
if (testMode) {
const found = demoState.keys.find(item => item.id === key.id); if (!found) throw new Error("测试 Key 不存在"); found.status = "archived"; saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
}
await managedFetch(KEYS_API, { method: "DELETE", body: JSON.stringify({ id: key.id }) }); closeDrawer(); await loadKeyManagement();
}
catch (error) { keyStatusNode.textContent = "归档失败: " + error.message; }
@@ -972,15 +865,7 @@
async function loadKeyStats(key) {
try {
let payload;
if (testMode) {
const recent = demoState.usage.filter(item => item.key_alias === key.name);
const since = new Date(); since.setHours(0, 0, 0, 0);
const summarize = records => ({ requests: records.length, total_tokens: records.reduce((sum, item) => sum + item.total_tokens, 0), cost_micros: Math.round(records.reduce((sum, item) => sum + (item.cost_usd || 0), 0) * 1000000) });
payload = { stats: { total: summarize(recent), today: summarize(recent.filter(item => new Date(item.requested_at) >= since)) }, recent };
} else {
payload = await managedFetch(KEY_STATS_API + "?id=" + encodeURIComponent(key.id));
}
const payload = await dataFetch(KEY_STATS_API + "?id=" + encodeURIComponent(key.id));
const total = payload.stats.total, today = payload.stats.today;
document.querySelector("#drawer-title").textContent = `${key.name} · 统计`;
document.querySelector("#drawer-subtitle").textContent = "历史统计永久保留";
@@ -999,6 +884,10 @@
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", !managementAuthorized && !hasLong);
}
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));
@@ -1008,25 +897,21 @@
if (view === "keys") loadKeyManagement();
}
function setMode(mode) {
testMode = mode === "demo";
document.body.classList.toggle("demo-mode", testMode);
document.querySelectorAll(".mode-button").forEach(button => button.classList.toggle("active", button.dataset.mode === mode));
document.querySelector("#demo-banner").classList.toggle("hidden", !testMode);
keyInput.disabled = testMode;
keyInput.placeholder = testMode ? "测试模式无需密钥" : "输入管理密钥";
lastSignature = ""; currentPage = 1; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = []; currentCatalog = null;
usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
render([]); renderManagedKeys(); renderPrices(); renderCatalogResults([]); catalogSummaryNode.textContent = "";
try { sessionStorage.setItem("billing:data-mode", mode); } catch (_) {}
function reloadCurrentView() {
currentPage = 1; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
if (active === "keys") loadKeyManagement();
if (active === "usage") { loadUsageFilterOptions(); load(true, 1); }
if (active === "pricing") loadPrices();
}
function clearPriceForm() {
function showPriceEditor(show) {
priceEditorContentNode.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";
@@ -1034,6 +919,9 @@
document.querySelector("#fast-multiplier").value = "2.5";
showPriceSource({ kind: "manual" });
setLongFields(false);
syncLongSectionVisibility(false);
showPriceEditor(showEditor);
renderPrices();
}
function priceSourceText(source) {
@@ -1047,6 +935,7 @@
}
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;
@@ -1064,6 +953,9 @@
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 pricePayload() {
@@ -1090,7 +982,10 @@
}
priceListNode.replaceChildren(...currentPrices.map(price => {
const row = document.createElement("div");
row.className = "price-row";
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";
@@ -1098,44 +993,32 @@
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);
const actions = document.createElement("div"); actions.className = "key-actions";
const edit = document.createElement("button");
edit.type = "button"; edit.className = "small"; edit.textContent = "编辑"; edit.addEventListener("click", () => fillPriceForm(price));
const remove = document.createElement("button");
remove.type = "button"; remove.className = "small danger"; remove.textContent = "删除"; remove.addEventListener("click", () => deletePrice(price.model));
actions.append(edit, remove); row.append(text, actions); return row;
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 loadPrices() {
if (testMode) {
currentPrices = demoState.prices.map(price => structuredClone(price)); renderPrices();
priceStatusNode.textContent = `测试数据 · 已配置 ${currentPrices.length} 个模型`; await loadCatalogStatus(); return;
}
if (!keyInput.value.trim()) return;
try {
const response = await fetch(PRICE_API, { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
const payload = await dataFetch(PRICE_API);
currentPrices = payload.prices || [];
renderPrices();
const selected = currentPrices.find(price => price.model === selectedPriceModel);
if (selected) fillPriceForm(selected);
else if (selectedPriceModel) clearPriceForm(false);
else showPriceEditor(false);
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
await loadCatalogStatus();
if (managementAuthorized) await loadCatalogStatus();
else { currentCatalog = null; catalogSummaryNode.textContent = ""; renderCatalogResults([]); }
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
}
async function savePrice() {
try {
if (testMode) {
const payload = pricePayload(); const index = demoState.prices.findIndex(price => price.model === payload.model);
if (!payload.model) throw new Error("请输入模型");
payload.source = { kind: "manual" };
if (index >= 0) demoState.prices[index] = payload; else demoState.prices.push(payload);
saveDemoState(); priceStatusNode.textContent = "测试价格已保存"; await loadPrices(); showPriceSource({ kind: "manual" }); return;
}
const response = await fetch(PRICE_API, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(pricePayload()) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
const payload = pricePayload();
selectedPriceModel = payload.model;
await managedFetch(PRICE_API, { method: "PUT", body: JSON.stringify(payload) });
priceStatusNode.textContent = "价格已保存";
await loadPrices();
showPriceSource({ kind: "manual" });
@@ -1152,14 +1035,14 @@
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 ? " · 长上下文" : ""}`;
const use = document.createElement("button"); use.type = "button"; use.className = "small"; use.textContent = "使用"; use.addEventListener("click", () => importCatalogPrice(entry));
text.append(title, detail); row.append(text, use); return row;
text.append(title, detail); row.append(text);
if (managementAuthorized) { 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;
}));
}
function catalogSummary(payload) {
currentCatalog = payload?.catalog || null;
if (testMode) return "测试目录 · 不访问 models.dev";
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}`;
@@ -1168,14 +1051,7 @@
async function searchCatalog() {
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
try {
if (testMode) {
const models = query ? demoCatalog().filter(entry => [entry.id, entry.provider, entry.provider_name, entry.model, entry.model_name].some(value => value.toLowerCase().includes(query))) : [];
currentCatalog = { revision: "demo-v1", models: demoCatalog().length, fetched_at: new Date().toISOString() };
catalogSummaryNode.textContent = "测试目录 · 不访问 models.dev";
renderCatalogResults(models); return;
}
if (!keyInput.value.trim()) { catalogSummaryNode.textContent = "输入管理密钥后读取目录"; return; }
const payload = await managedFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
const payload = await dataFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
catalogSummaryNode.textContent = catalogSummary(payload);
renderCatalogResults(payload.models || []);
} catch (error) { catalogSummaryNode.textContent = "读取参考价格失败:" + error.message; renderCatalogResults([]); }
@@ -1189,28 +1065,13 @@
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
if (!localModel) { priceStatusNode.textContent = "请先输入本地模型名称"; return; }
try {
let imported;
if (testMode) {
const existing = demoState.prices.find(price => price.model.toLowerCase() === localModel.toLowerCase());
imported = {
model: localModel, base: structuredClone(entry.base), long_context: entry.long_context ? structuredClone(entry.long_context) : undefined,
fast_pricing_enabled: existing?.fast_pricing_enabled || false, fast_multiplier: existing?.fast_multiplier || "2.5",
source: { kind: "models.dev", catalog_id: entry.id, provider: entry.provider, model: entry.model, revision: "demo-v1", fetched_at: new Date().toISOString() }
};
const index = demoState.prices.findIndex(price => price.model.toLowerCase() === localModel.toLowerCase());
if (index >= 0) demoState.prices[index] = imported; else demoState.prices.push(imported);
saveDemoState();
} else {
imported = await managedFetch(PRICE_IMPORT_API, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
}
const imported = await managedFetch(PRICE_IMPORT_API, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
await loadPrices(); fillPriceForm(imported); priceStatusNode.textContent = `${localModel} 已采用 ${entry.provider_name || entry.provider} / ${entry.model} 参考价`;
} catch (error) { priceStatusNode.textContent = "导入参考价格失败:" + error.message; }
}
async function refreshCatalog() {
try {
if (testMode) { catalogSummaryNode.textContent = "测试目录已刷新 · 未访问 models.dev"; return; }
if (!keyInput.value.trim()) throw new Error("请输入管理密钥");
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
const preview = await managedFetch(CATALOG_REFRESH_API, { method: "POST" });
currentCatalog = preview.catalog;
@@ -1231,46 +1092,25 @@
async function deletePrice(model) {
try {
if (testMode) {
demoState.prices = demoState.prices.filter(price => price.model !== model); saveDemoState();
if (document.querySelector("#price-model").value.trim() === model) clearPriceForm(); await loadPrices(); return;
}
if (!confirm(`删除 ${model} 的真实价格配置?`)) return;
const response = await fetch(PRICE_API, { method: "DELETE", headers: authHeaders(true), body: JSON.stringify({ model }) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
if (document.querySelector("#price-model").value.trim() === model) clearPriceForm();
await managedFetch(PRICE_API, { method: "DELETE", body: JSON.stringify({ model }) });
if (selectedPriceModel === model) clearPriceForm(false);
await loadPrices();
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
}
async function load(manual = false, page = currentPage, cursor = "") {
if (loading) return;
if (testMode) {
const payload = demoUsagePage(page);
currentPagination = payload.pagination;
render(payload.records);
statusNode.textContent = "";
return;
}
const key = keyInput.value.trim();
if (!key) {
statusNode.textContent = "请输入管理密钥";
return;
}
loading = true;
if (manual) statusNode.textContent = "正在读取";
try {
const params = usageFilterQuery();
params.set("page", String(page || 1));
params.set("page_size", String(PAGE_SIZE));
if (cursor && !cursor.startsWith("demo-")) params.set("cursor", cursor);
const response = await fetch(API + "?" + params.toString(), { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
if (cursor) params.set("cursor", cursor);
const payload = await dataFetch(API + "?" + params.toString());
currentPagination = payload.pagination || { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0 };
const records = payload.records || [];
lastSignature = JSON.stringify(payload);
render(records);
statusNode.textContent = "";
} catch (error) {
@@ -1281,7 +1121,8 @@
}
keyInput.value = storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
keyInput.addEventListener("change", () => sessionStorage.setItem("billing:management-key", keyInput.value.trim()));
keyInput.addEventListener("change", () => { sessionStorage.setItem("billing:management-key", keyInput.value.trim()); setAccessState(false); reloadCurrentView(); });
keyInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); keyInput.blur(); } });
document.querySelector("#refresh").addEventListener("click", () => load(true, currentPage));
document.querySelector("#apply-usage-filters").addEventListener("click", () => { currentPage = 1; load(true, 1); });
document.querySelector("#clear-usage-filters").addEventListener("click", () => { ["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; load(true, 1); });
@@ -1290,12 +1131,11 @@
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);
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
document.querySelector("#reload-keys").addEventListener("click", loadKeyManagement);
document.querySelector("#include-archived").addEventListener("change", loadKeyManagement);
document.querySelector("#close-drawer").addEventListener("click", closeDrawer);
document.querySelector("#cancel-editor").addEventListener("click", closeDrawer);
drawerOverlayNode.addEventListener("click", closeDrawer);
@@ -1306,14 +1146,12 @@
document.querySelector("#editor-reset-period").addEventListener("change", syncEditorRoute);
document.querySelector("#reset-billing").addEventListener("click", resetManagedBilling);
document.querySelectorAll(".nav-button").forEach(button => button.addEventListener("click", () => setView(button.dataset.view)));
document.querySelectorAll(".mode-button").forEach(button => button.addEventListener("click", () => setMode(button.dataset.mode)));
document.querySelector("#reset-demo").addEventListener("click", () => { demoState = freshDemoState(); saveDemoState(); currentPage = 1; setMode("demo"); });
document.addEventListener("keydown", event => { if (event.key === "Escape") closeDrawer(); });
document.addEventListener("visibilitychange", () => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
document.addEventListener("visibilitychange", () => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
renderColumnControls();
setMode(sessionStorage.getItem("billing:data-mode") || "live");
setAccessState(false);
setView(sessionStorage.getItem("billing:active-view") || "keys");
setInterval(() => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
setInterval(() => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
</script>
</body>
</html>