refactor(portal): dedup inline scripts in accounts + batch-import + providers
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled

All three admin pages had two parallel inline <script> blocks (a modern S1
that used adminRuntime + a legacy S2 that was self-contained). Both had
a nested <script> text inside S1 that the browser tolerated only because
the second script re-ran any state-affecting calls. Merge into a single
inline script per page; fix the nested <script> comment.

- providers.html: 100371 -> 62761 chars (-37610, -37%)
- accounts.html:  54878 -> 33098 chars  (-21780, -40%)
- batch-import:   43861 -> 26570 chars  (-17291, -39%)

Also rename draftProviderIDInput -> providerIDInput in providers.html
(the old draft-provider-id input was removed during the earlier workflow
merge, leaving the script with a null addEventListener on draft id).

All scripts pass node --check. Both test_tksea_portal_assets.sh and
verify_frontend_smoke.sh PASS.
This commit is contained in:
Hermes Agent
2026-06-03 13:14:31 +08:00
parent 09f7c07de3
commit 56474264d6
3 changed files with 301 additions and 2041 deletions

View File

@@ -253,6 +253,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4"></textarea>
<script src="/portal/admin-common.js"></script>
<script src="/portal/portal.js"></script>
<script>
const AdminCommon = window.Sub2ApiAdminCommon;
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "batch-import");
(function injectIcons(){
@@ -263,7 +264,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4"></textarea>
M("bimp-m4", "package");
})();
<script>
// <script>
AdminCommon.renderAdminNav(document.querySelector("[data-admin-nav]"), "batch-import");
const storageKey = "sub2api-crm-batch-import-admin-v1";
const state = {
@@ -654,467 +655,6 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4"></textarea>
updateAccessModeFields();
syncHeaderMetrics();
refreshAdminSession().catch(() => {});
</script>
<script>
const storageKey = "sub2api-crm-batch-import-admin-v1";
const state = {
currentRunID: "",
currentItems: [],
currentRun: null,
};
const statusbar = document.getElementById("statusbar");
const apiBaseInput = document.getElementById("api-base");
const hostIDInput = document.getElementById("host-id");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const adminLoginButton = document.getElementById("admin-login-btn");
const adminLogoutButton = document.getElementById("admin-logout-btn");
const adminSessionStatus = document.getElementById("admin-session-status");
const modeInput = document.getElementById("mode");
const accessModeInput = document.getElementById("access-mode");
const confirmTimeoutInput = document.getElementById("confirm-timeout");
const probeAPIKeyInput = document.getElementById("probe-api-key");
const subscriptionUsersInput = document.getElementById("subscription-users");
const subscriptionDaysInput = document.getElementById("subscription-days");
const entriesInput = document.getElementById("entries");
const runIDInput = document.getElementById("run-id");
const selfServiceFields = document.getElementById("self-service-fields");
const subscriptionFields = document.getElementById("subscription-fields");
const metricApiRoot = document.getElementById("metric-api-root");
const metricRunID = document.getElementById("metric-run-id");
const metricRunState = document.getElementById("metric-run-state");
const runMeta = document.getElementById("run-meta");
const itemsTbody = document.getElementById("items-tbody");
const filterQueryInput = document.getElementById("filter-query");
const filterMatchedStateInput = document.getElementById("filter-matched-state");
const filterAccountResolutionInput = document.getElementById("filter-account-resolution");
const summaryTargets = {
total: document.getElementById("sum-total"),
completed: document.getElementById("sum-completed"),
active: document.getElementById("sum-active"),
degraded: document.getElementById("sum-degraded"),
broken: document.getElementById("sum-broken"),
};
function setStatus(message, tone = "") {
statusbar.textContent = message;
if (tone) {
statusbar.dataset.tone = tone;
} else {
delete statusbar.dataset.tone;
}
}
function defaultApiBase() {
return `${window.location.origin}/portal-admin-api`;
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
hostID: hostIDInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
mode: modeInput.value,
accessMode: accessModeInput.value,
confirmTimeoutSec: confirmTimeoutInput.value,
probeAPIKey: probeAPIKeyInput.value.trim(),
subscriptionUsers: subscriptionUsersInput.value.trim(),
subscriptionDays: subscriptionDaysInput.value,
entries: entriesInput.value,
}));
setStatus("本地配置已保存。", "success");
syncHeaderMetrics();
}
function restoreConfig() {
const raw = localStorage.getItem(storageKey);
if (!raw) {
apiBaseInput.value = defaultApiBase();
hostIDInput.value = "";
confirmTimeoutInput.value = "10";
subscriptionDaysInput.value = "30";
return;
}
try {
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
hostIDInput.value = payload.hostID || "";
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
modeInput.value = payload.mode || "strict";
accessModeInput.value = payload.accessMode || "self_service";
confirmTimeoutInput.value = payload.confirmTimeoutSec || "10";
probeAPIKeyInput.value = payload.probeAPIKey || "";
subscriptionUsersInput.value = payload.subscriptionUsers || "";
subscriptionDaysInput.value = payload.subscriptionDays || "30";
entriesInput.value = payload.entries || entriesInput.value;
} catch (error) {
apiBaseInput.value = defaultApiBase();
}
}
function loadSampleEntries() {
entriesInput.value = [
"https://api.example.com/v1|sk-example-1|kimi-k2.6",
"https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4",
].join("\\n");
setStatus("示例 entries 已恢复。");
}
function updateAccessModeFields() {
const accessMode = accessModeInput.value;
const subscriptionMode = accessMode === "subscription";
selfServiceFields.hidden = subscriptionMode;
subscriptionFields.hidden = !subscriptionMode;
}
function normalizeApiBase() {
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/+$/, "");
}
function authHeaders() {
const headers = {
"Content-Type": "application/json",
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function parseEntries() {
return entriesInput.value
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [baseURL = "", apiKey = "", models = ""] = line.split("|").map((part) => part.trim());
if (!baseURL || !apiKey) {
throw new Error(`${index + 1} 行格式不完整,需要 base_url|api_key|models`);
}
return {
base_url: baseURL,
api_key: apiKey,
requested_models: models ? models.split(",").map((item) => item.trim()).filter(Boolean) : [],
};
});
}
function buildCreatePayload() {
const payload = {
host_id: hostIDInput.value.trim(),
mode: modeInput.value,
access_mode: accessModeInput.value,
confirm_wait_timeout_sec: Number(confirmTimeoutInput.value || 10),
entries: parseEntries(),
};
if (!payload.host_id) {
throw new Error("host_id 不能为空");
}
if (payload.access_mode === "self_service") {
payload.probe_api_key = probeAPIKeyInput.value.trim();
if (!payload.probe_api_key) {
throw new Error("self_service 模式下 probe_api_key 不能为空");
}
} else {
payload.subscription_users = subscriptionUsersInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
payload.subscription_days = Number(subscriptionDaysInput.value || 30);
if (!payload.subscription_users.length) {
throw new Error("subscription 模式下 subscription_users 不能为空");
}
if (!payload.subscription_days) {
throw new Error("subscription 模式下 subscription_days 不能为空");
}
}
return payload;
}
async function requestJSON(path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
const response = await fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (error) {
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
async function refreshAdminSession() {
try {
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
if (payload.username && !adminUsernameInput.value.trim()) {
adminUsernameInput.value = payload.username;
}
if (payload.authenticated) {
setStatus(`管理员已登录:${payload.username}`, "success");
adminSessionStatus.textContent = `已登录:${payload.username}`;
} else if (payload.login_enabled) {
adminSessionStatus.textContent = "未登录,可直接使用管理员用户名密码建立会话。";
} else {
adminSessionStatus.textContent = "当前实例未启用管理员密码登录,只能使用 Bearer token。";
}
return payload;
} catch (error) {
adminSessionStatus.textContent = `管理员会话检查失败:${error.message}`;
throw error;
}
}
async function loginAdminSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
throw new Error("管理员用户名和密码不能为空");
}
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
adminPasswordInput.value = "";
saveConfig();
adminSessionStatus.textContent = `已登录:${payload.username}`;
return payload;
}
async function logoutAdminSession() {
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
adminPasswordInput.value = "";
adminSessionStatus.textContent = "管理员会话已退出。";
}
function syncHeaderMetrics() {
metricApiRoot.textContent = normalizeApiBase();
metricRunID.textContent = state.currentRunID || "-";
metricRunState.textContent = state.currentRun?.state || "-";
}
function renderRunMeta(run) {
runMeta.innerHTML = "";
const meta = [
`run_id=${run.run_id}`,
`state=${run.state}`,
`mode=${run.mode}`,
`access_mode=${run.access_mode}`,
];
meta.forEach((entry) => {
const code = document.createElement("code");
code.textContent = entry;
runMeta.appendChild(code);
});
}
function toneClass(tone) {
if (!tone) return "tone-gray";
return `tone-${tone}`;
}
function renderBadges(badges) {
if (!Array.isArray(badges) || !badges.length) {
return '<span class="subtle">-</span>';
}
return `<div class="badge-row">${badges.map((badge) => `<span class="badge ${toneClass(badge.tone)}">${escapeHTML(badge.label)}</span>`).join("")}</div>`;
}
function renderItems(items) {
if (!items.length) {
itemsTbody.innerHTML = '<tr><td colspan="7" class="subtle">当前过滤条件下没有条目。</td></tr>';
return;
}
itemsTbody.innerHTML = items.map((item) => {
const advisory = Array.isArray(item.advisory_messages) && item.advisory_messages.length
? item.advisory_messages.map((message) => `<div>${escapeHTML(message)}</div>`).join("")
: '<span class="subtle">-</span>';
return `
<tr>
<td>
<strong>${escapeHTML(item.provider_id)}</strong><br>
<code>${escapeHTML(item.api_key_fingerprint || "-")}</code>
</td>
<td><code>${escapeHTML(item.base_url)}</code></td>
<td>
<div>${escapeHTML(item.resolved_smoke_model || "-")}</div>
<div class="subtle">${escapeHTML((item.canonical_model_families || []).join(", ") || "-")}</div>
</td>
<td>
<div><strong>${escapeHTML(item.matched_account_state || "-")}</strong></div>
<div class="subtle">${escapeHTML(item.account_resolution || "-")}</div>
</td>
<td>
<div>${escapeHTML(item.access_status || "-")}</div>
<div class="subtle">${escapeHTML(item.confirmation_status || "-")} / ${escapeHTML(item.current_stage || "-")}</div>
</td>
<td>${renderBadges(item.badges)}</td>
<td>${advisory}</td>
</tr>
`;
}).join("");
}
function renderRunSummary(run) {
state.currentRun = run;
summaryTargets.total.textContent = String(run.total_items || 0);
summaryTargets.completed.textContent = String(run.completed_items || 0);
summaryTargets.active.textContent = String(run.active_items || 0);
summaryTargets.degraded.textContent = String(run.degraded_items || 0);
summaryTargets.broken.textContent = String(run.broken_items || 0);
renderRunMeta(run);
syncHeaderMetrics();
}
function clearResults() {
state.currentRun = null;
state.currentRunID = "";
state.currentItems = [];
runIDInput.value = "";
renderRunSummary({
run_id: "-",
state: "-",
mode: "-",
access_mode: "-",
total_items: 0,
completed_items: 0,
active_items: 0,
degraded_items: 0,
broken_items: 0,
});
runMeta.innerHTML = "";
itemsTbody.innerHTML = '<tr><td colspan="7" class="subtle">还没有结果。</td></tr>';
setStatus("结果已清空。");
}
async function createRun() {
const button = document.getElementById("create-run-btn");
button.disabled = true;
try {
saveConfig();
setStatus("正在创建 batch import run ...");
const payload = buildCreatePayload();
const created = await requestJSON("/api/batch-import/runs", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
state.currentRunID = created.run_id;
runIDInput.value = created.run_id;
syncHeaderMetrics();
setStatus(`run 已创建:${created.run_id},正在拉取详情。`, "success");
await refreshRun();
} catch (error) {
setStatus(`创建失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function buildItemsQuery() {
const params = new URLSearchParams();
if (filterQueryInput.value.trim()) params.set("q", filterQueryInput.value.trim());
if (filterMatchedStateInput.value) params.set("matched_account_state", filterMatchedStateInput.value);
if (filterAccountResolutionInput.value) params.set("account_resolution", filterAccountResolutionInput.value);
return params.toString();
}
async function refreshRun() {
const runID = runIDInput.value.trim();
if (!runID) {
setStatus("请先输入 run_id。", "warning");
return;
}
const button = document.getElementById("refresh-run-btn");
button.disabled = true;
try {
state.currentRunID = runID;
syncHeaderMetrics();
setStatus(`正在刷新 ${runID} ...`);
const runPayload = await requestJSON(`/api/batch-import/runs/${encodeURIComponent(runID)}`, {
headers: authHeaders(),
});
renderRunSummary(runPayload.run);
const query = buildItemsQuery();
const itemsPayload = await requestJSON(`/api/batch-import/runs/${encodeURIComponent(runID)}/items${query ? `?${query}` : ""}`, {
headers: authHeaders(),
});
state.currentItems = itemsPayload.items || [];
renderItems(state.currentItems);
setStatus(`run ${runID} 已刷新,当前显示 ${state.currentItems.length} 条 item。`, "success");
} catch (error) {
setStatus(`刷新失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
document.getElementById("create-run-btn").addEventListener("click", createRun);
document.getElementById("refresh-run-btn").addEventListener("click", refreshRun);
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
setStatus("管理员会话已建立。", "success");
} catch (error) {
setStatus(error.message, "danger");
}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(error.message, "danger");
}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
restoreConfig();
updateAccessModeFields();
syncHeaderMetrics();
refreshAdminSession().catch(() => {});
</script>
</script>
</body>
</html>

View File

@@ -264,6 +264,7 @@
<script src="/portal/admin-common.js"></script>
<script src="/portal/portal.js"></script>
<script>
const AdminCommon = window.Sub2ApiAdminCommon;
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "accounts");
(function injectIcons(){
@@ -274,7 +275,7 @@
M("ac-m4", "alert");
})();
<script>
// <script>
AdminCommon.renderAdminNav(document.querySelector("[data-admin-nav]"), "accounts");
const storageKey = "sub2api-provider-accounts-admin";
const state = {
@@ -711,497 +712,6 @@
hydrateConfig();
refreshSession();
loadAccounts();
</script>
<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>
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff