2026-05-27 20:23:42 +08:00
|
|
|
|
<!doctype html>
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<html lang="zh-CN" data-theme="dark">
|
2026-05-27 20:23:42 +08:00
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<title>Batch Import Admin · 管理台</title>
|
|
|
|
|
|
<link rel="stylesheet" href="/portal/portal.css">
|
|
|
|
|
|
<link rel="stylesheet" href="/portal/admin-common.css">
|
2026-05-27 20:23:42 +08:00
|
|
|
|
<style>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
/* 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; } }
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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> 状态语义。
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</p>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</ul>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
</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>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</div>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</div>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</div>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</section>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<section class="grid">
|
2026-05-27 20:23:42 +08:00
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<h2>发起导入</h2>
|
|
|
|
|
|
<p class="panel-desc">
|
2026-05-28 11:01:29 +08:00
|
|
|
|
优先使用管理员登录会话调用当前控制面的 batch-import API;必要时也可以回退到 Bearer token。
|
2026-05-27 20:23:42 +08:00
|
|
|
|
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field-grid two">
|
|
|
|
|
|
<label>API Base
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<span class="hint">目标子机的 host_id。要从「现有 host 列表」里选一个;若列表为空则先在 providers.html 加载 pack 目录。</span>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>Host ID
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field-grid two">
|
2026-05-28 11:01:29 +08:00
|
|
|
|
<label>Admin Token(可选)
|
|
|
|
|
|
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<input id="admin-token" type="password" placeholder="secret-token">
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>Mode
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<span class="hint">逗号分隔,至少 1 个用户。</span>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 11:01:29 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-27 20:23:42 +08:00
|
|
|
|
<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">
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<select id="mode">
|
|
|
|
|
|
<option value="strict">strict</option>
|
|
|
|
|
|
<option value="partial">partial</option>
|
|
|
|
|
|
</select>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>Subscription Days
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<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>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<label style="margin-top: 12px;">Entries
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>
|
2026-06-03 20:01:44 +08:00
|
|
|
|
<input id="subscription-days" type="number" min="1" value="30">
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<script src="/portal/admin-common.js"></script>
|
|
|
|
|
|
<script src="/portal/portal.js"></script>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
<script>
|
2026-06-03 13:14:31 +08:00
|
|
|
|
|
2026-06-03 09:11:07 +08:00
|
|
|
|
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");
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-06-03 13:14:31 +08:00
|
|
|
|
// <script>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
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(() => {});
|
2026-06-03 13:14:31 +08:00
|
|
|
|
</script>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
</body>
|
2026-05-27 20:23:42 +08:00
|
|
|
|
</html>
|