feat(vnext2): close portal key management ui on real host
This commit is contained in:
@@ -151,7 +151,7 @@
|
||||
<h2>已有 Key</h2>
|
||||
<p>历史 Key 会优先投影回逻辑分组产品层;你仍可以直接在列表里一键复制,无需先重新创建。</p>
|
||||
</div>
|
||||
<div class="tiny mono">GET /api/v1/keys?page=1&page_size=20</div>
|
||||
<div class="tiny mono">GET /portal-admin-api/api/keys</div>
|
||||
</div>
|
||||
<div id="keys-list" class="list"></div>
|
||||
</article>
|
||||
@@ -214,7 +214,7 @@
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<div class="result-box">
|
||||
<strong>Access Token</strong>
|
||||
<strong>Portal 登录 Token(调试)</strong>
|
||||
<textarea id="access-token" readonly></textarea>
|
||||
<div class="button-row">
|
||||
<button id="copy-token-btn" class="ghost inline">复制 Access Token</button>
|
||||
@@ -242,6 +242,13 @@
|
||||
<div class="mono">model = 按当前分组对应模型名填写</div>
|
||||
<div class="mono">api_key = 你刚生成的 key</div>
|
||||
</div>
|
||||
<div class="result-box">
|
||||
<strong>curl 示例</strong>
|
||||
<textarea id="curl-example" readonly></textarea>
|
||||
<div class="button-row">
|
||||
<button id="copy-curl-btn" class="ghost inline">复制 curl 示例</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-note">如果某个逻辑分组显示“待补开通”,说明你的账号还没有对应的申请 Key 依赖链路。此时可以先注册并登录,再联系管理员补开通,无需重新创建账号。</p>
|
||||
<p class="footer-note">当前页面已经把“目录、权限、订阅、Key”统一投影到逻辑分组产品层;宿主兼容分组只作为过渡实现细节保留在后端发放流程里,不再作为普通用户主视角。</p>
|
||||
@@ -335,6 +342,45 @@
|
||||
};
|
||||
let toastTimer = null;
|
||||
|
||||
function portalSubjectID() {
|
||||
const user = state.user || {};
|
||||
const raw = String(user.id || user.email || user.username || "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return user.id ? `portal-user:${raw}` : `portal-email:${raw.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function selectedLogicalGroupRow() {
|
||||
const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
|
||||
state.selectionLogicalGroupID = logicalGroupID;
|
||||
return logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID) || null;
|
||||
}
|
||||
|
||||
function selectedPublicModel(row) {
|
||||
const models = portalLogicalGroupModels(row && row.logicalGroup);
|
||||
return models[0] || "gpt-5.4";
|
||||
}
|
||||
|
||||
function buildCurlExample(plaintextKey, row) {
|
||||
const model = selectedPublicModel(row);
|
||||
const apiKey = plaintextKey || "<YOUR_API_KEY>";
|
||||
return [
|
||||
'curl https://sub.tksea.top/v1/chat/completions \\\\',
|
||||
' -H "Content-Type: application/json" \\\\',
|
||||
` -H "Authorization: Bearer ${apiKey}" \\\\`,
|
||||
" -d '{",
|
||||
` \"model\": \"${model}\",`,
|
||||
' "messages": [{"role": "user", "content": "ping"}],',
|
||||
' "max_tokens": 32',
|
||||
" }'"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderCurlExample(plaintextKey, row) {
|
||||
$("curl-example").value = buildCurlExample(plaintextKey, row);
|
||||
}
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
@@ -526,6 +572,35 @@
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestControlPlane(path, options = {}) {
|
||||
const headers = Object.assign({ Accept: "application/json" }, options.headers || {});
|
||||
const subjectID = portalSubjectID();
|
||||
if (!subjectID) {
|
||||
throw new Error("当前缺少 portal subject,请先登录");
|
||||
}
|
||||
headers["X-Portal-Subject"] = subjectID;
|
||||
const res = await fetch("/portal-admin-api/api" + path, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body
|
||||
});
|
||||
const text = await res.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = payload.message || payload.raw || ("HTTP " + res.status);
|
||||
const error = new Error(message);
|
||||
error.statusCode = res.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestJSON(path, method, payload, useAuth = true) {
|
||||
return request(path, {
|
||||
method,
|
||||
@@ -1031,15 +1106,15 @@
|
||||
}
|
||||
|
||||
function renderSelectionSummary() {
|
||||
const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
|
||||
state.selectionLogicalGroupID = logicalGroupID;
|
||||
const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
|
||||
const row = selectedLogicalGroupRow();
|
||||
if (!row) {
|
||||
$("selection-summary").innerHTML = "当前还没有可选逻辑分组。";
|
||||
$("dependency-summary").innerHTML = "当前还没有可用于判断申请状态的逻辑分组。";
|
||||
$("create-key-btn").disabled = true;
|
||||
renderCurlExample($("api-key").value.trim(), null);
|
||||
return;
|
||||
}
|
||||
const logicalGroupID = state.selectionLogicalGroupID;
|
||||
const models = portalLogicalGroupModels(row.logicalGroup);
|
||||
const dependency = dependencyStateForRow(row);
|
||||
const canCreate = !!state.accessToken && dependency.canCreate;
|
||||
@@ -1057,8 +1132,10 @@
|
||||
'<div><strong>' + escapeHTML(dependency.badgeText) + '</strong></div>',
|
||||
'<div>' + escapeHTML(dependency.lineCountText) + '</div>',
|
||||
'<div>' + escapeHTML(dependency.actionHint) + '</div>',
|
||||
'<div>首次成功标准:<span class="mono">POST /v1/chat/completions = 200</span></div>',
|
||||
(!state.accessToken ? '<div>当前未登录。登录后才能真正发起 Key 创建请求。</div>' : '')
|
||||
].join("");
|
||||
renderCurlExample($("api-key").value.trim(), row);
|
||||
}
|
||||
|
||||
function renderKeys() {
|
||||
@@ -1070,28 +1147,26 @@
|
||||
}
|
||||
|
||||
list.innerHTML = items.map((item) => {
|
||||
const groupID = Number(item.group_id);
|
||||
const meta = Number.isFinite(groupID) ? knownLegacyGroup(groupID) : null;
|
||||
const logicalCandidates = meta
|
||||
? (state.portalLogicalGroups || []).filter((group) => meta.models.some((model) => portalLogicalGroupModels(group).includes(String(model).trim())))
|
||||
: [];
|
||||
const logicalCandidateText = logicalCandidates.length
|
||||
? logicalCandidates.map((group) => group.display_name || group.logical_group_id).join(" / ")
|
||||
: "未建立逻辑分组映射";
|
||||
const row = (state.portalLogicalGroups || []).find((group) => String(group.logical_group_id || "") === String(item.logical_group_id || ""));
|
||||
const logicalGroupName = row ? (row.display_name || row.logical_group_id) : (item.logical_group_id || "未建立逻辑分组映射");
|
||||
const modelText = Array.isArray(item.allowed_models) && item.allowed_models.length ? item.allowed_models.join(", ") : "--";
|
||||
const statusClass = String(item.admin_status || "active") === "active" ? "active" : "pending";
|
||||
return (
|
||||
'<article class="key-item">' +
|
||||
'<div class="key-top">' +
|
||||
'<div class="key-name">' + (item.name || "未命名 Key") + '</div>' +
|
||||
'<div class="key-name">' + escapeHTML(item.display_name || item.key_id || "未命名 Key") + '</div>' +
|
||||
'<div class="section-actions">' +
|
||||
'<span class="badge ' + (String(item.status || "") === "active" ? "active" : "pending") + '">' + (item.status || "--") + '</span>' +
|
||||
'<button class="ghost inline copy-existing-key-btn" data-key="' + (item.key || "") + '">复制 Key</button>' +
|
||||
'<span class="badge ' + statusClass + '">' + escapeHTML(item.admin_status || "--") + '</span>' +
|
||||
'<span class="badge">quota ' + escapeHTML(item.quota_status || "--") + '</span>' +
|
||||
'<button class="ghost inline reset-existing-key-btn" data-key-id="' + escapeHTML(item.key_id || "") + '">重置并显示新明文</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="key-meta">' +
|
||||
'<div><span class="kv-label">Key</span><div class="mono">' + maskKey(item.key || "") + '</div></div>' +
|
||||
'<div><span class="kv-label">申请来源</span><div>' + escapeHTML(logicalCandidateText) + '</div></div>' +
|
||||
'<div><span class="kv-label">逻辑分组</span><div>' + escapeHTML(logicalCandidateText) + '</div></div>' +
|
||||
'<div><span class="kv-label">Masked Preview</span><div class="mono">' + escapeHTML(item.masked_preview || "--") + '</div></div>' +
|
||||
'<div><span class="kv-label">逻辑分组</span><div>' + escapeHTML(logicalGroupName) + '</div></div>' +
|
||||
'<div><span class="kv-label">公开模型</span><div class="mono">' + escapeHTML(modelText) + '</div></div>' +
|
||||
'<div><span class="kv-label">创建时间</span><div>' + formatDate(item.created_at) + '</div></div>' +
|
||||
'<div><span class="kv-label">最近使用</span><div>' + formatDate(item.last_used_at) + '</div></div>' +
|
||||
'<div><span class="kv-label">到期时间</span><div>' + formatDate(item.expires_at) + '</div></div>' +
|
||||
'</div>' +
|
||||
'</article>'
|
||||
@@ -1121,21 +1196,28 @@
|
||||
state.groups = [];
|
||||
state.subscriptions = [];
|
||||
state.keys = [];
|
||||
renderCurlExample("", selectedLogicalGroupRow());
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [user, groups, subscriptions, keysPage] = await Promise.all([
|
||||
const [user, groups, subscriptions] = await Promise.all([
|
||||
request("/auth/me"),
|
||||
request("/groups/available"),
|
||||
request("/subscriptions"),
|
||||
request("/keys?page=1&page_size=20")
|
||||
request("/subscriptions")
|
||||
]);
|
||||
state.user = user || null;
|
||||
state.groups = Array.isArray(groups) ? groups : [];
|
||||
state.subscriptions = Array.isArray(subscriptions) ? subscriptions : [];
|
||||
state.keys = Array.isArray(keysPage && keysPage.items) ? keysPage.items : [];
|
||||
try {
|
||||
const keysPayload = await requestControlPlane("/keys");
|
||||
state.keys = Array.isArray(keysPayload && keysPayload.keys) ? keysPayload.keys : [];
|
||||
} catch (keyErr) {
|
||||
state.keys = [];
|
||||
setStatus("key-status", "bad", "CRM key 列表拉取失败: " + keyErr.message);
|
||||
}
|
||||
renderCurlExample($("api-key").value.trim(), selectedLogicalGroupRow());
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
clearSession();
|
||||
@@ -1189,30 +1271,36 @@
|
||||
|
||||
async function handleCreateKey() {
|
||||
const name = $("key-name").value.trim();
|
||||
const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
|
||||
const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
|
||||
const row = selectedLogicalGroupRow();
|
||||
if (!row) {
|
||||
setStatus("key-status", "bad", "当前还没有可用于申请测试 Key 的逻辑分组。");
|
||||
return;
|
||||
}
|
||||
if (row.enabledCandidates.length !== 1) {
|
||||
const tip = row.enabledCandidates.length > 1
|
||||
? "当前逻辑分组命中多条申请链路,暂不自动选择,请联系管理员整理归属。"
|
||||
: "当前逻辑分组尚未命中唯一申请链路,暂不能直接申请测试 Key。";
|
||||
setStatus("key-status", "bad", tip);
|
||||
const logicalGroupID = String(row.logicalGroup.logical_group_id || "").trim();
|
||||
if (!name) {
|
||||
setStatus("key-status", "bad", "Key 名称不能为空。");
|
||||
return;
|
||||
}
|
||||
const dependency = dependencyStateForRow(row);
|
||||
if (!dependency.canCreate) {
|
||||
setStatus("key-status", "bad", dependency.actionHint);
|
||||
return;
|
||||
}
|
||||
const legacyGroup = row.enabledCandidates[0];
|
||||
setBusy("create-key-btn", true);
|
||||
try {
|
||||
const data = await requestJSON("/keys", "POST", {
|
||||
name,
|
||||
group_id: Number(legacyGroup.id)
|
||||
}, true);
|
||||
state.lastCreatedKey = data.key || "";
|
||||
const payload = await requestControlPlane("/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
logical_group_id: logicalGroupID,
|
||||
display_name: name,
|
||||
allowed_models: portalLogicalGroupModels(row.logicalGroup)
|
||||
})
|
||||
});
|
||||
state.lastCreatedKey = String(payload.plaintext_key || "");
|
||||
$("api-key").value = state.lastCreatedKey;
|
||||
setStatus("key-status", "ok", "Key 创建成功。已按逻辑分组“" + (row.logicalGroup.display_name || row.logicalGroup.logical_group_id) + "”的已就绪申请链路发放测试 Key。");
|
||||
renderSelectionSummary();
|
||||
renderCurlExample(state.lastCreatedKey, row);
|
||||
setStatus("key-status", "ok", "Key 创建成功。明文只显示这一次,请立即复制;验收目标是用这把 key 完成一次 /v1/chat/completions=200。\nkey_id=" + (payload.key && payload.key.key_id ? payload.key.key_id : "--"));
|
||||
await refreshUserState();
|
||||
} catch (err) {
|
||||
setStatus("key-status", "bad", "创建 Key 失败: " + err.message);
|
||||
@@ -1226,8 +1314,31 @@
|
||||
await copyText(value, statusID, label, button);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function handleResetExistingKey(keyID, button) {
|
||||
if (!keyID) {
|
||||
setStatus("key-status", "bad", "缺少 key_id,无法重置。");
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await requestControlPlane(`/keys/${encodeURIComponent(keyID)}/reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}"
|
||||
});
|
||||
const row = selectedLogicalGroupRow();
|
||||
state.lastCreatedKey = String(payload.plaintext_key || "");
|
||||
$("api-key").value = state.lastCreatedKey;
|
||||
renderCurlExample(state.lastCreatedKey, row);
|
||||
setStatus("key-status", "ok", "Key 已重置并返回新明文,请立即复制。key_id=" + keyID);
|
||||
await refreshUserState();
|
||||
} catch (err) {
|
||||
setStatus("key-status", "bad", "重置 Key 失败: " + err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("create-key-btn").addEventListener("click", handleCreateKey);
|
||||
$("refresh-session-btn").addEventListener("click", refreshUserState);
|
||||
$("logout-btn").addEventListener("click", () => {
|
||||
@@ -1239,17 +1350,19 @@
|
||||
$("auth-password").addEventListener("keydown", (e) => { if (e.key === "Enter") $("auth-btn").click(); });
|
||||
$("copy-token-btn").addEventListener("click", (event) => copyField("access-token", "auth-status", "Access Token", event.currentTarget));
|
||||
$("copy-key-btn").addEventListener("click", (event) => copyField("api-key", "key-status", "API Key", event.currentTarget));
|
||||
$("copy-curl-btn").addEventListener("click", (event) => copyField("curl-example", "key-status", "curl 示例", event.currentTarget));
|
||||
$("keys-list").addEventListener("click", async (event) => {
|
||||
const button = event.target.closest(".copy-existing-key-btn");
|
||||
if (!button) {
|
||||
const resetButton = event.target.closest(".reset-existing-key-btn");
|
||||
if (!resetButton) {
|
||||
return;
|
||||
}
|
||||
await copyText(button.dataset.key || "", "key-status", "已有 Key", button);
|
||||
await handleResetExistingKey(resetButton.dataset.keyId || "", resetButton);
|
||||
});
|
||||
$("group-id").addEventListener("change", renderSelectionSummary);
|
||||
|
||||
restoreSession();
|
||||
renderAll();
|
||||
renderCurlExample("", null);
|
||||
refreshUserState();
|
||||
</script>
|
||||
<script src="/portal/portal.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user