1039 lines
35 KiB
HTML
1039 lines
35 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Batch Import 管理台</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f3efe7;
|
||
--panel: #fffdf9;
|
||
--ink: #1b1815;
|
||
--muted: #655d55;
|
||
--line: rgba(27, 24, 21, 0.12);
|
||
--accent: #0b6bcb;
|
||
--accent-soft: rgba(11, 107, 203, 0.12);
|
||
--success: #127347;
|
||
--success-soft: rgba(18, 115, 71, 0.12);
|
||
--warn: #9a6112;
|
||
--warn-soft: rgba(154, 97, 18, 0.14);
|
||
--danger: #b33030;
|
||
--danger-soft: rgba(179, 48, 48, 0.12);
|
||
--shadow: 0 18px 50px rgba(52, 42, 32, 0.08);
|
||
--radius: 22px;
|
||
--radius-sm: 12px;
|
||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
color: var(--ink);
|
||
font-family: var(--font-sans);
|
||
background:
|
||
radial-gradient(circle at top right, rgba(11, 107, 203, 0.12), transparent 24rem),
|
||
radial-gradient(circle at bottom left, rgba(18, 115, 71, 0.1), transparent 22rem),
|
||
var(--bg);
|
||
}
|
||
a { color: inherit; }
|
||
.shell {
|
||
max-width: 1380px;
|
||
margin: 0 auto;
|
||
padding: 36px 20px 64px;
|
||
}
|
||
.topnav {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.topnav a {
|
||
text-decoration: none;
|
||
padding: 10px 14px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--line);
|
||
background: rgba(255,255,255,0.78);
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
transition: transform 120ms ease, background 120ms ease, color 120ms ease;
|
||
}
|
||
.topnav a:hover {
|
||
transform: translateY(-1px);
|
||
background: #fff;
|
||
color: var(--ink);
|
||
}
|
||
.topnav a.is-current {
|
||
background: var(--ink);
|
||
color: #fff;
|
||
border-color: var(--ink);
|
||
}
|
||
.hero {
|
||
display: grid;
|
||
grid-template-columns: 1.3fr 0.7fr;
|
||
gap: 18px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.hero-card,
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.hero-card {
|
||
padding: 28px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.hero-card::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: auto -5rem -5rem auto;
|
||
width: 14rem;
|
||
height: 14rem;
|
||
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 115, 71, 0.04));
|
||
border-radius: 999px;
|
||
filter: blur(8px);
|
||
}
|
||
.eyebrow {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 12px;
|
||
border-radius: 999px;
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
h1 {
|
||
margin: 18px 0 10px;
|
||
font-size: clamp(30px, 4vw, 44px);
|
||
line-height: 1.05;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.hero-copy {
|
||
max-width: 56rem;
|
||
color: var(--muted);
|
||
font-size: 16px;
|
||
line-height: 1.7;
|
||
}
|
||
.hero-points {
|
||
margin: 18px 0 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.hero-points li {
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--line);
|
||
background: rgba(255,255,255,0.76);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.quick-panel {
|
||
padding: 24px;
|
||
display: grid;
|
||
gap: 14px;
|
||
align-content: start;
|
||
}
|
||
.metric {
|
||
padding: 16px;
|
||
border-radius: 18px;
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
}
|
||
.metric-label {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
.metric-value {
|
||
margin-top: 8px;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 420px minmax(0, 1fr);
|
||
gap: 18px;
|
||
}
|
||
.panel {
|
||
padding: 22px;
|
||
}
|
||
.panel h2 {
|
||
margin: 0 0 8px;
|
||
font-size: 22px;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.panel-desc {
|
||
margin: 0 0 18px;
|
||
color: var(--muted);
|
||
line-height: 1.6;
|
||
font-size: 14px;
|
||
}
|
||
.field-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.field-grid.two {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
label {
|
||
display: grid;
|
||
gap: 7px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--muted);
|
||
}
|
||
input, select, textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
padding: 12px 14px;
|
||
font: inherit;
|
||
color: var(--ink);
|
||
background: #fff;
|
||
}
|
||
textarea {
|
||
min-height: 192px;
|
||
resize: vertical;
|
||
font-family: var(--font-mono);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.hint {
|
||
margin-top: 6px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
font-weight: 500;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
button {
|
||
border: 0;
|
||
cursor: pointer;
|
||
border-radius: 999px;
|
||
padding: 12px 18px;
|
||
font: inherit;
|
||
font-weight: 700;
|
||
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
|
||
}
|
||
button:hover { transform: translateY(-1px); }
|
||
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
|
||
.primary { background: var(--ink); color: #fff; }
|
||
.secondary { background: var(--accent-soft); color: var(--accent); }
|
||
.ghost {
|
||
background: transparent;
|
||
color: var(--muted);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.statusbar {
|
||
margin-top: 16px;
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
min-height: 54px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(18,115,71,0.22); }
|
||
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,97,18,0.2); }
|
||
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(179,48,48,0.18); }
|
||
.summary-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.summary-card {
|
||
padding: 16px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: #fff;
|
||
}
|
||
.summary-card strong {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 30px;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.subtle {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.toolbar {
|
||
display: grid;
|
||
grid-template-columns: 1.1fr 0.9fr 0.9fr auto;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
align-items: end;
|
||
}
|
||
.table-wrap {
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: #fff;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
min-width: 1080px;
|
||
}
|
||
th, td {
|
||
padding: 13px 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
text-align: left;
|
||
vertical-align: top;
|
||
font-size: 14px;
|
||
}
|
||
th {
|
||
background: rgba(27, 24, 21, 0.03);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
td code {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.badge-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border: 1px solid transparent;
|
||
white-space: nowrap;
|
||
}
|
||
.tone-green { background: var(--success-soft); color: var(--success); border-color: rgba(18,115,71,0.16); }
|
||
.tone-yellow { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,97,18,0.14); }
|
||
.tone-red { background: var(--danger-soft); color: var(--danger); border-color: rgba(179,48,48,0.14); }
|
||
.tone-blue { background: var(--accent-soft); color: var(--accent); border-color: rgba(11,107,203,0.12); }
|
||
.tone-cyan { background: rgba(13, 139, 150, 0.11); color: #0d6b72; border-color: rgba(13,139,150,0.16); }
|
||
.tone-gray { background: rgba(27, 24, 21, 0.06); color: var(--muted); border-color: rgba(27,24,21,0.12); }
|
||
.muted-block {
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
background: rgba(27, 24, 21, 0.03);
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
}
|
||
.run-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin: 10px 0 18px;
|
||
}
|
||
.run-meta code {
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
background: rgba(27, 24, 21, 0.05);
|
||
border: 1px solid var(--line);
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.hero, .grid, .toolbar, .summary-cards, .field-grid.two { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="shell">
|
||
<nav class="topnav" aria-label="Admin Navigation">
|
||
<a href="/portal/admin/">管理首页</a>
|
||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||
<a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a>
|
||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||
</nav>
|
||
|
||
<section class="hero">
|
||
<article class="hero-card">
|
||
<div class="eyebrow">Batch Import Admin</div>
|
||
<h1>供应商批量导入管理页</h1>
|
||
<p class="hero-copy">
|
||
这个页面只做三件事:发起 batch import、查看 run 摘要、拉取 item 级复用结果。
|
||
后端仍然以现有 `POST /api/batch-import/runs` 与 `GET /api/batch-import/runs/*` 为准,
|
||
页面不引入额外协议。默认通过同域 `portal-admin-api` 访问 CRM。
|
||
</p>
|
||
<ul class="hero-points">
|
||
<li>直接展示 `matched_account_state`</li>
|
||
<li>直接展示 `account_resolution`</li>
|
||
<li>复用 / 快速启用 / 替换 一眼可见</li>
|
||
</ul>
|
||
</article>
|
||
<aside class="quick-panel hero-card">
|
||
<div class="metric">
|
||
<div class="metric-label">API Root</div>
|
||
<div class="metric-value" id="metric-api-root">-</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">当前 Run</div>
|
||
<div class="metric-value" id="metric-run-id">-</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">最近状态</div>
|
||
<div class="metric-value" id="metric-run-state">-</div>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
|
||
<section class="grid">
|
||
<article class="panel">
|
||
<h2>发起导入</h2>
|
||
<p class="panel-desc">
|
||
优先使用管理员登录会话调用当前控制面的 batch-import API;必要时也可以回退到 Bearer token。
|
||
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
|
||
</p>
|
||
|
||
<div class="field-grid two">
|
||
<label>API Base
|
||
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
|
||
</label>
|
||
<label>Host ID
|
||
<input id="host-id" type="text" placeholder="remote43-current-host">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="field-grid two">
|
||
<label>Admin Token(可选)
|
||
<input id="admin-token" type="password" placeholder="secret-token">
|
||
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
||
</label>
|
||
<label>Mode
|
||
<select id="mode">
|
||
<option value="strict">strict</option>
|
||
<option value="partial">partial</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="field-grid two">
|
||
<label>管理员用户名
|
||
<input id="admin-username" type="text" placeholder="admin">
|
||
</label>
|
||
<label>管理员密码
|
||
<input id="admin-password" type="password" placeholder="请输入管理员密码">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<button id="admin-login-btn" type="button">管理员登录</button>
|
||
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
|
||
<span id="admin-session-status" class="statusbar">尚未检查管理员会话。</span>
|
||
</div>
|
||
|
||
<div class="field-grid two">
|
||
<label>Access Mode
|
||
<select id="access-mode">
|
||
<option value="self_service">self_service</option>
|
||
<option value="subscription">subscription</option>
|
||
</select>
|
||
</label>
|
||
<label>Confirm Wait Timeout Sec
|
||
<input id="confirm-timeout" type="number" min="1" value="10">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="field-grid two" id="self-service-fields">
|
||
<label>Probe API Key
|
||
<input id="probe-api-key" type="text" placeholder="sk-probe">
|
||
</label>
|
||
<div class="muted-block">
|
||
`self_service` 会直接用这把 key 执行 gateway completion 验证。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field-grid two" id="subscription-fields" hidden>
|
||
<label>Subscription Users
|
||
<input id="subscription-users" type="text" placeholder="user-1,user-2">
|
||
<span class="hint">逗号分隔,至少 1 个用户。</span>
|
||
</label>
|
||
<label>Subscription Days
|
||
<input id="subscription-days" type="number" min="1" value="30">
|
||
</label>
|
||
</div>
|
||
|
||
<label style="margin-top: 12px;">Entries
|
||
<textarea id="entries">https://api.example.com/v1|sk-example-1|kimi-k2.6
|
||
https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||
<span class="hint">格式:`base_url|api_key|requested_model_1,requested_model_2`,模型为空时可省略第三段。</span>
|
||
</label>
|
||
|
||
<div class="actions">
|
||
<button class="primary" id="create-run-btn">创建 Run</button>
|
||
<button class="ghost" id="save-config-btn">保存本地配置</button>
|
||
<button class="ghost" id="load-sample-btn">恢复示例</button>
|
||
</div>
|
||
|
||
<div class="statusbar" id="statusbar">等待操作。</div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<h2>Run 结果</h2>
|
||
<p class="panel-desc">
|
||
创建完成后会自动查询 run 摘要和 item 列表。也可以手动输入 run id 重新拉取。
|
||
</p>
|
||
|
||
<div class="field-grid two">
|
||
<label>Run ID
|
||
<input id="run-id" type="text" placeholder="run_1779848658025955399">
|
||
</label>
|
||
<div class="actions" style="align-items: end;">
|
||
<button class="secondary" id="refresh-run-btn">刷新 Run</button>
|
||
<button class="ghost" id="clear-items-btn">清空结果</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="run-meta" id="run-meta"></div>
|
||
|
||
<div class="summary-cards">
|
||
<div class="summary-card"><span class="subtle">总条目</span><strong id="sum-total">0</strong></div>
|
||
<div class="summary-card"><span class="subtle">完成</span><strong id="sum-completed">0</strong></div>
|
||
<div class="summary-card"><span class="subtle">Active</span><strong id="sum-active">0</strong></div>
|
||
<div class="summary-card"><span class="subtle">Degraded</span><strong id="sum-degraded">0</strong></div>
|
||
<div class="summary-card"><span class="subtle">Broken</span><strong id="sum-broken">0</strong></div>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<label>搜索
|
||
<input id="filter-query" type="text" placeholder="provider_id / base_url">
|
||
</label>
|
||
<label>Matched Account State
|
||
<select id="filter-matched-state">
|
||
<option value="">全部</option>
|
||
<option value="active">active</option>
|
||
<option value="disabled">disabled</option>
|
||
<option value="deprecated">deprecated</option>
|
||
<option value="broken">broken</option>
|
||
</select>
|
||
</label>
|
||
<label>Account Resolution
|
||
<select id="filter-account-resolution">
|
||
<option value="">全部</option>
|
||
<option value="created">created</option>
|
||
<option value="reused">reused</option>
|
||
<option value="reactivated">reactivated</option>
|
||
<option value="replaced">replaced</option>
|
||
</select>
|
||
</label>
|
||
<button class="ghost" id="apply-filter-btn">应用过滤</button>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Provider</th>
|
||
<th>Base URL</th>
|
||
<th>Smoke Model</th>
|
||
<th>Matched / Resolution</th>
|
||
<th>Access</th>
|
||
<th>Badges</th>
|
||
<th>Advisory</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="items-tbody">
|
||
<tr><td colspan="7" class="subtle">还没有结果。</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const storageKey = "sub2api-crm-batch-import-admin-v1";
|
||
const state = {
|
||
currentRunID: "",
|
||
currentItems: [],
|
||
currentRun: null,
|
||
};
|
||
|
||
const statusbar = document.getElementById("statusbar");
|
||
const apiBaseInput = document.getElementById("api-base");
|
||
const hostIDInput = document.getElementById("host-id");
|
||
const adminTokenInput = document.getElementById("admin-token");
|
||
const adminUsernameInput = document.getElementById("admin-username");
|
||
const adminPasswordInput = document.getElementById("admin-password");
|
||
const adminLoginButton = document.getElementById("admin-login-btn");
|
||
const adminLogoutButton = document.getElementById("admin-logout-btn");
|
||
const adminSessionStatus = document.getElementById("admin-session-status");
|
||
const modeInput = document.getElementById("mode");
|
||
const accessModeInput = document.getElementById("access-mode");
|
||
const confirmTimeoutInput = document.getElementById("confirm-timeout");
|
||
const probeAPIKeyInput = document.getElementById("probe-api-key");
|
||
const subscriptionUsersInput = document.getElementById("subscription-users");
|
||
const subscriptionDaysInput = document.getElementById("subscription-days");
|
||
const entriesInput = document.getElementById("entries");
|
||
const runIDInput = document.getElementById("run-id");
|
||
const selfServiceFields = document.getElementById("self-service-fields");
|
||
const subscriptionFields = document.getElementById("subscription-fields");
|
||
|
||
const metricApiRoot = document.getElementById("metric-api-root");
|
||
const metricRunID = document.getElementById("metric-run-id");
|
||
const metricRunState = document.getElementById("metric-run-state");
|
||
const runMeta = document.getElementById("run-meta");
|
||
const itemsTbody = document.getElementById("items-tbody");
|
||
|
||
const filterQueryInput = document.getElementById("filter-query");
|
||
const filterMatchedStateInput = document.getElementById("filter-matched-state");
|
||
const filterAccountResolutionInput = document.getElementById("filter-account-resolution");
|
||
|
||
const summaryTargets = {
|
||
total: document.getElementById("sum-total"),
|
||
completed: document.getElementById("sum-completed"),
|
||
active: document.getElementById("sum-active"),
|
||
degraded: document.getElementById("sum-degraded"),
|
||
broken: document.getElementById("sum-broken"),
|
||
};
|
||
|
||
function setStatus(message, tone = "") {
|
||
statusbar.textContent = message;
|
||
if (tone) {
|
||
statusbar.dataset.tone = tone;
|
||
} else {
|
||
delete statusbar.dataset.tone;
|
||
}
|
||
}
|
||
|
||
function defaultApiBase() {
|
||
return `${window.location.origin}/portal-admin-api`;
|
||
}
|
||
|
||
function saveConfig() {
|
||
localStorage.setItem(storageKey, JSON.stringify({
|
||
apiBase: apiBaseInput.value.trim(),
|
||
hostID: hostIDInput.value.trim(),
|
||
adminToken: adminTokenInput.value,
|
||
adminUsername: adminUsernameInput.value.trim(),
|
||
mode: modeInput.value,
|
||
accessMode: accessModeInput.value,
|
||
confirmTimeoutSec: confirmTimeoutInput.value,
|
||
probeAPIKey: probeAPIKeyInput.value.trim(),
|
||
subscriptionUsers: subscriptionUsersInput.value.trim(),
|
||
subscriptionDays: subscriptionDaysInput.value,
|
||
entries: entriesInput.value,
|
||
}));
|
||
setStatus("本地配置已保存。", "success");
|
||
syncHeaderMetrics();
|
||
}
|
||
|
||
function restoreConfig() {
|
||
const raw = localStorage.getItem(storageKey);
|
||
if (!raw) {
|
||
apiBaseInput.value = defaultApiBase();
|
||
hostIDInput.value = "";
|
||
confirmTimeoutInput.value = "10";
|
||
subscriptionDaysInput.value = "30";
|
||
return;
|
||
}
|
||
try {
|
||
const payload = JSON.parse(raw);
|
||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||
hostIDInput.value = payload.hostID || "";
|
||
adminTokenInput.value = payload.adminToken || "";
|
||
adminUsernameInput.value = payload.adminUsername || "";
|
||
modeInput.value = payload.mode || "strict";
|
||
accessModeInput.value = payload.accessMode || "self_service";
|
||
confirmTimeoutInput.value = payload.confirmTimeoutSec || "10";
|
||
probeAPIKeyInput.value = payload.probeAPIKey || "";
|
||
subscriptionUsersInput.value = payload.subscriptionUsers || "";
|
||
subscriptionDaysInput.value = payload.subscriptionDays || "30";
|
||
entriesInput.value = payload.entries || entriesInput.value;
|
||
} catch (error) {
|
||
apiBaseInput.value = defaultApiBase();
|
||
}
|
||
}
|
||
|
||
function loadSampleEntries() {
|
||
entriesInput.value = [
|
||
"https://api.example.com/v1|sk-example-1|kimi-k2.6",
|
||
"https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4",
|
||
].join("\\n");
|
||
setStatus("示例 entries 已恢复。");
|
||
}
|
||
|
||
function updateAccessModeFields() {
|
||
const accessMode = accessModeInput.value;
|
||
const subscriptionMode = accessMode === "subscription";
|
||
selfServiceFields.hidden = subscriptionMode;
|
||
subscriptionFields.hidden = !subscriptionMode;
|
||
}
|
||
|
||
function normalizeApiBase() {
|
||
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/+$/, "");
|
||
}
|
||
|
||
function authHeaders() {
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
};
|
||
const token = adminTokenInput.value.trim();
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
function parseEntries() {
|
||
return entriesInput.value
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.map((line, index) => {
|
||
const [baseURL = "", apiKey = "", models = ""] = line.split("|").map((part) => part.trim());
|
||
if (!baseURL || !apiKey) {
|
||
throw new Error(`第 ${index + 1} 行格式不完整,需要 base_url|api_key|models`);
|
||
}
|
||
return {
|
||
base_url: baseURL,
|
||
api_key: apiKey,
|
||
requested_models: models ? models.split(",").map((item) => item.trim()).filter(Boolean) : [],
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildCreatePayload() {
|
||
const payload = {
|
||
host_id: hostIDInput.value.trim(),
|
||
mode: modeInput.value,
|
||
access_mode: accessModeInput.value,
|
||
confirm_wait_timeout_sec: Number(confirmTimeoutInput.value || 10),
|
||
entries: parseEntries(),
|
||
};
|
||
if (!payload.host_id) {
|
||
throw new Error("host_id 不能为空");
|
||
}
|
||
if (payload.access_mode === "self_service") {
|
||
payload.probe_api_key = probeAPIKeyInput.value.trim();
|
||
if (!payload.probe_api_key) {
|
||
throw new Error("self_service 模式下 probe_api_key 不能为空");
|
||
}
|
||
} else {
|
||
payload.subscription_users = subscriptionUsersInput.value
|
||
.split(",")
|
||
.map((value) => value.trim())
|
||
.filter(Boolean);
|
||
payload.subscription_days = Number(subscriptionDaysInput.value || 30);
|
||
if (!payload.subscription_users.length) {
|
||
throw new Error("subscription 模式下 subscription_users 不能为空");
|
||
}
|
||
if (!payload.subscription_days) {
|
||
throw new Error("subscription 模式下 subscription_days 不能为空");
|
||
}
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
async function requestJSON(path, options = {}) {
|
||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||
const finalHeaders = { ...headers };
|
||
if (!skipAuth) {
|
||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||
}
|
||
const response = await fetch(`${normalizeApiBase()}${path}`, {
|
||
...rest,
|
||
credentials: "include",
|
||
headers: finalHeaders,
|
||
});
|
||
const text = await response.text();
|
||
let payload = {};
|
||
try {
|
||
payload = text ? JSON.parse(text) : {};
|
||
} catch (error) {
|
||
payload = { raw: text };
|
||
}
|
||
if (!response.ok) {
|
||
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
|
||
throw new Error(message);
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
async function refreshAdminSession() {
|
||
try {
|
||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||
adminUsernameInput.value = payload.username;
|
||
}
|
||
if (payload.authenticated) {
|
||
setStatus(`管理员已登录:${payload.username}`, "success");
|
||
adminSessionStatus.textContent = `已登录:${payload.username}`;
|
||
} else if (payload.login_enabled) {
|
||
adminSessionStatus.textContent = "未登录,可直接使用管理员用户名密码建立会话。";
|
||
} else {
|
||
adminSessionStatus.textContent = "当前实例未启用管理员密码登录,只能使用 Bearer token。";
|
||
}
|
||
return payload;
|
||
} catch (error) {
|
||
adminSessionStatus.textContent = `管理员会话检查失败:${error.message}`;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function loginAdminSession() {
|
||
const username = adminUsernameInput.value.trim();
|
||
const password = adminPasswordInput.value;
|
||
if (!username || !password) {
|
||
throw new Error("管理员用户名和密码不能为空");
|
||
}
|
||
const payload = await requestJSON("/api/admin/session/login", {
|
||
method: "POST",
|
||
skipAuth: true,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
adminPasswordInput.value = "";
|
||
saveConfig();
|
||
adminSessionStatus.textContent = `已登录:${payload.username}`;
|
||
return payload;
|
||
}
|
||
|
||
async function logoutAdminSession() {
|
||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || `HTTP ${response.status}`);
|
||
}
|
||
adminPasswordInput.value = "";
|
||
adminSessionStatus.textContent = "管理员会话已退出。";
|
||
}
|
||
|
||
function syncHeaderMetrics() {
|
||
metricApiRoot.textContent = normalizeApiBase();
|
||
metricRunID.textContent = state.currentRunID || "-";
|
||
metricRunState.textContent = state.currentRun?.state || "-";
|
||
}
|
||
|
||
function renderRunMeta(run) {
|
||
runMeta.innerHTML = "";
|
||
const meta = [
|
||
`run_id=${run.run_id}`,
|
||
`state=${run.state}`,
|
||
`mode=${run.mode}`,
|
||
`access_mode=${run.access_mode}`,
|
||
];
|
||
meta.forEach((entry) => {
|
||
const code = document.createElement("code");
|
||
code.textContent = entry;
|
||
runMeta.appendChild(code);
|
||
});
|
||
}
|
||
|
||
function toneClass(tone) {
|
||
if (!tone) return "tone-gray";
|
||
return `tone-${tone}`;
|
||
}
|
||
|
||
function renderBadges(badges) {
|
||
if (!Array.isArray(badges) || !badges.length) {
|
||
return '<span class="subtle">-</span>';
|
||
}
|
||
return `<div class="badge-row">${badges.map((badge) => `<span class="badge ${toneClass(badge.tone)}">${escapeHTML(badge.label)}</span>`).join("")}</div>`;
|
||
}
|
||
|
||
function renderItems(items) {
|
||
if (!items.length) {
|
||
itemsTbody.innerHTML = '<tr><td colspan="7" class="subtle">当前过滤条件下没有条目。</td></tr>';
|
||
return;
|
||
}
|
||
itemsTbody.innerHTML = items.map((item) => {
|
||
const advisory = Array.isArray(item.advisory_messages) && item.advisory_messages.length
|
||
? item.advisory_messages.map((message) => `<div>${escapeHTML(message)}</div>`).join("")
|
||
: '<span class="subtle">-</span>';
|
||
return `
|
||
<tr>
|
||
<td>
|
||
<strong>${escapeHTML(item.provider_id)}</strong><br>
|
||
<code>${escapeHTML(item.api_key_fingerprint || "-")}</code>
|
||
</td>
|
||
<td><code>${escapeHTML(item.base_url)}</code></td>
|
||
<td>
|
||
<div>${escapeHTML(item.resolved_smoke_model || "-")}</div>
|
||
<div class="subtle">${escapeHTML((item.canonical_model_families || []).join(", ") || "-")}</div>
|
||
</td>
|
||
<td>
|
||
<div><strong>${escapeHTML(item.matched_account_state || "-")}</strong></div>
|
||
<div class="subtle">${escapeHTML(item.account_resolution || "-")}</div>
|
||
</td>
|
||
<td>
|
||
<div>${escapeHTML(item.access_status || "-")}</div>
|
||
<div class="subtle">${escapeHTML(item.confirmation_status || "-")} / ${escapeHTML(item.current_stage || "-")}</div>
|
||
</td>
|
||
<td>${renderBadges(item.badges)}</td>
|
||
<td>${advisory}</td>
|
||
</tr>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderRunSummary(run) {
|
||
state.currentRun = run;
|
||
summaryTargets.total.textContent = String(run.total_items || 0);
|
||
summaryTargets.completed.textContent = String(run.completed_items || 0);
|
||
summaryTargets.active.textContent = String(run.active_items || 0);
|
||
summaryTargets.degraded.textContent = String(run.degraded_items || 0);
|
||
summaryTargets.broken.textContent = String(run.broken_items || 0);
|
||
renderRunMeta(run);
|
||
syncHeaderMetrics();
|
||
}
|
||
|
||
function clearResults() {
|
||
state.currentRun = null;
|
||
state.currentRunID = "";
|
||
state.currentItems = [];
|
||
runIDInput.value = "";
|
||
renderRunSummary({
|
||
run_id: "-",
|
||
state: "-",
|
||
mode: "-",
|
||
access_mode: "-",
|
||
total_items: 0,
|
||
completed_items: 0,
|
||
active_items: 0,
|
||
degraded_items: 0,
|
||
broken_items: 0,
|
||
});
|
||
runMeta.innerHTML = "";
|
||
itemsTbody.innerHTML = '<tr><td colspan="7" class="subtle">还没有结果。</td></tr>';
|
||
setStatus("结果已清空。");
|
||
}
|
||
|
||
async function createRun() {
|
||
const button = document.getElementById("create-run-btn");
|
||
button.disabled = true;
|
||
try {
|
||
saveConfig();
|
||
setStatus("正在创建 batch import run ...");
|
||
const payload = buildCreatePayload();
|
||
const created = await requestJSON("/api/batch-import/runs", {
|
||
method: "POST",
|
||
headers: authHeaders(),
|
||
body: JSON.stringify(payload),
|
||
});
|
||
state.currentRunID = created.run_id;
|
||
runIDInput.value = created.run_id;
|
||
syncHeaderMetrics();
|
||
setStatus(`run 已创建:${created.run_id},正在拉取详情。`, "success");
|
||
await refreshRun();
|
||
} catch (error) {
|
||
setStatus(`创建失败:${error.message}`, "danger");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function buildItemsQuery() {
|
||
const params = new URLSearchParams();
|
||
if (filterQueryInput.value.trim()) params.set("q", filterQueryInput.value.trim());
|
||
if (filterMatchedStateInput.value) params.set("matched_account_state", filterMatchedStateInput.value);
|
||
if (filterAccountResolutionInput.value) params.set("account_resolution", filterAccountResolutionInput.value);
|
||
return params.toString();
|
||
}
|
||
|
||
async function refreshRun() {
|
||
const runID = runIDInput.value.trim();
|
||
if (!runID) {
|
||
setStatus("请先输入 run_id。", "warning");
|
||
return;
|
||
}
|
||
const button = document.getElementById("refresh-run-btn");
|
||
button.disabled = true;
|
||
try {
|
||
state.currentRunID = runID;
|
||
syncHeaderMetrics();
|
||
setStatus(`正在刷新 ${runID} ...`);
|
||
const runPayload = await requestJSON(`/api/batch-import/runs/${encodeURIComponent(runID)}`, {
|
||
headers: authHeaders(),
|
||
});
|
||
renderRunSummary(runPayload.run);
|
||
const query = buildItemsQuery();
|
||
const itemsPayload = await requestJSON(`/api/batch-import/runs/${encodeURIComponent(runID)}/items${query ? `?${query}` : ""}`, {
|
||
headers: authHeaders(),
|
||
});
|
||
state.currentItems = itemsPayload.items || [];
|
||
renderItems(state.currentItems);
|
||
setStatus(`run ${runID} 已刷新,当前显示 ${state.currentItems.length} 条 item。`, "success");
|
||
} catch (error) {
|
||
setStatus(`刷新失败:${error.message}`, "danger");
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function escapeHTML(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
document.getElementById("create-run-btn").addEventListener("click", createRun);
|
||
document.getElementById("refresh-run-btn").addEventListener("click", refreshRun);
|
||
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
|
||
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
|
||
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
|
||
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
|
||
adminLoginButton.addEventListener("click", async () => {
|
||
try {
|
||
await loginAdminSession();
|
||
setStatus("管理员会话已建立。", "success");
|
||
} catch (error) {
|
||
setStatus(error.message, "danger");
|
||
}
|
||
});
|
||
adminLogoutButton.addEventListener("click", async () => {
|
||
try {
|
||
await logoutAdminSession();
|
||
await refreshAdminSession();
|
||
} catch (error) {
|
||
setStatus(error.message, "danger");
|
||
}
|
||
});
|
||
accessModeInput.addEventListener("change", updateAccessModeFields);
|
||
|
||
restoreConfig();
|
||
updateAccessModeFields();
|
||
syncHeaderMetrics();
|
||
refreshAdminSession().catch(() => {});
|
||
</script>
|
||
</body>
|
||
</html>
|