refactor(portal): migrate 8 pages to portal.css+portal.js design system
Each page now uses the new page-hero + stat-card + statusbar pattern with the Linear/Vercel-aligned token system, while preserving all admin-common.js nav render contract and 70+ test-contract strings. - public portal: index.html (1816 → 1280 lines) - admin entry: admin/index.html - admin pages: logical-groups / route-health / accounts / providers - batch import: admin/batch-import.html (39-line redirect to admin-batch-import.html for legacy URL compatibility) - admin-batch-import.html: real legacy URL handler page Verified: - bash scripts/test/test_tksea_portal_assets.sh → PASS - bash scripts/test/verify_frontend_smoke.sh → PASS (all 7 admin pages + public portal render with smoke-admin / Smoke Logical Group / Smoke Provider Account / smoke-route-primary visible) - 8 screenshot artifacts at /tmp/portal-screenshots/ (1440×2400 chromium headless, 269KB–1.2MB each = real content)
This commit is contained in:
@@ -3,415 +3,78 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Batch Import 管理台</title>
|
||||
<title>Batch Import Admin · 管理台</title>
|
||||
<link rel="stylesheet" href="/portal/portal.css">
|
||||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3efe7;
|
||||
--panel: #fffdf9;
|
||||
--ink: #1b1815;
|
||||
--muted: #655d55;
|
||||
--line: rgba(27, 24, 21, 0.12);
|
||||
--accent: #0b6bcb;
|
||||
--accent-soft: rgba(11, 107, 203, 0.12);
|
||||
--success: #127347;
|
||||
--success-soft: rgba(18, 115, 71, 0.12);
|
||||
--warn: #9a6112;
|
||||
--warn-soft: rgba(154, 97, 18, 0.14);
|
||||
--danger: #b33030;
|
||||
--danger-soft: rgba(179, 48, 48, 0.12);
|
||||
--shadow: 0 18px 50px rgba(52, 42, 32, 0.08);
|
||||
--radius: 22px;
|
||||
--radius-sm: 12px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(11, 107, 203, 0.12), transparent 24rem),
|
||||
radial-gradient(circle at bottom left, rgba(18, 115, 71, 0.1), transparent 22rem),
|
||||
var(--bg);
|
||||
}
|
||||
a { color: inherit; }
|
||||
.shell {
|
||||
max-width: 1380px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 20px 64px;
|
||||
}
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topnav a {
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.topnav a:hover {
|
||||
transform: translateY(-1px);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero-card,
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card {
|
||||
padding: 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -5rem -5rem auto;
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 115, 71, 0.04));
|
||||
border-radius: 999px;
|
||||
filter: blur(8px);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(30px, 4vw, 44px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.hero-copy {
|
||||
max-width: 56rem;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.hero-points {
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.76);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.quick-panel {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 420px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.panel-desc {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field-grid.two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
textarea {
|
||||
min-height: 192px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
|
||||
.primary { background: var(--ink); color: #fff; }
|
||||
.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.statusbar {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(18,115,71,0.22); }
|
||||
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,97,18,0.2); }
|
||||
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(179,48,48,0.18); }
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr 0.9fr auto;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1080px;
|
||||
}
|
||||
th, td {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
th {
|
||||
background: rgba(27, 24, 21, 0.03);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
td code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tone-green { background: var(--success-soft); color: var(--success); border-color: rgba(18,115,71,0.16); }
|
||||
.tone-yellow { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,97,18,0.14); }
|
||||
.tone-red { background: var(--danger-soft); color: var(--danger); border-color: rgba(179,48,48,0.14); }
|
||||
.tone-blue { background: var(--accent-soft); color: var(--accent); border-color: rgba(11,107,203,0.12); }
|
||||
.tone-cyan { background: rgba(13, 139, 150, 0.11); color: #0d6b72; border-color: rgba(13,139,150,0.16); }
|
||||
.tone-gray { background: rgba(27, 24, 21, 0.06); color: var(--muted); border-color: rgba(27,24,21,0.12); }
|
||||
.muted-block {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(27, 24, 21, 0.03);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.run-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 10px 0 18px;
|
||||
}
|
||||
.run-meta code {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(27, 24, 21, 0.05);
|
||||
border: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.hero, .grid, .toolbar, .summary-cards, .field-grid.two { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* 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">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
</nav>
|
||||
<main class="shell fade-in">
|
||||
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="batch-import"></nav>
|
||||
|
||||
<section class="hero">
|
||||
<article class="hero-card">
|
||||
<div class="eyebrow">Batch Import Admin</div>
|
||||
<h1>供应商批量导入管理页</h1>
|
||||
<p class="hero-copy">
|
||||
这个页面只做三件事:发起 batch import、查看 run 摘要、拉取 item 级复用结果。
|
||||
后端仍然以现有 `POST /api/batch-import/runs` 与 `GET /api/batch-import/runs/*` 为准,
|
||||
页面不引入额外协议。默认通过同域 `portal-admin-api` 访问 CRM。
|
||||
<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 class="hero-points">
|
||||
<li>直接展示 `matched_account_state`</li>
|
||||
<li>直接展示 `account_resolution`</li>
|
||||
<li>复用 / 快速启用 / 替换 一眼可见</li>
|
||||
<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>
|
||||
</article>
|
||||
<aside class="quick-panel hero-card">
|
||||
<div class="metric">
|
||||
<div class="metric-label">API Root</div>
|
||||
<div class="metric-value" id="metric-api-root">-</div>
|
||||
</div>
|
||||
<div class="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="metric">
|
||||
<div class="metric-label">当前 Run</div>
|
||||
<div class="metric-value" id="metric-run-id">-</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="metric">
|
||||
<div class="metric-label">最近状态</div>
|
||||
<div class="metric-value" id="metric-run-state">-</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>
|
||||
</aside>
|
||||
<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">
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<h2>发起导入</h2>
|
||||
<p class="panel-desc">
|
||||
@@ -575,7 +238,412 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
</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("&", "&")
|
||||
.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(() => {});
|
||||
</script>
|
||||
<script>
|
||||
const storageKey = "sub2api-crm-batch-import-admin-v1";
|
||||
const state = {
|
||||
currentRunID: "",
|
||||
@@ -1036,5 +1104,5 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
syncHeaderMetrics();
|
||||
refreshAdminSession().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,424 +4,97 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Provider Accounts Admin</title>
|
||||
<link rel="stylesheet" href="/portal/portal.css">
|
||||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3ede4;
|
||||
--panel: rgba(255, 252, 246, 0.94);
|
||||
--ink: #201b17;
|
||||
--muted: #685d54;
|
||||
--line: rgba(32, 27, 23, 0.12);
|
||||
--accent: #0b6bcb;
|
||||
--accent-soft: rgba(11, 107, 203, 0.12);
|
||||
--success: #126b43;
|
||||
--success-soft: rgba(18, 107, 67, 0.1);
|
||||
--warn: #9b6215;
|
||||
--warn-soft: rgba(155, 98, 21, 0.12);
|
||||
--danger: #b23131;
|
||||
--danger-soft: rgba(178, 49, 49, 0.1);
|
||||
--shadow: 0 26px 72px rgba(47, 38, 29, 0.1);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
/* Page-specific layout only. Tokens, cards, buttons come from portal.css + admin-common.css. */
|
||||
.layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: var(--s-5); }
|
||||
.field-grid { display: grid; gap: 12px; }
|
||||
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
||||
.field-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.catalog { display: grid; gap: 12px; max-height: 32rem; overflow: auto; padding-right: 4px; }
|
||||
.catalog-item, .route-item {
|
||||
padding: 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elev-1); cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(11, 107, 203, 0.16), transparent 26rem),
|
||||
radial-gradient(circle at bottom right, rgba(18, 107, 67, 0.12), transparent 24rem),
|
||||
var(--bg);
|
||||
}
|
||||
a { color: inherit; }
|
||||
code, pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.shell {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 20px 64px;
|
||||
}
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topnav a {
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
.topnav a:hover { transform: translateY(-1px); background: #fff; }
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card, .panel {
|
||||
padding: 26px;
|
||||
}
|
||||
.hero-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4rem;
|
||||
bottom: -4rem;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 107, 67, 0.06));
|
||||
filter: blur(10px);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.hero-copy, .panel-desc {
|
||||
color: var(--muted);
|
||||
line-height: 1.75;
|
||||
font-size: 15px;
|
||||
}
|
||||
.hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 440px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field-grid.two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.field-grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
textarea {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
|
||||
.primary { background: var(--ink); color: #fff; }
|
||||
.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(178, 49, 49, 0.2);
|
||||
}
|
||||
.statusbar {
|
||||
margin-top: 16px;
|
||||
min-height: 54px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.row-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.86);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.row-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(11, 107, 203, 0.24);
|
||||
box-shadow: 0 16px 36px rgba(47, 38, 29, 0.08);
|
||||
}
|
||||
.row-card.is-selected {
|
||||
border-color: rgba(11, 107, 203, 0.34);
|
||||
background: linear-gradient(180deg, rgba(11, 107, 203, 0.08), rgba(255,255,255,0.96));
|
||||
}
|
||||
.row-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.row-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.badge.active {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge.disabled {
|
||||
background: rgba(120, 113, 108, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.deprecated {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.badge.broken {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.meta-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.detail-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
padding: 16px;
|
||||
}
|
||||
.detail-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-card span, .detail-card code {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.binding-box {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.84);
|
||||
}
|
||||
.empty {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.58);
|
||||
}
|
||||
.raw-json {
|
||||
margin-top: 16px;
|
||||
background: #161311;
|
||||
color: #f6efe8;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
.catalog-item:hover, .route-item:hover { transform: translateY(-1px); border-color: rgba(20,184,166,0.32); }
|
||||
.catalog-item.is-selected, .route-item.is-selected { background: var(--color-primary-soft); border-color: rgba(20,184,166,0.32); }
|
||||
.catalog-item strong, .route-item strong { display: block; margin-bottom: 6px; font-size: 15px; color: var(--text-strong); }
|
||||
.catalog-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.grid-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); }
|
||||
.list-card { padding: 16px; border-radius: var(--r-md); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); }
|
||||
.list-card strong { display: block; margin-bottom: 6px; color: var(--text-strong); }
|
||||
.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; }
|
||||
.tone-healthy { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
|
||||
.tone-cooldown { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
|
||||
.tone-failing { background: var(--color-danger-soft); color: var(--color-danger); border-color: rgba(239,68,68,0.2); }
|
||||
.tone-disabled { background: var(--color-neutral-soft); color: var(--color-neutral); border-color: rgba(100,116,139,0.2); }
|
||||
.tone-ready { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
|
||||
.tone-note { background: var(--color-primary-soft); color: var(--color-primary); border-color: rgba(20,184,166,0.2); }
|
||||
.tone-warn { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
|
||||
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); }
|
||||
.table-wrap { margin-top: 12px; }
|
||||
@media (max-width: 1200px) {
|
||||
.hero, .layout, .field-grid.two, .field-grid.three, .detail-grid { grid-template-columns: 1fr; }
|
||||
.layout, .grid-columns, .field-grid.two, .field-grid.three { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html" class="is-current">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
</nav>
|
||||
<main class="shell fade-in">
|
||||
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="accounts"></nav>
|
||||
|
||||
<section class="hero">
|
||||
<article class="card hero-card">
|
||||
<div class="eyebrow">Provider Accounts</div>
|
||||
<h1>把导入结果升级成可读、可筛选、可启停的帐号资产库存</h1>
|
||||
<p class="hero-copy">
|
||||
这页直接消费 <code>/api/provider-accounts</code> 与三个启停动作,把每条供应商帐号摊开到
|
||||
<code>provider / logical_group / route / shadow_group / shadow_host</code> 维度。
|
||||
当前首版明确只修改插件 SQLite 里的帐号资产状态,不假装已经联动修改宿主 account 记录。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>列表会先做一次 provider_accounts 回填</li>
|
||||
<li>人工 disabled / deprecated 不会被列表刷新刷回 active</li>
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<span class="page-hero__eyebrow">Provider Accounts</span>
|
||||
<h1>把 provider_accounts 库存与归属整理收进同一面</h1>
|
||||
<p>这页把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个 <code>logical_group / route / shadow_group / shadow_host</code>,并提供人工 <code>enable / disable / retire</code> 动作。显式整理归属是冲突(<code>conflict</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);">启停只改插件库存,不直接改宿主 account 记录</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);">显式整理归属:<code>/binding</code></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<aside class="card metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">API Root</div>
|
||||
<div class="metric-value" id="metric-api-root">-</div>
|
||||
</div>
|
||||
<div class="stack" style="gap:var(--s-3);">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-primary" id="ac-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="metric">
|
||||
<div class="metric-label">Accounts</div>
|
||||
<div class="metric-value" id="metric-total">0</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-info" id="ac-m2"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Account count</p>
|
||||
<p class="stat-value" id="metric-total">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Active / Disabled</div>
|
||||
<div class="metric-value" id="metric-live">0 / 0</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-success" id="ac-m3"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Live accounts</p>
|
||||
<p class="stat-value" id="metric-live">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Deprecated / Broken</div>
|
||||
<div class="metric-value" id="metric-dead">0 / 0</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-danger" id="ac-m4"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Dead / conflict</p>
|
||||
<p class="stat-value" id="metric-dead">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layout">
|
||||
<section class="layout">
|
||||
<div class="stack">
|
||||
<article class="card panel">
|
||||
<h2>连接与过滤</h2>
|
||||
@@ -588,7 +261,458 @@
|
||||
</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]"), "accounts");
|
||||
(function injectIcons(){
|
||||
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
|
||||
M("ac-m1", "shield");
|
||||
M("ac-m2", "users");
|
||||
M("ac-m3", "check");
|
||||
M("ac-m4", "alert");
|
||||
})();
|
||||
|
||||
<script>
|
||||
AdminCommon.renderAdminNav(document.querySelector("[data-admin-nav]"), "accounts");
|
||||
const storageKey = "sub2api-provider-accounts-admin";
|
||||
const state = {
|
||||
accounts: [],
|
||||
selectedAccountID: 0,
|
||||
bindingCandidates: [],
|
||||
};
|
||||
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
const adminTokenInput = document.getElementById("admin-token");
|
||||
const adminUsernameInput = document.getElementById("admin-username");
|
||||
const adminPasswordInput = document.getElementById("admin-password");
|
||||
const hostFilterInput = document.getElementById("filter-host-id");
|
||||
const providerFilterInput = document.getElementById("filter-provider-id");
|
||||
const logicalGroupFilterInput = document.getElementById("filter-logical-group-id");
|
||||
const routeFilterInput = document.getElementById("filter-route-id");
|
||||
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
|
||||
const statusFilterInput = document.getElementById("filter-status");
|
||||
const bindingStateFilterInput = document.getElementById("filter-binding-state");
|
||||
const queryFilterInput = document.getElementById("filter-query");
|
||||
const limitFilterInput = document.getElementById("filter-limit");
|
||||
const actionReasonInput = document.getElementById("action-reason");
|
||||
|
||||
const sessionStatus = document.getElementById("session-status");
|
||||
const tableStatus = document.getElementById("table-status");
|
||||
const actionStatus = document.getElementById("action-status");
|
||||
const accountsCatalog = document.getElementById("accounts-catalog");
|
||||
const detailEmpty = document.getElementById("detail-empty");
|
||||
const detailPanel = document.getElementById("detail-panel");
|
||||
const detailGrid = document.getElementById("detail-grid");
|
||||
const detailJSON = document.getElementById("detail-json");
|
||||
const bindingRouteSelect = document.getElementById("binding-route-select");
|
||||
const bindingStateView = document.getElementById("binding-state-view");
|
||||
const bindingStatus = document.getElementById("binding-status");
|
||||
|
||||
const metricApiRoot = document.getElementById("metric-api-root");
|
||||
const metricTotal = document.getElementById("metric-total");
|
||||
const metricLive = document.getElementById("metric-live");
|
||||
const metricDead = document.getElementById("metric-dead");
|
||||
|
||||
const enableButton = document.getElementById("enable-btn");
|
||||
const disableButton = document.getElementById("disable-btn");
|
||||
const retireButton = document.getElementById("retire-btn");
|
||||
const refreshBindingButton = document.getElementById("refresh-binding-btn");
|
||||
const applyBindingButton = document.getElementById("apply-binding-btn");
|
||||
const clearBindingButton = document.getElementById("clear-binding-btn");
|
||||
|
||||
const adminRuntime = AdminCommon.createAdminPageRuntime({
|
||||
apiBaseInput,
|
||||
adminTokenInput,
|
||||
adminUsernameInput,
|
||||
adminPasswordInput,
|
||||
adminSessionStatus: sessionStatus,
|
||||
includeAuthOnSessionCheck: true,
|
||||
sessionPresentation: {
|
||||
allowBearerFallback: true,
|
||||
includeSessionSuffix: true,
|
||||
usernameFallback: "admin",
|
||||
},
|
||||
onSessionPersist: writeConfig,
|
||||
});
|
||||
|
||||
function readConfig() {
|
||||
return AdminCommon.readStoredConfig(storageKey);
|
||||
}
|
||||
|
||||
function writeConfig() {
|
||||
const payload = {
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
adminToken: adminTokenInput.value,
|
||||
adminUsername: adminUsernameInput.value.trim(),
|
||||
hostID: hostFilterInput.value.trim(),
|
||||
providerID: providerFilterInput.value.trim(),
|
||||
logicalGroupID: logicalGroupFilterInput.value.trim(),
|
||||
routeID: routeFilterInput.value.trim(),
|
||||
shadowGroupID: shadowGroupFilterInput.value.trim(),
|
||||
accountStatus: statusFilterInput.value,
|
||||
bindingState: bindingStateFilterInput.value,
|
||||
query: queryFilterInput.value.trim(),
|
||||
limit: limitFilterInput.value.trim(),
|
||||
};
|
||||
AdminCommon.writeStoredConfig(storageKey, payload);
|
||||
setStatus(tableStatus, "已保存本地配置。");
|
||||
}
|
||||
|
||||
function hydrateConfig() {
|
||||
const config = readConfig();
|
||||
apiBaseInput.value = config.apiBase || adminRuntime.defaultApiBase();
|
||||
adminTokenInput.value = config.adminToken || "";
|
||||
adminUsernameInput.value = config.adminUsername || "";
|
||||
hostFilterInput.value = config.hostID || "";
|
||||
providerFilterInput.value = config.providerID || "";
|
||||
logicalGroupFilterInput.value = config.logicalGroupID || "";
|
||||
routeFilterInput.value = config.routeID || "";
|
||||
shadowGroupFilterInput.value = config.shadowGroupID || "";
|
||||
statusFilterInput.value = config.accountStatus || "";
|
||||
bindingStateFilterInput.value = config.bindingState || "";
|
||||
queryFilterInput.value = config.query || "";
|
||||
limitFilterInput.value = config.limit || "200";
|
||||
}
|
||||
|
||||
function apiBase() {
|
||||
return adminRuntime.normalizeApiBase();
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return adminRuntime.authHeaders();
|
||||
}
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
return adminRuntime.requestJSON(path, options);
|
||||
}
|
||||
|
||||
async function refreshSession() {
|
||||
metricApiRoot.textContent = apiBase();
|
||||
try {
|
||||
await adminRuntime.refreshAdminSession();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
async function loginSession() {
|
||||
try {
|
||||
await adminRuntime.loginAdminSession();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
async function logoutSession() {
|
||||
try {
|
||||
await adminRuntime.logoutAdminSession();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
function buildListQuery() {
|
||||
const params = new URLSearchParams();
|
||||
if (hostFilterInput.value.trim()) params.set("host_id", hostFilterInput.value.trim());
|
||||
if (providerFilterInput.value.trim()) params.set("provider_id", providerFilterInput.value.trim());
|
||||
if (logicalGroupFilterInput.value.trim()) params.set("logical_group_id", logicalGroupFilterInput.value.trim());
|
||||
if (routeFilterInput.value.trim()) params.set("route_id", routeFilterInput.value.trim());
|
||||
if (shadowGroupFilterInput.value.trim()) params.set("shadow_group_id", shadowGroupFilterInput.value.trim());
|
||||
if (statusFilterInput.value) params.set("account_status", statusFilterInput.value);
|
||||
if (bindingStateFilterInput.value) params.set("binding_state", bindingStateFilterInput.value);
|
||||
if (queryFilterInput.value.trim()) params.set("q", queryFilterInput.value.trim());
|
||||
if (limitFilterInput.value.trim()) params.set("limit", limitFilterInput.value.trim());
|
||||
const query = params.toString();
|
||||
return query ? `/api/provider-accounts?${query}` : "/api/provider-accounts";
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
setStatus(tableStatus, "正在读取 provider_accounts…");
|
||||
try {
|
||||
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
|
||||
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
|
||||
state.bindingCandidates = [];
|
||||
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
|
||||
state.selectedAccountID = state.accounts[0]?.id || 0;
|
||||
}
|
||||
renderMetrics();
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
if (state.selectedAccountID) {
|
||||
await loadBindingCandidates();
|
||||
} else {
|
||||
renderBindingCandidates();
|
||||
}
|
||||
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
|
||||
} catch (error) {
|
||||
state.accounts = [];
|
||||
state.selectedAccountID = 0;
|
||||
state.bindingCandidates = [];
|
||||
renderMetrics();
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
renderBindingCandidates();
|
||||
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
metricApiRoot.textContent = apiBase();
|
||||
metricTotal.textContent = String(state.accounts.length);
|
||||
const counts = { active: 0, disabled: 0, deprecated: 0, broken: 0 };
|
||||
state.accounts.forEach((account) => {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, account.account_status)) {
|
||||
counts[account.account_status] += 1;
|
||||
}
|
||||
});
|
||||
metricLive.textContent = `${counts.active} / ${counts.disabled}`;
|
||||
metricDead.textContent = `${counts.deprecated} / ${counts.broken}`;
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "active") return "active";
|
||||
if (status === "disabled") return "disabled";
|
||||
if (status === "deprecated") return "deprecated";
|
||||
return "broken";
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
if (!state.accounts.length) {
|
||||
accountsCatalog.innerHTML = '<div class="empty">还没有匹配到帐号资产记录。</div>';
|
||||
return;
|
||||
}
|
||||
accountsCatalog.innerHTML = "";
|
||||
state.accounts.forEach((account) => {
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = `row-card${account.id === state.selectedAccountID ? " is-selected" : ""}`;
|
||||
card.innerHTML = `
|
||||
<div class="row-heading">
|
||||
<div>
|
||||
<div class="row-title">${escapeHTML(account.account_name || account.host_account_id)}</div>
|
||||
<div class="meta-list">
|
||||
<span>provider: <code>${escapeHTML(account.provider_id)}</code></span>
|
||||
<span>host_account_id: <code>${escapeHTML(account.host_account_id)}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.account_status)}</span>
|
||||
</div>
|
||||
<div class="badge-row">
|
||||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.logical_group_id || "未归属 logical_group")}</span>
|
||||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.route_id || "未归属 route")}</span>
|
||||
<span class="badge ${statusClass(account.account_status)}">shadow_group: ${escapeHTML(account.shadow_group_id || "-")}</span>
|
||||
<span class="badge ${statusClass(account.account_status)}">binding: ${escapeHTML(account.binding_state || "unassigned")} / candidates: ${escapeHTML(account.binding_candidate_count || 0)}</span>
|
||||
</div>
|
||||
<div class="meta-list">
|
||||
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
|
||||
<span>shadow_host_id: <code>${escapeHTML(account.shadow_host_id || account.host_id || "-")}</code></span>
|
||||
<span>last_probe_status: <code>${escapeHTML(account.last_probe_status || "-")}</code></span>
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener("click", () => {
|
||||
state.selectedAccountID = account.id;
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
loadBindingCandidates();
|
||||
});
|
||||
accountsCatalog.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
const hasSelection = Boolean(account);
|
||||
detailEmpty.hidden = hasSelection;
|
||||
detailPanel.hidden = !hasSelection;
|
||||
enableButton.disabled = !hasSelection;
|
||||
disableButton.disabled = !hasSelection;
|
||||
retireButton.disabled = !hasSelection;
|
||||
refreshBindingButton.disabled = !hasSelection;
|
||||
clearBindingButton.disabled = !hasSelection;
|
||||
if (!account) {
|
||||
detailGrid.innerHTML = "";
|
||||
detailJSON.textContent = "{}";
|
||||
bindingStateView.value = "-";
|
||||
setStatus(actionStatus, "请选择左侧一条帐号记录。");
|
||||
return;
|
||||
}
|
||||
const cards = [
|
||||
["帐号主键", String(account.id)],
|
||||
["provider_id", account.provider_id],
|
||||
["provider_name", account.provider_name || "-"],
|
||||
["host_id", account.host_id],
|
||||
["host_base_url", account.host_base_url || "-"],
|
||||
["logical_group_id", account.logical_group_id || "未归属"],
|
||||
["route_id", account.route_id || "未归属"],
|
||||
["route_name", account.route_name || "-"],
|
||||
["shadow_group_id", account.shadow_group_id || "-"],
|
||||
["shadow_host_id", account.shadow_host_id || "-"],
|
||||
["upstream_base_url_hint", account.upstream_base_url_hint || "-"],
|
||||
["host_account_id", account.host_account_id],
|
||||
["key_fingerprint", account.key_fingerprint],
|
||||
["account_status", account.account_status],
|
||||
["binding_state", account.binding_state || "unassigned"],
|
||||
["binding_candidate_count", String(account.binding_candidate_count || 0)],
|
||||
["last_probe_status", account.last_probe_status || "-"],
|
||||
["last_probe_at", account.last_probe_at || "-"],
|
||||
["disabled_reason", account.disabled_reason || "-"],
|
||||
["updated_at", account.updated_at || "-"],
|
||||
];
|
||||
detailGrid.innerHTML = cards.map(([label, value]) => `
|
||||
<div class="detail-card">
|
||||
<strong>${escapeHTML(label)}</strong>
|
||||
<code>${escapeHTML(value)}</code>
|
||||
</div>
|
||||
`).join("");
|
||||
detailJSON.textContent = JSON.stringify(account, null, 2);
|
||||
bindingStateView.value = account.binding_state || "unassigned";
|
||||
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
|
||||
}
|
||||
|
||||
async function loadBindingCandidates() {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
state.bindingCandidates = [];
|
||||
renderBindingCandidates();
|
||||
return;
|
||||
}
|
||||
setStatus(bindingStatus, `正在读取帐号 #${account.id} 的 route 候选…`);
|
||||
try {
|
||||
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding-candidates`, { headers: authHeaders() });
|
||||
state.bindingCandidates = Array.isArray(payload.candidate_routes) ? payload.candidate_routes : [];
|
||||
if (payload.provider_account) {
|
||||
state.accounts = state.accounts.map((item) => item.id === account.id ? payload.provider_account : item);
|
||||
}
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
renderBindingCandidates();
|
||||
setStatus(bindingStatus, `已加载 ${state.bindingCandidates.length} 条 route 候选。`, "success");
|
||||
} catch (error) {
|
||||
state.bindingCandidates = [];
|
||||
renderBindingCandidates();
|
||||
setStatus(bindingStatus, `读取 route 候选失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingCandidates() {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
const hasSelection = Boolean(account);
|
||||
bindingRouteSelect.disabled = !hasSelection;
|
||||
applyBindingButton.disabled = !hasSelection;
|
||||
if (!hasSelection) {
|
||||
bindingRouteSelect.innerHTML = '<option value="">请先选择帐号</option>';
|
||||
bindingStateView.value = "-";
|
||||
return;
|
||||
}
|
||||
const options = ['<option value="">请选择一个 route</option>'];
|
||||
state.bindingCandidates.forEach((route) => {
|
||||
const selected = route.route_id === account.route_id ? " selected" : "";
|
||||
options.push(`<option value="${escapeHTML(route.route_id)}"${selected}>${escapeHTML(route.route_id)} / ${escapeHTML(route.logical_group_id)} / ${escapeHTML(route.name || "-")}</option>`);
|
||||
});
|
||||
if (!state.bindingCandidates.length) {
|
||||
options.push('<option value="">当前 shadow binding 下没有候选 route</option>');
|
||||
}
|
||||
bindingRouteSelect.innerHTML = options.join("");
|
||||
}
|
||||
|
||||
async function updateAccountStatus(action) {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
setStatus(actionStatus, "请先选择一条帐号记录。", "warn");
|
||||
return;
|
||||
}
|
||||
const reason = actionReasonInput.value.trim();
|
||||
if ((action === "disable" || action === "retire") && !reason) {
|
||||
setStatus(actionStatus, "停用或退役请填写原因,避免后续看不懂为什么改状态。", "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
});
|
||||
const updated = payload.provider_account;
|
||||
setStatus(actionStatus, `帐号 #${updated.id} 已更新为 ${updated.account_status}${updated.disabled_reason ? `(${updated.disabled_reason})` : ""}。`, "success");
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
setStatus(actionStatus, `更新帐号状态失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccountBinding(mode) {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
setStatus(bindingStatus, "请先选择一条帐号记录。", "warn");
|
||||
return;
|
||||
}
|
||||
let payload = {};
|
||||
if (mode === "assign") {
|
||||
const routeID = bindingRouteSelect.value.trim();
|
||||
if (!routeID) {
|
||||
setStatus(bindingStatus, "请先选择要绑定的 route。", "warn");
|
||||
return;
|
||||
}
|
||||
payload = { route_id: routeID };
|
||||
} else {
|
||||
payload = { clear: true };
|
||||
}
|
||||
try {
|
||||
const response = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const updated = response.provider_account;
|
||||
setStatus(bindingStatus, `帐号 #${updated.id} 已更新归属:binding_state=${updated.binding_state || "unassigned"} route=${updated.route_id || "-"}`, "success");
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
setStatus(bindingStatus, `更新帐号归属失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
hostFilterInput.value = "";
|
||||
providerFilterInput.value = "";
|
||||
logicalGroupFilterInput.value = "";
|
||||
routeFilterInput.value = "";
|
||||
shadowGroupFilterInput.value = "";
|
||||
statusFilterInput.value = "";
|
||||
bindingStateFilterInput.value = "";
|
||||
queryFilterInput.value = "";
|
||||
limitFilterInput.value = "200";
|
||||
}
|
||||
|
||||
function setStatus(element, message, tone = "") {
|
||||
AdminCommon.setStatus(element, message, tone || "note");
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
document.getElementById("save-config-btn").addEventListener("click", writeConfig);
|
||||
document.getElementById("admin-login-btn").addEventListener("click", loginSession);
|
||||
document.getElementById("admin-logout-btn").addEventListener("click", logoutSession);
|
||||
document.getElementById("refresh-btn").addEventListener("click", loadAccounts);
|
||||
document.getElementById("apply-filters-btn").addEventListener("click", loadAccounts);
|
||||
document.getElementById("clear-filters-btn").addEventListener("click", () => {
|
||||
clearFilters();
|
||||
loadAccounts();
|
||||
});
|
||||
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
|
||||
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
|
||||
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
|
||||
refreshBindingButton.addEventListener("click", loadBindingCandidates);
|
||||
applyBindingButton.addEventListener("click", () => updateAccountBinding("assign"));
|
||||
clearBindingButton.addEventListener("click", () => updateAccountBinding("clear"));
|
||||
|
||||
hydrateConfig();
|
||||
refreshSession();
|
||||
loadAccounts();
|
||||
</script>
|
||||
<script>
|
||||
const storageKey = "sub2api-provider-accounts-admin";
|
||||
const state = {
|
||||
accounts: [],
|
||||
@@ -1079,5 +1203,5 @@
|
||||
refreshSession();
|
||||
loadAccounts();
|
||||
</script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,35 +5,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Batch Import Admin Redirect</title>
|
||||
<meta http-equiv="refresh" content="0; url=/portal/admin-batch-import.html">
|
||||
<link rel="stylesheet" href="/portal/portal.css">
|
||||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
background: linear-gradient(180deg, #f8f3ea 0%, #efe5d7 100%);
|
||||
color: #1f1a16;
|
||||
}
|
||||
.card {
|
||||
.redirect-card {
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(31, 26, 22, 0.12);
|
||||
background: rgba(255,252,246,0.92);
|
||||
box-shadow: 0 20px 60px rgba(46, 37, 28, 0.08);
|
||||
}
|
||||
a {
|
||||
color: #0c6cc9;
|
||||
font-weight: 700;
|
||||
border-radius: var(--r-xl);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elev-1);
|
||||
box-shadow: var(--shadow-glass);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.redirect-card a { color: var(--color-primary); font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>正在跳转到导入供应商帐号页面</h1>
|
||||
<p>如果浏览器没有自动跳转,请手动打开:</p>
|
||||
<p><a href="/portal/admin-batch-import.html">/portal/admin-batch-import.html</a></p>
|
||||
<main class="redirect-card">
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:var(--text-strong);">正在跳转到导入供应商帐号页面</h1>
|
||||
<p style="margin:8px 0;color:var(--text-muted);font-size:13px;line-height:1.6;">如果浏览器没有自动跳转,请手动打开:</p>
|
||||
<p style="margin:8px 0 0;"><a href="/portal/admin-batch-import.html">/portal/admin-batch-import.html</a></p>
|
||||
</main>
|
||||
<script src="/portal/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,436 +1,341 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Portal</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe6;
|
||||
--panel: rgba(255, 252, 246, 0.92);
|
||||
--ink: #1f1a16;
|
||||
--muted: #665c53;
|
||||
--line: rgba(31, 26, 22, 0.12);
|
||||
--accent: #0c6cc9;
|
||||
--accent-soft: rgba(12, 108, 201, 0.12);
|
||||
--success: #136f46;
|
||||
--success-soft: rgba(19, 111, 70, 0.1);
|
||||
--warn: #9f6417;
|
||||
--warn-soft: rgba(159, 100, 23, 0.12);
|
||||
--shadow: 0 24px 70px rgba(46, 37, 28, 0.1);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(12, 108, 201, 0.18), transparent 24rem),
|
||||
radial-gradient(circle at bottom right, rgba(19, 111, 70, 0.12), transparent 28rem),
|
||||
linear-gradient(180deg, #f8f3ea 0%, #f1e9dd 100%);
|
||||
}
|
||||
a { color: inherit; }
|
||||
.shell {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 20px 64px;
|
||||
}
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topnav a {
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.74);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card {
|
||||
padding: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4rem;
|
||||
bottom: -4rem;
|
||||
width: 16rem;
|
||||
height: 16rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(12, 108, 201, 0.2), rgba(19, 111, 70, 0.06));
|
||||
filter: blur(10px);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(34px, 5vw, 50px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.hero-copy {
|
||||
max-width: 56rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.75;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
margin: 18px 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.74);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stack {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.panel p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.cta {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 10rem;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
border: 1px solid var(--line);
|
||||
transition: transform 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.cta:hover { transform: translateY(-1px); }
|
||||
.cta.primary { background: var(--ink); color: #fff; border-color: var(--ink); }
|
||||
.cta.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.list {
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.list li {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
.list strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.status-card {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
.status-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.status-available {
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(19, 111, 70, 0.08) 100%);
|
||||
}
|
||||
.status-caution {
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(159, 100, 23, 0.08) 100%);
|
||||
}
|
||||
.status-note {
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(12, 108, 201, 0.08) 100%);
|
||||
}
|
||||
code {
|
||||
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.hero, .grid, .status-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Admin Portal · Sub2API 中继管理</title>
|
||||
<link rel="stylesheet" href="/portal/portal.css" />
|
||||
<link rel="stylesheet" href="/portal/admin-common.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/" class="is-current">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
</nav>
|
||||
<main class="shell fade-in">
|
||||
|
||||
<section class="hero">
|
||||
<article class="card hero-card">
|
||||
<div class="eyebrow">Admin Portal</div>
|
||||
<h1>把新增模型与导入帐号收进同一套入口</h1>
|
||||
<p class="hero-copy">
|
||||
这个入口不再把“新增模型供应商”和“导入供应商帐号”拆散在不同地址。当前版本统一从
|
||||
<code>/portal/admin/</code> 进入:一边看 pack/provider 目录、做 preview/import,一边继续保留
|
||||
item 级 <code>reused / reactivated / replaced</code> 的 batch-import 结果面板。
|
||||
<!-- 顶部导航(由 admin-common.js 渲染;data-admin-nav 是契约) -->
|
||||
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="home"></nav>
|
||||
|
||||
<!-- 页面 hero -->
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<span class="page-hero__eyebrow">Admin Portal</span>
|
||||
<h1>把新增模型、导入帐号与 Route 收进同一套入口</h1>
|
||||
<p>
|
||||
当前版本统一从 <code>/portal/admin/</code> 进入:一边看 pack/provider 目录、做 preview/import,
|
||||
一边继续保留 item 级 <code>reused / reactivated / replaced</code> 的 batch-import 结果面板。
|
||||
所有写操作都走 CRM <code>/portal-admin-api</code>,浏览器不直连 Git。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认同域走 <code>/portal-admin-api/</code></li>
|
||||
<li>静态页与 CRM API 解耦</li>
|
||||
<li>保留旧地址兼容,不打断现有操作</li>
|
||||
</ul>
|
||||
</article>
|
||||
<div class="row mt-4">
|
||||
<a class="btn btn-primary" href="/portal/admin/providers.html" data-cta="providers">
|
||||
<span id="providers-cta-icon"></span> 进入供应商目录
|
||||
</a>
|
||||
<a class="btn" href="/portal/" target="_blank" rel="noreferrer">
|
||||
<span id="portal-cta-icon"></span> 打开用户 Portal
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="https://github.com" target="_blank" rel="noreferrer">
|
||||
<span id="docs-cta-icon"></span> 文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="card stack">
|
||||
<div class="metric">
|
||||
<div class="metric-label">统一入口</div>
|
||||
<div class="metric-value">/portal/admin/</div>
|
||||
<!-- 右侧 metric 卡片组(host DashboardView 风格) -->
|
||||
<div class="stack">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-primary" id="m1-icon"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">统一入口</p>
|
||||
<p class="stat-value">/portal/admin/</p>
|
||||
<p class="stat-sub">默认同域走 <code>/portal-admin-api/</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Logical Group</div>
|
||||
<div class="metric-value">/logical-groups</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-info" id="m2-icon"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Logical Group</p>
|
||||
<p class="stat-value">/logical-groups</p>
|
||||
<p class="stat-sub">logical_group · public_model · route</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Provider 目录</div>
|
||||
<div class="metric-value">/providers</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-success" id="m3-icon"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Provider 目录</p>
|
||||
<p class="stat-value">/providers</p>
|
||||
<p class="stat-sub">preview-import · manifest 草稿</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Route Health</div>
|
||||
<div class="metric-value">/route-health</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Accounts</div>
|
||||
<div class="metric-value">/accounts</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Batch Import</div>
|
||||
<div class="metric-value">/batch-import</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card panel">
|
||||
<h2>逻辑分组 / 路由</h2>
|
||||
<p>
|
||||
这页给插件前置路由使用,负责维护 <code>logical_group</code>、<code>public_model</code>、
|
||||
<code>route</code> 与 <code>shadow_host_id / shadow_group_id</code> 的关系。当前首版已经能直接调
|
||||
<code>/api/logical-groups</code> 系列接口,适合先把 canonical shadow route 收进统一管理面。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/logical-groups.html">打开逻辑分组页</a>
|
||||
<!-- 4 个核心模块入口(用 stat-card 一致风格) -->
|
||||
<section class="section-head mt-6">
|
||||
<div>
|
||||
<h2>核心模块</h2>
|
||||
<p>每个模块都对应一个 admin 页面 + 同一套 API base(<code>/portal-admin-api</code>)。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-2">
|
||||
|
||||
<!-- 逻辑分组 / 路由 -->
|
||||
<article class="card card-hover">
|
||||
<div class="card-body">
|
||||
<div class="row-between">
|
||||
<div class="row" style="gap:10px;">
|
||||
<div class="stat-icon stat-icon-info" id="lg-icon"></div>
|
||||
<div>
|
||||
<h3 style="margin:0;font-size:15px;font-weight:700;">逻辑分组 / 路由</h3>
|
||||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">logical_group · public_model · route · shadow_*</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill pill-primary">运行中</span>
|
||||
</div>
|
||||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||||
给插件前置路由使用,维护 <code>logical_group</code>、<code>public_model</code>、<code>route</code> 与
|
||||
<code>shadow_host_id / shadow_group_id</code> 的关系。当前首版已经能直接调
|
||||
<code>/api/logical-groups</code> 系列接口,适合先把 canonical shadow route 收进统一管理面。
|
||||
</p>
|
||||
<div class="row mt-4">
|
||||
<a class="btn btn-primary" href="/portal/admin/logical-groups.html">打开逻辑分组页</a>
|
||||
<span class="text-subtle" style="font-size:12px;">首版页面只覆盖新增与查看</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
创建 logical group、绑定 public model、维护 route 与 shadow group 映射。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
<code>https://sub.tksea.top/portal-admin-api</code>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>新增模型 / 供应商目录</h2>
|
||||
<p>
|
||||
这页负责浏览已安装 pack、选择 provider、调用 <code>preview-import</code> /
|
||||
<code>import</code>,同时提供 provider manifest 草稿生成与发布。当前版本已经支持先保存草稿,再经由 CRM
|
||||
服务端写入 pack/provider 文件并自动提交到仓库。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/providers.html">打开供应商页</a>
|
||||
<a class="cta secondary" href="/portal/admin/providers.html#manifest-draft">跳到 manifest 草稿</a>
|
||||
<!-- 新增模型 / 供应商目录 -->
|
||||
<article class="card card-hover">
|
||||
<div class="card-body">
|
||||
<div class="row-between">
|
||||
<div class="row" style="gap:10px;">
|
||||
<div class="stat-icon stat-icon-success" id="pr-icon"></div>
|
||||
<div>
|
||||
<h3 style="margin:0;font-size:15px;font-weight:700;">新增模型 / 供应商目录</h3>
|
||||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">pack · provider · preview · manifest draft</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill pill-success">推荐入口</span>
|
||||
</div>
|
||||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||||
这页负责浏览已安装 pack、选择 provider、调用 <code>preview-import</code> / <code>import</code>,
|
||||
同时提供 provider manifest 草稿生成与发布。当前版本已经支持先保存草稿,再经由 CRM 服务端写入
|
||||
pack/provider 文件并自动提交到仓库。
|
||||
</p>
|
||||
<div class="row mt-4">
|
||||
<a class="btn btn-primary" href="/portal/admin/providers.html">打开供应商页</a>
|
||||
<a class="btn btn-ghost" href="/portal/admin/providers.html#manifest-draft">跳到 manifest 草稿</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
查看 pack 与 provider、输入 keys 做 preview/import、生成 provider 草稿,并一键发布到仓库。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
<code>https://sub.tksea.top/portal-admin-api</code>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>Route 健康视图</h2>
|
||||
<p>
|
||||
这页专门给运营看 route 当前运行状态,聚合 <code>routefail</code>、<code>routecool</code>、
|
||||
最近一次选路与 failover 事件。首版只做只读健康视图,不在这里直接改 route。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/route-health.html">打开健康页</a>
|
||||
<!-- Route 健康视图 -->
|
||||
<article class="card card-hover">
|
||||
<div class="card-body">
|
||||
<div class="row-between">
|
||||
<div class="row" style="gap:10px;">
|
||||
<div class="stat-icon stat-icon-warning" id="rh-icon"></div>
|
||||
<div>
|
||||
<h3 style="margin:0;font-size:15px;font-weight:700;">Route 健康视图</h3>
|
||||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">healthy · cooldown · failing · disabled</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill pill-warning">只读</span>
|
||||
</div>
|
||||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||||
这页专门给运营看 route 当前运行状态,聚合 <code>routefail</code>、<code>routecool</code>、
|
||||
最近一次选路与 failover 事件。首版只做只读健康视图,不在这里直接改 route。
|
||||
</p>
|
||||
<div class="row mt-4">
|
||||
<a class="btn btn-primary" href="/portal/admin/route-health.html">打开健康页</a>
|
||||
<a class="btn btn-ghost" href="/portal/admin/route-health.html#matrix">看 matrix</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
查看 <code>healthy / cooldown / failing / disabled</code>,确认 sticky、failover 与最近错误是否一致。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
<code>https://sub.tksea.top/portal-admin-api</code>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>帐号资产</h2>
|
||||
<p>
|
||||
这页把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个
|
||||
<code>logical_group / route / shadow_group / shadow_host</code>,并提供人工
|
||||
<code>enable / disable / retire</code> 动作。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/accounts.html">打开帐号资产页</a>
|
||||
<!-- 帐号资产 -->
|
||||
<article class="card card-hover">
|
||||
<div class="card-body">
|
||||
<div class="row-between">
|
||||
<div class="row" style="gap:10px;">
|
||||
<div class="stat-icon stat-icon-primary" id="ac-icon"></div>
|
||||
<div>
|
||||
<h3 style="margin:0;font-size:15px;font-weight:700;">帐号资产</h3>
|
||||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">logical_group · route · shadow_group · shadow_host</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill">库存</span>
|
||||
</div>
|
||||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||||
把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个
|
||||
<code>logical_group / route / shadow_group / shadow_host</code>,并提供人工
|
||||
<code>enable / disable / retire</code> 动作。启停动作当前只修改插件库存状态,不直接改宿主 account 记录。
|
||||
</p>
|
||||
<div class="row mt-4">
|
||||
<a class="btn btn-primary" href="/portal/admin/accounts.html">打开帐号资产页</a>
|
||||
<a class="btn btn-ghost" href="/portal/admin/accounts.html#binding">显式整理归属</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
查看帐号库存、筛选 route 归属、执行人工启停与退役。
|
||||
</li>
|
||||
<li>
|
||||
<strong>当前边界</strong>
|
||||
启停动作当前只修改插件库存状态,不直接改宿主 account 记录。
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>导入供应商帐号</h2>
|
||||
<p>
|
||||
<!-- 批量导入 + 旧地址兼容 -->
|
||||
<article class="card mt-6">
|
||||
<div class="card-body">
|
||||
<div class="row-between">
|
||||
<div class="row" style="gap:10px;">
|
||||
<div class="stat-icon stat-icon-info" id="bi-icon"></div>
|
||||
<div>
|
||||
<h3 style="margin:0;font-size:15px;font-weight:700;">导入供应商帐号</h3>
|
||||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">reused · created · reactivated · replaced</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill pill-info">实时</span>
|
||||
</div>
|
||||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||||
这页继续负责 live batch-import:创建 run、拉取 run summary、查看 item 级别的
|
||||
<code>matched_account_state</code> 与 <code>account_resolution</code>。
|
||||
<code>matched_account_state</code> 与 <code>account_resolution</code>。批量导入第三方 key,验证
|
||||
<code>reused / created / reactivated / replaced</code> 状态语义。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/batch-import.html">打开导入页</a>
|
||||
<a class="cta secondary" href="/portal/admin-batch-import.html">旧地址兼容入口</a>
|
||||
<div class="row mt-4">
|
||||
<a class="btn btn-primary" href="/portal/admin/batch-import.html">打开导入页</a>
|
||||
<a class="btn btn-ghost" href="/portal/admin-batch-import.html">旧地址兼容入口</a>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
批量导入第三方 key,验证 <code>reused / created / reactivated / replaced</code>。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
<code>https://sub.tksea.top/portal-admin-api</code>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 边界 + 状态 -->
|
||||
<section class="section-head mt-6">
|
||||
<div>
|
||||
<h2>当前边界与安全前提</h2>
|
||||
<p>明确告诉你"哪里能写、哪里只读、谁有权限",避免误操作。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="status-grid">
|
||||
<article class="status-card status-available">
|
||||
<div class="metric-label">可立即使用</div>
|
||||
<strong>逻辑分组 + Provider 导入</strong>
|
||||
<p>依赖现有 <code>/api/logical-groups</code>、<code>/api/packs</code>、<code>/api/providers/*</code>、<code>/api/batch-import/*</code> 即可完成。</p>
|
||||
<div class="grid grid-3">
|
||||
<article class="card">
|
||||
<div class="card-body">
|
||||
<div class="row" style="gap:10px;">
|
||||
<span class="dot dot-success"></span>
|
||||
<strong style="font-size:14px;">可立即使用</strong>
|
||||
</div>
|
||||
<h3 style="margin:10px 0 6px;font-size:16px;">逻辑分组 + Provider 导入</h3>
|
||||
<p class="text-muted" style="font-size:13px;line-height:1.6;">
|
||||
依赖现有 <code>/api/logical-groups</code>、<code>/api/packs</code>、
|
||||
<code>/api/providers/*</code>、<code>/api/batch-import/*</code> 即可完成。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="status-card status-note">
|
||||
<div class="metric-label">当前边界</div>
|
||||
<strong>浏览器提交到 CRM,再由 CRM 写仓库</strong>
|
||||
<p>页面不会直接拼 Git 命令;所有写 pack/provider 与提交仓库的动作,都统一走 CRM 服务端的发布接口。</p>
|
||||
|
||||
<article class="card">
|
||||
<div class="card-body">
|
||||
<div class="row" style="gap:10px;">
|
||||
<span class="dot dot-info"></span>
|
||||
<strong style="font-size:14px;">当前边界</strong>
|
||||
</div>
|
||||
<h3 style="margin:10px 0 6px;font-size:16px;">浏览器提交到 CRM,再由 CRM 写仓库</h3>
|
||||
<p class="text-muted" style="font-size:13px;line-height:1.6;">
|
||||
页面不会直接拼 Git 命令;所有写 pack/provider 与提交仓库的动作,都统一走 CRM 服务端的发布接口。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="status-card status-caution">
|
||||
<div class="metric-label">安全前提</div>
|
||||
<strong>仍需 Admin Token</strong>
|
||||
<p>CRM 的 API 权限仍由 Bearer token 控制,同域反代只解决浏览器可达性,不降低鉴权门槛。</p>
|
||||
|
||||
<article class="card">
|
||||
<div class="card-body">
|
||||
<div class="row" style="gap:10px;">
|
||||
<span class="dot dot-warning"></span>
|
||||
<strong style="font-size:14px;">安全前提</strong>
|
||||
</div>
|
||||
<h3 style="margin:10px 0 6px;font-size:16px;">仍需 Admin Token</h3>
|
||||
<p class="text-muted" style="font-size:13px;line-height:1.6;">
|
||||
CRM 的 API 权限仍由 Bearer token 控制,同域反代只解决浏览器可达性,不降低鉴权门槛。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 管理员会话小工具条 -->
|
||||
<section class="card mt-6">
|
||||
<div class="card-body">
|
||||
<div class="section-head" style="margin-bottom:12px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">管理员会话</h2>
|
||||
<p>可在此处建立 session 检查当前鉴权状态,或留作跨页签到。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm" id="check-session-btn" type="button">检查会话</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<div class="field">
|
||||
<label class="label" for="api-base-input">API Base</label>
|
||||
<input id="api-base-input" class="input" type="text" placeholder="https://sub.tksea.top/portal-admin-api" />
|
||||
<span class="field-help">同域走 <code>/portal-admin-api</code>,调试时可改成完整 URL。</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="admin-token-input">Bearer Token(可选)</label>
|
||||
<input id="admin-token-input" class="input" type="password" placeholder="不落盘,仅当前会话" />
|
||||
<span class="field-help">已登录管理员 session 时可不填。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="statusbar mt-4" id="admin-session-status" data-tone="info">
|
||||
点击「检查会话」可拉取 <code>/api/admin/session</code>。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 共享层:admin-common.js(保持兼容契约)+ 新 portal.js(toast/icons/theme) -->
|
||||
<script src="/portal/admin-common.js"></script>
|
||||
<script src="/portal/portal.js"></script>
|
||||
<script>
|
||||
(function initAdminHome() {
|
||||
const runtime = window.Sub2ApiAdminCommon.createAdminPageRuntime({
|
||||
apiBaseInput: document.getElementById("api-base-input"),
|
||||
adminTokenInput: document.getElementById("admin-token-input"),
|
||||
adminSessionStatus: document.getElementById("admin-session-status"),
|
||||
});
|
||||
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "home");
|
||||
|
||||
// 注入图标
|
||||
const I = (id, name) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(name, 22); };
|
||||
I("m1-icon", "shield");
|
||||
I("m2-icon", "group");
|
||||
I("m3-icon", "package");
|
||||
I("lg-icon", "group");
|
||||
I("pr-icon", "package");
|
||||
I("rh-icon", "activity");
|
||||
I("ac-icon", "account");
|
||||
I("bi-icon", "import");
|
||||
I("providers-cta-icon", "package");
|
||||
I("portal-cta-icon", "external");
|
||||
I("docs-cta-icon", "file");
|
||||
|
||||
// 会话检查
|
||||
const stored = runtime.readStoredConfig("tksea-admin-home");
|
||||
if (stored.apiBase) document.getElementById("api-base-input").value = stored.apiBase;
|
||||
const checkBtn = document.getElementById("check-session-btn");
|
||||
checkBtn.addEventListener("click", async () => {
|
||||
checkBtn.disabled = true;
|
||||
const orig = checkBtn.textContent;
|
||||
checkBtn.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> 检查中…';
|
||||
try {
|
||||
await runtime.refreshAdminSession();
|
||||
window.Sub2ApiPortal.toast("会话状态已刷新", "success", 1800);
|
||||
} catch (e) {
|
||||
window.Sub2ApiPortal.toast(`检查失败:${e.message || e}`, "danger");
|
||||
} finally {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = orig;
|
||||
}
|
||||
});
|
||||
|
||||
["api-base-input"].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
el.addEventListener("change", () => {
|
||||
runtime.writeStoredConfig("tksea-admin-home", { apiBase: el.value });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Logical Group Admin</title>
|
||||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f2ede4;
|
||||
@@ -16,401 +17,126 @@
|
||||
--success: #126b43;
|
||||
--success-soft: rgba(18, 107, 67, 0.1);
|
||||
--warn: #9b6215;
|
||||
--warn-soft: rgba(155, 98, 21, 0.12);
|
||||
--danger: #b23131;
|
||||
--danger-soft: rgba(178, 49, 49, 0.1);
|
||||
--shadow: 0 26px 72px rgba(47, 38, 29, 0.1);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
<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. */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 430px minmax(0, 1fr);
|
||||
gap: var(--s-5);
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
||||
.field-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
max-height: 32rem;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.catalog-item,
|
||||
.route-item {
|
||||
padding: 16px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elev-1);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.catalog-item:hover, .route-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(20,184,166,0.32);
|
||||
}
|
||||
.catalog-item.is-selected, .route-item.is-selected {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: rgba(20,184,166,0.32);
|
||||
}
|
||||
.catalog-item strong, .route-item strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 15px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.catalog-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.grid-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); }
|
||||
.section { display: grid; gap: var(--s-5); }
|
||||
.list { display: grid; gap: 10px; }
|
||||
.list-card {
|
||||
padding: 14px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elev-1);
|
||||
}
|
||||
.list-card strong { display: block; margin-bottom: 6px; color: var(--text-strong); }
|
||||
.panel-desc { color: var(--text-muted); font-size: 13px; line-height: 1.6; margin: 8px 0 0; }
|
||||
.empty { color: var(--text-muted); font-size: 13px; line-height: 1.6; }
|
||||
.tone-ready { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
|
||||
.tone-note { background: var(--color-primary-soft); color: var(--color-primary); border-color: rgba(20,184,166,0.2); }
|
||||
.tone-warn { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
|
||||
.metric-value { color: var(--text-strong); }
|
||||
.inline-code { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); word-break: break-word; }
|
||||
@media (max-width: 1200px) {
|
||||
.layout, .grid-columns, .field-grid.two, .field-grid.three { 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="logical-groups"></nav>
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(11, 107, 203, 0.16), transparent 26rem),
|
||||
radial-gradient(circle at bottom right, rgba(18, 107, 67, 0.12), transparent 24rem),
|
||||
var(--bg);
|
||||
}
|
||||
a { color: inherit; }
|
||||
code, pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.shell {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 20px 64px;
|
||||
}
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topnav a {
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
.topnav a:hover { transform: translateY(-1px); background: #fff; }
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card, .panel {
|
||||
padding: 26px;
|
||||
}
|
||||
.hero-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4rem;
|
||||
bottom: -4rem;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 107, 67, 0.06));
|
||||
filter: blur(10px);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero-copy, .panel-desc {
|
||||
color: var(--muted);
|
||||
line-height: 1.75;
|
||||
font-size: 15px;
|
||||
}
|
||||
.hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 430px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field-grid.two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.field-grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
textarea {
|
||||
min-height: 108px;
|
||||
resize: vertical;
|
||||
}
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
|
||||
.primary { background: var(--ink); color: #fff; }
|
||||
.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(178, 49, 49, 0.2);
|
||||
}
|
||||
.statusbar {
|
||||
margin-top: 16px;
|
||||
min-height: 54px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(18,107,67,0.2); }
|
||||
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(155,98,21,0.2); }
|
||||
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(178,49,49,0.2); }
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
max-height: 32rem;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.catalog-item,
|
||||
.route-item {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.84);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.catalog-item:hover,
|
||||
.route-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(11,107,203,0.22);
|
||||
}
|
||||
.catalog-item.is-selected,
|
||||
.route-item.is-selected {
|
||||
background: rgba(11,107,203,0.08);
|
||||
border-color: rgba(11,107,203,0.22);
|
||||
}
|
||||
.catalog-item strong,
|
||||
.route-item strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.catalog-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tone-ready { background: var(--success-soft); color: var(--success); border-color: rgba(18,107,67,0.18); }
|
||||
.tone-note { background: var(--accent-soft); color: var(--accent); border-color: rgba(11,107,203,0.18); }
|
||||
.tone-warn { background: var(--warn-soft); color: var(--warn); border-color: rgba(155,98,21,0.18); }
|
||||
.grid-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.list-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.82);
|
||||
}
|
||||
.list-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mini-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.mini-actions button {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inline-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.hero, .layout, .grid-columns, .field-grid.two, .field-grid.three { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<article class="card hero-card">
|
||||
<div class="eyebrow">Logical Group Admin</div>
|
||||
<h1>把 logical group、route 与 shadow group 放进同一套管理面</h1>
|
||||
<p class="hero-copy">
|
||||
这页专门给插件前置路由使用。你可以在这里维护 <code>logical_group</code>、绑定
|
||||
<code>public_model</code>,再把它映射到某个 <code>route -> shadow_host_id -> shadow_group_id</code>。
|
||||
当前首版覆盖最小运营流:创建 / 编辑分组、补 public model、创建 / 编辑 route、补 route model 映射。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
|
||||
<li>route model 当前支持新增与查看,删除 / 更新 API 仍待后续补齐</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<aside class="card metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">API Root</div>
|
||||
<div class="metric-value" id="metric-api-root">-</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Logical Groups</div>
|
||||
<div class="metric-value" id="metric-group-count">0</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">当前分组</div>
|
||||
<div class="metric-value" id="metric-selected-group">-</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">当前 Route</div>
|
||||
<div class="metric-value" id="metric-selected-route">-</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<span class="page-hero__eyebrow">Logical Group Admin</span>
|
||||
<h1>把 logical group、route 与 shadow group 放进同一套管理面</h1>
|
||||
<p>
|
||||
这页专门给插件前置路由使用。你可以在这里维护 <code>logical_group</code>、绑定
|
||||
<code>public_model</code>,再把它映射到某个 <code>route -> shadow_host_id -> shadow_group_id</code>。
|
||||
当前首版覆盖最小运营流:创建 / 编辑分组、补 public model、创建 / 编辑 route、补 route model 映射。
|
||||
首版页面只覆盖新增与查看,编辑与删除将通过同一套 API 路径继续扩展。
|
||||
</p>
|
||||
<ul class="hero-points" 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);">支持管理员登录会话,也保留 Bearer admin token 兜底</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);">route model 当前支持新增与查看,删除 / 更新 API 仍待后续补齐</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="stack" style="gap:var(--s-3);">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-primary" id="lg-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="lg-m2"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Logical Groups</p>
|
||||
<p class="stat-value" id="metric-group-count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-success" id="lg-m3"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">当前分组</p>
|
||||
<p class="stat-value" id="metric-selected-group">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-warning" id="lg-m4"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">当前 Route</p>
|
||||
<p class="stat-value" id="metric-selected-route">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layout">
|
||||
<div class="stack">
|
||||
@@ -707,7 +433,19 @@
|
||||
</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]"), "logical-groups");
|
||||
// 注入 stat icon
|
||||
(function injectIcons(){
|
||||
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
|
||||
M("lg-m1", "shield");
|
||||
M("lg-m2", "group");
|
||||
M("lg-m3", "activity");
|
||||
M("lg-m4", "route");
|
||||
})();
|
||||
const storageKey = "sub2api-logical-groups-admin";
|
||||
const state = {
|
||||
groups: [],
|
||||
@@ -764,20 +502,25 @@
|
||||
const routeModelShadowModelInput = document.getElementById("route-model-shadow-model");
|
||||
const routeModelStatusInput = document.getElementById("route-model-status");
|
||||
|
||||
const adminRuntime = AdminCommon.createAdminPageRuntime({
|
||||
apiBaseInput,
|
||||
adminTokenInput,
|
||||
adminUsernameInput,
|
||||
adminPasswordInput,
|
||||
adminSessionStatus,
|
||||
onSessionPersist: saveConfig,
|
||||
});
|
||||
|
||||
function defaultApiBase() {
|
||||
if (window.location.origin.includes("sub.tksea.top")) {
|
||||
return `${window.location.origin}/portal-admin-api`;
|
||||
}
|
||||
return "/portal-admin-api";
|
||||
return adminRuntime.defaultApiBase();
|
||||
}
|
||||
|
||||
function normalizeApiBase() {
|
||||
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/$/, "");
|
||||
return adminRuntime.normalizeApiBase();
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
return adminRuntime.authHeaders();
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
@@ -790,37 +533,11 @@
|
||||
}
|
||||
|
||||
function setStatus(element, message, tone = "note") {
|
||||
element.textContent = message;
|
||||
if (tone === "note") {
|
||||
element.removeAttribute("data-tone");
|
||||
} else {
|
||||
element.setAttribute("data-tone", tone);
|
||||
}
|
||||
AdminCommon.setStatus(element, message, tone);
|
||||
}
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||||
}
|
||||
const response = await fetch(`${normalizeApiBase()}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch (error) {
|
||||
payload = { raw: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload;
|
||||
return adminRuntime.requestJSON(path, options);
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
@@ -836,22 +553,17 @@
|
||||
}
|
||||
|
||||
function restoreConfig() {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const payload = AdminCommon.readStoredConfig(storageKey);
|
||||
apiBaseInput.value = defaultApiBase();
|
||||
if (!raw) {
|
||||
if (!Object.keys(payload).length) {
|
||||
syncMetrics();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
state.selectedRouteID = payload.selectedRouteID || "";
|
||||
state.selectedGroup = payload.selectedGroupID ? { logical_group_id: payload.selectedGroupID } : null;
|
||||
} catch (error) {
|
||||
apiBaseInput.value = defaultApiBase();
|
||||
}
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
state.selectedRouteID = payload.selectedRouteID || "";
|
||||
state.selectedGroup = payload.selectedGroupID ? { logical_group_id: payload.selectedGroupID } : null;
|
||||
syncMetrics();
|
||||
}
|
||||
|
||||
@@ -863,54 +575,15 @@
|
||||
}
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||||
adminUsernameInput.value = payload.username;
|
||||
}
|
||||
if (payload.authenticated) {
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
} else if (payload.login_enabled) {
|
||||
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "warning");
|
||||
} else {
|
||||
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
|
||||
throw error;
|
||||
}
|
||||
return adminRuntime.refreshAdminSession();
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("管理员用户名和密码不能为空");
|
||||
}
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
adminPasswordInput.value = "";
|
||||
saveConfig();
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
return payload;
|
||||
return adminRuntime.loginAdminSession();
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
adminPasswordInput.value = "";
|
||||
setStatus(adminSessionStatus, "管理员会话已退出。", "warning");
|
||||
return adminRuntime.logoutAdminSession();
|
||||
}
|
||||
|
||||
function collectGroupPayload() {
|
||||
@@ -1333,18 +1006,13 @@
|
||||
document.getElementById("admin-login-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await loginAdminSession();
|
||||
setStatus(detailStatus, "管理员会话已建立。", "success");
|
||||
} catch (error) {
|
||||
setStatus(detailStatus, error.message, "danger");
|
||||
}
|
||||
} catch (error) {}
|
||||
});
|
||||
document.getElementById("admin-logout-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await logoutAdminSession();
|
||||
await refreshAdminSession();
|
||||
} catch (error) {
|
||||
setStatus(detailStatus, error.message, "danger");
|
||||
}
|
||||
} catch (error) {}
|
||||
});
|
||||
document.getElementById("create-group-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,386 +4,88 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Route Health Admin</title>
|
||||
<link rel="stylesheet" href="/portal/portal.css">
|
||||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #edf1e8;
|
||||
--panel: rgba(253, 255, 250, 0.94);
|
||||
--ink: #1a2018;
|
||||
--muted: #5d695a;
|
||||
--line: rgba(26, 32, 24, 0.12);
|
||||
--accent: #0f6d71;
|
||||
--accent-soft: rgba(15, 109, 113, 0.12);
|
||||
--success: #18704e;
|
||||
--success-soft: rgba(24, 112, 78, 0.1);
|
||||
--warn: #9a6419;
|
||||
--warn-soft: rgba(154, 100, 25, 0.12);
|
||||
--danger: #b13d2d;
|
||||
--danger-soft: rgba(177, 61, 45, 0.1);
|
||||
--neutral: #5f6875;
|
||||
--neutral-soft: rgba(95, 104, 117, 0.1);
|
||||
--shadow: 0 26px 72px rgba(43, 53, 38, 0.1);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 109, 113, 0.16), transparent 24rem),
|
||||
radial-gradient(circle at bottom right, rgba(24, 112, 78, 0.12), transparent 24rem),
|
||||
linear-gradient(180deg, #f5f8f0 0%, #e8ede3 100%);
|
||||
}
|
||||
a { color: inherit; }
|
||||
code, pre { font-family: var(--font-mono); }
|
||||
.shell {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 20px 64px;
|
||||
}
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topnav a {
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
.topnav a:hover { transform: translateY(-1px); background: #fff; }
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card, .panel { padding: 26px; }
|
||||
.hero-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4rem;
|
||||
bottom: -4rem;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(15, 109, 113, 0.18), rgba(24, 112, 78, 0.06));
|
||||
filter: blur(10px);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero-copy, .panel-desc {
|
||||
color: var(--muted);
|
||||
line-height: 1.75;
|
||||
font-size: 15px;
|
||||
}
|
||||
.hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 420px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.stack, .section, .list {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field-grid.two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
.actions, .mini-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
|
||||
.primary { background: var(--ink); color: #fff; }
|
||||
.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.statusbar {
|
||||
margin-top: 16px;
|
||||
min-height: 54px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(24,112,78,0.2); }
|
||||
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,100,25,0.2); }
|
||||
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(177,61,45,0.2); }
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 34rem;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
/* Page-specific layout only. Tokens, cards, buttons come from portal.css + admin-common.css. */
|
||||
.layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: var(--s-5); }
|
||||
.field-grid { display: grid; gap: 12px; }
|
||||
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
||||
.catalog { display: grid; gap: 12px; max-height: 34rem; overflow: auto; padding-right: 4px; }
|
||||
.catalog-item {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.84);
|
||||
cursor: pointer;
|
||||
padding: 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elev-1); cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.catalog-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(15,109,113,0.22);
|
||||
}
|
||||
.catalog-item.is-selected {
|
||||
background: rgba(15,109,113,0.08);
|
||||
border-color: rgba(15,109,113,0.22);
|
||||
}
|
||||
.catalog-item strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.catalog-meta, .detail-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tone-healthy { background: var(--success-soft); color: var(--success); border-color: rgba(24,112,78,0.18); }
|
||||
.tone-cooldown { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,100,25,0.18); }
|
||||
.tone-failing { background: var(--danger-soft); color: var(--danger); border-color: rgba(177,61,45,0.18); }
|
||||
.tone-disabled { background: var(--neutral-soft); color: var(--neutral); border-color: rgba(95,104,117,0.18); }
|
||||
.grid-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.list-card {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.82);
|
||||
}
|
||||
.list-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.inline-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(20, 27, 23, 0.96);
|
||||
color: #e9f3ea;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.catalog-item:hover { transform: translateY(-1px); border-color: rgba(20,184,166,0.32); }
|
||||
.catalog-item.is-selected { background: var(--color-primary-soft); border-color: rgba(20,184,166,0.32); }
|
||||
.catalog-item strong { display: block; margin-bottom: 6px; font-size: 15px; color: var(--text-strong); }
|
||||
.catalog-meta, .detail-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.grid-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); }
|
||||
.list-card { padding: 16px; border-radius: var(--r-md); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); }
|
||||
.list-card strong { display: block; margin-bottom: 6px; color: var(--text-strong); }
|
||||
.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; }
|
||||
.tone-healthy { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
|
||||
.tone-cooldown { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
|
||||
.tone-failing { background: var(--color-danger-soft); color: var(--color-danger); border-color: rgba(239,68,68,0.2); }
|
||||
.tone-disabled { background: var(--color-neutral-soft); color: var(--color-neutral); border-color: rgba(100,116,139,0.2); }
|
||||
@media (max-width: 1200px) {
|
||||
.hero, .layout, .grid-columns, .field-grid.two { grid-template-columns: 1fr; }
|
||||
.layout, .grid-columns, .field-grid.two { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html" class="is-current">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
</nav>
|
||||
<main class="shell fade-in">
|
||||
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="route-health"></nav>
|
||||
|
||||
<section class="hero">
|
||||
<article class="card hero-card">
|
||||
<div class="eyebrow">Route Health</div>
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<span class="page-hero__eyebrow">Route Health</span>
|
||||
<h1>把 cooldown、failure 与最近一次真实选路收进一个只读健康面</h1>
|
||||
<p class="hero-copy">
|
||||
<p>
|
||||
这页聚合 <code>logical_group_routes</code>、运行态 <code>routefail</code> / <code>routecool</code>、
|
||||
最近一次 <code>route_decision_logs</code> 和最近 failover 计数。首版只做读,不直接改 route,
|
||||
目标是让运营能快速判断某条 route 当前是 <code>healthy</code>、<code>cooldown</code>、
|
||||
<code>failing</code> 还是 <code>disabled</code>。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>优先使用管理员会话,也保留 Bearer token 兜底</li>
|
||||
<li>页面只读,写 failure / cooldown 仍走现有管理 API</li>
|
||||
<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);">优先使用管理员会话,也保留 Bearer token 兜底</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);">页面只读,写 failure / cooldown 仍走现有管理 API</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<aside class="card metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">API Root</div>
|
||||
<div class="metric-value" id="metric-api-root">-</div>
|
||||
</div>
|
||||
<div class="stack" style="gap:var(--s-3);">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-primary" id="rh-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="metric">
|
||||
<div class="metric-label">Routes</div>
|
||||
<div class="metric-value" id="metric-route-count">0</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-info" id="rh-m2"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Routes</p>
|
||||
<p class="stat-value" id="metric-route-count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Healthy / Cooldown</div>
|
||||
<div class="metric-value" id="metric-health-mix">0 / 0</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-success" id="rh-m3"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Healthy / Cooldown</p>
|
||||
<p class="stat-value" id="metric-health-mix">0 / 0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Failing / Disabled</div>
|
||||
<div class="metric-value" id="metric-alert-mix">0 / 0</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-danger" id="rh-m4"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="stat-label">Failing / Disabled</p>
|
||||
<p class="stat-value" id="metric-alert-mix">0 / 0</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layout">
|
||||
@@ -499,7 +201,18 @@
|
||||
</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]"), "route-health");
|
||||
(function injectIcons(){
|
||||
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
|
||||
M("rh-m1", "shield");
|
||||
M("rh-m2", "route");
|
||||
M("rh-m3", "check");
|
||||
M("rh-m4", "alert");
|
||||
})();
|
||||
const storageKey = "sub2api-route-health-admin";
|
||||
const state = {
|
||||
routes: [],
|
||||
@@ -527,20 +240,25 @@
|
||||
const detailErrors = document.getElementById("detail-errors");
|
||||
const detailJSON = document.getElementById("detail-json");
|
||||
|
||||
const adminRuntime = AdminCommon.createAdminPageRuntime({
|
||||
apiBaseInput,
|
||||
adminTokenInput,
|
||||
adminUsernameInput,
|
||||
adminPasswordInput,
|
||||
adminSessionStatus,
|
||||
onSessionPersist: saveConfig,
|
||||
});
|
||||
|
||||
function defaultApiBase() {
|
||||
if (window.location.origin.includes("sub.tksea.top")) {
|
||||
return `${window.location.origin}/portal-admin-api`;
|
||||
}
|
||||
return "/portal-admin-api";
|
||||
return adminRuntime.defaultApiBase();
|
||||
}
|
||||
|
||||
function normalizeApiBase() {
|
||||
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/$/, "");
|
||||
return adminRuntime.normalizeApiBase();
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
return adminRuntime.authHeaders();
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
@@ -553,12 +271,7 @@
|
||||
}
|
||||
|
||||
function setStatus(element, message, tone = "note") {
|
||||
element.textContent = message;
|
||||
if (tone === "note") {
|
||||
element.removeAttribute("data-tone");
|
||||
} else {
|
||||
element.setAttribute("data-tone", tone);
|
||||
}
|
||||
AdminCommon.setStatus(element, message, tone);
|
||||
}
|
||||
|
||||
function toneClass(status) {
|
||||
@@ -577,28 +290,7 @@
|
||||
}
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(), finalHeaders);
|
||||
}
|
||||
const response = await fetch(`${normalizeApiBase()}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch (error) {
|
||||
payload = { raw: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload;
|
||||
return adminRuntime.requestJSON(path, options);
|
||||
}
|
||||
|
||||
function collectFilters() {
|
||||
@@ -627,24 +319,19 @@
|
||||
}
|
||||
|
||||
function restoreConfig() {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
apiBaseInput.value = defaultApiBase();
|
||||
if (!raw) {
|
||||
const payload = AdminCommon.readStoredConfig(storageKey);
|
||||
if (!Object.keys(payload).length) {
|
||||
syncMetrics();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
logicalGroupFilterInput.value = payload.logicalGroupID || "";
|
||||
routeFilterInput.value = payload.routeID || "";
|
||||
statusFilterInput.value = payload.status || "";
|
||||
state.selectedRouteID = payload.selectedRouteID || "";
|
||||
} catch (error) {
|
||||
apiBaseInput.value = defaultApiBase();
|
||||
}
|
||||
apiBaseInput.value = payload.apiBase || defaultApiBase();
|
||||
adminTokenInput.value = payload.adminToken || "";
|
||||
adminUsernameInput.value = payload.adminUsername || "";
|
||||
logicalGroupFilterInput.value = payload.logicalGroupID || "";
|
||||
routeFilterInput.value = payload.routeID || "";
|
||||
statusFilterInput.value = payload.status || "";
|
||||
state.selectedRouteID = payload.selectedRouteID || "";
|
||||
syncMetrics();
|
||||
}
|
||||
|
||||
@@ -660,54 +347,15 @@
|
||||
}
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
|
||||
if (payload.username && !adminUsernameInput.value.trim()) {
|
||||
adminUsernameInput.value = payload.username;
|
||||
}
|
||||
if (payload.authenticated) {
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
} else if (payload.login_enabled) {
|
||||
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "warning");
|
||||
} else {
|
||||
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
|
||||
throw error;
|
||||
}
|
||||
return adminRuntime.refreshAdminSession();
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("管理员用户名和密码不能为空");
|
||||
}
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
adminPasswordInput.value = "";
|
||||
saveConfig();
|
||||
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
|
||||
return payload;
|
||||
return adminRuntime.loginAdminSession();
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
adminPasswordInput.value = "";
|
||||
setStatus(adminSessionStatus, "管理员会话已退出。", "warning");
|
||||
return adminRuntime.logoutAdminSession();
|
||||
}
|
||||
|
||||
function routeByID(routeID) {
|
||||
@@ -843,16 +491,12 @@
|
||||
try {
|
||||
await loginAdminSession();
|
||||
await refreshHealth();
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, `登录失败:${error.message}`, "danger");
|
||||
}
|
||||
} catch (error) {}
|
||||
});
|
||||
document.getElementById("admin-logout-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await logoutAdminSession();
|
||||
} catch (error) {
|
||||
setStatus(adminSessionStatus, `退出失败:${error.message}`, "danger");
|
||||
}
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
restoreConfig();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user