1084 lines
41 KiB
HTML
1084 lines
41 KiB
HTML
<!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("&", "&")
|
||
.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>
|