feat: 统一管理台主题与 Key 展示

This commit is contained in:
chuan
2026-08-15 18:35:33 +08:00
parent ddebe3c4a2
commit d2a0bebe16
9 changed files with 113 additions and 62 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ CGO_ENABLED=1 go test ./...
管理页面提供:
- 自动生成或手动输入 Key,完整 Key 可随时复制
- 自动生成或手动输入至少 6 位的 Key,列表脱敏显示,点击脱敏文本可复制完整 Key
- `active` / `disabled` 状态切换,以及不可恢复但保留历史的归档;
- CPA 自动选择上游,或严格绑定一个 OAuth/API Key 上游凭证;
- 按客户端请求模型配置允许列表;
+2 -2
View File
@@ -22,7 +22,7 @@
Key 列表展示:
- 用户名称和完整 Key
- 用户名称和脱敏 Key
- 启用、禁用或归档状态;
- 当前余额和额度;
- 活跃并发与并发上限;
@@ -130,7 +130,7 @@ Key 列表展示:
- 管理台不直接操作 SQLite,只调用公开管理接口。
- 统计卡片和图表使用服务端汇总,不从当前明细页推算。
- 业务校验由服务端完成,前端校验只用于尽早提示。
- 完整 Key 仅受管理认证保护的 Key 接口和页面中显示
- 完整 Key 仅受管理认证保护的 Key 接口返回;页面列表脱敏显示,点击后复制完整值
- 上游 Token、Cookie 和原始凭证不进入管理台。
- 界面保持单个嵌入资源,避免运行时依赖外部 CDN。
- 窄屏下表单和工具栏自动重排,明细表保留横向滚动能力。
+1
View File
@@ -54,6 +54,7 @@ Key 值允许 1–256 个非空白、非控制字符。未手动填写时,系
- “允许全部模型”开启时,不再读取单独的模型规则。
- “允许全部模型”关闭时,至少需要一条模型规则。
- 手工输入的 Key 必须为 6-256 个不含空白或控制字符的字符;留空时自动生成。
- 不含 `*` 的规则执行精确匹配,例如 `gpt-5.6-sol`
- `*` 可以匹配任意长度的文本,例如 `deepseek-*``*-flash``gpt-*-codex`
- 空模型名不能通过模型准入。
+21
View File
@@ -186,6 +186,27 @@ func TestCreateKeyCopiesDefaultRuleSnapshot(t *testing.T) {
}
}
func TestCreateKeyRejectsShortSecret(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\n"))); err != nil {
t.Fatal(err)
}
response := managementCallBody(t, app, http.MethodPost, managementBase+routeKeys, []byte(`{"name":"short","secret":"12345"}`))
if response.StatusCode != http.StatusBadRequest || !strings.Contains(string(response.Body), "6-256") {
t.Fatalf("short Key response=%d body=%s", response.StatusCode, response.Body)
}
}
func TestManagedSecretValidation(t *testing.T) {
for secret, want := range map[string]bool{
"12345": false, "000000": true, "alice-000000": true, "with space": false, strings.Repeat("x", 257): false,
} {
if got := validManagedSecret(secret); got != want {
t.Fatalf("validManagedSecret(%q)=%v, want %v", secret, got, want)
}
}
}
func TestBillingAdmissionConcurrencyAndUsageSettlement(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
+6
View File
@@ -65,6 +65,12 @@ func TestLifecycleConfigYAMLUsesBase64WireEncoding(t *testing.T) {
}
}
func TestConfigRejectsShortBootstrapKey(t *testing.T) {
if _, err := decodeConfig([]byte("bootstrap_key: 12345\n")); err == nil {
t.Fatal("short bootstrap_key unexpectedly accepted")
}
}
func TestUsageRecordWireFieldsMatchTargetContract(t *testing.T) {
raw, err := json.Marshal(UsageRecord{})
if err != nil {
+5 -2
View File
@@ -33,8 +33,11 @@ func decodeConfig(raw []byte) (Config, error) {
if strings.TrimSpace(cfg.DatabasePath) == "" {
return Config{}, fmt.Errorf("database_path 不能为空")
}
if strings.TrimSpace(cfg.BootstrapName) == "" || strings.TrimSpace(cfg.BootstrapKey) == "" {
return Config{}, fmt.Errorf("bootstrap_name 和 bootstrap_key 不能为空")
if strings.TrimSpace(cfg.BootstrapName) == "" {
return Config{}, fmt.Errorf("bootstrap_name 不能为空")
}
if !validManagedSecret(cfg.BootstrapKey) {
return Config{}, fmt.Errorf("bootstrap_key 必须为 6-256 个不含空白或控制字符的字符")
}
return cfg, nil
}
+2 -2
View File
@@ -124,7 +124,7 @@ func (a *App) createManagedKey(body []byte) ManagementResponse {
}
}
if !validManagedSecret(secret) {
return managementError(http.StatusBadRequest, "invalid_key", "Key 必须为 1-256 个不含空白或控制字符的字符")
return managementError(http.StatusBadRequest, "invalid_key", "Key 必须为 6-256 个不含空白或控制字符的字符")
}
keyID, err := generatedToken("key_")
if err != nil {
@@ -465,7 +465,7 @@ func generatedToken(prefix string) (string, error) {
}
func validManagedSecret(secret string) bool {
if secret == "" || len(secret) > 256 {
if len(secret) < 6 || len(secret) > 256 {
return false
}
for _, value := range secret {
+6 -1
View File
@@ -454,11 +454,16 @@ 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 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格"} {
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格", "点击“管理”修改访问、路由与额度", "由 CPA 调度", `copy.textContent = "复制"`} {
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 "自由选择"`} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain compact Key display feature %q", feature)
}
}
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
t.Fatal("UI pagination controls are not above the usage table")
}
+69 -54
View File
@@ -7,37 +7,43 @@
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #0b0f17; --panel: #111722; --panel-raised: #151d2a; --panel-soft: #0f1520;
--border: #263142; --border-soft: #1d2736; --text: #e7edf6; --muted: #8b98aa;
--accent: #7dd3fc; --accent-strong: #38bdf8; --accent-soft: #0c2b3d;
--success: #34d399; --danger: #fb7185; --warning: #fbbf24;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
/* Keep these tokens aligned with CLIProxyAPI Manager's dark theme. */
--bg-primary: #1d1b18; --bg-secondary: #151412; --bg-tertiary: #262320; --bg-hover: #2e2a26; --bg-quinary: #191714; --floating-surface: #2a2723;
--border-color: #3a3530; --border-primary: #4a453f; --border-hover: #5a544d;
--text-primary: #f6f4f1; --text-secondary: #c9c3bb; --text-tertiary: #9c958d; --text-quaternary: #6f6962;
--primary-color: #8b8680; --primary-hover: #9a948e; --primary-active: #a6a099;
--success-color: #10b981; --error-color: #c65746; --warning-color: #ffd862; --amber-color: #f59e0b;
--bg: var(--bg-primary); --panel: var(--bg-secondary); --panel-raised: var(--floating-surface); --panel-soft: var(--bg-quinary);
--border: var(--border-color); --border-soft: var(--border-color); --text: var(--text-primary); --muted: var(--text-tertiary);
--accent: var(--primary-active); --accent-strong: var(--primary-color); --accent-soft: var(--bg-tertiary);
--success: var(--success-color); --danger: var(--error-color); --warning: var(--warning-color);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 58px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: #0b0f17eb; backdrop-filter: blur(16px); }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 58px; padding: 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 #155e75; border-radius: 8px; background: linear-gradient(145deg, #164e63, #0c2534); color: #bae6fd; font-size: 11px; font-weight: 800; letter-spacing: .04em; }
.brand-mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
.brand strong { font-size: 14px; letter-spacing: .02em; }
.brand span { color: var(--muted); font-size: 12px; }
.auth-box { display: flex; align-items: center; gap: 8px; }
.auth-box label { color: var(--muted); font-size: 12px; }
.mode-switch { display: flex; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: #080d14; }
.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: #ffffff08; }
.mode-button.active { background: #1b283a; color: var(--text); }
.demo-mode .mode-button[data-mode="demo"] { background: #78350f; color: #fef3c7; }
.demo-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 22px; border-bottom: 1px solid #92400e; background: #451a03; color: #fde68a; font-size: 11px; }
.demo-banner button { border-color: #92400e; background: #78350f; color: #fef3c7; }
input, select { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; outline: none; background: #0d1420; color: inherit; transition: border-color .15s, box-shadow .15s; }
input:focus, select:focus { border-color: #0e7490; box-shadow: 0 0 0 3px #0891b21f; }
.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; }
input, select { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; outline: none; background: var(--bg-tertiary); color: inherit; transition: border-color .15s, box-shadow .15s; }
input:focus, select:focus { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
input:disabled, select:disabled { cursor: not-allowed; opacity: .55; }
input[type="checkbox"] { width: 15px; height: 15px; padding: 0; accent-color: var(--accent-strong); }
#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: #0d121b; overflow-x: auto; overflow-y: hidden; }
.workspace-nav { display: flex; align-items: stretch; gap: 3px; height: 50px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); background: var(--bg-secondary); overflow-x: auto; overflow-y: hidden; }
.nav-button { position: relative; display: flex; flex: 0 0 auto; align-items: center; height: 49px; border: 0; padding: 0 13px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; }
.nav-button:hover { color: var(--text); }
.nav-button.active { color: var(--text); }
@@ -52,11 +58,11 @@
h2 { margin-bottom: 10px; font-size: 15px; }
.section-heading p, .subtle { margin-bottom: 0; color: var(--muted); font-size: 12px; }
.tools { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
button, summary { border: 1px solid var(--border); border-radius: 7px; padding: 8px 11px; background: #151d2a; color: var(--text); font-size: 12px; }
button:hover, summary:hover { border-color: #3b4b61; background: #192334; }
button.primary { border-color: #0e7490; background: #0e7490; color: white; }
button.primary:hover { background: #0891b2; }
button.danger { color: #fecdd3; }
button, summary { border: 1px solid var(--border); border-radius: 7px; padding: 8px 11px; background: var(--bg-tertiary); color: var(--text); font-size: 12px; }
button:hover, summary:hover { border-color: var(--border-hover); background: var(--bg-hover); }
button.primary { border-color: var(--primary-color); background: var(--primary-color); color: white; }
button.primary:hover { border-color: var(--primary-hover); background: var(--primary-hover); }
button.danger { color: #f1b0a6; }
button.small { padding: 5px 8px; }
button.icon-button { width: 32px; height: 32px; padding: 0; font-size: 17px; }
details { position: relative; }
@@ -66,10 +72,10 @@
.column-options label { display: flex; align-items: center; gap: 6px; }
.status { min-height: 20px; color: var(--muted); font-size: 12px; }
.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: linear-gradient(145deg, #121a27, #101620); }
.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: #64748b; font-size: 11px; }
.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; }
@@ -80,9 +86,9 @@
.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: #cbd5e1; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: #202b3b; }
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: linear-gradient(90deg, #0e7490, #38bdf8); }
.bar-label { overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: var(--bg-tertiary); }
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--primary-active); }
.bar-count { color: var(--muted); text-align: right; }
.user-usage { display: grid; overflow-x: auto; }
.user-usage-row { display: grid; grid-template-columns: minmax(90px, 1.2fr) 68px minmax(90px, .8fr) 78px minmax(120px, 1fr); align-items: center; gap: 10px; min-width: 570px; min-height: 31px; border-bottom: 1px solid var(--border-soft); font-size: 10px; }
@@ -95,15 +101,15 @@
.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: #111925; }
.price-row:hover { border-color: #3b4b61; background: #151f2d; }
.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:hover { border-color: var(--border-hover); background: var(--bg-hover); }
.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; }
.price-section { margin-bottom: 10px; padding: 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: #0e1520; }
.price-section { margin-bottom: 10px; padding: 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--bg-quinary); }
.price-section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 11px; }
.price-section-header h3 { margin: 0; font-size: 11px; }
.price-section-header .inline-check { color: var(--text); }
@@ -116,15 +122,18 @@
.price-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 3px; }
.inline-check { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
.key-identity { display: flex; align-items: center; gap: 9px; }
.avatar { display: grid; flex: 0 0 auto; width: 28px; height: 28px; place-items: center; border: 1px solid #285266; border-radius: 8px; background: var(--accent-soft); color: #bae6fd; font-size: 11px; font-weight: 700; }
.avatar { display: grid; flex: 0 0 auto; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--accent-soft); color: var(--text-secondary); font-size: 11px; font-weight: 700; }
.key-identity strong { display: block; font-size: 12px; }
.key-identity small { display: block; max-width: 160px; overflow: hidden; color: #64748b; font-size: 10px; text-overflow: ellipsis; }
.credential { display: flex; align-items: center; gap: 6px; }
code { color: #cbd5e1; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.key-identity small { display: block; max-width: 160px; overflow: hidden; color: var(--text-quaternary); font-size: 10px; text-overflow: ellipsis; }
.credential { display: flex; align-items: center; }
.credential-copy { cursor: copy; border-radius: 4px; padding: 3px 4px; }
.credential-copy:hover { background: var(--bg-hover); color: var(--text-primary); }
.credential-copy:focus-visible { outline: 2px solid var(--primary-active); outline-offset: 2px; }
code { color: var(--text-secondary); font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 10px; }
.badge::before { width: 5px; height: 5px; border-radius: 50%; background: currentColor; content: ""; }
.badge.active { border-color: #14532d; background: #052e1b; color: var(--success); }
.badge.disabled, .badge.archived { color: #94a3b8; }
.badge.active { border-color: #059669; background: #064e3b4d; color: #6ee7b7; }
.badge.disabled, .badge.archived { color: var(--text-tertiary); }
.route-main { font-size: 12px; }
.route-sub { margin-top: 2px; max-width: 220px; overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; }
.key-actions { display: flex; gap: 6px; }
@@ -132,29 +141,29 @@
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; }
th, td { padding: 8px 10px; border-bottom: 1px solid var(--border-soft); text-align: right; }
th { position: sticky; top: 0; z-index: 1; background: #141c29; color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .03em; text-transform: uppercase; }
th { position: sticky; top: 0; z-index: 1; background: var(--bg-quinary); color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .03em; text-transform: uppercase; }
th.left, td.left { text-align: left; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #ffffff05; }
.ok { color: #34d399; }
.failed { color: #f87171; }
.drawer-overlay { position: fixed; z-index: 40; inset: 0; background: #020617a6; backdrop-filter: blur(2px); }
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(540px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: #0f1621; box-shadow: -20px 0 60px #0008; }
tbody tr:hover { background: color-mix(in srgb, var(--text-primary) 3%, transparent); }
.ok { color: var(--success-color); }
.failed { color: var(--error-color); }
.drawer-overlay { position: fixed; z-index: 40; inset: 0; background: #000000a6; backdrop-filter: blur(2px); }
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(540px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: var(--bg-primary); box-shadow: -20px 0 60px #0008; }
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 17px 18px 13px; border-bottom: 1px solid var(--border-soft); }
.drawer-header h2 { margin-bottom: 3px; }
.drawer-header p { margin-bottom: 0; color: var(--muted); font-size: 11px; }
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px; }
.drawer-form { display: grid; gap: 13px; }
.drawer-section { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: #0c131e; }
.drawer-section { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--bg-secondary); }
.drawer-section h3 { margin: 0; font-size: 11px; }
.field-hint { margin: -2px 0 0; color: #64748b; font-size: 9px; }
.field-hint { margin: -2px 0 0; color: var(--text-quaternary); font-size: 9px; }
.billing-preview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.billing-preview div { padding: 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: #111925; }
.billing-preview div { padding: 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--bg-primary); }
.billing-preview span { display: block; color: var(--muted); font-size: 9px; }
.billing-preview strong { display: block; margin-top: 3px; font-size: 14px; }
.billing-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ledger-list { display: grid; max-height: 210px; overflow: auto; gap: 5px; }
.ledger-item { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; padding: 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: #101824; }
.ledger-item { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; padding: 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--bg-primary); }
.ledger-item strong { font-size: 10px; }
.ledger-item .positive { color: var(--success); }
.ledger-item .negative { color: var(--danger); }
@@ -174,7 +183,7 @@
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 0; }
.page-buttons { display: flex; align-items: center; gap: 4px; }
.page-buttons button { min-width: 29px; height: 27px; padding: 3px 7px; }
.page-buttons button.active { border-color: #0e7490; background: #0e7490; color: white; }
.page-buttons button.active { border-color: var(--primary-color); background: var(--primary-color); color: white; }
.page-buttons button:disabled { cursor: default; opacity: .4; }
.page-ellipsis { width: 22px; color: var(--muted); text-align: center; font-size: 10px; }
.usage-filters { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: 9px; margin-bottom: 10px; padding: 11px; }
@@ -209,7 +218,7 @@
</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><span class="subtle">点击“管理”修改访问、路由与额度</span></div>
<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>
</section>
@@ -257,10 +266,10 @@
<div class="drawer-body">
<div id="key-editor" class="drawer-form">
<div class="drawer-section"><h3>用户</h3><label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
<label class="field">完整 Key<input id="editor-secret" class="secret" placeholder="留空自动生成"></label></div>
<label class="field">完整 Key<input id="editor-secret" class="secret" minlength="6" maxlength="256" placeholder="留空自动生成"></label></div>
<div id="managed-fields" class="drawer-form">
<div class="drawer-section"><h3>访问与路由</h3>
<div class="field-row"><label class="field">状态<select id="editor-status"><option value="active">启用</option><option value="disabled">禁用</option><option value="archived">已归档</option></select></label><label class="field">路由<select id="editor-route"><option value="auto">选择</option><option value="strict">指定账号</option></select></label></div>
<div class="field-row"><label class="field">状态<select id="editor-status"><option value="active">启用</option><option value="disabled">禁用</option><option value="archived">已归档</option></select></label><label class="field">路由<select id="editor-route"><option value="auto">选择</option><option value="strict">指定账号</option></select></label></div>
<label class="field">上游账号<select id="editor-upstream"></select></label>
<label class="inline-check"><input id="editor-all-models" type="checkbox">允许全部模型</label>
<label class="field">允许模型规则<input id="editor-models" placeholder="deepseek-*gpt-5.6-sol"></label><p class="field-hint">多个规则用逗号分隔;* 匹配任意字符,未匹配的模型会被拒绝。</p></div>
@@ -675,9 +684,14 @@
}
function routeLabel(key) {
if (key.route_mode !== "strict") return { main: "自选择", sub: "由 CPA 调度" };
if (key.route_mode !== "strict") return "自选择";
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
return { main: "指定账号", sub: upstreamLabel(account) };
return account ? account.display_name || account.cpa_auth_id || account.id : "指定";
}
function maskedSecret(value) {
const secret = String(value || "");
return secret.slice(0, 2) + "******" + secret.slice(-4);
}
function copyText(value, button) {
@@ -760,12 +774,12 @@
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.textContent = key.secret;
const copy = document.createElement("button"); copy.type = "button"; copy.className = "small"; copy.textContent = "复制"; copy.addEventListener("click", () => copyText(key.secret, copy)); credential.append(secret, copy);
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 status = document.createElement("span"); status.className = `badge ${key.status}`; status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
const billing = key.billing || {}; const balance = document.createElement("div"); const balanceMain = document.createElement("div"); balanceMain.className = "route-main"; balanceMain.textContent = money(billing.balance_usd); const balanceSub = document.createElement("div"); balanceSub.className = "route-sub"; balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`; balance.append(balanceMain, balanceSub);
const concurrency = document.createElement("span"); concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
const route = routeLabel(key); const routeNode = document.createElement("div"); const routeMain = document.createElement("div"); routeMain.className = "route-main"; routeMain.textContent = route.main; const routeSub = document.createElement("div"); routeSub.className = "route-sub"; routeSub.textContent = route.sub; routeNode.append(routeMain, routeSub);
const routeNode = document.createElement("span"); routeNode.className = "route-main"; routeNode.textContent = routeLabel(key);
const models = document.createElement("span"); models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
const actions = document.createElement("div"); actions.className = "key-actions";
const stats = document.createElement("button"); stats.type = "button"; stats.className = "small"; stats.textContent = "统计"; stats.addEventListener("click", () => loadKeyStats(key));
@@ -841,6 +855,7 @@
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();