feat: harden runtime import and frontend verification workflows
This commit is contained in:
313
deploy/tksea-portal/admin-common.js
Normal file
313
deploy/tksea-portal/admin-common.js
Normal file
@@ -0,0 +1,313 @@
|
||||
(function initSub2ApiAdminCommon(global) {
|
||||
const ADMIN_LINKS = [
|
||||
{ key: "home", href: "/portal/admin/", label: "管理首页" },
|
||||
{ key: "logical-groups", href: "/portal/admin/logical-groups.html", label: "逻辑分组 / 路由" },
|
||||
{ key: "route-health", href: "/portal/admin/route-health.html", label: "Route 健康视图" },
|
||||
{ key: "accounts", href: "/portal/admin/accounts.html", label: "帐号资产" },
|
||||
{ key: "providers", href: "/portal/admin/providers.html", label: "新增模型 / 供应商目录" },
|
||||
{ key: "batch-import", href: "/portal/admin/batch-import.html", label: "导入供应商帐号" },
|
||||
{ key: "portal", href: "/portal/", label: "用户 Portal", target: "_blank", rel: "noreferrer" },
|
||||
];
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function defaultApiBase() {
|
||||
const origin = global.location && typeof global.location.origin === "string" ? global.location.origin : "";
|
||||
if (!origin || origin === "null") {
|
||||
return "/portal-admin-api";
|
||||
}
|
||||
return `${origin}/portal-admin-api`;
|
||||
}
|
||||
|
||||
function normalizeApiBase(value) {
|
||||
return (String(value || "").trim() || defaultApiBase()).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function authHeaders(tokenValue) {
|
||||
const token = String(tokenValue || "").trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function normalizeTone(tone) {
|
||||
const value = String(tone || "").trim().toLowerCase();
|
||||
if (!value || value === "note" || value === "info") {
|
||||
return "note";
|
||||
}
|
||||
if (value === "warn") {
|
||||
return "warning";
|
||||
}
|
||||
if (value === "error") {
|
||||
return "danger";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setStatus(element, message, tone = "note") {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.textContent = message;
|
||||
const normalizedTone = normalizeTone(tone);
|
||||
if (normalizedTone !== "note") {
|
||||
element.setAttribute("data-tone", normalizedTone);
|
||||
} else {
|
||||
element.removeAttribute("data-tone");
|
||||
}
|
||||
}
|
||||
|
||||
function describeSessionPayload(payload, options = {}) {
|
||||
const fallbackUsername = String(options.usernameFallback || "admin").trim() || "admin";
|
||||
const currentUsername = String(payload?.username || "").trim();
|
||||
const effectiveUsername = currentUsername || fallbackUsername;
|
||||
if (payload?.authenticated) {
|
||||
const suffix = options.includeSessionSuffix ? "(session)" : "";
|
||||
return {
|
||||
tone: "success",
|
||||
message: `管理员会话已建立:${currentUsername || "unknown"}${suffix}`,
|
||||
};
|
||||
}
|
||||
if (payload?.login_enabled) {
|
||||
if (options.allowBearerFallback) {
|
||||
return {
|
||||
tone: "warning",
|
||||
message: `当前未登录。可用管理员用户名 ${effectiveUsername} 建立 session,或继续使用 Bearer token。`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "warning",
|
||||
message: "当前未登录。可直接使用管理员用户名密码建立会话。",
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "warning",
|
||||
message: "当前实例未启用管理员登录,只能使用 Bearer token。",
|
||||
};
|
||||
}
|
||||
|
||||
function sessionStateSpec(kind, context = {}, options = {}) {
|
||||
const username = String(context.username || options.usernameFallback || "unknown").trim() || "unknown";
|
||||
const message = context.error instanceof Error ? context.error.message : String(context.error || "").trim();
|
||||
switch (kind) {
|
||||
case "missing_credentials":
|
||||
return {
|
||||
tone: "warning",
|
||||
message: "请先输入管理员用户名和密码。",
|
||||
};
|
||||
case "login_success":
|
||||
return {
|
||||
tone: "success",
|
||||
message: `管理员会话已建立:${username}`,
|
||||
};
|
||||
case "login_failed":
|
||||
return {
|
||||
tone: "danger",
|
||||
message: `管理员登录失败:${message || "未知错误"}`,
|
||||
};
|
||||
case "logout_success":
|
||||
return {
|
||||
tone: "warning",
|
||||
message: "管理员会话已退出。",
|
||||
};
|
||||
case "logout_failed":
|
||||
return {
|
||||
tone: "danger",
|
||||
message: `退出会话失败:${message || "未知错误"}`,
|
||||
};
|
||||
case "check_failed":
|
||||
return {
|
||||
tone: "danger",
|
||||
message: `检查管理员会话失败:${message || "未知错误"}`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
tone: "note",
|
||||
message: String(context.message || ""),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionPayload(element, payload, options = {}) {
|
||||
const status = describeSessionPayload(payload, options);
|
||||
setStatus(element, status.message, status.tone);
|
||||
return status;
|
||||
}
|
||||
|
||||
function setSessionState(element, kind, context = {}, options = {}) {
|
||||
const status = sessionStateSpec(kind, context, options);
|
||||
setStatus(element, status.message, status.tone);
|
||||
return status;
|
||||
}
|
||||
|
||||
async function requestJSON(client, path, options = {}) {
|
||||
const { skipAuth = false, headers = {}, ...rest } = options;
|
||||
const finalHeaders = { Accept: "application/json", ...headers };
|
||||
if (!skipAuth) {
|
||||
Object.assign(finalHeaders, authHeaders(client.adminTokenInput && client.adminTokenInput.value));
|
||||
}
|
||||
const response = await fetch(`${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: finalHeaders,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message || payload?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readStoredConfig(storageKey) {
|
||||
try {
|
||||
return JSON.parse(global.localStorage.getItem(storageKey) || "{}");
|
||||
} catch (error) {
|
||||
console.warn(`failed to parse ${storageKey}`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredConfig(storageKey, payload) {
|
||||
global.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function renderAdminNav(container, currentKey) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.innerHTML = ADMIN_LINKS.map((link) => {
|
||||
const currentClass = currentKey === link.key ? " is-current" : "";
|
||||
const target = link.target ? ` target="${link.target}"` : "";
|
||||
const rel = link.rel ? ` rel="${link.rel}"` : "";
|
||||
return `<a href="${escapeHTML(link.href)}"${target}${rel} class="${currentClass.trim()}">${escapeHTML(link.label)}</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function createAdminPageRuntime(options) {
|
||||
const client = {
|
||||
apiBaseInput: options.apiBaseInput,
|
||||
adminTokenInput: options.adminTokenInput,
|
||||
};
|
||||
const sessionPresentation = options.sessionPresentation || {};
|
||||
|
||||
async function refreshAdminSession() {
|
||||
try {
|
||||
const payload = await requestJSON(client, "/api/admin/session", {
|
||||
skipAuth: !options.includeAuthOnSessionCheck,
|
||||
});
|
||||
if (payload.username && options.adminUsernameInput && !options.adminUsernameInput.value.trim()) {
|
||||
options.adminUsernameInput.value = payload.username;
|
||||
}
|
||||
applySessionPayload(options.adminSessionStatus, payload, sessionPresentation);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
setSessionState(options.adminSessionStatus, "check_failed", { error }, sessionPresentation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAdminSession() {
|
||||
const username = options.adminUsernameInput ? options.adminUsernameInput.value.trim() : "";
|
||||
const password = options.adminPasswordInput ? options.adminPasswordInput.value : "";
|
||||
if (!username || !password) {
|
||||
const error = new Error("管理员用户名和密码不能为空");
|
||||
setSessionState(options.adminSessionStatus, "missing_credentials", {}, sessionPresentation);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const payload = await requestJSON(client, "/api/admin/session/login", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (options.adminPasswordInput) {
|
||||
options.adminPasswordInput.value = "";
|
||||
}
|
||||
if (typeof options.onSessionPersist === "function") {
|
||||
options.onSessionPersist();
|
||||
}
|
||||
setSessionState(options.adminSessionStatus, "login_success", { username: payload.username || username }, sessionPresentation);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
setSessionState(options.adminSessionStatus, "login_failed", { error }, sessionPresentation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAdminSession() {
|
||||
try {
|
||||
const response = await fetch(`${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: authHeaders(options.adminTokenInput && options.adminTokenInput.value),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
if (options.adminPasswordInput) {
|
||||
options.adminPasswordInput.value = "";
|
||||
}
|
||||
setSessionState(options.adminSessionStatus, "logout_success", {}, sessionPresentation);
|
||||
} catch (error) {
|
||||
setSessionState(options.adminSessionStatus, "logout_failed", { error }, sessionPresentation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultApiBase,
|
||||
normalizeApiBase() {
|
||||
return normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value);
|
||||
},
|
||||
authHeaders() {
|
||||
return authHeaders(options.adminTokenInput && options.adminTokenInput.value);
|
||||
},
|
||||
requestJSON(path, requestOptions = {}) {
|
||||
return requestJSON(client, path, requestOptions);
|
||||
},
|
||||
readStoredConfig,
|
||||
writeStoredConfig,
|
||||
renderAdminNav,
|
||||
setStatus,
|
||||
applySessionPayload(element, payload) {
|
||||
return applySessionPayload(element, payload, sessionPresentation);
|
||||
},
|
||||
setSessionState(element, kind, context = {}) {
|
||||
return setSessionState(element, kind, context, sessionPresentation);
|
||||
},
|
||||
refreshAdminSession,
|
||||
loginAdminSession,
|
||||
logoutAdminSession,
|
||||
};
|
||||
}
|
||||
|
||||
global.Sub2ApiAdminCommon = {
|
||||
ADMIN_LINKS,
|
||||
createAdminPageRuntime,
|
||||
defaultApiBase,
|
||||
normalizeApiBase,
|
||||
authHeaders,
|
||||
normalizeTone,
|
||||
setStatus,
|
||||
describeSessionPayload,
|
||||
applySessionPayload,
|
||||
setSessionState,
|
||||
readStoredConfig,
|
||||
writeStoredConfig,
|
||||
renderAdminNav,
|
||||
};
|
||||
})(window);
|
||||
@@ -27,7 +27,7 @@ location /portal/ {
|
||||
}
|
||||
|
||||
location /portal-proxy/ {
|
||||
proxy_pass http://127.0.0.1:18169/;
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -36,7 +36,7 @@ location /portal-proxy/ {
|
||||
}
|
||||
|
||||
location /portal-admin-api/ {
|
||||
proxy_pass http://127.0.0.1:18173/;
|
||||
proxy_pass http://127.0.0.1:18190/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -49,7 +49,7 @@ location /kimi-portal/ {
|
||||
}
|
||||
|
||||
location /kimi-portal-proxy/ {
|
||||
proxy_pass http://127.0.0.1:18169/;
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -58,7 +58,7 @@ location /kimi-portal-proxy/ {
|
||||
}
|
||||
|
||||
location /kimi/ {
|
||||
proxy_pass http://127.0.0.1:18169/;
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -67,7 +67,7 @@ location /kimi/ {
|
||||
}
|
||||
|
||||
location /kimi-v1/ {
|
||||
proxy_pass http://127.0.0.1:18169/v1/;
|
||||
proxy_pass http://127.0.0.1:8080/v1/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
Reference in New Issue
Block a user