Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin-batch-import.html
2026-05-29 13:37:43 +08:00

1040 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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/route-health.html">Route 健康视图</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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>