Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/accounts.html
2026-05-29 19:07:01 +08:00

1084 lines
41 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">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Provider Accounts Admin</title>
<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;
}
* { 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;
}
@media (max-width: 1200px) {
.hero, .layout, .field-grid.two, .field-grid.three, .detail-grid { 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>
<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>
</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">Accounts</div>
<div class="metric-value" id="metric-total">0</div>
</div>
<div class="metric">
<div class="metric-label">Active / Disabled</div>
<div class="metric-value" id="metric-live">0 / 0</div>
</div>
<div class="metric">
<div class="metric-label">Deprecated / Broken</div>
<div class="metric-value" id="metric-dead">0 / 0</div>
</div>
</aside>
</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>
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");
function readConfig() {
try {
return JSON.parse(localStorage.getItem(storageKey) || "{}");
} catch (error) {
console.warn("failed to parse config", error);
return {};
}
}
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(),
};
localStorage.setItem(storageKey, JSON.stringify(payload));
setStatus(tableStatus, "已保存本地配置。");
}
function hydrateConfig() {
const config = readConfig();
apiBaseInput.value = config.apiBase || "/portal-admin-api";
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 (apiBaseInput.value.trim() || "/portal-admin-api").replace(/\/+$/, "");
}
function authHeaders() {
const token = adminTokenInput.value.trim();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function requestJSON(path, options = {}) {
const response = await fetch(`${apiBase()}${path}`, {
credentials: "include",
...options,
headers: {
Accept: "application/json",
...(options.headers || {}),
},
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
const message = payload?.error?.message || payload?.message || response.statusText || "request failed";
throw new Error(`${response.status} ${message}`);
}
return payload;
}
async function refreshSession() {
metricApiRoot.textContent = apiBase();
try {
const payload = await requestJSON("/api/admin/session", { headers: authHeaders() });
if (payload.authenticated) {
setStatus(sessionStatus, `管理员会话已建立:${payload.username || "unknown"}session`, "success");
} else if (payload.login_enabled) {
setStatus(sessionStatus, `当前未登录。可用管理员用户名 ${payload.username || "admin"} 建立 session或继续使用 Bearer token。`, "warn");
} else {
setStatus(sessionStatus, "当前实例未启用管理员登录,只能使用 Bearer token。", "warn");
}
} catch (error) {
setStatus(sessionStatus, `检查管理员会话失败:${error.message}`, "danger");
}
}
async function loginSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
setStatus(sessionStatus, "请先输入管理员用户名和密码。", "warn");
return;
}
try {
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ username, password }),
});
setStatus(sessionStatus, `管理员会话已建立:${payload.username || username}`, "success");
} catch (error) {
setStatus(sessionStatus, `管理员登录失败:${error.message}`, "danger");
}
}
async function logoutSession() {
try {
await requestJSON("/api/admin/session/logout", {
method: "POST",
headers: authHeaders(),
});
setStatus(sessionStatus, "管理员会话已退出。", "warn");
} catch (error) {
setStatus(sessionStatus, `退出会话失败:${error.message}`, "danger");
}
}
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 = "") {
element.textContent = message;
element.style.color = tone === "danger"
? "var(--danger)"
: tone === "warn"
? "var(--warn)"
: tone === "success"
? "var(--success)"
: "var(--muted)";
element.style.borderColor = tone === "danger"
? "rgba(178, 49, 49, 0.24)"
: tone === "warn"
? "rgba(155, 98, 21, 0.22)"
: tone === "success"
? "rgba(18, 107, 67, 0.22)"
: "var(--line)";
element.style.background = tone === "danger"
? "rgba(178, 49, 49, 0.08)"
: tone === "warn"
? "rgba(155, 98, 21, 0.08)"
: tone === "success"
? "rgba(18, 107, 67, 0.08)"
: "#fff";
}
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>