Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin-batch-import.html

661 lines
27 KiB
HTML
Raw Normal View History

<!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">
<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
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
<span class="hint">目标子机的 host_id。要从「现有 host 列表」里选一个;若列表为空则先在 providers.html 加载 pack 目录。</span>
</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>
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
<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>
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
<input id="admin-token" type="password" placeholder="secret-token">
</label>
<label>Mode
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
<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">
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
<select id="mode">
<option value="strict">strict</option>
<option value="partial">partial</option>
</select>
</label>
<label>Subscription Days
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
<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
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>
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
<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>