feat(accounts): add provider account admin view

This commit is contained in:
phamnazage-jpg
2026-05-29 15:50:28 +08:00
parent 82f3636521
commit c982c595b8
14 changed files with 1352 additions and 93 deletions

View File

@@ -0,0 +1,931 @@
<!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;
}
.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 two" style="margin-top:12px;">
<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>
</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 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,
};
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 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 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");
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,
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 || "";
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 (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 : [];
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
state.selectedAccountID = state.accounts[0]?.id || 0;
}
renderMetrics();
renderCatalog();
renderDetail();
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
} catch (error) {
state.accounts = [];
state.selectedAccountID = 0;
renderMetrics();
renderCatalog();
renderDetail();
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>
</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();
});
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;
if (!account) {
detailGrid.innerHTML = "";
detailJSON.textContent = "{}";
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],
["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);
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
}
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");
}
}
function clearFilters() {
hostFilterInput.value = "";
providerFilterInput.value = "";
logicalGroupFilterInput.value = "";
routeFilterInput.value = "";
shadowGroupFilterInput.value = "";
statusFilterInput.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"));
hydrateConfig();
refreshSession();
loadAccounts();
</script>
</body>
</html>

View File

@@ -252,6 +252,7 @@
<a href="/portal/admin/" class="is-current">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
@@ -290,6 +291,10 @@
<div class="metric-label">Route Health</div>
<div class="metric-value">/route-health</div>
</div>
<div class="metric">
<div class="metric-label">Accounts</div>
<div class="metric-value">/accounts</div>
</div>
<div class="metric">
<div class="metric-label">Batch Import</div>
<div class="metric-value">/batch-import</div>
@@ -364,6 +369,28 @@
</ul>
</article>
<article class="card panel">
<h2>帐号资产</h2>
<p>
这页把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个
<code>logical_group / route / shadow_group / shadow_host</code>,并提供人工
<code>enable / disable / retire</code> 动作。
</p>
<div class="cta-row">
<a class="cta primary" href="/portal/admin/accounts.html">打开帐号资产页</a>
</div>
<ul class="list">
<li>
<strong>适用动作</strong>
查看帐号库存、筛选 route 归属、执行人工启停与退役。
</li>
<li>
<strong>当前边界</strong>
启停动作当前只修改插件库存状态,不直接改宿主 account 记录。
</li>
</ul>
</article>
<article class="card panel">
<h2>导入供应商帐号</h2>
<p>

View File

@@ -370,6 +370,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -361,6 +361,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -343,6 +343,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html" class="is-current">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>