1158 lines
84 KiB
HTML
1158 lines
84 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Billing 管理台</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
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; padding-top: 18px; }
|
||
/* CPA Manager overlays its own controls on the iframe's top-right corner. */
|
||
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 58px; padding: 0 222px 0 22px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg-primary) 92%, transparent); backdrop-filter: blur(16px); }
|
||
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||
.brand-mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--border-primary); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
|
||
.brand strong { font-size: 14px; letter-spacing: .02em; }
|
||
.brand span { color: var(--muted); font-size: 12px; }
|
||
.auth-box { display: flex; align-items: center; gap: 8px; }
|
||
body.read-only .admin-only { display: none !important; }
|
||
input, select { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; outline: none; background: var(--bg-tertiary); color: inherit; transition: border-color .15s, box-shadow .15s; }
|
||
input:focus, select:focus { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
|
||
input:disabled, select:disabled { cursor: not-allowed; opacity: .55; }
|
||
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: 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); }
|
||
.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: 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; }
|
||
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 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: 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: var(--panel); }
|
||
.summary-card span { color: var(--muted); font-size: 11px; }
|
||
.summary-card strong { display: block; margin-top: 5px; font-size: 22px; font-weight: 650; letter-spacing: -.03em; }
|
||
.summary-card small { display: block; margin-top: 2px; color: var(--text-quaternary); font-size: 11px; }
|
||
.surface { border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||
.surface-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; min-height: 45px; padding: 8px 10px 8px 13px; border-bottom: 1px solid var(--border-soft); }
|
||
.surface-toolbar .status { min-height: 0; }
|
||
.chart-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 9px; margin-bottom: 12px; }
|
||
.chart-card { min-height: 180px; padding: 13px 14px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--panel); }
|
||
.chart-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 13px; }
|
||
.chart-card h2 { margin-bottom: 2px; font-size: 12px; }
|
||
.chart-card p { margin-bottom: 0; color: var(--muted); font-size: 10px; }
|
||
.bar-chart { display: grid; gap: 9px; }
|
||
.bar-row { display: grid; grid-template-columns: minmax(70px, 110px) minmax(100px, 1fr) 42px; align-items: center; gap: 9px; font-size: 10px; }
|
||
.bar-label { overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
|
||
.bar-track { height: 7px; overflow: hidden; border-radius: 999px; background: var(--bg-tertiary); }
|
||
.bar-value { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--primary-active); }
|
||
.bar-count { color: var(--muted); text-align: right; }
|
||
.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: block; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); cursor: pointer; outline: none; }
|
||
.price-row:hover { border-color: var(--border-hover); background: var(--bg-hover); }
|
||
.price-row:focus-visible { border-color: var(--primary-active); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 20%, transparent); }
|
||
.price-row.selected { border-color: var(--border-primary); background: var(--bg-tertiary); }
|
||
.price-row .price-name { overflow: hidden; font-size: 11px; font-weight: 600; text-overflow: ellipsis; }
|
||
.price-row small { display: block; margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.price-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: 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); }
|
||
.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; }
|
||
.catalog-toolbar { display: flex; align-items: center; gap: 7px; }
|
||
.catalog-toolbar input { flex: 1 1 260px; }
|
||
.catalog-summary { min-height: 17px; margin-bottom: 8px; color: var(--muted); font-size: 10px; }
|
||
.catalog-results { display: grid; gap: 6px; max-height: 250px; margin-top: 8px; overflow-y: auto; }
|
||
.catalog-result { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-primary); }
|
||
.catalog-result strong { display: block; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.catalog-result small { display: block; margin-top: 3px; color: var(--muted); font-size: 9px; }
|
||
.price-source { min-height: 18px; margin-top: 8px; color: var(--text-tertiary); font-size: 10px; }
|
||
.price-source.catalog-linked { color: var(--text-secondary); }
|
||
.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 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: 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: #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; }
|
||
.hidden { display: none !important; }
|
||
.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: 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: 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: var(--bg-secondary); }
|
||
.drawer-section h3 { margin: 0; font-size: 11px; }
|
||
.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: 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: var(--bg-primary); }
|
||
.ledger-item strong { font-size: 10px; }
|
||
.ledger-item .positive { color: var(--success); }
|
||
.ledger-item .negative { color: var(--danger); }
|
||
.ledger-item small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.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: 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; }
|
||
.section-heading .tools { position: relative; }
|
||
.usage-filter-panel { position: static; margin: 0; }
|
||
.usage-filter-panel > summary { display: inline-flex; align-items: center; }
|
||
.usage-filter-panel .usage-filters { position: absolute; z-index: 15; top: calc(100% + 8px); right: 0; width: min(960px, calc(100vw - 44px)); margin-bottom: 0; box-shadow: 0 16px 40px #0009; }
|
||
.usage-filter { display: grid; gap: 5px; min-width: 0; color: var(--muted); font-size: 9px; }
|
||
.usage-filter input, .usage-filter select { height: 32px; padding: 6px 8px; font-size: 11px; }
|
||
.usage-filter.request-filter { grid-column: span 2; }
|
||
.usage-filter-actions { display: flex; align-items: end; justify-content: flex-end; gap: 7px; grid-column: span 2; }
|
||
@media (max-width: 1100px) { .usage-filters { grid-template-columns: repeat(3, minmax(140px, 1fr)); } }
|
||
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .summary-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); overflow-x: auto; } .chart-grid { grid-template-columns: 1fr; } }
|
||
@media (max-width: 680px) { .app-shell { padding-top: 14px; } .topbar { align-items: flex-start; min-height: 0; padding: 11px 12px; } .brand span { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
|
||
</style>
|
||
</head>
|
||
<body class="read-only">
|
||
<div class="app-shell">
|
||
<header class="topbar">
|
||
<div class="brand"><div class="brand-mark">B</div><div><strong>Billing</strong><span> · 管理控制台</span></div></div>
|
||
<div class="auth-box"><input id="key" type="password" autocomplete="off" aria-label="管理密钥"></div>
|
||
</header>
|
||
<nav class="workspace-nav" aria-label="工作区">
|
||
<button class="nav-button active" type="button" data-view="keys">用户 Key</button>
|
||
<button class="nav-button" type="button" data-view="usage">请求明细</button>
|
||
<button class="nav-button" type="button" data-view="pricing">价格配置</button>
|
||
</nav>
|
||
<main>
|
||
<section id="view-keys" class="view active">
|
||
<div class="summary-grid">
|
||
<article class="summary-card"><span>今日请求</span><strong id="metric-requests">—</strong><small>自然日内实际请求</small></article>
|
||
<article class="summary-card"><span>今日 Token</span><strong id="metric-tokens">—</strong><small>输入与输出合计</small></article>
|
||
<article class="summary-card"><span>今日成本</span><strong id="metric-cost">—</strong><small>按已配置价格计算</small></article>
|
||
</div>
|
||
<div class="chart-grid"><article class="chart-card"><header><div><h2>今日用户用量</h2></div></header><div id="user-usage" class="user-usage"></div></article><article class="chart-card"><header><div><h2>近 7 日 Token</h2></div></header><div id="daily-chart" class="bar-chart"></div></article></div>
|
||
<div id="key-management" class="surface admin-only">
|
||
<div class="surface-toolbar"><span id="key-status" class="status"></span><button id="create-key" class="primary" type="button">创建 Key</button></div>
|
||
<div class="table-wrap"><table class="key-table"><thead><tr><th class="left">用户</th><th class="left">Key</th><th class="left">状态</th><th class="left">余额 / 额度</th><th class="left">并发</th><th class="left">路由</th><th class="left">允许模型</th><th class="left">查看</th></tr></thead><tbody id="key-rows"></tbody></table></div>
|
||
</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 id="usage-filter-panel" class="usage-filter-panel"><summary>查询过滤</summary><div class="surface usage-filters">
|
||
<label class="usage-filter">开始时间<input id="usage-from" type="datetime-local" step="1"></label>
|
||
<label class="usage-filter">结束时间<input id="usage-to" type="datetime-local" step="1"></label>
|
||
<label class="usage-filter">用户<select id="usage-key"><option value="">全部用户</option></select></label>
|
||
<label class="usage-filter">模型<input id="usage-model" list="usage-model-options" placeholder="全部模型"><datalist id="usage-model-options"></datalist></label>
|
||
<label class="usage-filter">结果<select id="usage-result"><option value="">全部结果</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="rejected">已拒绝</option><option value="canceled">已取消</option></select></label>
|
||
<label class="usage-filter">上游<select id="usage-auth"><option value="">全部上游</option></select></label>
|
||
<label class="usage-filter">端点<select id="usage-endpoint"><option value="">全部端点</option><option value="responses">responses</option><option value="compact">compact</option><option value="chat">chat</option></select></label>
|
||
<label class="usage-filter request-filter">请求 ID<input id="usage-request-id" placeholder="粘贴完整 Request ID"></label>
|
||
<div class="usage-filter-actions"><button id="clear-usage-filters" type="button">清空</button><button id="apply-usage-filters" class="primary" type="button">查询</button></div>
|
||
</div></details>
|
||
<details><summary>列</summary><div id="column-options" class="column-options"></div></details><button id="refresh" type="button">刷新</button>
|
||
</div></div>
|
||
<div class="surface"><div class="table-wrap"><table><thead><tr id="headers"></tr></thead><tbody id="rows"></tbody></table></div></div>
|
||
</section>
|
||
|
||
<section id="view-pricing" class="view">
|
||
<div class="section-heading"><div><h1>价格配置</h1></div><div id="price-status" class="status"></div></div>
|
||
<div class="surface price-panel price-layout">
|
||
<aside class="price-sidebar"><div class="price-sidebar-header"><h2>已配置模型</h2><button id="new-price" class="small admin-only" type="button">新增</button></div><div id="price-list" class="price-list"></div></aside>
|
||
<section class="price-editor">
|
||
<div id="price-editor-content" class="hidden">
|
||
<div class="price-editor-header"><h2>模型价格</h2></div>
|
||
<div class="price-section"><label class="field price-model-field">模型名称<input id="price-model" placeholder="gpt-5.6-sol"></label><div id="price-source" class="price-source">手动配置</div></div>
|
||
<div class="price-section admin-only"><div class="price-section-header"><h3>models.dev 参考价格</h3><button id="refresh-catalog" class="small" type="button">更新目录</button></div><div id="catalog-summary" class="catalog-summary"></div><div class="catalog-toolbar"><input id="catalog-query" placeholder="搜索模型或供应商"><button id="search-catalog" class="small" type="button">搜索</button></div><div id="catalog-results" class="catalog-results"></div></div>
|
||
<div class="price-section"><div class="price-section-header"><h3>基础价格 · $ / 1M Token</h3></div><div class="price-grid">
|
||
<label>输入<input id="price-input" inputmode="decimal"></label><label>缓存读取<input id="price-cache-read" inputmode="decimal"></label><label>缓存写入<input id="price-cache-write" inputmode="decimal"></label><label>输出<input id="price-output" inputmode="decimal"></label>
|
||
</div></div>
|
||
<div id="long-price-section" class="price-section hidden"><div class="price-section-header"><h3>长上下文价格</h3><label class="inline-check"><input id="long-enabled" type="checkbox">启用</label></div><div class="price-grid long-grid">
|
||
<label class="long-field hidden">输入门槛<input id="long-threshold" type="number" min="0" step="1"></label><label class="long-field hidden">门槛比较<select id="long-comparison"><option value="gt">大于</option><option value="gte">大于等于</option></select></label><label class="long-field hidden">输入<input id="long-input" inputmode="decimal"></label><label class="long-field hidden">缓存读取<input id="long-cache-read" inputmode="decimal"></label><label class="long-field hidden">缓存写入<input id="long-cache-write" inputmode="decimal"></label><label class="long-field hidden">输出<input id="long-output" inputmode="decimal"></label>
|
||
</div></div>
|
||
<div class="price-section"><div class="price-section-header"><h3>Fast 价格</h3></div><div class="fast-row"><label class="inline-check"><input id="fast-pricing-enabled" type="checkbox">使用倍率计价</label><label class="field">倍率<input id="fast-multiplier" inputmode="decimal" value="2.5"></label></div></div>
|
||
<div class="price-actions admin-only"><button id="delete-price" class="danger hidden" type="button">删除</button><button id="clear-price" type="button">清空</button><button id="save-price" class="primary" type="button">保存价格</button></div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</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">
|
||
<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" 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>
|
||
<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>
|
||
<div class="drawer-section"><h3>额度与周期</h3>
|
||
<div id="billing-preview" class="billing-preview"><div><span>额度</span><strong id="billing-quota-view">$0.0000</strong></div><div><span>已用</span><strong id="billing-spent-view">$0.0000</strong></div><div><span>余额</span><strong id="billing-balance-view">$0.0000</strong></div></div>
|
||
<div class="field-row"><label class="field">本周期额度(USD)<input id="editor-quota" inputmode="decimal" value="0"></label><label class="field">并发上限<input id="editor-concurrency" type="number" min="1" max="64" step="1" value="4"></label></div>
|
||
<div class="field-row"><label class="field">自动重置<select id="editor-reset-period"><option value="none">不重置</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option></select></label><label class="field">下次重置时间<input id="editor-next-reset" type="datetime-local" step="1"></label></div>
|
||
<p class="field-hint">请求结束后按实际费用扣款;最后一个并发请求可能产生负余额。</p>
|
||
<div class="billing-tools"><span id="billing-status" class="status"></span><button id="reset-billing" class="small" type="button">立即重置额度</button></div>
|
||
</div>
|
||
<div id="ledger-section" class="drawer-section"><h3>最近账目</h3><div id="billing-ledger" class="ledger-list"></div></div>
|
||
</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 MANAGEMENT_API = "/v0/management/plugins/billing";
|
||
const API = MANAGEMENT_API + "/usage";
|
||
const SUMMARY_API = MANAGEMENT_API + "/usage-summary";
|
||
const PRICE_API = MANAGEMENT_API + "/prices";
|
||
const PRICE_IMPORT_API = MANAGEMENT_API + "/prices/import";
|
||
const CATALOG_API = MANAGEMENT_API + "/price-catalog";
|
||
const CATALOG_REFRESH_API = MANAGEMENT_API + "/price-catalog/refresh";
|
||
const CATALOG_APPLY_API = MANAGEMENT_API + "/price-catalog/apply";
|
||
const KEYS_API = MANAGEMENT_API + "/keys";
|
||
const KEY_STATS_API = MANAGEMENT_API + "/key-stats";
|
||
const UPSTREAMS_API = MANAGEMENT_API + "/upstreams";
|
||
const MODELS_API = MANAGEMENT_API + "/model-suggestions";
|
||
const BILLING_RESET_API = MANAGEMENT_API + "/billing-reset";
|
||
const BILLING_LEDGER_API = MANAGEMENT_API + "/billing-ledger";
|
||
const READONLY_API = location.pathname;
|
||
const keyInput = document.querySelector("#key");
|
||
const statusNode = document.querySelector("#status");
|
||
const rowsNode = document.querySelector("#rows");
|
||
const headersNode = document.querySelector("#headers");
|
||
const columnOptionsNode = document.querySelector("#column-options");
|
||
const columnStoreKey = "billing:usage-columns";
|
||
const priceStatusNode = document.querySelector("#price-status");
|
||
const priceListNode = document.querySelector("#price-list");
|
||
const priceEditorContentNode = document.querySelector("#price-editor-content");
|
||
const priceSourceNode = document.querySelector("#price-source");
|
||
const catalogSummaryNode = document.querySelector("#catalog-summary");
|
||
const catalogResultsNode = document.querySelector("#catalog-results");
|
||
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;
|
||
let currentRecords = [];
|
||
let currentPage = 1;
|
||
let currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
|
||
let usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
|
||
let loading = false;
|
||
let currentPrices = [];
|
||
let selectedPriceModel = "";
|
||
let currentCatalog = null;
|
||
let managedKeys = [];
|
||
let upstreamAccounts = [];
|
||
let modelSuggestions = [];
|
||
let editingKey = null;
|
||
let managementAuthorized = false;
|
||
|
||
function storedPanelKey() {
|
||
const prefix = "enc::v1::";
|
||
const salt = "cli-proxy-api-webui::secure-storage";
|
||
let raw = localStorage.getItem("cli-proxy-auth");
|
||
if (!raw) return "";
|
||
if (raw.startsWith(prefix)) {
|
||
const secret = new TextEncoder().encode(salt + "|" + location.host + "|" + navigator.userAgent);
|
||
const encoded = atob(raw.slice(prefix.length));
|
||
const bytes = new Uint8Array(encoded.length);
|
||
for (let i = 0; i < encoded.length; i++) bytes[i] = encoded.charCodeAt(i) ^ secret[i % secret.length];
|
||
raw = new TextDecoder().decode(bytes);
|
||
}
|
||
try {
|
||
const value = JSON.parse(raw);
|
||
return value?.state?.managementKey || "";
|
||
} catch (_) { return ""; }
|
||
}
|
||
|
||
function duration(value) {
|
||
return value > 0 ? value.toLocaleString() + " ms" : "-";
|
||
}
|
||
|
||
function number(value) {
|
||
return Number.isFinite(value) ? value.toLocaleString() : "-";
|
||
}
|
||
|
||
function percent(value) {
|
||
return Number.isFinite(value) ? value.toFixed(2) + "%" : "-";
|
||
}
|
||
|
||
function speed(value) {
|
||
return Number.isFinite(value) ? value.toFixed(1) + " tok/s" : "-";
|
||
}
|
||
|
||
function cost(record) {
|
||
return record.cost_available && Number.isFinite(record.cost_usd) ? "$" + record.cost_usd.toFixed(4) : "-";
|
||
}
|
||
|
||
function money(value) {
|
||
const amount = Number(value || 0);
|
||
return "$" + (Number.isFinite(amount) ? amount : 0).toFixed(4);
|
||
}
|
||
|
||
function localDateTimeValue(value) {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||
return local.toISOString().slice(0, 19);
|
||
}
|
||
|
||
function billingPayload() {
|
||
const period = document.querySelector("#editor-reset-period").value;
|
||
const nextValue = document.querySelector("#editor-next-reset").value;
|
||
return {
|
||
quota_usd: document.querySelector("#editor-quota").value.trim() || "0",
|
||
reset_period: period,
|
||
next_reset_at: period === "none" || !nextValue ? null : new Date(nextValue).toISOString(),
|
||
max_concurrency: Number(document.querySelector("#editor-concurrency").value || 4)
|
||
};
|
||
}
|
||
|
||
function isCompactEndpoint(value) {
|
||
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "").split("?", 1)[0].replace(/\/+$/, "");
|
||
return path.endsWith("/responses/compact");
|
||
}
|
||
|
||
function endpoint(value) {
|
||
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "");
|
||
if (isCompactEndpoint(path)) return "compact";
|
||
if (path.endsWith("/chat/completions")) return "chat";
|
||
if (path.endsWith("/responses")) return "responses";
|
||
return path.replace(/^\/v1\//, "") || "-";
|
||
}
|
||
|
||
function result(record) {
|
||
if (record.outcome === "canceled") return "已取消";
|
||
if (record.outcome === "rejected") return "已拒绝";
|
||
if (record.failed || record.outcome === "failed") return record.status_code ? `失败 (${record.status_code})` : "失败";
|
||
return "成功";
|
||
}
|
||
|
||
const columns = [
|
||
{ id: "time", label: "时间", align: "left", value: record => new Date(record.requested_at).toLocaleString() },
|
||
{ id: "key", label: "Key / 别名", align: "left", value: record => record.key_alias || record.api_key || "-" },
|
||
{ id: "upstream", label: "上游账号", align: "left", value: record => record.auth_id || record.auth_index || record.auth_type || "-" },
|
||
{ id: "model", label: "模型", align: "left", value: record => record.model || "-" },
|
||
{ id: "reasoning_effort", label: "推理强度", align: "left", value: record => record.reasoning_effort || "-" },
|
||
{ id: "service_tier", label: "模式", align: "left", value: record => String(record.speed || "").toLowerCase() === "fast" ? "fast" : (record.service_tier || "-") },
|
||
{ id: "result", label: "结果", align: "left", value: record => result(record) },
|
||
{ id: "request_type", label: "类型", align: "left", value: record => record.request_type || "-" },
|
||
{ id: "endpoint", label: "端点", align: "left", value: record => endpoint(record.endpoint) },
|
||
{ id: "ttft", label: "首字延迟", value: record => isCompactEndpoint(record.endpoint) ? "-" : duration(record.ttft_ms) },
|
||
{ id: "speed", label: "生成速度", value: record => isCompactEndpoint(record.endpoint) ? "-" : speed(record.speed_tps) },
|
||
{ id: "input", label: "输入", value: record => number(record.input_tokens) },
|
||
{ id: "output", label: "输出", value: record => number(record.output_tokens) },
|
||
{ id: "reasoning", label: "推理", value: record => number(record.reasoning_tokens) },
|
||
{ id: "cache_read", label: "缓存读取", value: record => number(record.cache_read_tokens) },
|
||
{ id: "cache_write", label: "缓存写入", value: record => number(record.cache_write_tokens) },
|
||
{ id: "cache_rate", label: "缓存率", value: record => percent(record.cache_rate) },
|
||
{ id: "total", label: "Token 总数", value: record => number(record.total_tokens) },
|
||
{ id: "cost", label: "总成本", value: record => cost(record) },
|
||
{ id: "client_ip", label: "客户端 IP", align: "left", value: record => record.client_ip || "-" }
|
||
];
|
||
|
||
function loadVisibleColumns() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(columnStoreKey));
|
||
if (Array.isArray(saved)) {
|
||
const valid = saved.filter(id => columns.some(column => column.id === id));
|
||
if (valid.length) return new Set(valid);
|
||
}
|
||
} catch (_) {}
|
||
return new Set(columns.map(column => column.id));
|
||
}
|
||
|
||
let visibleColumns = loadVisibleColumns();
|
||
|
||
function renderColumnControls() {
|
||
headersNode.replaceChildren(...columns.filter(column => visibleColumns.has(column.id)).map(column => {
|
||
const header = document.createElement("th");
|
||
header.textContent = column.label;
|
||
if (column.align) header.className = column.align;
|
||
return header;
|
||
}));
|
||
columnOptionsNode.replaceChildren(...columns.map(column => {
|
||
const label = document.createElement("label");
|
||
const input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.checked = visibleColumns.has(column.id);
|
||
input.addEventListener("change", () => {
|
||
if (!input.checked && visibleColumns.size === 1) {
|
||
input.checked = true;
|
||
return;
|
||
}
|
||
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
|
||
try { localStorage.setItem(columnStoreKey, JSON.stringify(columns.filter(item => visibleColumns.has(item.id)).map(item => item.id))); } catch (_) {}
|
||
renderColumnControls();
|
||
render(currentRecords);
|
||
});
|
||
label.append(input, column.label);
|
||
return label;
|
||
}));
|
||
}
|
||
|
||
function render(records) {
|
||
currentRecords = records;
|
||
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
|
||
rowsNode.replaceChildren(...currentRecords.map(record => {
|
||
const row = document.createElement("tr");
|
||
activeColumns.forEach(column => {
|
||
const cell = document.createElement("td");
|
||
cell.textContent = column.value(record);
|
||
if (column.align) cell.classList.add(column.align);
|
||
if (column.id === "result") cell.classList.add(record.failed ? "failed" : "ok");
|
||
row.appendChild(cell);
|
||
});
|
||
return row;
|
||
}));
|
||
renderPagination();
|
||
}
|
||
|
||
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() {
|
||
const pageCount = currentPagination.total_pages;
|
||
currentPage = currentPagination.page || 1;
|
||
const controls = [];
|
||
const button = (label, page, disabled = false, active = false, cursor = "") => {
|
||
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", () => load(true, page, cursor));
|
||
return node;
|
||
};
|
||
controls.push(button("上一页", currentPage - 1, currentPage <= 1, false, currentPagination.previous_cursor || ""));
|
||
pageItems(currentPage, Math.max(1, 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, pageCount === 0 || currentPage >= pageCount, false, currentPagination.next_cursor || ""));
|
||
pageButtonsNode.replaceChildren(...controls);
|
||
}
|
||
|
||
function usageFilterQuery() {
|
||
const params = new URLSearchParams();
|
||
const from = document.querySelector("#usage-from").value;
|
||
const to = document.querySelector("#usage-to").value;
|
||
if (from) params.set("from", new Date(from).toISOString());
|
||
if (to) params.set("to", new Date(to).toISOString());
|
||
[["key_id", "#usage-key"], ["model", "#usage-model"], ["result", "#usage-result"], ["auth_id", "#usage-auth"], ["endpoint", "#usage-endpoint"], ["request_id", "#usage-request-id"]].forEach(([name, selector]) => {
|
||
const value = document.querySelector(selector).value.trim();
|
||
if (value) params.set(name, value);
|
||
});
|
||
return params;
|
||
}
|
||
|
||
function populateUsageFilterOptions(keys, upstreams, models) {
|
||
const keySelect = document.querySelector("#usage-key");
|
||
const authSelect = document.querySelector("#usage-auth");
|
||
const selectedKey = keySelect.value, selectedAuth = authSelect.value;
|
||
keySelect.replaceChildren(option("", "全部用户"), ...keys.map(key => option(key.id, key.name, key.id === selectedKey)));
|
||
authSelect.replaceChildren(option("", "全部上游"), ...upstreams.map(account => option(account.cpa_auth_id, account.display_name || account.cpa_auth_id, account.cpa_auth_id === selectedAuth)));
|
||
document.querySelector("#usage-model-options").replaceChildren(...models.map(model => option(model, model)));
|
||
}
|
||
|
||
async function loadUsageFilterOptions() {
|
||
try {
|
||
const [keys, upstreams, models] = await Promise.all([dataFetch(KEYS_API + "?include_archived=1"), dataFetch(UPSTREAMS_API), dataFetch(MODELS_API)]);
|
||
populateUsageFilterOptions(keys.keys || [], upstreams.accounts || [], models.models || []);
|
||
} catch (error) { statusNode.textContent = "读取筛选项失败: " + error.message; }
|
||
}
|
||
|
||
function authHeaders(json = false) {
|
||
const headers = { Authorization: "Bearer " + keyInput.value.trim() };
|
||
if (json) headers["Content-Type"] = "application/json";
|
||
return headers;
|
||
}
|
||
|
||
function setAccessState(authorized) {
|
||
managementAuthorized = authorized;
|
||
document.body.classList.toggle("read-only", !authorized);
|
||
document.querySelectorAll("#view-pricing .price-editor input, #view-pricing .price-editor select").forEach(node => {
|
||
if (node.id !== "catalog-query") node.disabled = !authorized;
|
||
});
|
||
syncLongSectionVisibility();
|
||
if (!authorized) closeDrawer();
|
||
}
|
||
|
||
function readOnlyURL(managementURL) {
|
||
const source = new URL(managementURL, location.origin);
|
||
const views = {
|
||
[API]: "usage", [SUMMARY_API]: "usage-summary", [PRICE_API]: "prices",
|
||
[CATALOG_API]: "price-catalog", [KEYS_API]: "keys", [KEY_STATS_API]: "key-stats",
|
||
[UPSTREAMS_API]: "upstreams", [MODELS_API]: "model-suggestions"
|
||
};
|
||
const view = views[source.pathname];
|
||
if (!view) throw new Error("该数据不支持匿名读取");
|
||
source.searchParams.set("view", view);
|
||
return READONLY_API + "?" + source.searchParams.toString();
|
||
}
|
||
|
||
async function responsePayload(response) {
|
||
try { return await response.json(); } catch (_) { return {}; }
|
||
}
|
||
|
||
async function dataFetch(url) {
|
||
const key = keyInput.value.trim();
|
||
if (key) {
|
||
const response = await fetch(url, { headers: authHeaders() });
|
||
const payload = await responsePayload(response);
|
||
if (response.ok) { setAccessState(true); return payload; }
|
||
if (response.status !== 401 && response.status !== 403) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
setAccessState(false);
|
||
} else {
|
||
setAccessState(false);
|
||
}
|
||
const response = await fetch(readOnlyURL(url), { headers: { Accept: "application/json" } });
|
||
const payload = await responsePayload(response);
|
||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
return payload;
|
||
}
|
||
|
||
async function managedFetch(url, options = {}) {
|
||
if (!keyInput.value.trim()) { setAccessState(false); throw new Error("请输入管理密钥"); }
|
||
const headers = authHeaders(Boolean(options.body));
|
||
const response = await fetch(url, { ...options, headers: { ...headers, ...(options.headers || {}) } });
|
||
const payload = await responsePayload(response);
|
||
if (!response.ok) {
|
||
if (response.status === 401 || response.status === 403) setAccessState(false);
|
||
throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||
}
|
||
setAccessState(true);
|
||
return payload;
|
||
}
|
||
|
||
function option(value, label, selected = false) {
|
||
const node = document.createElement("option");
|
||
node.value = value; node.textContent = label; node.selected = selected;
|
||
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 "自由选择";
|
||
const account = upstreamAccounts.find(item => item.id === key.upstream_account_id);
|
||
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) {
|
||
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;
|
||
const period = document.querySelector("#editor-reset-period").value;
|
||
const nextReset = document.querySelector("#editor-next-reset");
|
||
nextReset.disabled = archived || period === "none";
|
||
if (period !== "none" && !nextReset.value) {
|
||
const next = new Date();
|
||
if (period === "daily") next.setDate(next.getDate() + 1);
|
||
else if (period === "weekly") next.setDate(next.getDate() + 7);
|
||
else next.setMonth(next.getMonth() + 1);
|
||
nextReset.value = localDateTimeValue(next);
|
||
}
|
||
}
|
||
|
||
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.remove("hidden");
|
||
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";
|
||
const billing = key?.billing || { quota_usd: "0", spent_usd: "0", balance_usd: "0", reset_period: "none", max_concurrency: 4, active_requests: 0 };
|
||
document.querySelector("#editor-quota").value = billing.quota_usd || "0";
|
||
document.querySelector("#editor-concurrency").value = billing.max_concurrency || 4;
|
||
document.querySelector("#editor-reset-period").value = billing.reset_period || "none";
|
||
document.querySelector("#editor-next-reset").value = localDateTimeValue(billing.next_reset_at);
|
||
document.querySelector("#billing-quota-view").textContent = money(billing.quota_usd);
|
||
document.querySelector("#billing-spent-view").textContent = money(billing.spent_usd);
|
||
document.querySelector("#billing-balance-view").textContent = money(billing.balance_usd);
|
||
document.querySelector("#billing-status").textContent = creating ? "新用户默认额度为 $0" : `${billing.active_requests || 0} 个请求正在执行`;
|
||
document.querySelector("#billing-preview").classList.toggle("hidden", creating);
|
||
document.querySelector("#ledger-section").classList.toggle("hidden", creating);
|
||
document.querySelector("#reset-billing").classList.toggle("hidden", creating || key?.status === "archived");
|
||
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", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
|
||
syncEditorRoute(); openDrawer();
|
||
if (!creating) loadBillingLedger(key);
|
||
}
|
||
|
||
function renderManagedKeys() {
|
||
keyRowsNode.replaceChildren(...managedKeys.map(key => {
|
||
const row = document.createElement("tr");
|
||
const archived = key.status === "archived";
|
||
const identity = document.createElement("div"); identity.className = "key-identity";
|
||
const avatar = document.createElement("span"); avatar.className = "avatar"; avatar.textContent = (key.name || "K").slice(0, 2).toUpperCase();
|
||
const identityText = document.createElement("div"); const name = document.createElement("strong"); name.textContent = key.name; const id = document.createElement("small"); id.textContent = key.id; identityText.append(name, id); identity.append(avatar, identityText);
|
||
const credential = document.createElement("div"); credential.className = "credential"; const secret = document.createElement("code"); secret.className = "credential-copy"; secret.textContent = managementAuthorized ? maskedSecret(key.secret) : (key.masked_secret || "******");
|
||
if (managementAuthorized && key.secret) { secret.tabIndex = 0; secret.setAttribute("role", "button"); secret.setAttribute("aria-label", "复制完整 Key"); secret.title = "点击复制完整 Key"; secret.addEventListener("click", () => copyText(key.secret, secret)); secret.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); copyText(key.secret, secret); } }); }
|
||
credential.append(secret);
|
||
const status = document.createElement("span"); status.className = `badge ${key.status}`; status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
|
||
const billing = key.billing || {}; const balance = document.createElement("div"); const balanceMain = document.createElement("div"); balanceMain.className = "route-main"; balanceMain.textContent = money(billing.balance_usd); const balanceSub = document.createElement("div"); balanceSub.className = "route-sub"; balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`; balance.append(balanceMain, balanceSub);
|
||
const concurrency = document.createElement("span"); concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
|
||
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));
|
||
actions.append(stats);
|
||
if (managementAuthorized) { const manage = document.createElement("button"); manage.type = "button"; manage.className = "small admin-only"; manage.textContent = archived ? "查看" : "管理"; manage.addEventListener("click", () => openKeyEditor(key)); actions.append(manage); }
|
||
[identity, credential, status, balance, concurrency, routeNode, models, actions].forEach(node => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
|
||
return row;
|
||
}));
|
||
const todaySummary = usageDashboard.today || {};
|
||
document.querySelector("#metric-requests").textContent = number(todaySummary.requests || 0);
|
||
document.querySelector("#metric-tokens").textContent = compactNumber(todaySummary.total_tokens || 0);
|
||
document.querySelector("#metric-cost").textContent = "$" + Number(todaySummary.cost_usd || 0).toFixed(4);
|
||
renderUserCharts();
|
||
}
|
||
|
||
function compactNumber(value) {
|
||
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
|
||
}
|
||
|
||
function renderUserCharts() {
|
||
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
|
||
(usageDashboard.users || []).forEach(user => {
|
||
const name = user.key_alias || "未识别";
|
||
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
|
||
const value = byUser.get(name); const today = user.today || {};
|
||
value.requests = today.requests || 0; value.tokens = today.total_tokens || 0; value.cost = today.cost_usd || 0;
|
||
value.last = user.last_used_at ? new Date(user.last_used_at) : null;
|
||
});
|
||
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
|
||
const 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 = (usageDashboard.days || []).map(item => ({ date: new Date(item.date + "T00:00:00"), tokens: item.total_tokens || 0 }));
|
||
const maximum = Math.max(1, ...days.map(day => day.tokens));
|
||
document.querySelector("#daily-chart").replaceChildren(...days.map(day => { const row = document.createElement("div"); row.className = "bar-row"; const label = document.createElement("span"); label.className = "bar-label"; label.textContent = `${day.date.getMonth() + 1}/${day.date.getDate()}`; const track = document.createElement("span"); track.className = "bar-track"; const value = document.createElement("span"); value.className = "bar-value"; value.style.width = `${day.tokens / maximum * 100}%`; track.append(value); const count = document.createElement("span"); count.className = "bar-count"; count.textContent = compactNumber(day.tokens); row.append(label, track, count); return row; }));
|
||
}
|
||
|
||
async function loadKeyManagement() {
|
||
keyStatusNode.textContent = "正在读取";
|
||
try {
|
||
const [upstreams, models, summary] = await Promise.all([dataFetch(UPSTREAMS_API), dataFetch(MODELS_API), dataFetch(SUMMARY_API)]);
|
||
upstreamAccounts = upstreams.accounts || [];
|
||
modelSuggestions = models.models || [];
|
||
usageDashboard = summary;
|
||
const keys = await dataFetch(KEYS_API);
|
||
managedKeys = keys.keys || [];
|
||
renderManagedKeys();
|
||
keyStatusNode.textContent = managementAuthorized ? `共 ${managedKeys.length} 个 Key,${upstreamAccounts.length} 个上游账号${upstreams.warning ? ";同步警告:" + upstreams.warning : ""}` : "";
|
||
} catch (error) { keyStatusNode.textContent = "读取失败: " + error.message; }
|
||
}
|
||
|
||
async function createManagedKey() {
|
||
const name = document.querySelector("#editor-name").value.trim();
|
||
const secret = document.querySelector("#editor-secret").value.trim();
|
||
const route = document.querySelector("#editor-route").value;
|
||
const allModels = document.querySelector("#editor-all-models").checked;
|
||
const payload = { name, secret, route_mode: route, upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "", all_models: allModels, models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean), billing: billingPayload() };
|
||
try {
|
||
const created = await managedFetch(KEYS_API, { method: "POST", body: JSON.stringify(payload) });
|
||
keyStatusNode.textContent = `已创建 ${created.name}:${created.secret}`;
|
||
closeDrawer(); await loadKeyManagement();
|
||
} catch (error) { keyStatusNode.textContent = "创建失败: " + error.message; }
|
||
}
|
||
|
||
async function updateManagedKey(payload) {
|
||
try {
|
||
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),
|
||
billing: billingPayload()
|
||
});
|
||
}
|
||
|
||
async function loadBillingLedger(key) {
|
||
const node = document.querySelector("#billing-ledger");
|
||
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "正在读取" }));
|
||
try {
|
||
const entries = (await managedFetch(BILLING_LEDGER_API + "?id=" + encodeURIComponent(key.id) + "&limit=50")).entries || [];
|
||
node.replaceChildren(...(entries.length ? entries.map(entry => {
|
||
const row = document.createElement("div"); row.className = "ledger-item";
|
||
const label = document.createElement("strong"); label.textContent = ({ charge: "请求扣费", quota_change: "调整额度", cycle_reset: "额度重置" }[entry.kind] || entry.kind);
|
||
const amount = document.createElement("strong"); const numeric = Number(entry.amount_usd || 0); amount.className = numeric < 0 ? "negative" : "positive"; amount.textContent = (numeric > 0 ? "+" : "") + money(numeric);
|
||
const detail = document.createElement("small"); detail.textContent = `${new Date(entry.occurred_at).toLocaleString()} · 余额 ${money(entry.balance_after_usd)}${entry.model ? " · " + entry.model : ""}${entry.request_id ? " · " + entry.request_id : ""}`;
|
||
row.append(label, amount, detail); return row;
|
||
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无账目" })]));
|
||
} catch (error) { node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "读取失败:" + error.message })); }
|
||
}
|
||
|
||
async function resetManagedBilling() {
|
||
if (!editingKey || !confirm(`立即重置 ${editingKey.name} 的额度?当前剩余余额不会结转。`)) return;
|
||
try {
|
||
await managedFetch(BILLING_RESET_API, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
|
||
await loadKeyManagement(); const refreshed = managedKeys.find(item => item.id === editingKey.id); if (refreshed) openKeyEditor(refreshed);
|
||
} catch (error) { document.querySelector("#billing-status").textContent = "重置失败:" + error.message; }
|
||
}
|
||
|
||
async function archiveManagedKey(key) {
|
||
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
|
||
try {
|
||
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 dataFetch(KEY_STATS_API + "?id=" + encodeURIComponent(key.id));
|
||
const total = payload.stats.total, today = payload.stats.today;
|
||
document.querySelector("#drawer-title").textContent = `${key.name} · 统计`;
|
||
document.querySelector("#drawer-subtitle").textContent = "历史统计永久保留";
|
||
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; }
|
||
}
|
||
|
||
function setLongFields(enabled) {
|
||
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
|
||
}
|
||
|
||
function syncLongSectionVisibility(hasLong = document.querySelector("#long-enabled").checked) {
|
||
document.querySelector("#long-price-section").classList.toggle("hidden", !managementAuthorized && !hasLong);
|
||
}
|
||
|
||
function setView(view) {
|
||
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
|
||
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
|
||
try { sessionStorage.setItem("billing:active-view", view); } catch (_) {}
|
||
if (view === "usage") { loadUsageFilterOptions(); load(true, 1); }
|
||
if (view === "pricing") loadPrices();
|
||
if (view === "keys") loadKeyManagement();
|
||
}
|
||
|
||
function reloadCurrentView() {
|
||
currentPage = 1; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
|
||
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
|
||
if (active === "keys") loadKeyManagement();
|
||
if (active === "usage") { loadUsageFilterOptions(); load(true, 1); }
|
||
if (active === "pricing") loadPrices();
|
||
}
|
||
|
||
function showPriceEditor(show) {
|
||
priceEditorContentNode.classList.toggle("hidden", !show);
|
||
document.querySelector("#delete-price").classList.toggle("hidden", !selectedPriceModel);
|
||
}
|
||
|
||
function clearPriceForm(showEditor = true) {
|
||
selectedPriceModel = "";
|
||
["price-model", "price-input", "price-cache-read", "price-cache-write", "price-output", "long-threshold", "long-input", "long-cache-read", "long-cache-write", "long-output"].forEach(id => document.querySelector("#" + id).value = "");
|
||
document.querySelector("#long-enabled").checked = false;
|
||
document.querySelector("#long-comparison").value = "gt";
|
||
document.querySelector("#fast-pricing-enabled").checked = false;
|
||
document.querySelector("#fast-multiplier").value = "2.5";
|
||
showPriceSource({ kind: "manual" });
|
||
setLongFields(false);
|
||
syncLongSectionVisibility(false);
|
||
showPriceEditor(showEditor);
|
||
renderPrices();
|
||
}
|
||
|
||
function priceSourceText(source) {
|
||
if (source?.kind === "models.dev") return `models.dev · ${source.provider || "-"} / ${source.model || source.catalog_id || "-"}`;
|
||
return "手动配置";
|
||
}
|
||
|
||
function showPriceSource(source) {
|
||
priceSourceNode.textContent = priceSourceText(source);
|
||
priceSourceNode.classList.toggle("catalog-linked", source?.kind === "models.dev");
|
||
}
|
||
|
||
function fillPriceForm(price) {
|
||
selectedPriceModel = price.model;
|
||
document.querySelector("#price-model").value = price.model;
|
||
document.querySelector("#price-input").value = price.base.input_per_1m;
|
||
document.querySelector("#price-cache-read").value = price.base.cache_read_per_1m;
|
||
document.querySelector("#price-cache-write").value = price.base.cache_write_per_1m;
|
||
document.querySelector("#price-output").value = price.base.output_per_1m;
|
||
const long = price.long_context;
|
||
document.querySelector("#long-enabled").checked = Boolean(long);
|
||
document.querySelector("#long-threshold").value = long?.threshold_input_tokens ?? "";
|
||
document.querySelector("#long-comparison").value = long?.comparison || "gt";
|
||
document.querySelector("#long-input").value = long?.input_per_1m ?? "";
|
||
document.querySelector("#long-cache-read").value = long?.cache_read_per_1m ?? "";
|
||
document.querySelector("#long-cache-write").value = long?.cache_write_per_1m ?? "";
|
||
document.querySelector("#long-output").value = long?.output_per_1m ?? "";
|
||
document.querySelector("#fast-pricing-enabled").checked = price.fast_pricing_enabled;
|
||
document.querySelector("#fast-multiplier").value = price.fast_multiplier || "2.5";
|
||
showPriceSource(price.source || { kind: "manual" });
|
||
setLongFields(Boolean(long));
|
||
syncLongSectionVisibility(Boolean(long));
|
||
showPriceEditor(true);
|
||
document.querySelectorAll(".price-row").forEach(row => row.classList.toggle("selected", row.dataset.model === selectedPriceModel));
|
||
}
|
||
|
||
function pricePayload() {
|
||
const value = id => document.querySelector("#" + id).value.trim();
|
||
const payload = {
|
||
model: value("price-model"),
|
||
base: { input_per_1m: value("price-input"), cache_read_per_1m: value("price-cache-read"), cache_write_per_1m: value("price-cache-write"), output_per_1m: value("price-output") },
|
||
fast_pricing_enabled: document.querySelector("#fast-pricing-enabled").checked,
|
||
fast_multiplier: value("fast-multiplier")
|
||
};
|
||
if (document.querySelector("#long-enabled").checked) {
|
||
payload.long_context = {
|
||
threshold_input_tokens: Number(value("long-threshold")), comparison: value("long-comparison"),
|
||
input_per_1m: value("long-input"), cache_read_per_1m: value("long-cache-read"), cache_write_per_1m: value("long-cache-write"), output_per_1m: value("long-output")
|
||
};
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
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" + (price.model === selectedPriceModel ? " selected" : "");
|
||
row.dataset.model = price.model;
|
||
row.tabIndex = 0;
|
||
row.setAttribute("role", "button");
|
||
const text = document.createElement("div");
|
||
const model = document.createElement("div");
|
||
model.className = "price-name";
|
||
model.textContent = price.model;
|
||
const detail = document.createElement("small");
|
||
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""} · ${priceSourceText(price.source)}`;
|
||
text.append(model, detail);
|
||
row.addEventListener("click", () => fillPriceForm(price));
|
||
row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); fillPriceForm(price); } });
|
||
row.append(text); return row;
|
||
}));
|
||
}
|
||
|
||
async function loadPrices() {
|
||
try {
|
||
const payload = await dataFetch(PRICE_API);
|
||
currentPrices = payload.prices || [];
|
||
renderPrices();
|
||
const selected = currentPrices.find(price => price.model === selectedPriceModel);
|
||
if (selected) fillPriceForm(selected);
|
||
else if (selectedPriceModel) clearPriceForm(false);
|
||
else showPriceEditor(false);
|
||
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
|
||
if (managementAuthorized) await loadCatalogStatus();
|
||
else { currentCatalog = null; catalogSummaryNode.textContent = ""; renderCatalogResults([]); }
|
||
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
|
||
}
|
||
|
||
async function savePrice() {
|
||
try {
|
||
const payload = pricePayload();
|
||
selectedPriceModel = payload.model;
|
||
await managedFetch(PRICE_API, { method: "PUT", body: JSON.stringify(payload) });
|
||
priceStatusNode.textContent = "价格已保存";
|
||
await loadPrices();
|
||
showPriceSource({ kind: "manual" });
|
||
} catch (error) { priceStatusNode.textContent = "保存失败: " + error.message; }
|
||
}
|
||
|
||
function renderCatalogResults(models) {
|
||
if (!models.length) {
|
||
catalogResultsNode.replaceChildren();
|
||
return;
|
||
}
|
||
catalogResultsNode.replaceChildren(...models.map(entry => {
|
||
const row = document.createElement("div"); row.className = "catalog-result";
|
||
const text = document.createElement("div");
|
||
const title = document.createElement("strong"); title.textContent = `${entry.provider_name || entry.provider} · ${entry.model_name || entry.model}`;
|
||
const detail = document.createElement("small"); detail.textContent = `${entry.id} · 输入 ${entry.base.input_per_1m} / 输出 ${entry.base.output_per_1m}${entry.long_context ? " · 长上下文" : ""}`;
|
||
text.append(title, detail); row.append(text);
|
||
if (managementAuthorized) { const use = document.createElement("button"); use.type = "button"; use.className = "small admin-only"; use.textContent = "使用"; use.addEventListener("click", () => importCatalogPrice(entry)); row.append(use); }
|
||
return row;
|
||
}));
|
||
}
|
||
|
||
function catalogSummary(payload) {
|
||
currentCatalog = payload?.catalog || null;
|
||
if (!payload?.loaded) return payload?.last_error ? `尚无可用目录 · ${payload.last_error}` : "尚未下载目录";
|
||
const updated = currentCatalog?.fetched_at ? new Date(currentCatalog.fetched_at).toLocaleString() : "-";
|
||
return `${currentCatalog?.models || 0} 条参考价格 · ${updated}`;
|
||
}
|
||
|
||
async function searchCatalog() {
|
||
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
|
||
try {
|
||
const payload = await dataFetch(CATALOG_API + "?q=" + encodeURIComponent(query));
|
||
catalogSummaryNode.textContent = catalogSummary(payload);
|
||
renderCatalogResults(payload.models || []);
|
||
} catch (error) { catalogSummaryNode.textContent = "读取参考价格失败:" + error.message; renderCatalogResults([]); }
|
||
}
|
||
|
||
async function loadCatalogStatus() {
|
||
await searchCatalog();
|
||
}
|
||
|
||
async function importCatalogPrice(entry) {
|
||
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
|
||
if (!localModel) { priceStatusNode.textContent = "请先输入本地模型名称"; return; }
|
||
try {
|
||
const imported = await managedFetch(PRICE_IMPORT_API, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
|
||
await loadPrices(); fillPriceForm(imported); priceStatusNode.textContent = `${localModel} 已采用 ${entry.provider_name || entry.provider} / ${entry.model} 参考价`;
|
||
} catch (error) { priceStatusNode.textContent = "导入参考价格失败:" + error.message; }
|
||
}
|
||
|
||
async function refreshCatalog() {
|
||
try {
|
||
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
|
||
const preview = await managedFetch(CATALOG_REFRESH_API, { method: "POST" });
|
||
currentCatalog = preview.catalog;
|
||
const changed = (preview.changes || []).filter(change => change.status === "changed");
|
||
const missing = (preview.changes || []).filter(change => change.status === "missing");
|
||
if (!changed.length) {
|
||
catalogSummaryNode.textContent = `目录已更新 · 没有价格变化${missing.length ? ` · ${missing.length} 个来源已下架` : ""}`;
|
||
await searchCatalog(); return;
|
||
}
|
||
const lines = changed.slice(0, 8).map(change => `${change.model}: 输入 ${change.before.base.input_per_1m} → ${change.after.base.input_per_1m},输出 ${change.before.base.output_per_1m} → ${change.after.base.output_per_1m}`);
|
||
const confirmed = confirm(`发现 ${changed.length} 个已关联价格发生变化:\n\n${lines.join("\n")}${changed.length > lines.length ? "\n…" : ""}\n\n确认更新这些本地价格?`);
|
||
if (!confirmed) { catalogSummaryNode.textContent = `目录已更新 · ${changed.length} 个变化尚未应用`; return; }
|
||
const applied = await managedFetch(CATALOG_APPLY_API, { method: "POST", body: JSON.stringify({ revision: preview.catalog.revision, models: changed.map(change => change.model) }) });
|
||
catalogSummaryNode.textContent = `已确认更新 ${applied.updated} 个本地价格`;
|
||
await loadPrices();
|
||
} catch (error) { catalogSummaryNode.textContent = "更新目录失败:" + error.message; }
|
||
}
|
||
|
||
async function deletePrice(model) {
|
||
try {
|
||
if (!confirm(`删除 ${model} 的真实价格配置?`)) return;
|
||
await managedFetch(PRICE_API, { method: "DELETE", body: JSON.stringify({ model }) });
|
||
if (selectedPriceModel === model) clearPriceForm(false);
|
||
await loadPrices();
|
||
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
|
||
}
|
||
|
||
async function load(manual = false, page = currentPage, cursor = "") {
|
||
if (loading) return;
|
||
loading = true;
|
||
if (manual) statusNode.textContent = "正在读取";
|
||
try {
|
||
const params = usageFilterQuery();
|
||
params.set("page", String(page || 1));
|
||
params.set("page_size", String(PAGE_SIZE));
|
||
if (cursor) params.set("cursor", cursor);
|
||
const payload = await dataFetch(API + "?" + params.toString());
|
||
currentPagination = payload.pagination || { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0 };
|
||
const records = payload.records || [];
|
||
render(records);
|
||
statusNode.textContent = "";
|
||
} catch (error) {
|
||
statusNode.textContent = "读取失败: " + error.message;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
keyInput.value = storedPanelKey() || sessionStorage.getItem("billing:management-key") || "";
|
||
keyInput.addEventListener("change", () => { sessionStorage.setItem("billing:management-key", keyInput.value.trim()); setAccessState(false); reloadCurrentView(); });
|
||
keyInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); keyInput.blur(); } });
|
||
document.querySelector("#refresh").addEventListener("click", () => load(true, currentPage));
|
||
document.querySelector("#apply-usage-filters").addEventListener("click", () => { currentPage = 1; load(true, 1); });
|
||
document.querySelector("#clear-usage-filters").addEventListener("click", () => { ["usage-from", "usage-to", "usage-model", "usage-request-id"].forEach(id => document.querySelector("#" + id).value = ""); ["usage-key", "usage-result", "usage-auth", "usage-endpoint"].forEach(id => document.querySelector("#" + id).value = ""); currentPage = 1; load(true, 1); });
|
||
document.querySelector("#usage-request-id").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); currentPage = 1; load(true, 1); } });
|
||
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("#new-price").addEventListener("click", clearPriceForm);
|
||
document.querySelector("#delete-price").addEventListener("click", () => selectedPriceModel && deletePrice(selectedPriceModel));
|
||
document.querySelector("#search-catalog").addEventListener("click", searchCatalog);
|
||
document.querySelector("#catalog-query").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchCatalog(); } });
|
||
document.querySelector("#refresh-catalog").addEventListener("click", refreshCatalog);
|
||
document.querySelector("#create-key").addEventListener("click", () => openKeyEditor());
|
||
document.querySelector("#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.querySelector("#editor-reset-period").addEventListener("change", syncEditorRoute);
|
||
document.querySelector("#reset-billing").addEventListener("click", resetManagedBilling);
|
||
document.querySelectorAll(".nav-button").forEach(button => button.addEventListener("click", () => setView(button.dataset.view)));
|
||
document.addEventListener("keydown", event => { if (event.key === "Escape") closeDrawer(); });
|
||
document.addEventListener("visibilitychange", () => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
|
||
renderColumnControls();
|
||
setAccessState(false);
|
||
setView(sessionStorage.getItem("billing:active-view") || "keys");
|
||
setInterval(() => { if (currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
|
||
</script>
|
||
</body>
|
||
</html>
|