Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin-batch-import.html
Hermes Agent 23fd8db77d
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled
refactor(portal): move .hint info-banner ABOVE its <input> + upgrade to teal-accented banner style
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
2026-06-03 20:01:44 +08:00

661 lines
27 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" 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("&", "&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();
} 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>