All three admin pages had two parallel inline <script> blocks (a modern S1 that used adminRuntime + a legacy S2 that was self-contained). Both had a nested <script> text inside S1 that the browser tolerated only because the second script re-ran any state-affecting calls. Merge into a single inline script per page; fix the nested <script> comment. - providers.html: 100371 -> 62761 chars (-37610, -37%) - accounts.html: 54878 -> 33098 chars (-21780, -40%) - batch-import: 43861 -> 26570 chars (-17291, -39%) Also rename draftProviderIDInput -> providerIDInput in providers.html (the old draft-provider-id input was removed during the earlier workflow merge, leaving the script with a null addEventListener on draft id). All scripts pass node --check. Both test_tksea_portal_assets.sh and verify_frontend_smoke.sh PASS.
718 lines
34 KiB
HTML
718 lines
34 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Provider Accounts Admin</title>
|
||
<link rel="stylesheet" href="/portal/portal.css">
|
||
<link rel="stylesheet" href="/portal/admin-common.css">
|
||
<style>
|
||
/* Page-specific layout only. Tokens, cards, buttons come from portal.css + admin-common.css. */
|
||
.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;
|
||
}
|
||
.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) {
|
||
.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="accounts"></nav>
|
||
|
||
<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>
|
||
</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="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="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="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>
|
||
</div>
|
||
</section>
|
||
<section class="layout">
|
||
<div class="stack">
|
||
<article class="card panel">
|
||
<h2>连接与过滤</h2>
|
||
<p class="panel-desc">
|
||
这页默认优先走管理员 session,也保留 Bearer token 兜底。过滤只影响读取列表,不会修改帐号状态。
|
||
</p>
|
||
<div class="field-grid">
|
||
<label>
|
||
API Base
|
||
<input id="api-base" type="text" value="/portal-admin-api">
|
||
</label>
|
||
<label>
|
||
Bearer Admin Token(可选)
|
||
<input id="admin-token" type="password" placeholder="未启用 session 时可填">
|
||
</label>
|
||
</div>
|
||
<div class="field-grid two" style="margin-top:12px;">
|
||
<label>
|
||
管理员用户名
|
||
<input id="admin-username" type="text" placeholder="portal-admin">
|
||
</label>
|
||
<label>
|
||
管理员密码
|
||
<input id="admin-password" type="password" placeholder="请输入当前实例管理员密码">
|
||
</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="secondary" id="admin-login-btn" type="button">管理员登录</button>
|
||
<button class="ghost" id="admin-logout-btn" type="button">退出会话</button>
|
||
<button class="ghost" id="save-config-btn" type="button">保存本地配置</button>
|
||
<button class="ghost" id="refresh-btn" type="button">刷新帐号库存</button>
|
||
</div>
|
||
<div class="statusbar" id="session-status">正在检查管理员会话…</div>
|
||
|
||
<div class="field-grid three" style="margin-top:18px;">
|
||
<label>
|
||
host_id
|
||
<input id="filter-host-id" type="text" placeholder="例如 remote43">
|
||
</label>
|
||
<label>
|
||
provider_id
|
||
<input id="filter-provider-id" type="text" placeholder="例如 gpt-asxs-shadow-lab">
|
||
</label>
|
||
<label>
|
||
logical_group_id
|
||
<input id="filter-logical-group-id" type="text" placeholder="例如 gpt-shared">
|
||
</label>
|
||
</div>
|
||
<div class="field-grid three" style="margin-top:12px;">
|
||
<label>
|
||
route_id
|
||
<input id="filter-route-id" type="text" placeholder="例如 asxs-primary">
|
||
</label>
|
||
<label>
|
||
shadow_group_id
|
||
<input id="filter-shadow-group-id" type="text" placeholder="例如 9">
|
||
</label>
|
||
<label>
|
||
account_status
|
||
<select id="filter-status">
|
||
<option value="">全部状态</option>
|
||
<option value="active">active</option>
|
||
<option value="disabled">disabled</option>
|
||
<option value="deprecated">deprecated</option>
|
||
<option value="broken">broken</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="field-grid three" style="margin-top:12px;">
|
||
<label>
|
||
binding_state
|
||
<select id="filter-binding-state">
|
||
<option value="">全部归属状态</option>
|
||
<option value="assigned">assigned</option>
|
||
<option value="unassigned">unassigned</option>
|
||
<option value="conflict">conflict</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
搜索
|
||
<input id="filter-query" type="text" placeholder="provider / logical_group / host_account / fingerprint">
|
||
</label>
|
||
<label>
|
||
limit
|
||
<input id="filter-limit" type="number" min="1" max="500" value="200">
|
||
</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="primary" id="apply-filters-btn" type="button">应用过滤</button>
|
||
<button class="ghost" id="clear-filters-btn" type="button">清空过滤</button>
|
||
</div>
|
||
<div class="statusbar" id="table-status">帐号库存结果会显示在这里。</div>
|
||
</article>
|
||
|
||
<article class="card panel">
|
||
<h2>帐号资产清单</h2>
|
||
<p class="panel-desc">
|
||
选中一条帐号后,右侧会展示完整归属和当前启停操作。未补齐 route 的帐号不会被隐藏,而是明确显示为“未归属”。
|
||
</p>
|
||
<div class="catalog" id="accounts-catalog">
|
||
<div class="empty">还没有帐号库存数据。</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<article class="card panel">
|
||
<h2>帐号归属详情</h2>
|
||
<p class="panel-desc">
|
||
这里回答三个问题:这条帐号属于谁、挂到哪条 route、当前是人工停用还是自动探测异常。所有启停动作都只改插件库存状态。
|
||
</p>
|
||
<p class="panel-desc">
|
||
当前显式使用的动作接口是:
|
||
<code>/api/provider-accounts/{account_id}/enable</code>、
|
||
<code>/api/provider-accounts/{account_id}/disable</code>、
|
||
<code>/api/provider-accounts/{account_id}/retire</code>、
|
||
<code>/api/provider-accounts/{account_id}/binding-candidates</code>、
|
||
<code>/api/provider-accounts/{account_id}/binding</code>。
|
||
</p>
|
||
<div class="field-grid" style="margin-top:12px;">
|
||
<label>
|
||
状态变更原因
|
||
<textarea id="action-reason" placeholder="例如 manual_disable / quota_pause / provider_rotation"></textarea>
|
||
</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="secondary" id="enable-btn" type="button" disabled>启用帐号</button>
|
||
<button class="ghost" id="disable-btn" type="button" disabled>停用帐号</button>
|
||
<button class="danger" id="retire-btn" type="button" disabled>标记退役</button>
|
||
</div>
|
||
<div class="statusbar" id="action-status">请选择左侧一条帐号记录。</div>
|
||
|
||
<div class="binding-box">
|
||
<h2 style="margin:0 0 8px; font-size:20px;">显式整理归属</h2>
|
||
<p class="panel-desc">
|
||
当帐号因为同一 <code>shadow_host_id + shadow_group_id</code> 对应多条 route 而显示为
|
||
<code>conflict</code> 时,直接在这里挑一条 route 绑定;也可以清空 binding,保留为未归属。
|
||
</p>
|
||
<div class="field-grid two" style="margin-top:12px;">
|
||
<label>
|
||
route 候选
|
||
<select id="binding-route-select" disabled>
|
||
<option value="">请先选择帐号</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
当前 binding_state
|
||
<input id="binding-state-view" type="text" readonly value="-">
|
||
</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="ghost" id="refresh-binding-btn" type="button" disabled>刷新候选 route</button>
|
||
<button class="secondary" id="apply-binding-btn" type="button" disabled>绑定到所选 route</button>
|
||
<button class="ghost" id="clear-binding-btn" type="button" disabled>清空 route 归属</button>
|
||
</div>
|
||
<div class="statusbar" id="binding-status">选择左侧一条帐号后,这里会加载 route 候选。</div>
|
||
</div>
|
||
|
||
<div id="detail-empty" class="empty" style="margin-top:16px;">选择左侧一条帐号后,这里会显示 route / shadow group / logical group 归属详情。</div>
|
||
<div id="detail-panel" hidden>
|
||
<div class="detail-grid" id="detail-grid"></div>
|
||
<pre class="raw-json" id="detail-json">{}</pre>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</main>
|
||
|
||
<script src="/portal/admin-common.js"></script>
|
||
<script src="/portal/portal.js"></script>
|
||
<script>
|
||
|
||
const AdminCommon = window.Sub2ApiAdminCommon;
|
||
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "accounts");
|
||
(function injectIcons(){
|
||
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
|
||
M("ac-m1", "shield");
|
||
M("ac-m2", "users");
|
||
M("ac-m3", "check");
|
||
M("ac-m4", "alert");
|
||
})();
|
||
|
||
// <script>
|
||
AdminCommon.renderAdminNav(document.querySelector("[data-admin-nav]"), "accounts");
|
||
const storageKey = "sub2api-provider-accounts-admin";
|
||
const state = {
|
||
accounts: [],
|
||
selectedAccountID: 0,
|
||
bindingCandidates: [],
|
||
};
|
||
|
||
const apiBaseInput = document.getElementById("api-base");
|
||
const adminTokenInput = document.getElementById("admin-token");
|
||
const adminUsernameInput = document.getElementById("admin-username");
|
||
const adminPasswordInput = document.getElementById("admin-password");
|
||
const hostFilterInput = document.getElementById("filter-host-id");
|
||
const providerFilterInput = document.getElementById("filter-provider-id");
|
||
const logicalGroupFilterInput = document.getElementById("filter-logical-group-id");
|
||
const routeFilterInput = document.getElementById("filter-route-id");
|
||
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
|
||
const statusFilterInput = document.getElementById("filter-status");
|
||
const bindingStateFilterInput = document.getElementById("filter-binding-state");
|
||
const queryFilterInput = document.getElementById("filter-query");
|
||
const limitFilterInput = document.getElementById("filter-limit");
|
||
const actionReasonInput = document.getElementById("action-reason");
|
||
|
||
const sessionStatus = document.getElementById("session-status");
|
||
const tableStatus = document.getElementById("table-status");
|
||
const actionStatus = document.getElementById("action-status");
|
||
const accountsCatalog = document.getElementById("accounts-catalog");
|
||
const detailEmpty = document.getElementById("detail-empty");
|
||
const detailPanel = document.getElementById("detail-panel");
|
||
const detailGrid = document.getElementById("detail-grid");
|
||
const detailJSON = document.getElementById("detail-json");
|
||
const bindingRouteSelect = document.getElementById("binding-route-select");
|
||
const bindingStateView = document.getElementById("binding-state-view");
|
||
const bindingStatus = document.getElementById("binding-status");
|
||
|
||
const metricApiRoot = document.getElementById("metric-api-root");
|
||
const metricTotal = document.getElementById("metric-total");
|
||
const metricLive = document.getElementById("metric-live");
|
||
const metricDead = document.getElementById("metric-dead");
|
||
|
||
const enableButton = document.getElementById("enable-btn");
|
||
const disableButton = document.getElementById("disable-btn");
|
||
const retireButton = document.getElementById("retire-btn");
|
||
const refreshBindingButton = document.getElementById("refresh-binding-btn");
|
||
const applyBindingButton = document.getElementById("apply-binding-btn");
|
||
const clearBindingButton = document.getElementById("clear-binding-btn");
|
||
|
||
const adminRuntime = AdminCommon.createAdminPageRuntime({
|
||
apiBaseInput,
|
||
adminTokenInput,
|
||
adminUsernameInput,
|
||
adminPasswordInput,
|
||
adminSessionStatus: sessionStatus,
|
||
includeAuthOnSessionCheck: true,
|
||
sessionPresentation: {
|
||
allowBearerFallback: true,
|
||
includeSessionSuffix: true,
|
||
usernameFallback: "admin",
|
||
},
|
||
onSessionPersist: writeConfig,
|
||
});
|
||
|
||
function readConfig() {
|
||
return AdminCommon.readStoredConfig(storageKey);
|
||
}
|
||
|
||
function writeConfig() {
|
||
const payload = {
|
||
apiBase: apiBaseInput.value.trim(),
|
||
adminToken: adminTokenInput.value,
|
||
adminUsername: adminUsernameInput.value.trim(),
|
||
hostID: hostFilterInput.value.trim(),
|
||
providerID: providerFilterInput.value.trim(),
|
||
logicalGroupID: logicalGroupFilterInput.value.trim(),
|
||
routeID: routeFilterInput.value.trim(),
|
||
shadowGroupID: shadowGroupFilterInput.value.trim(),
|
||
accountStatus: statusFilterInput.value,
|
||
bindingState: bindingStateFilterInput.value,
|
||
query: queryFilterInput.value.trim(),
|
||
limit: limitFilterInput.value.trim(),
|
||
};
|
||
AdminCommon.writeStoredConfig(storageKey, payload);
|
||
setStatus(tableStatus, "已保存本地配置。");
|
||
}
|
||
|
||
function hydrateConfig() {
|
||
const config = readConfig();
|
||
apiBaseInput.value = config.apiBase || adminRuntime.defaultApiBase();
|
||
adminTokenInput.value = config.adminToken || "";
|
||
adminUsernameInput.value = config.adminUsername || "";
|
||
hostFilterInput.value = config.hostID || "";
|
||
providerFilterInput.value = config.providerID || "";
|
||
logicalGroupFilterInput.value = config.logicalGroupID || "";
|
||
routeFilterInput.value = config.routeID || "";
|
||
shadowGroupFilterInput.value = config.shadowGroupID || "";
|
||
statusFilterInput.value = config.accountStatus || "";
|
||
bindingStateFilterInput.value = config.bindingState || "";
|
||
queryFilterInput.value = config.query || "";
|
||
limitFilterInput.value = config.limit || "200";
|
||
}
|
||
|
||
function apiBase() {
|
||
return adminRuntime.normalizeApiBase();
|
||
}
|
||
|
||
function authHeaders() {
|
||
return adminRuntime.authHeaders();
|
||
}
|
||
|
||
async function requestJSON(path, options = {}) {
|
||
return adminRuntime.requestJSON(path, options);
|
||
}
|
||
|
||
async function refreshSession() {
|
||
metricApiRoot.textContent = apiBase();
|
||
try {
|
||
await adminRuntime.refreshAdminSession();
|
||
} catch (error) {}
|
||
}
|
||
|
||
async function loginSession() {
|
||
try {
|
||
await adminRuntime.loginAdminSession();
|
||
} catch (error) {}
|
||
}
|
||
|
||
async function logoutSession() {
|
||
try {
|
||
await adminRuntime.logoutAdminSession();
|
||
} catch (error) {}
|
||
}
|
||
|
||
function buildListQuery() {
|
||
const params = new URLSearchParams();
|
||
if (hostFilterInput.value.trim()) params.set("host_id", hostFilterInput.value.trim());
|
||
if (providerFilterInput.value.trim()) params.set("provider_id", providerFilterInput.value.trim());
|
||
if (logicalGroupFilterInput.value.trim()) params.set("logical_group_id", logicalGroupFilterInput.value.trim());
|
||
if (routeFilterInput.value.trim()) params.set("route_id", routeFilterInput.value.trim());
|
||
if (shadowGroupFilterInput.value.trim()) params.set("shadow_group_id", shadowGroupFilterInput.value.trim());
|
||
if (statusFilterInput.value) params.set("account_status", statusFilterInput.value);
|
||
if (bindingStateFilterInput.value) params.set("binding_state", bindingStateFilterInput.value);
|
||
if (queryFilterInput.value.trim()) params.set("q", queryFilterInput.value.trim());
|
||
if (limitFilterInput.value.trim()) params.set("limit", limitFilterInput.value.trim());
|
||
const query = params.toString();
|
||
return query ? `/api/provider-accounts?${query}` : "/api/provider-accounts";
|
||
}
|
||
|
||
async function loadAccounts() {
|
||
setStatus(tableStatus, "正在读取 provider_accounts…");
|
||
try {
|
||
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
|
||
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
|
||
state.bindingCandidates = [];
|
||
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
|
||
state.selectedAccountID = state.accounts[0]?.id || 0;
|
||
}
|
||
renderMetrics();
|
||
renderCatalog();
|
||
renderDetail();
|
||
if (state.selectedAccountID) {
|
||
await loadBindingCandidates();
|
||
} else {
|
||
renderBindingCandidates();
|
||
}
|
||
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
|
||
} catch (error) {
|
||
state.accounts = [];
|
||
state.selectedAccountID = 0;
|
||
state.bindingCandidates = [];
|
||
renderMetrics();
|
||
renderCatalog();
|
||
renderDetail();
|
||
renderBindingCandidates();
|
||
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
|
||
}
|
||
}
|
||
|
||
function renderMetrics() {
|
||
metricApiRoot.textContent = apiBase();
|
||
metricTotal.textContent = String(state.accounts.length);
|
||
const counts = { active: 0, disabled: 0, deprecated: 0, broken: 0 };
|
||
state.accounts.forEach((account) => {
|
||
if (Object.prototype.hasOwnProperty.call(counts, account.account_status)) {
|
||
counts[account.account_status] += 1;
|
||
}
|
||
});
|
||
metricLive.textContent = `${counts.active} / ${counts.disabled}`;
|
||
metricDead.textContent = `${counts.deprecated} / ${counts.broken}`;
|
||
}
|
||
|
||
function statusClass(status) {
|
||
if (status === "active") return "active";
|
||
if (status === "disabled") return "disabled";
|
||
if (status === "deprecated") return "deprecated";
|
||
return "broken";
|
||
}
|
||
|
||
function renderCatalog() {
|
||
if (!state.accounts.length) {
|
||
accountsCatalog.innerHTML = '<div class="empty">还没有匹配到帐号资产记录。</div>';
|
||
return;
|
||
}
|
||
accountsCatalog.innerHTML = "";
|
||
state.accounts.forEach((account) => {
|
||
const card = document.createElement("button");
|
||
card.type = "button";
|
||
card.className = `row-card${account.id === state.selectedAccountID ? " is-selected" : ""}`;
|
||
card.innerHTML = `
|
||
<div class="row-heading">
|
||
<div>
|
||
<div class="row-title">${escapeHTML(account.account_name || account.host_account_id)}</div>
|
||
<div class="meta-list">
|
||
<span>provider: <code>${escapeHTML(account.provider_id)}</code></span>
|
||
<span>host_account_id: <code>${escapeHTML(account.host_account_id)}</code></span>
|
||
</div>
|
||
</div>
|
||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.account_status)}</span>
|
||
</div>
|
||
<div class="badge-row">
|
||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.logical_group_id || "未归属 logical_group")}</span>
|
||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.route_id || "未归属 route")}</span>
|
||
<span class="badge ${statusClass(account.account_status)}">shadow_group: ${escapeHTML(account.shadow_group_id || "-")}</span>
|
||
<span class="badge ${statusClass(account.account_status)}">binding: ${escapeHTML(account.binding_state || "unassigned")} / candidates: ${escapeHTML(account.binding_candidate_count || 0)}</span>
|
||
</div>
|
||
<div class="meta-list">
|
||
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
|
||
<span>shadow_host_id: <code>${escapeHTML(account.shadow_host_id || account.host_id || "-")}</code></span>
|
||
<span>last_probe_status: <code>${escapeHTML(account.last_probe_status || "-")}</code></span>
|
||
</div>
|
||
`;
|
||
card.addEventListener("click", () => {
|
||
state.selectedAccountID = account.id;
|
||
renderCatalog();
|
||
renderDetail();
|
||
loadBindingCandidates();
|
||
});
|
||
accountsCatalog.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderDetail() {
|
||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||
const hasSelection = Boolean(account);
|
||
detailEmpty.hidden = hasSelection;
|
||
detailPanel.hidden = !hasSelection;
|
||
enableButton.disabled = !hasSelection;
|
||
disableButton.disabled = !hasSelection;
|
||
retireButton.disabled = !hasSelection;
|
||
refreshBindingButton.disabled = !hasSelection;
|
||
clearBindingButton.disabled = !hasSelection;
|
||
if (!account) {
|
||
detailGrid.innerHTML = "";
|
||
detailJSON.textContent = "{}";
|
||
bindingStateView.value = "-";
|
||
setStatus(actionStatus, "请选择左侧一条帐号记录。");
|
||
return;
|
||
}
|
||
const cards = [
|
||
["帐号主键", String(account.id)],
|
||
["provider_id", account.provider_id],
|
||
["provider_name", account.provider_name || "-"],
|
||
["host_id", account.host_id],
|
||
["host_base_url", account.host_base_url || "-"],
|
||
["logical_group_id", account.logical_group_id || "未归属"],
|
||
["route_id", account.route_id || "未归属"],
|
||
["route_name", account.route_name || "-"],
|
||
["shadow_group_id", account.shadow_group_id || "-"],
|
||
["shadow_host_id", account.shadow_host_id || "-"],
|
||
["upstream_base_url_hint", account.upstream_base_url_hint || "-"],
|
||
["host_account_id", account.host_account_id],
|
||
["key_fingerprint", account.key_fingerprint],
|
||
["account_status", account.account_status],
|
||
["binding_state", account.binding_state || "unassigned"],
|
||
["binding_candidate_count", String(account.binding_candidate_count || 0)],
|
||
["last_probe_status", account.last_probe_status || "-"],
|
||
["last_probe_at", account.last_probe_at || "-"],
|
||
["disabled_reason", account.disabled_reason || "-"],
|
||
["updated_at", account.updated_at || "-"],
|
||
];
|
||
detailGrid.innerHTML = cards.map(([label, value]) => `
|
||
<div class="detail-card">
|
||
<strong>${escapeHTML(label)}</strong>
|
||
<code>${escapeHTML(value)}</code>
|
||
</div>
|
||
`).join("");
|
||
detailJSON.textContent = JSON.stringify(account, null, 2);
|
||
bindingStateView.value = account.binding_state || "unassigned";
|
||
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
|
||
}
|
||
|
||
async function loadBindingCandidates() {
|
||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||
if (!account) {
|
||
state.bindingCandidates = [];
|
||
renderBindingCandidates();
|
||
return;
|
||
}
|
||
setStatus(bindingStatus, `正在读取帐号 #${account.id} 的 route 候选…`);
|
||
try {
|
||
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding-candidates`, { headers: authHeaders() });
|
||
state.bindingCandidates = Array.isArray(payload.candidate_routes) ? payload.candidate_routes : [];
|
||
if (payload.provider_account) {
|
||
state.accounts = state.accounts.map((item) => item.id === account.id ? payload.provider_account : item);
|
||
}
|
||
renderCatalog();
|
||
renderDetail();
|
||
renderBindingCandidates();
|
||
setStatus(bindingStatus, `已加载 ${state.bindingCandidates.length} 条 route 候选。`, "success");
|
||
} catch (error) {
|
||
state.bindingCandidates = [];
|
||
renderBindingCandidates();
|
||
setStatus(bindingStatus, `读取 route 候选失败:${error.message}`, "danger");
|
||
}
|
||
}
|
||
|
||
function renderBindingCandidates() {
|
||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||
const hasSelection = Boolean(account);
|
||
bindingRouteSelect.disabled = !hasSelection;
|
||
applyBindingButton.disabled = !hasSelection;
|
||
if (!hasSelection) {
|
||
bindingRouteSelect.innerHTML = '<option value="">请先选择帐号</option>';
|
||
bindingStateView.value = "-";
|
||
return;
|
||
}
|
||
const options = ['<option value="">请选择一个 route</option>'];
|
||
state.bindingCandidates.forEach((route) => {
|
||
const selected = route.route_id === account.route_id ? " selected" : "";
|
||
options.push(`<option value="${escapeHTML(route.route_id)}"${selected}>${escapeHTML(route.route_id)} / ${escapeHTML(route.logical_group_id)} / ${escapeHTML(route.name || "-")}</option>`);
|
||
});
|
||
if (!state.bindingCandidates.length) {
|
||
options.push('<option value="">当前 shadow binding 下没有候选 route</option>');
|
||
}
|
||
bindingRouteSelect.innerHTML = options.join("");
|
||
}
|
||
|
||
async function updateAccountStatus(action) {
|
||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||
if (!account) {
|
||
setStatus(actionStatus, "请先选择一条帐号记录。", "warn");
|
||
return;
|
||
}
|
||
const reason = actionReasonInput.value.trim();
|
||
if ((action === "disable" || action === "retire") && !reason) {
|
||
setStatus(actionStatus, "停用或退役请填写原因,避免后续看不懂为什么改状态。", "warn");
|
||
return;
|
||
}
|
||
try {
|
||
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/${action}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||
body: JSON.stringify(reason ? { reason } : {}),
|
||
});
|
||
const updated = payload.provider_account;
|
||
setStatus(actionStatus, `帐号 #${updated.id} 已更新为 ${updated.account_status}${updated.disabled_reason ? `(${updated.disabled_reason})` : ""}。`, "success");
|
||
await loadAccounts();
|
||
} catch (error) {
|
||
setStatus(actionStatus, `更新帐号状态失败:${error.message}`, "danger");
|
||
}
|
||
}
|
||
|
||
async function updateAccountBinding(mode) {
|
||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||
if (!account) {
|
||
setStatus(bindingStatus, "请先选择一条帐号记录。", "warn");
|
||
return;
|
||
}
|
||
let payload = {};
|
||
if (mode === "assign") {
|
||
const routeID = bindingRouteSelect.value.trim();
|
||
if (!routeID) {
|
||
setStatus(bindingStatus, "请先选择要绑定的 route。", "warn");
|
||
return;
|
||
}
|
||
payload = { route_id: routeID };
|
||
} else {
|
||
payload = { clear: true };
|
||
}
|
||
try {
|
||
const response = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const updated = response.provider_account;
|
||
setStatus(bindingStatus, `帐号 #${updated.id} 已更新归属:binding_state=${updated.binding_state || "unassigned"} route=${updated.route_id || "-"}`, "success");
|
||
await loadAccounts();
|
||
} catch (error) {
|
||
setStatus(bindingStatus, `更新帐号归属失败:${error.message}`, "danger");
|
||
}
|
||
}
|
||
|
||
function clearFilters() {
|
||
hostFilterInput.value = "";
|
||
providerFilterInput.value = "";
|
||
logicalGroupFilterInput.value = "";
|
||
routeFilterInput.value = "";
|
||
shadowGroupFilterInput.value = "";
|
||
statusFilterInput.value = "";
|
||
bindingStateFilterInput.value = "";
|
||
queryFilterInput.value = "";
|
||
limitFilterInput.value = "200";
|
||
}
|
||
|
||
function setStatus(element, message, tone = "") {
|
||
AdminCommon.setStatus(element, message, tone || "note");
|
||
}
|
||
|
||
function escapeHTML(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
document.getElementById("save-config-btn").addEventListener("click", writeConfig);
|
||
document.getElementById("admin-login-btn").addEventListener("click", loginSession);
|
||
document.getElementById("admin-logout-btn").addEventListener("click", logoutSession);
|
||
document.getElementById("refresh-btn").addEventListener("click", loadAccounts);
|
||
document.getElementById("apply-filters-btn").addEventListener("click", loadAccounts);
|
||
document.getElementById("clear-filters-btn").addEventListener("click", () => {
|
||
clearFilters();
|
||
loadAccounts();
|
||
});
|
||
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
|
||
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
|
||
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
|
||
refreshBindingButton.addEventListener("click", loadBindingCandidates);
|
||
applyBindingButton.addEventListener("click", () => updateAccountBinding("assign"));
|
||
clearBindingButton.addEventListener("click", () => updateAccountBinding("clear"));
|
||
|
||
hydrateConfig();
|
||
refreshSession();
|
||
loadAccounts();
|
||
</script>
|
||
</body>
|
||
</html>
|