feat(admin): add session-based portal login

This commit is contained in:
phamnazage-jpg
2026-05-28 11:01:29 +08:00
parent 03c4b5236f
commit de33ff3492
15 changed files with 833 additions and 75 deletions

View File

@@ -412,7 +412,7 @@
<article class="panel">
<h2>发起导入</h2>
<p class="panel-desc">
用 admin token 直接调用当前控制面的 batch-import API。
优先使用管理员登录会话调用当前控制面的 batch-import API;必要时也可以回退到 Bearer token
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
</p>
@@ -426,8 +426,9 @@
</div>
<div class="field-grid two">
<label>Admin Token
<label>Admin Token(可选)
<input id="admin-token" type="password" placeholder="secret-token">
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
</label>
<label>Mode
<select id="mode">
@@ -437,6 +438,21 @@
</label>
</div>
<div class="field-grid two">
<label>管理员用户名
<input id="admin-username" type="text" placeholder="admin">
</label>
<label>管理员密码
<input id="admin-password" type="password" placeholder="请输入管理员密码">
</label>
</div>
<div class="toolbar">
<button id="admin-login-btn" type="button">管理员登录</button>
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
<span id="admin-session-status" class="statusbar">尚未检查管理员会话。</span>
</div>
<div class="field-grid two">
<label>Access Mode
<select id="access-mode">
@@ -568,6 +584,11 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
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");
@@ -615,6 +636,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
apiBase: apiBaseInput.value.trim(),
hostID: hostIDInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
mode: modeInput.value,
accessMode: accessModeInput.value,
confirmTimeoutSec: confirmTimeoutInput.value,
@@ -641,6 +663,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
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";
@@ -673,14 +696,14 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
}
function authHeaders() {
const token = adminTokenInput.value.trim();
if (!token) {
throw new Error("admin token 不能为空");
}
return {
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function parseEntries() {
@@ -734,7 +757,16 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
}
async function requestJSON(path, options = {}) {
const response = await fetch(`${normalizeApiBase()}${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 {
@@ -749,6 +781,58 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
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 || "-";
@@ -926,11 +1010,28 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
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>
</body>
</html>

View File

@@ -375,7 +375,7 @@
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>支持同域 Bearer admin token</li>
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
<li>支持 provider 草稿发布到 pack 仓库</li>
</ul>
</article>
@@ -408,11 +408,27 @@
<label>API Base
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
</label>
<label>Admin Token
<label>Admin Token(可选)
<input id="admin-token" type="password" placeholder="crm-admin-token">
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
</label>
</div>
<div class="field-grid two">
<label>管理员用户名
<input id="admin-username" type="text" placeholder="admin">
</label>
<label>管理员密码
<input id="admin-password" type="password" placeholder="请输入管理员密码">
</label>
</div>
<div class="actions">
<button id="admin-login-btn" type="button">管理员登录</button>
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
<span id="admin-session-status" class="status">尚未检查管理员会话。</span>
</div>
<div class="field-grid two">
<label>Pack
<select id="pack-id"></select>
@@ -592,6 +608,11 @@
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 adminLoginButton = document.getElementById("admin-login-btn");
const adminLogoutButton = document.getElementById("admin-logout-btn");
const adminSessionStatus = document.getElementById("admin-session-status");
const packIDInput = document.getElementById("pack-id");
const hostIDInput = document.getElementById("host-id");
const packPathInput = document.getElementById("pack-path");
@@ -641,18 +662,27 @@
}
function authHeaders() {
const token = adminTokenInput.value.trim();
if (!token) {
throw new Error("admin token 不能为空");
}
return {
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function requestJSON(path, options = {}) {
return fetch(`${normalizeApiBase()}${path}`, options)
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
return fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
})
.then(async (response) => {
const text = await response.text();
let payload = {};
@@ -679,6 +709,7 @@
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
packID: packIDInput.value,
hostID: hostIDInput.value,
packPath: packPathInput.value.trim(),
@@ -714,6 +745,7 @@
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
packPathInput.value = payload.packPath || "/app/packs/openai-cn-pack";
providerIDInput.value = payload.providerID || "";
modeInput.value = payload.mode || "strict";
@@ -739,6 +771,57 @@
subscriptionFields.hidden = accessModeInput.value !== "subscription";
}
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(adminSessionStatus, `已登录:${payload.username}`, "success");
} else if (payload.login_enabled) {
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "note");
} else {
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
}
return payload;
} catch (error) {
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
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();
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
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 = "";
setStatus(adminSessionStatus, "管理员会话已退出。", "note");
}
function renderSelectOptions(select, values, currentValue, emptyLabel) {
select.innerHTML = "";
if (!values.length) {
@@ -1203,6 +1286,22 @@
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
await loadCatalog();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
packIDInput.addEventListener("change", loadCatalog);
providerIDInput.addEventListener("input", syncMetrics);
@@ -1211,6 +1310,7 @@
restoreConfig();
updateAccessModeFields();
syncMetrics();
refreshAdminSession().catch(() => {});
renderServerDrafts();
</script>
</body>