refactor(portal): dedup inline scripts in accounts + batch-import + providers
All three admin pages had two parallel inline <script> blocks (a modern S1 that used adminRuntime + a legacy S2 that was self-contained). Both had a nested <script> text inside S1 that the browser tolerated only because the second script re-ran any state-affecting calls. Merge into a single inline script per page; fix the nested <script> comment. - providers.html: 100371 -> 62761 chars (-37610, -37%) - accounts.html: 54878 -> 33098 chars (-21780, -40%) - batch-import: 43861 -> 26570 chars (-17291, -39%) Also rename draftProviderIDInput -> providerIDInput in providers.html (the old draft-provider-id input was removed during the earlier workflow merge, leaving the script with a null addEventListener on draft id). All scripts pass node --check. Both test_tksea_portal_assets.sh and verify_frontend_smoke.sh PASS.
This commit is contained in:
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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("&", "&")
|
||||
.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>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user