Two-part UX upgrade:
1. CSS — upgrade .hint from a generic "card with slate background" to a
proper info-banner:
- background: var(--color-primary-soft) (translucent teal 12%) instead
of var(--bg-elev-2) (slate card) — visually distinct from any
<input> card, so it can never be confused with one
- border-left: 2px solid var(--color-primary) — clear "this is a hint"
teal accent
- padding: 8px 12px 8px 14px (smaller, lighter)
- font-size: 12.5px (slightly larger for readability)
- margin: 4px 0 8px 0 (sits between field label and input)
- .hint code { monospace, teal-300, teal-tinted background } for inline
<code> tokens
- .hint strong { text-default color } for emphasis
Also: label > input/select/textarea forced to display:block width:100%
margin-top:6px (after the hint, hint + input collapse to margin-top:0)
2. HTML — reorder 11 labels across providers.html (7) and
admin-batch-import.html (4) so the .hint span sits BEFORE the
<input>/<select>/<textarea> it describes. Datalist stays adjacent to its
owning input.
Pattern before: <label>Field
<input>
<span class="hint">desc</span>
</label>
Pattern after: <label>Field
<span class="hint">desc</span>
<input>
</label>
Why: Linear/Vercel canonical form pattern is label + info banner above +
clean input below. The previous "input then hint" layout was just an
artifact of how the inline-script-dedup pass emitted the fields, not a
deliberate UX choice.
Verification (chrome remote-debugging, 7 pages, all .hint elements):
Page n_hints covered
https://sub.tksea.top/portal/ 2 0
https://sub.tksea.top/portal/admin/ 0 0
https://sub.tksea.top/portal/admin/providers.html 8 0
https://sub.tksea.top/portal/admin/accounts.html 0 0
https://sub.tksea.top/portal/admin/logical-groups 1 0
https://sub.tksea.top/portal/admin/route-health 0 0
https://sub.tksea.top/portal/admin-batch-import 4 0
Total: 7/7 pages, 0 hint covered by any input
Local tests still PASS:
- test_tksea_portal_assets.sh
- verify_frontend_smoke.sh
661 lines
27 KiB
HTML
661 lines
27 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Batch Import Admin · 管理台</title>
|
||
<link rel="stylesheet" href="/portal/portal.css">
|
||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||
<style>
|
||
/* Page-specific layout only. Tokens, cards, buttons come from portal.css + admin-common.css. */
|
||
.grid { display: grid; gap: var(--s-5); }
|
||
.grid-2 { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); }
|
||
.field-grid { display: grid; gap: 12px; }
|
||
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
||
.field-grid.thwo { grid-template-columns: 2fr 1fr 1fr; }
|
||
.empty { color: var(--text-muted); font-size: 13px; line-height: 1.6; }
|
||
.panel-desc { color: var(--text-muted); font-size: 13px; line-height: 1.6; margin: 8px 0 0; }
|
||
.inline-code { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); word-break: break-word; }
|
||
.table-wrap { margin-top: 12px; }
|
||
.run-summary { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
||
.run-summary > div { padding: 8px 12px; border-radius: var(--r-md); background: var(--bg-elev-3); border: 1px solid var(--border-subtle); }
|
||
pre { margin: 0; padding: 16px; border-radius: var(--r-md); border: 1px solid var(--border-subtle); background: rgba(2,6,23,0.6); color: var(--text-default); font-size: 12px; line-height: 1.65; overflow: auto; white-space: pre-wrap; word-break: break-word; }
|
||
[data-theme="light"] pre { background: var(--slate-900); color: var(--slate-100); }
|
||
@media (max-width: 1100px) { .grid-2 { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="shell fade-in">
|
||
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="batch-import"></nav>
|
||
|
||
<section class="page-hero">
|
||
<div>
|
||
<span class="page-hero__eyebrow">Batch Import</span>
|
||
<h1>live batch-import:拉取 run 与 item 级别的 account_resolution</h1>
|
||
<p>
|
||
这页继续负责 live batch-import:创建 run、拉取 run summary、查看 item 级别的
|
||
<code>matched_account_state</code> 与 <code>account_resolution</code>。批量导入第三方 key,验证
|
||
<code>reused / created / reactivated / replaced</code> 状态语义。
|
||
</p>
|
||
<ul style="display:flex;flex-wrap:wrap;gap:10px;margin:18px 0 0;padding:0;list-style:none;">
|
||
<li style="padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);">默认 API Base:<code>/portal-admin-api</code></li>
|
||
<li style="padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);">Runs 列表:<code>/api/batch-import/runs</code></li>
|
||
<li style="padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);">Run 详情:<code>/api/batch-import/runs/{run_id}/items</code></li>
|
||
</ul>
|
||
</div>
|
||
<div class="stack" style="gap:var(--s-3);">
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-primary" id="bimp-m1"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">API Root</p>
|
||
<p class="stat-value" id="metric-api-root">-</p>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-info" id="bimp-m2"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">当前 Run ID</p>
|
||
<p class="stat-value" id="metric-run-id">-</p>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-success" id="bimp-m3"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">Run State</p>
|
||
<p class="stat-value" id="metric-run-state">-</p>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-warning" id="bimp-m4"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">Run Items</p>
|
||
<p class="stat-value" id="metric-run-items">-</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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
|
||
<span class="hint">目标子机的 host_id。要从「现有 host 列表」里选一个;若列表为空则先在 providers.html 加载 pack 目录。</span>
|
||
</label>
|
||
<label>Host ID
|
||
<input id="host-id" type="text" placeholder="remote43-current-host" list="preset-host-id">
|
||
<datalist id="preset-host-id">
|
||
<option value="remote43-current-host"></option>
|
||
<option value="remote43"></option>
|
||
<option value="localhost"></option>
|
||
<option value="dev-host"></option>
|
||
</datalist>
|
||
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="field-grid two">
|
||
<label>Admin Token(可选)
|
||
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
||
<input id="admin-token" type="password" placeholder="secret-token">
|
||
</label>
|
||
<label>Mode
|
||
<span class="hint">逗号分隔,至少 1 个用户。</span>
|
||
</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">
|
||
<select id="mode">
|
||
<option value="strict">strict</option>
|
||
<option value="partial">partial</option>
|
||
</select>
|
||
</label>
|
||
<label>Subscription Days
|
||
<span class="hint">
|
||
格式:<code>base_url|api_key|requested_model_1,requested_model_2</code><br>
|
||
· 每行一条供应商帐号;多个 key 走多行即可(批量导入)。<br>
|
||
· 第三段 <code>requested_model</code> 可省略,CRM 会从 host 已发布 provider 自动推断。<br>
|
||
· 同 base_url 可合并多行;不同 base_url 走独立 row 即可。
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<label style="margin-top: 12px;">Entries
|
||
<textarea id="entries" rows="6" placeholder="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>
|
||
<input id="subscription-days" type="number" min="1" value="30">
|
||
</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 src="/portal/admin-common.js"></script>
|
||
<script src="/portal/portal.js"></script>
|
||
<script>
|
||
|
||
const AdminCommon = window.Sub2ApiAdminCommon;
|
||
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "batch-import");
|
||
(function injectIcons(){
|
||
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
|
||
M("bimp-m1", "shield");
|
||
M("bimp-m2", "import");
|
||
M("bimp-m3", "activity");
|
||
M("bimp-m4", "package");
|
||
})();
|
||
|
||
// <script>
|
||
AdminCommon.renderAdminNav(document.querySelector("[data-admin-nav]"), "batch-import");
|
||
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"),
|
||
};
|
||
|
||
const adminRuntime = AdminCommon.createAdminPageRuntime({
|
||
apiBaseInput,
|
||
adminTokenInput,
|
||
adminUsernameInput,
|
||
adminPasswordInput,
|
||
adminSessionStatus,
|
||
onSessionPersist: saveConfig,
|
||
});
|
||
|
||
function setStatus(message, tone = "note") {
|
||
AdminCommon.setStatus(statusbar, message, tone);
|
||
}
|
||
|
||
function defaultApiBase() {
|
||
return adminRuntime.defaultApiBase();
|
||
}
|
||
|
||
function saveConfig() {
|
||
const payload = {
|
||
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,
|
||
};
|
||
AdminCommon.writeStoredConfig(storageKey, payload);
|
||
setStatus("本地配置已保存。", "success");
|
||
syncHeaderMetrics();
|
||
}
|
||
|
||
function restoreConfig() {
|
||
const payload = AdminCommon.readStoredConfig(storageKey);
|
||
if (!Object.keys(payload).length) {
|
||
apiBaseInput.value = defaultApiBase();
|
||
hostIDInput.value = "";
|
||
confirmTimeoutInput.value = "10";
|
||
subscriptionDaysInput.value = "30";
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
|
||
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 adminRuntime.normalizeApiBase();
|
||
}
|
||
|
||
function authHeaders() {
|
||
return {
|
||
"Content-Type": "application/json",
|
||
...adminRuntime.authHeaders(),
|
||
};
|
||
}
|
||
|
||
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 = {}) {
|
||
return adminRuntime.requestJSON(path, options);
|
||
}
|
||
|
||
async function refreshAdminSession() {
|
||
return adminRuntime.refreshAdminSession();
|
||
}
|
||
|
||
async function loginAdminSession() {
|
||
return adminRuntime.loginAdminSession();
|
||
}
|
||
|
||
async function logoutAdminSession() {
|
||
return adminRuntime.logoutAdminSession();
|
||
}
|
||
|
||
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();
|
||
} catch (error) {}
|
||
});
|
||
adminLogoutButton.addEventListener("click", async () => {
|
||
try {
|
||
await logoutAdminSession();
|
||
await refreshAdminSession();
|
||
} catch (error) {}
|
||
});
|
||
accessModeInput.addEventListener("change", updateAccessModeFields);
|
||
|
||
restoreConfig();
|
||
updateAccessModeFields();
|
||
syncHeaderMetrics();
|
||
refreshAdminSession().catch(() => {});
|
||
</script>
|
||
</body>
|
||
</html>
|