Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/accounts.html
Hermes Agent 56474264d6
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled
refactor(portal): dedup inline scripts in accounts + batch-import + providers
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.
2026-06-03 13:14:31 +08:00

718 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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("&", "&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>
</body>
</html>