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:
phamnazage-jpg
2026-06-03 09:11:07 +08:00
parent 3a9061e11d
commit cc8fc900ca
8 changed files with 2720 additions and 3202 deletions

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
document.getElementById("create-run-btn").addEventListener("click", createRun);
document.getElementById("refresh-run-btn").addEventListener("click", refreshRun);
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
} catch (error) {}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
restoreConfig();
updateAccessModeFields();
syncHeaderMetrics();
refreshAdminSession().catch(() => {});
</script>
<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>

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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>

View File

@@ -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>

View File

@@ -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.jstoast/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>

View File

@@ -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

View File

@@ -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