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>
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
说明:
|
||||
|
||||
- vNext.1 已完成代码/文档/发布闭环。
|
||||
- vNext.2 当前只完成了 V2-4:key self-service API + 用户首次调用 200 真实线上闭环。
|
||||
- vNext.2 的 V2-5(portal key 管理 UI)尚未开始验收,vNext.3(治理/SLO)尚未开始实现。
|
||||
- 因此按“全量 vNext goal”口径仍然是未完成;按阶段口径可判定:vNext.1 完成、vNext.2 部分完成(V2-4 已完成)。
|
||||
- vNext.2 已完成 V2-4 + V2-5:key self-service API、portal key 管理 UI、用户 portal reset 后首次调用 200 真实线上闭环。
|
||||
- vNext.3(治理/SLO)尚未开始实现。
|
||||
- 因此按“全量 vNext goal”口径仍然是未完成;按阶段口径可判定:vNext.1 完成、vNext.2 完成、vNext.3 未完成。
|
||||
|
||||
## 二、5 个核心问题 Checklist(全量 vNext 目标)
|
||||
|
||||
真相源:`docs/EXECUTION_BOARD.md`
|
||||
|
||||
| 问题 | 规划要求 | 当前状态 | 证据 |
|
||||
| ---------------------------------------------------- | -------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1. 宿主协议稳定支持哪些主流大模型 | 必须有真实协议矩阵 + 真实验收脚本 + 当前输出 | vNext.1 已闭环 | `verify_host_protocol_matrix.sh` 与相关 artifact 已存在 |
|
||||
| 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go`、pool 测试、真实验收脚本已存在 |
|
||||
| 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | V2-5 待完成 | `PORTAL_KEY_EXPERIENCE.md` 已审核通过,但 UI 闭环尚未完成 |
|
||||
| 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | V2-4 已完成 | `KEY_SELF_SERVICE_API.md`、`verify_user_key_self_service.sh`、`artifacts/user-key-self-service/20260605_195408/99-summary.json` |
|
||||
| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 待完成 | `KEY_ACCOUNT_GOVERNANCE.md` 仅设计存在,真实治理实现未开始 |
|
||||
| 问题 | 规划要求 | 当前状态 | 证据 |
|
||||
| ---------------------------------------------------- | -------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1. 宿主协议稳定支持哪些主流大模型 | 必须有真实协议矩阵 + 真实验收脚本 + 当前输出 | vNext.1 已闭环 | `verify_host_protocol_matrix.sh` 与相关 artifact 已存在 |
|
||||
| 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go`、pool 测试、真实验收脚本已存在 |
|
||||
| 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | V2-5 已完成 | `PORTAL_KEY_EXPERIENCE.md`、`deploy/tksea-portal/index.html`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` |
|
||||
| 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | V2-4/V2-5 已完成 | `KEY_SELF_SERVICE_API.md`、`verify_user_key_self_service.sh`、`artifacts/user-key-self-service/20260605_195408/99-summary.json`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` |
|
||||
| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 待完成 | `KEY_ACCOUNT_GOVERNANCE.md` 仅设计存在,真实治理实现未开始 |
|
||||
|
||||
## 三、vNext.1 发布范围 Checklist
|
||||
|
||||
@@ -83,12 +83,12 @@
|
||||
|
||||
### Phase 3(vNext.2)
|
||||
|
||||
- Task 3.1 用户信息架构设计:设计已存在
|
||||
- Task 3.1 用户信息架构设计:设计已存在并已按设计接线到 portal
|
||||
- Task 3.2 key 发放 API:已实现并上线验证
|
||||
- Task 3.3 用户首次调用闭环:已完成真实 `chat/completions=200`
|
||||
- 尚缺:portal key 管理 UI(V2-5)
|
||||
- Task 3.4 portal key 管理 UI:已完成真实登录、已有 Key、reset、新明文、curl 示例与首呼 200 闭环
|
||||
|
||||
状态:部分完成(V2-4 已闭环,V2-5 未完成)
|
||||
状态:vNext.2 已闭环
|
||||
|
||||
### Phase 4(vNext.3)
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
### vNext.2 尚缺
|
||||
|
||||
- `deploy/tksea-portal/` 中 portal key 管理 UI 的实现与真实前端验收(V2-5)
|
||||
- 无
|
||||
|
||||
### vNext.3 尚缺
|
||||
|
||||
@@ -130,22 +130,13 @@
|
||||
|
||||
1. ✅ vNext.1 全部 5 项发布项已完成代码/文档/发布闭环
|
||||
2. ✅ V2-4 已完成后端实现、线上部署、真实 user-key 首呼 200 验收
|
||||
3. ✅ user-key artifact 已补齐:`artifacts/user-key-self-service/20260605_195408/99-summary.json`
|
||||
4. ⚠️ V2-5 portal key 管理 UI 未完成
|
||||
3. ✅ V2-5 已完成 portal 登录→已有 Key→reset 新明文→curl 示例更新→真实首呼 200 闭环
|
||||
4. ✅ V2-4/V2-5 artifacts 已补齐:`artifacts/user-key-self-service/20260605_195408/99-summary.json`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json`
|
||||
5. ⚠️ V3-1 key/account governance + SLO 未完成
|
||||
|
||||
## 七、最短下一步路径
|
||||
|
||||
### 立即执行:V2-5
|
||||
|
||||
1. 实现 `deploy/tksea-portal/` 的 key 管理 UI
|
||||
2. 执行前端门禁:
|
||||
- `bash ./scripts/test/test_tksea_portal_assets.sh`
|
||||
- `bash ./scripts/test/verify_frontend_smoke.sh`
|
||||
- 若涉及显式动作,执行 `bash ./scripts/acceptance/verify_provider_admin_actions.sh` 或对应 portal 验收
|
||||
3. 部署 remote43 并做公网页面验收
|
||||
|
||||
### 然后执行:V3-1
|
||||
### 立即执行:V3-1
|
||||
|
||||
1. 实现 key/account governance 状态模型
|
||||
2. 补治理 API / 测试 / 验收脚本
|
||||
@@ -154,8 +145,8 @@
|
||||
## 八、当前判定(唯一有效口径)
|
||||
|
||||
- 按 vNext.1 发布范围:**完成**
|
||||
- 按 vNext.2 当前执行项:**部分完成**(V2-4 完成,V2-5 未完成)
|
||||
- 按 vNext.2 当前执行项:**完成**(V2-4 + V2-5 已真实闭环)
|
||||
- 按全量 vNext 规划:**未完成**
|
||||
- 当前结论:
|
||||
- V2-4 已真实闭环,可进入提交/推送
|
||||
- 继续推进 V2-5(portal key UI)与 V3-1(governance)后,才能宣告全量 goal 完成
|
||||
- V2-4 / V2-5 已真实闭环,可提交/推送
|
||||
- 继续推进 V3-1(governance)后,才能宣告全量 goal 完成
|
||||
|
||||
@@ -58,9 +58,38 @@
|
||||
- `POST https://sub.tksea.top/v1/chat/completions` with user key -> `200`
|
||||
- 当前结论:
|
||||
- vNext.2 / V2-4(key self-service API + 用户首次调用 200 闭环)已完成真实线上闭环
|
||||
- 仍未完成的 vNext 范围为:
|
||||
- V2-5 portal key 管理 UI
|
||||
- V3-1 key/account governance + SLO/治理闭环
|
||||
- 后续仍需完成 V2-5 portal key 管理 UI 与 V3-1 governance
|
||||
|
||||
## 2026-06-06 vNext.2 / V2-5 真实闭环
|
||||
|
||||
- portal key 管理 UI 已完成实现、部署和真实公网验收:
|
||||
- 关键代码:
|
||||
- `deploy/tksea-portal/index.html`
|
||||
- `scripts/test/test_tksea_portal_assets.sh`
|
||||
- 关键运行时接线:
|
||||
- 页面使用 `/portal-admin-api/api/keys`
|
||||
- `X-Portal-Subject` 由登录用户稳定生成(`portal-user:<id>` / `portal-email:<email>`)
|
||||
- 已有 Key 列表展示 `masked_preview` / `allowed_models` / `quota_status`
|
||||
- reset 动作回填新明文 key 与 curl 示例
|
||||
- 真实线上验收:
|
||||
- 创建临时 smoke 用户(宿主管理 API)-> `200`
|
||||
- 预发 CRM key `POST /portal-admin-api/api/keys` -> `201`
|
||||
- 浏览器真实登录 `https://sub.tksea.top/portal/`:
|
||||
- 已登录
|
||||
- 已激活产品权限 = `1`
|
||||
- 已有 KEY = `1`
|
||||
- 列表出现 `ui smoke key`、masked preview `sk-****e2f0`
|
||||
- 存在 `重置并显示新明文` 按钮
|
||||
- 浏览器真实 reset 后:
|
||||
- 结果区出现新明文 key
|
||||
- 列表 masked preview 更新为 `sk-****99fa`
|
||||
- curl 示例自动注入 reset 后新 key
|
||||
- 使用 reset 后新 key 调用 `POST https://sub.tksea.top/v1/chat/completions` -> `200`,响应 `pong`
|
||||
- artifact:
|
||||
- `artifacts/portal-ui-v25/20260606_1009/99-summary.json`
|
||||
- 当前结论:
|
||||
- vNext.2 / V2-5(portal key 管理 UI)已完成真实线上闭环
|
||||
- 剩余未完成范围:V3-1 key/account governance + SLO/治理闭环
|
||||
|
||||
## 2026-05-22 当前真相
|
||||
|
||||
|
||||
@@ -49,8 +49,10 @@ assert_contains_file "$HTML_FILE" "localStorage.setItem"
|
||||
assert_contains_file "$HTML_FILE" "/auth/me"
|
||||
assert_contains_file "$HTML_FILE" "/groups/available"
|
||||
assert_contains_file "$HTML_FILE" "/subscriptions"
|
||||
assert_contains_file "$HTML_FILE" "/keys?page=1&page_size=20"
|
||||
assert_contains_file "$HTML_FILE" "copy-existing-key-btn"
|
||||
assert_contains_file "$HTML_FILE" "/portal-admin-api/api/keys"
|
||||
assert_contains_file "$HTML_FILE" "requestControlPlane(\"/keys\")"
|
||||
assert_contains_file "$HTML_FILE" "reset-existing-key-btn"
|
||||
assert_contains_file "$HTML_FILE" "curl-example"
|
||||
assert_contains_file "$HTML_FILE" "已有 Key"
|
||||
assert_contains_file "$HTML_FILE" "showToast"
|
||||
assert_contains_file "$HTML_FILE" "逻辑分组目录"
|
||||
|
||||
Reference in New Issue
Block a user