feat: 重构管理台并隔离测试数据
This commit is contained in:
@@ -371,7 +371,7 @@ func TestUsageManagementResponseContainsDisplayFields(t *testing.T) {
|
||||
func TestUsageResourceServesTablePage(t *testing.T) {
|
||||
response := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI)
|
||||
page := string(response.Body)
|
||||
if response.StatusCode != http.StatusOK || !strings.Contains(page, "最近用量记录") {
|
||||
if response.StatusCode != http.StatusOK || !strings.Contains(page, "请求明细") {
|
||||
t.Fatalf("unexpected UI response: status=%d", response.StatusCode)
|
||||
}
|
||||
for _, column := range []string{"Key / 别名", "推理强度", "生成速度", "缓存写入", "总成本", "客户端 IP"} {
|
||||
@@ -382,9 +382,33 @@ func TestUsageResourceServesTablePage(t *testing.T) {
|
||||
if !strings.Contains(page, `return "compact"`) || !strings.Contains(page, "isCompactEndpoint(record.endpoint)") {
|
||||
t.Fatal("UI does not contain compact display rules")
|
||||
}
|
||||
for _, feature := range []string{"下游 Keys", "创建 Key", "指定账号", "允许模型", "永久归档"} {
|
||||
for _, feature := range []string{"创建 Key", "指定账号", "允许模型", "永久归档"} {
|
||||
if !strings.Contains(page, feature) {
|
||||
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"`, `overflow-y: hidden`, "测试模式禁止访问真实管理接口"} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain workspace feature %q", feature)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "<script src=") || strings.Contains(page, "<link rel=\"stylesheet\" href=") {
|
||||
t.Fatal("UI unexpectedly depends on external assets")
|
||||
}
|
||||
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1–100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格"} {
|
||||
if strings.Contains(page, removed) {
|
||||
t.Fatalf("UI still contains removed description %q", removed)
|
||||
}
|
||||
}
|
||||
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
|
||||
t.Fatal("UI pagination controls are not above the usage table")
|
||||
}
|
||||
for _, feature := range []string{`class="surface price-panel price-layout"`, `id="new-price"`, "基础价格 · $ / 1M Token", "长上下文价格", "Fast 价格", "删除 ${model} 的真实价格配置"} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain redesigned pricing feature %q", feature)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "scrollIntoView") {
|
||||
t.Fatal("UI pagination still changes the document scroll position")
|
||||
}
|
||||
}
|
||||
|
||||
+577
-148
@@ -3,118 +3,241 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>CPA 用量记录</title>
|
||||
<title>CPA Ext 管理台</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 24px; background: #111827; color: #e5e7eb; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
.tools { display: flex; align-items: center; gap: 8px; }
|
||||
input, select, button { border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; background: #1f2937; color: inherit; }
|
||||
: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;
|
||||
}
|
||||
* { 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); }
|
||||
.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 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-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; }
|
||||
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; }
|
||||
.nav-button { position: relative; display: flex; flex: 0 0 auto; align-items: center; height: 49px; border: 0; padding: 0 13px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; }
|
||||
.nav-button:hover { color: var(--text); }
|
||||
.nav-button.active { color: var(--text); }
|
||||
.nav-button.active::after { position: absolute; right: 10px; bottom: -1px; left: 10px; height: 2px; border-radius: 2px 2px 0 0; background: var(--accent-strong); content: ""; }
|
||||
main { padding: 18px 22px 28px; }
|
||||
.view { display: none; }
|
||||
.view.active { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-heading.actions-only { justify-content: flex-end; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 4px; font-size: 19px; letter-spacing: -.01em; }
|
||||
h2 { margin-bottom: 10px; font-size: 15px; }
|
||||
.section-heading p, .subtle { margin-bottom: 0; color: var(--muted); font-size: 12px; }
|
||||
.tools { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
|
||||
button, summary { border: 1px solid var(--border); border-radius: 7px; padding: 8px 11px; background: #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.small { padding: 5px 8px; }
|
||||
button.icon-button { width: 32px; height: 32px; padding: 0; font-size: 17px; }
|
||||
details { position: relative; }
|
||||
summary { list-style: none; cursor: pointer; border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; background: #1f2937; }
|
||||
summary { list-style: none; cursor: pointer; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
.column-options { position: absolute; z-index: 10; right: 0; top: calc(100% + 6px); display: grid; grid-template-columns: repeat(2, max-content); gap: 8px 18px; padding: 12px; border: 1px solid #374151; border-radius: 6px; background: #1f2937; box-shadow: 0 12px 30px #0008; }
|
||||
.column-options { position: absolute; z-index: 10; right: 0; top: calc(100% + 6px); display: grid; grid-template-columns: repeat(2, max-content); gap: 8px 18px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel-raised); box-shadow: 0 16px 40px #0009; }
|
||||
.column-options label { display: flex; align-items: center; gap: 6px; }
|
||||
.status { min-height: 24px; color: #9ca3af; }
|
||||
.pricing { position: static; margin-bottom: 16px; }
|
||||
.pricing > summary { display: inline-block; }
|
||||
.price-panel { margin-top: 10px; padding: 14px; border: 1px solid #374151; border-radius: 8px; background: #172033; }
|
||||
.price-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); gap: 10px; }
|
||||
.price-grid label { display: grid; gap: 5px; color: #9ca3af; font-size: 12px; }
|
||||
.price-grid .check { display: flex; align-items: center; gap: 7px; color: #e5e7eb; }
|
||||
.price-grid .check input { width: auto; }
|
||||
.price-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.price-list { display: grid; gap: 6px; margin-top: 14px; }
|
||||
.price-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 1px solid #374151; border-radius: 6px; }
|
||||
.price-row small { color: #9ca3af; }
|
||||
.managed { margin-bottom: 16px; padding: 14px; border: 1px solid #374151; border-radius: 8px; background: #172033; }
|
||||
.managed h2 { margin: 0 0 12px; font-size: 17px; }
|
||||
.managed-form { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(180px, 2fr) auto; gap: 8px; margin-bottom: 10px; }
|
||||
.managed-tools { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.managed-tools label, .inline-check { display: flex; align-items: center; gap: 6px; color: #9ca3af; }
|
||||
.managed-tools input, .inline-check input { width: auto; }
|
||||
.key-table input, .key-table select { min-width: 110px; padding: 6px 8px; }
|
||||
.key-table .secret { min-width: 210px; font-family: ui-monospace, monospace; }
|
||||
.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 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; }
|
||||
.surface { border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.surface-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 45px; padding: 8px 10px 8px 13px; border-bottom: 1px solid var(--border-soft); }
|
||||
.surface-toolbar .status { min-height: 0; }
|
||||
.chart-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 9px; margin-bottom: 12px; }
|
||||
.chart-card { min-height: 180px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||||
.chart-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 13px; }
|
||||
.chart-card h2 { margin-bottom: 2px; font-size: 12px; }
|
||||
.chart-card p { margin-bottom: 0; color: var(--muted); font-size: 10px; }
|
||||
.bar-chart { display: grid; gap: 9px; }
|
||||
.bar-row { display: grid; grid-template-columns: minmax(70px, 110px) minmax(100px, 1fr) 42px; align-items: center; gap: 9px; font-size: 10px; }
|
||||
.bar-label { overflow: hidden; color: #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-count { color: var(--muted); text-align: right; }
|
||||
.user-usage { display: grid; overflow-x: auto; }
|
||||
.user-usage-row { display: grid; grid-template-columns: minmax(90px, 1.2fr) 68px minmax(90px, .8fr) 78px minmax(120px, 1fr); align-items: center; gap: 10px; min-width: 570px; min-height: 31px; border-bottom: 1px solid var(--border-soft); font-size: 10px; }
|
||||
.user-usage-row:last-child { border-bottom: 0; }
|
||||
.user-usage-row.header { min-height: 26px; color: var(--muted); font-size: 9px; }
|
||||
.user-usage-row span:not(:first-child) { text-align: right; }
|
||||
.price-panel { padding: 0; }
|
||||
.price-layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); min-height: 520px; }
|
||||
.price-sidebar { padding: 13px; border-right: 1px solid var(--border-soft); background: var(--panel-soft); }
|
||||
.price-sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
||||
.price-sidebar-header h2 { margin: 0; font-size: 12px; }
|
||||
.price-list { display: grid; 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 .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-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 11px; }
|
||||
.price-section-header h3 { margin: 0; font-size: 11px; }
|
||||
.price-section-header .inline-check { color: var(--text); }
|
||||
.price-grid { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; }
|
||||
.price-grid.long-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
.field, .price-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 10px; }
|
||||
.price-model-field { max-width: 480px; }
|
||||
.fast-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(130px, 220px); align-items: end; gap: 16px; }
|
||||
.fast-row .inline-check { align-self: center; color: var(--text); }
|
||||
.price-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 3px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.stats-card { margin-top: 10px; padding: 10px; border: 1px solid #374151; border-radius: 6px; color: #cbd5e1; white-space: pre-wrap; }
|
||||
.hidden { display: none; }
|
||||
.table-wrap { overflow: auto; border: 1px solid #374151; border-radius: 8px; }
|
||||
.hidden { display: none !important; }
|
||||
.table-wrap { overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; }
|
||||
th, td { padding: 9px 10px; border-bottom: 1px solid #273244; text-align: right; }
|
||||
th { position: sticky; top: 0; background: #1f2937; color: #9ca3af; font-weight: 600; }
|
||||
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.left, td.left { text-align: left; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: #ffffff05; }
|
||||
.ok { color: #34d399; }
|
||||
.failed { color: #f87171; }
|
||||
@media (max-width: 900px) { .price-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } }
|
||||
@media (max-width: 720px) { body { padding: 12px; } header { align-items: stretch; flex-direction: column; } .price-grid, .managed-form { grid-template-columns: 1fr; } }
|
||||
.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(430px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: #0f1621; 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; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.drawer-footer { display: flex; justify-content: space-between; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border-soft); }
|
||||
.drawer-footer div { display: flex; gap: 7px; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; }
|
||||
.stats-tile { padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: var(--panel-soft); }
|
||||
.stats-tile span { color: var(--muted); font-size: 10px; }
|
||||
.stats-tile strong { display: block; margin-top: 5px; font-size: 17px; }
|
||||
.recent-list { display: grid; gap: 6px; }
|
||||
.recent-item { padding: 9px 10px; border: 1px solid var(--border-soft); border-radius: 7px; background: var(--panel-soft); }
|
||||
.recent-item strong { display: block; margin-bottom: 3px; font-size: 11px; }
|
||||
.recent-item span { color: var(--muted); font-size: 10px; }
|
||||
.empty { padding: 28px; color: var(--muted); text-align: center; }
|
||||
.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:disabled { cursor: default; opacity: .4; }
|
||||
.page-ellipsis { width: 22px; color: var(--muted); text-align: center; font-size: 10px; }
|
||||
@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) { .topbar { align-items: flex-start; height: auto; 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; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>最近用量记录</h1>
|
||||
<div class="tools">
|
||||
<input id="key" type="password" autocomplete="off" placeholder="管理密钥">
|
||||
<details>
|
||||
<summary>选择列</summary>
|
||||
<div id="column-options" class="column-options"></div>
|
||||
</details>
|
||||
<button id="refresh" type="button">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="managed">
|
||||
<h2>下游 Keys</h2>
|
||||
<div class="managed-form">
|
||||
<input id="new-key-name" placeholder="名称,例如 alice">
|
||||
<input id="new-key-secret" placeholder="自定义 Key;留空自动生成">
|
||||
<button id="create-key" type="button">创建 Key</button>
|
||||
</div>
|
||||
<div class="managed-tools">
|
||||
<button id="reload-keys" type="button">刷新账号与 Keys</button>
|
||||
<label><input id="include-archived" type="checkbox">显示已归档</label>
|
||||
<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></tr></thead>
|
||||
<tbody id="key-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="key-stats" class="stats-card hidden"></div>
|
||||
</section>
|
||||
<details class="pricing">
|
||||
<summary>价格设置</summary>
|
||||
<div class="price-panel">
|
||||
<div class="price-grid">
|
||||
<label>模型<input id="price-model" placeholder="gpt-5.6-sol"></label>
|
||||
<label>输入 ($/1M)<input id="price-input" inputmode="decimal"></label>
|
||||
<label>缓存读取 ($/1M)<input id="price-cache-read" inputmode="decimal"></label>
|
||||
<label>缓存写入 ($/1M)<input id="price-cache-write" inputmode="decimal"></label>
|
||||
<label>输出 ($/1M)<input id="price-output" inputmode="decimal"></label>
|
||||
<label class="check"><input id="long-enabled" type="checkbox">启用长上下文价格</label>
|
||||
<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>
|
||||
<label class="check"><input id="fast-pricing-enabled" type="checkbox">Fast 使用倍率计价</label>
|
||||
<label>Fast 倍率<input id="fast-multiplier" inputmode="decimal" value="2.5"></label>
|
||||
</div>
|
||||
<div class="price-actions"><button id="save-price" type="button">保存价格</button><button id="clear-price" type="button">清空表单</button></div>
|
||||
<div id="price-status" class="status"></div>
|
||||
<div id="price-list" class="price-list"></div>
|
||||
</div>
|
||||
</details>
|
||||
<div id="status" class="status"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr id="headers"></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="brand-mark">CX</div><div><strong>CPA Ext</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>
|
||||
</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>
|
||||
<button class="nav-button" type="button" data-view="pricing">价格配置</button>
|
||||
</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><span class="subtle">点击“管理”修改路由与权限</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></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"><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>
|
||||
<section class="price-editor">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="drawer-overlay" class="drawer-overlay hidden"></div>
|
||||
<aside id="key-drawer" class="drawer hidden" aria-hidden="true">
|
||||
<div class="drawer-header"><div><h2 id="drawer-title">管理 Key</h2><p id="drawer-subtitle">修改用户状态、路由和模型权限</p></div><button id="close-drawer" class="icon-button" type="button" aria-label="关闭">×</button></div>
|
||||
<div class="drawer-body">
|
||||
<div id="key-editor" class="drawer-form">
|
||||
<label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
|
||||
<label class="field">完整 Key<input id="editor-secret" class="secret" placeholder="留空自动生成"></label>
|
||||
<div id="managed-fields" class="drawer-form">
|
||||
<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="多个模型用逗号分隔"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="key-stats" class="hidden"><div id="stats-grid" class="stats-grid"></div><h3>最近请求</h3><div id="stats-recent" class="recent-list"></div></div>
|
||||
</div>
|
||||
<div id="editor-footer" class="drawer-footer"><button id="archive-key" class="danger hidden" type="button">永久归档</button><div><button id="cancel-editor" type="button">取消</button><button id="save-key" class="primary" type="button">保存</button></div></div>
|
||||
</aside>
|
||||
<script>
|
||||
const API = "/v0/management/plugins/cpa-ext/usage";
|
||||
const PRICE_API = "/v0/management/plugins/cpa-ext/prices";
|
||||
@@ -133,13 +256,81 @@
|
||||
const keyRowsNode = document.querySelector("#key-rows");
|
||||
const keyStatusNode = document.querySelector("#key-status");
|
||||
const keyStatsNode = document.querySelector("#key-stats");
|
||||
const drawerNode = document.querySelector("#key-drawer");
|
||||
const drawerOverlayNode = document.querySelector("#drawer-overlay");
|
||||
const editorNode = document.querySelector("#key-editor");
|
||||
const managedFieldsNode = document.querySelector("#managed-fields");
|
||||
const editorFooterNode = document.querySelector("#editor-footer");
|
||||
const statsGridNode = document.querySelector("#stats-grid");
|
||||
const statsRecentNode = document.querySelector("#stats-recent");
|
||||
const pageButtonsNode = document.querySelector("#page-buttons");
|
||||
const PAGE_SIZE = 100;
|
||||
const DEMO_STORE_KEY = "cpa-ext:demo-state:v2";
|
||||
let currentRecords = [];
|
||||
let currentPage = 1;
|
||||
let lastSignature = "";
|
||||
let loading = false;
|
||||
let currentPrices = [];
|
||||
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"
|
||||
};
|
||||
}
|
||||
|
||||
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: true, models: [] },
|
||||
{ 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: true, models: [] },
|
||||
{ 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(), 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"
|
||||
};
|
||||
});
|
||||
return { keys, upstreams, usage, prices: [demoPrice()] };
|
||||
}
|
||||
|
||||
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)) return value;
|
||||
} catch (_) {}
|
||||
return freshDemoState();
|
||||
}
|
||||
|
||||
function saveDemoState() {
|
||||
localStorage.setItem(DEMO_STORE_KEY, JSON.stringify(demoState));
|
||||
}
|
||||
|
||||
function storedPanelKey() {
|
||||
const prefix = "enc::v1::";
|
||||
@@ -264,8 +455,12 @@
|
||||
|
||||
function render(records) {
|
||||
currentRecords = records;
|
||||
const pageCount = Math.max(1, Math.ceil(currentRecords.length / PAGE_SIZE));
|
||||
currentPage = Math.min(Math.max(1, currentPage), pageCount);
|
||||
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
|
||||
rowsNode.replaceChildren(...records.map(record => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
const pageRecords = currentRecords.slice(start, start + PAGE_SIZE);
|
||||
rowsNode.replaceChildren(...pageRecords.map(record => {
|
||||
const row = document.createElement("tr");
|
||||
activeColumns.forEach(column => {
|
||||
const cell = document.createElement("td");
|
||||
@@ -276,6 +471,35 @@
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
renderPagination(pageCount, start, pageRecords.length);
|
||||
}
|
||||
|
||||
function pageItems(page, count) {
|
||||
if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1);
|
||||
const values = new Set([1, count, page - 1, page, page + 1]);
|
||||
const sorted = [...values].filter(value => value >= 1 && value <= count).sort((a, b) => a - b);
|
||||
const items = [];
|
||||
sorted.forEach((value, index) => {
|
||||
if (index && value - sorted[index - 1] > 1) items.push("ellipsis-" + value);
|
||||
items.push(value);
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderPagination(pageCount, start, visibleCount) {
|
||||
const controls = [];
|
||||
const button = (label, page, disabled = false, active = false) => {
|
||||
const node = document.createElement("button"); node.type = "button"; node.textContent = label; node.disabled = disabled; node.classList.toggle("active", active);
|
||||
if (!disabled && !active) node.addEventListener("click", () => { currentPage = page; render(currentRecords); });
|
||||
return node;
|
||||
};
|
||||
controls.push(button("上一页", currentPage - 1, currentPage === 1));
|
||||
pageItems(currentPage, pageCount).forEach(item => {
|
||||
if (typeof item === "string") { const ellipsis = document.createElement("span"); ellipsis.className = "page-ellipsis"; ellipsis.textContent = "…"; controls.push(ellipsis); }
|
||||
else controls.push(button(String(item), item, false, item === currentPage));
|
||||
});
|
||||
controls.push(button("下一页", currentPage + 1, currentPage === pageCount));
|
||||
pageButtonsNode.replaceChildren(...controls);
|
||||
}
|
||||
|
||||
function authHeaders(json = false) {
|
||||
@@ -285,6 +509,7 @@
|
||||
}
|
||||
|
||||
async function managedFetch(url, options = {}) {
|
||||
if (testMode) throw new Error("测试模式禁止访问真实管理接口");
|
||||
const headers = authHeaders(Boolean(options.body));
|
||||
const response = await fetch(url, { ...options, headers: { ...headers, ...(options.headers || {}) } });
|
||||
const payload = await response.json();
|
||||
@@ -298,50 +523,143 @@
|
||||
return node;
|
||||
}
|
||||
|
||||
function upstreamLabel(account) {
|
||||
if (!account) return "未指定";
|
||||
return `${account.provider || "unknown"} · ${account.display_name || account.cpa_auth_id || account.id}`;
|
||||
}
|
||||
|
||||
function routeLabel(key) {
|
||||
if (key.route_mode !== "strict") return { main: "自动选择", sub: "由 CPA 调度" };
|
||||
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
|
||||
return { main: "指定账号", sub: upstreamLabel(account) };
|
||||
}
|
||||
|
||||
function copyText(value, button) {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
const previous = button.textContent; button.textContent = "已复制";
|
||||
setTimeout(() => { button.textContent = previous; }, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
drawerNode.classList.remove("hidden");
|
||||
drawerOverlayNode.classList.remove("hidden");
|
||||
drawerNode.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerNode.classList.add("hidden");
|
||||
drawerOverlayNode.classList.add("hidden");
|
||||
drawerNode.setAttribute("aria-hidden", "true");
|
||||
editingKey = null;
|
||||
}
|
||||
|
||||
function syncEditorRoute() {
|
||||
const archived = editingKey?.status === "archived";
|
||||
const strict = document.querySelector("#editor-route").value === "strict";
|
||||
document.querySelector("#editor-upstream").disabled = archived || !strict;
|
||||
document.querySelector("#editor-models").disabled = archived || document.querySelector("#editor-all-models").checked;
|
||||
}
|
||||
|
||||
function openKeyEditor(key = null) {
|
||||
editingKey = key;
|
||||
const creating = !key;
|
||||
document.querySelector("#drawer-title").textContent = creating ? "创建 Key" : `管理 ${key.name}`;
|
||||
document.querySelector("#drawer-subtitle").textContent = creating ? "创建后可继续配置路由和模型权限" : "修改用户状态、路由和模型权限";
|
||||
editorNode.classList.remove("hidden"); keyStatsNode.classList.add("hidden"); editorFooterNode.classList.remove("hidden");
|
||||
managedFieldsNode.classList.toggle("hidden", creating);
|
||||
document.querySelector("#editor-name").value = key?.name || "";
|
||||
const secret = document.querySelector("#editor-secret"); secret.value = key?.secret || ""; secret.readOnly = !creating;
|
||||
document.querySelector("#editor-status").value = key?.status || "active";
|
||||
document.querySelector("#editor-route").value = key?.route_mode || "auto";
|
||||
const upstream = document.querySelector("#editor-upstream");
|
||||
upstream.replaceChildren(option("", "未指定", !key?.upstream_account_id), ...upstreamAccounts.map(account => option(account.id, `${upstreamLabel(account)}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key?.upstream_account_id)));
|
||||
document.querySelector("#editor-all-models").checked = key?.all_models ?? true;
|
||||
const models = document.querySelector("#editor-models"); models.value = (key?.models || []).join(", "); models.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol";
|
||||
document.querySelector("#archive-key").classList.toggle("hidden", creating || key.status === "archived");
|
||||
document.querySelector("#save-key").classList.toggle("hidden", key?.status === "archived");
|
||||
document.querySelector("#save-key").textContent = creating ? "创建 Key" : "保存修改";
|
||||
["editor-name", "editor-status", "editor-route", "editor-all-models"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
|
||||
syncEditorRoute(); openDrawer();
|
||||
}
|
||||
|
||||
function renderManagedKeys() {
|
||||
keyRowsNode.replaceChildren(...managedKeys.map(key => {
|
||||
const row = document.createElement("tr");
|
||||
const archived = key.status === "archived";
|
||||
const name = document.createElement("input"); name.value = key.name; name.disabled = archived;
|
||||
const secret = document.createElement("input"); secret.value = key.secret; secret.readOnly = true; secret.className = "secret";
|
||||
secret.addEventListener("focus", () => secret.select());
|
||||
const status = document.createElement("select");
|
||||
status.append(option("active", "启用", key.status === "active"), option("disabled", "禁用", key.status === "disabled"), option("archived", "已归档", archived));
|
||||
status.disabled = archived;
|
||||
const route = document.createElement("select");
|
||||
route.append(option("auto", "自动选择", key.route_mode === "auto"), option("strict", "指定账号", key.route_mode === "strict")); route.disabled = archived;
|
||||
const upstream = document.createElement("select");
|
||||
upstream.append(option("", "未指定", !key.upstream_account_id));
|
||||
upstreamAccounts.forEach(account => upstream.append(option(account.id, `${account.provider || "unknown"} · ${account.display_name}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key.upstream_account_id)));
|
||||
upstream.disabled = archived || route.value !== "strict";
|
||||
route.addEventListener("change", () => { upstream.disabled = archived || route.value !== "strict"; });
|
||||
const modelWrap = document.createElement("div");
|
||||
const allModels = document.createElement("input"); allModels.type = "checkbox"; allModels.checked = key.all_models; allModels.disabled = archived;
|
||||
const modelInput = document.createElement("input"); modelInput.value = (key.models || []).join(", "); modelInput.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol"; modelInput.disabled = archived || allModels.checked;
|
||||
allModels.addEventListener("change", () => { modelInput.disabled = archived || allModels.checked; });
|
||||
const allLabel = document.createElement("label"); allLabel.className = "inline-check"; allLabel.append(allModels, "全部"); modelWrap.append(allLabel, modelInput);
|
||||
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 status = document.createElement("span"); status.className = `badge ${key.status}`; status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
|
||||
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 models = document.createElement("span"); models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
|
||||
const actions = document.createElement("div"); actions.className = "key-actions";
|
||||
const copy = document.createElement("button"); copy.type = "button"; copy.textContent = "复制"; copy.addEventListener("click", () => navigator.clipboard?.writeText(key.secret));
|
||||
const stats = document.createElement("button"); stats.type = "button"; stats.textContent = "统计"; stats.addEventListener("click", () => loadKeyStats(key));
|
||||
actions.append(copy, stats);
|
||||
if (!archived) {
|
||||
const save = document.createElement("button"); save.type = "button"; save.textContent = "保存";
|
||||
save.addEventListener("click", () => updateManagedKey({ id: key.id, name: name.value.trim(), status: status.value, route_mode: route.value, upstream_account_id: route.value === "strict" ? upstream.value : "", all_models: allModels.checked, models: modelInput.value.split(",").map(value => value.trim()).filter(Boolean) }));
|
||||
const archive = document.createElement("button"); archive.type = "button"; archive.textContent = "归档"; archive.addEventListener("click", () => archiveManagedKey(key));
|
||||
actions.append(save, archive);
|
||||
}
|
||||
[name, secret, status, route, upstream, modelWrap, actions].forEach((node, index) => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
|
||||
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);
|
||||
[identity, credential, status, routeNode, models, actions].forEach(node => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
|
||||
return row;
|
||||
}));
|
||||
const today = startOfDay(new Date());
|
||||
const todayRecords = currentRecords.filter(record => new Date(record.requested_at) >= today);
|
||||
document.querySelector("#metric-requests").textContent = number(todayRecords.length);
|
||||
document.querySelector("#metric-tokens").textContent = compactNumber(todayRecords.reduce((sum, record) => sum + (record.total_tokens || 0), 0));
|
||||
const todayCost = todayRecords.reduce((sum, record) => sum + (record.cost_available && Number.isFinite(record.cost_usd) ? record.cost_usd : 0), 0);
|
||||
document.querySelector("#metric-cost").textContent = "$" + todayCost.toFixed(4);
|
||||
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);
|
||||
}
|
||||
|
||||
function renderUserCharts() {
|
||||
const today = startOfDay(new Date());
|
||||
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
|
||||
currentRecords.forEach(record => {
|
||||
const name = record.key_alias || record.api_key || "未识别";
|
||||
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
|
||||
const value = byUser.get(name); const requestedAt = new Date(record.requested_at);
|
||||
if (!value.last || requestedAt > value.last) value.last = requestedAt;
|
||||
if (requestedAt >= today) {
|
||||
value.requests++; value.tokens += record.total_tokens || 0;
|
||||
if (record.cost_available && Number.isFinite(record.cost_usd)) value.cost += record.cost_usd;
|
||||
}
|
||||
});
|
||||
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
|
||||
const usageNode = document.querySelector("#user-usage");
|
||||
const header = document.createElement("div"); header.className = "user-usage-row header"; ["用户", "今日请求", "今日 Token", "今日成本", "最近使用"].forEach(label => { const cell = document.createElement("span"); cell.textContent = label; header.append(cell); });
|
||||
usageNode.replaceChildren(header, ...userRows.map(item => { const row = document.createElement("div"); row.className = "user-usage-row"; [item.name, number(item.requests), number(item.tokens), "$" + item.cost.toFixed(4), item.last ? item.last.toLocaleString() : "从未"].forEach(value => { const cell = document.createElement("span"); cell.textContent = value; row.append(cell); }); return row; }));
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, index) => { const date = startOfDay(new Date()); date.setDate(date.getDate() - (6 - index)); return { date, tokens: 0 }; });
|
||||
currentRecords.forEach(record => { const requestedAt = new Date(record.requested_at); const day = days.find(item => requestedAt >= item.date && requestedAt < new Date(item.date.getTime() + 86400000)); if (day) day.tokens += record.total_tokens || 0; });
|
||||
const maximum = Math.max(1, ...days.map(day => day.tokens));
|
||||
document.querySelector("#daily-chart").replaceChildren(...days.map(day => { const row = document.createElement("div"); row.className = "bar-row"; const label = document.createElement("span"); label.className = "bar-label"; label.textContent = `${day.date.getMonth() + 1}/${day.date.getDate()}`; const track = document.createElement("span"); track.className = "bar-track"; const value = document.createElement("span"); value.className = "bar-value"; value.style.width = `${day.tokens / maximum * 100}%`; track.append(value); const count = document.createElement("span"); count.className = "bar-count"; count.textContent = compactNumber(day.tokens); row.append(label, track, count); return row; }));
|
||||
}
|
||||
|
||||
async function 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"];
|
||||
currentRecords = demoState.usage;
|
||||
renderManagedKeys();
|
||||
keyStatusNode.textContent = `测试数据 · ${managedKeys.length} 个 Key,${upstreamAccounts.length} 个上游账号`;
|
||||
return;
|
||||
}
|
||||
if (!keyInput.value.trim()) return;
|
||||
keyStatusNode.textContent = "正在同步";
|
||||
try {
|
||||
const [upstreams, models] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API)]);
|
||||
const [upstreams, models, usage] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(API)]);
|
||||
upstreamAccounts = upstreams.accounts || [];
|
||||
modelSuggestions = models.models || [];
|
||||
currentRecords = usage.records || [];
|
||||
const archived = document.querySelector("#include-archived").checked ? "?include_archived=1" : "";
|
||||
const keys = await managedFetch(KEYS_API + archived);
|
||||
managedKeys = keys.keys || [];
|
||||
@@ -351,34 +669,83 @@
|
||||
}
|
||||
|
||||
async function createManagedKey() {
|
||||
const name = document.querySelector("#new-key-name").value.trim();
|
||||
const secret = document.querySelector("#new-key-secret").value.trim();
|
||||
const name = document.querySelector("#editor-name").value.trim();
|
||||
const secret = document.querySelector("#editor-secret").value.trim();
|
||||
try {
|
||||
if (testMode) {
|
||||
if (!name) throw new Error("请输入名称");
|
||||
if (demoState.keys.some(key => key.name.toLowerCase() === name.toLowerCase())) throw new Error("名称已经存在");
|
||||
const created = { id: "demo-key-" + Date.now(), name, secret: secret || `demo-${Date.now()}-000000`, status: "active", route_mode: "auto", upstream_account_id: "", all_models: true, models: [] };
|
||||
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({ name, secret }) });
|
||||
document.querySelector("#new-key-name").value = ""; document.querySelector("#new-key-secret").value = "";
|
||||
keyStatusNode.textContent = `已创建 ${created.name}:${created.secret}`;
|
||||
await loadKeyManagement();
|
||||
closeDrawer(); await loadKeyManagement();
|
||||
} catch (error) { keyStatusNode.textContent = "创建失败: " + error.message; }
|
||||
}
|
||||
|
||||
async function updateManagedKey(payload) {
|
||||
try { await managedFetch(KEYS_API, { method: "PATCH", body: JSON.stringify(payload) }); await loadKeyManagement(); }
|
||||
try {
|
||||
if (testMode) {
|
||||
const index = demoState.keys.findIndex(key => key.id === payload.id);
|
||||
if (index < 0) throw new Error("测试 Key 不存在");
|
||||
demoState.keys[index] = { ...demoState.keys[index], ...payload }; saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
|
||||
}
|
||||
await managedFetch(KEYS_API, { method: "PATCH", body: JSON.stringify(payload) }); closeDrawer(); await loadKeyManagement();
|
||||
}
|
||||
catch (error) { keyStatusNode.textContent = "保存失败: " + error.message; }
|
||||
}
|
||||
|
||||
async function saveKeyEditor() {
|
||||
if (!editingKey) return createManagedKey();
|
||||
const route = document.querySelector("#editor-route").value;
|
||||
const allModels = document.querySelector("#editor-all-models").checked;
|
||||
return updateManagedKey({
|
||||
id: editingKey.id,
|
||||
name: document.querySelector("#editor-name").value.trim(),
|
||||
status: document.querySelector("#editor-status").value,
|
||||
route_mode: route,
|
||||
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
|
||||
all_models: allModels,
|
||||
models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean)
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveManagedKey(key) {
|
||||
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
|
||||
try { await managedFetch(KEYS_API, { method: "DELETE", body: JSON.stringify({ id: key.id }) }); await loadKeyManagement(); }
|
||||
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; }
|
||||
}
|
||||
|
||||
async function loadKeyStats(key) {
|
||||
try {
|
||||
const payload = await managedFetch(KEY_STATS_API + "?id=" + encodeURIComponent(key.id));
|
||||
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 total = payload.stats.total, today = payload.stats.today;
|
||||
const recent = (payload.recent || []).slice(0, 10).map(item => `${new Date(item.requested_at).toLocaleString()} · ${item.model || "-"} · ${number(item.total_tokens)} Token · ${item.auth_id || "自动"}`);
|
||||
keyStatsNode.textContent = `${key.name}\n累计:${total.requests} 次,${number(total.total_tokens)} Token,$${(total.cost_micros / 1000000).toFixed(4)}\n今日:${today.requests} 次,${number(today.total_tokens)} Token,$${(today.cost_micros / 1000000).toFixed(4)}\n最近请求:\n${recent.join("\n") || "暂无"}`;
|
||||
keyStatsNode.classList.remove("hidden");
|
||||
document.querySelector("#drawer-title").textContent = `${key.name} · 统计`;
|
||||
document.querySelector("#drawer-subtitle").textContent = "历史统计永久保留";
|
||||
editorNode.classList.add("hidden"); editorFooterNode.classList.add("hidden"); keyStatsNode.classList.remove("hidden");
|
||||
statsGridNode.replaceChildren(...[
|
||||
["今日请求", number(today.requests)], ["今日 Token", number(today.total_tokens)],
|
||||
["累计请求", number(total.requests)], ["累计成本", "$" + (total.cost_micros / 1000000).toFixed(4)]
|
||||
].map(([label, value]) => { const tile = document.createElement("div"); tile.className = "stats-tile"; const name = document.createElement("span"); name.textContent = label; const strong = document.createElement("strong"); strong.textContent = value; tile.append(name, strong); return tile; }));
|
||||
const recent = (payload.recent || []).slice(0, 10);
|
||||
statsRecentNode.replaceChildren(...(recent.length ? recent.map(item => { const row = document.createElement("div"); row.className = "recent-item"; const model = document.createElement("strong"); model.textContent = `${item.model || "-"} · ${result(item)}`; const detail = document.createElement("span"); detail.textContent = `${new Date(item.requested_at).toLocaleString()} · ${number(item.total_tokens)} Token · ${item.auth_id || "自动选择"}`; row.append(model, detail); return row; }) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无请求" })]));
|
||||
openDrawer();
|
||||
} catch (error) { keyStatusNode.textContent = "读取统计失败: " + error.message; }
|
||||
}
|
||||
|
||||
@@ -386,6 +753,32 @@
|
||||
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
|
||||
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
|
||||
try { sessionStorage.setItem("cpa-ext:active-view", view); } catch (_) {}
|
||||
if (view === "usage") load(true);
|
||||
if (view === "pricing") loadPrices();
|
||||
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; closeDrawer();
|
||||
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = [];
|
||||
render([]); renderManagedKeys(); renderPrices();
|
||||
try { sessionStorage.setItem("cpa-ext:data-mode", mode); } catch (_) {}
|
||||
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
|
||||
if (active === "keys") loadKeyManagement();
|
||||
if (active === "usage") load(true);
|
||||
if (active === "pricing") loadPrices();
|
||||
}
|
||||
|
||||
function clearPriceForm() {
|
||||
["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;
|
||||
@@ -432,25 +825,34 @@
|
||||
}
|
||||
|
||||
function renderPrices() {
|
||||
if (!currentPrices.length) {
|
||||
priceListNode.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "尚未配置模型" }));
|
||||
return;
|
||||
}
|
||||
priceListNode.replaceChildren(...currentPrices.map(price => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "price-row";
|
||||
const text = document.createElement("div");
|
||||
const model = document.createElement("div");
|
||||
model.className = "price-name";
|
||||
model.textContent = price.model;
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = `输入 $${price.base.input_per_1m},缓存读 $${price.base.cache_read_per_1m},缓存写 $${price.base.cache_write_per_1m},输出 $${price.base.output_per_1m}${price.long_context ? ",含长上下文" : ""}${price.fast_pricing_enabled ? ",Fast ×" + price.fast_multiplier : ""}`;
|
||||
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""}`;
|
||||
text.append(model, detail);
|
||||
const actions = document.createElement("div");
|
||||
const actions = document.createElement("div"); actions.className = "key-actions";
|
||||
const edit = document.createElement("button");
|
||||
edit.type = "button"; edit.textContent = "编辑"; edit.addEventListener("click", () => fillPriceForm(price));
|
||||
edit.type = "button"; edit.className = "small"; edit.textContent = "编辑"; edit.addEventListener("click", () => fillPriceForm(price));
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button"; remove.textContent = "删除"; remove.addEventListener("click", () => deletePrice(price.model));
|
||||
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;
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadPrices() {
|
||||
if (testMode) {
|
||||
currentPrices = demoState.prices.map(price => structuredClone(price)); renderPrices();
|
||||
priceStatusNode.textContent = `测试数据 · 已配置 ${currentPrices.length} 个模型`; return;
|
||||
}
|
||||
if (!keyInput.value.trim()) return;
|
||||
try {
|
||||
const response = await fetch(PRICE_API, { headers: authHeaders() });
|
||||
@@ -464,6 +866,12 @@
|
||||
|
||||
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("请输入模型");
|
||||
if (index >= 0) demoState.prices[index] = payload; else demoState.prices.push(payload);
|
||||
saveDemoState(); priceStatusNode.textContent = "测试价格已保存"; await loadPrices(); 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);
|
||||
@@ -474,6 +882,11 @@
|
||||
|
||||
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);
|
||||
@@ -484,6 +897,11 @@
|
||||
|
||||
async function load(manual = false) {
|
||||
if (loading) return;
|
||||
if (testMode) {
|
||||
currentRecords = demoState.usage; render(currentRecords);
|
||||
statusNode.textContent = "";
|
||||
return;
|
||||
}
|
||||
const key = keyInput.value.trim();
|
||||
if (!key) {
|
||||
statusNode.textContent = "请输入管理密钥";
|
||||
@@ -501,7 +919,7 @@
|
||||
lastSignature = signature;
|
||||
render(records);
|
||||
}
|
||||
statusNode.textContent = `显示 ${payload.records?.length || 0} 条,最多展示 ${payload.retained} 条`;
|
||||
statusNode.textContent = "";
|
||||
} catch (error) {
|
||||
statusNode.textContent = "读取失败: " + error.message;
|
||||
} finally {
|
||||
@@ -515,15 +933,26 @@
|
||||
document.querySelector("#long-enabled").addEventListener("change", event => setLongFields(event.target.checked));
|
||||
document.querySelector("#save-price").addEventListener("click", savePrice);
|
||||
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
|
||||
document.querySelector("#create-key").addEventListener("click", createManagedKey);
|
||||
document.querySelector("#new-price").addEventListener("click", clearPriceForm);
|
||||
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
|
||||
document.querySelector("#reload-keys").addEventListener("click", loadKeyManagement);
|
||||
document.querySelector("#include-archived").addEventListener("change", loadKeyManagement);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); });
|
||||
document.querySelector("#close-drawer").addEventListener("click", closeDrawer);
|
||||
document.querySelector("#cancel-editor").addEventListener("click", closeDrawer);
|
||||
drawerOverlayNode.addEventListener("click", closeDrawer);
|
||||
document.querySelector("#save-key").addEventListener("click", saveKeyEditor);
|
||||
document.querySelector("#archive-key").addEventListener("click", () => editingKey && archiveManagedKey(editingKey));
|
||||
document.querySelector("#editor-route").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#editor-all-models").addEventListener("change", syncEditorRoute);
|
||||
document.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 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(); });
|
||||
renderColumnControls();
|
||||
load();
|
||||
loadPrices();
|
||||
loadKeyManagement();
|
||||
setInterval(() => { if (!document.hidden) load(); }, 3000);
|
||||
setMode(sessionStorage.getItem("cpa-ext:data-mode") || "live");
|
||||
setView(sessionStorage.getItem("cpa-ext:active-view") || "keys");
|
||||
setInterval(() => { if (!testMode && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(); }, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user