feat(batch): add live reuse admin verification flow

This commit is contained in:
phamnazage-jpg
2026-05-27 20:23:42 +08:00
parent 02580cda0b
commit ebd86a4256
16 changed files with 1768 additions and 9 deletions

View File

@@ -8,6 +8,9 @@
- `tksea-portal/index.html`
- `https://sub.tksea.top/portal/` 的静态页面源码
- `tksea-portal/admin-batch-import.html`
- `https://sub.tksea.top/portal/admin-batch-import.html` 的最小管理页
- 直接消费 `POST /api/batch-import/runs``GET /api/batch-import/runs/*`
- `tksea-portal/nginx.sub.tksea.top.conf.example`
- `sub.tksea.top` 上 portal 路由与代理示例

View File

@@ -0,0 +1,902 @@
<!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;
}
.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">
<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/*` 为准,
页面不引入额外协议。
</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">
用 admin token 直接调用当前控制面的 batch-import API。
`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://crm.example.com">
</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">
</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>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 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;
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
hostID: hostIDInput.value.trim(),
adminToken: adminTokenInput.value,
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 || "";
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 token = adminTokenInput.value.trim();
if (!token) {
throw new Error("admin token 不能为空");
}
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
};
}
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 response = await fetch(`${normalizeApiBase()}${path}`, options);
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;
}
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);
accessModeInput.addEventListener("change", updateAccessModeFields);
restoreConfig();
updateAccessModeFields();
syncHeaderMetrics();
</script>
</body>
</html>