diff --git a/deploy/tksea-portal/index.html b/deploy/tksea-portal/index.html
index f5b8a8b0..052765d7 100644
--- a/deploy/tksea-portal/index.html
+++ b/deploy/tksea-portal/index.html
@@ -151,7 +151,7 @@
已有 Key
历史 Key 会优先投影回逻辑分组产品层;你仍可以直接在列表里一键复制,无需先重新创建。
- GET /api/v1/keys?page=1&page_size=20
+ GET /portal-admin-api/api/keys
@@ -214,7 +214,7 @@
-
Access Token
+
Portal 登录 Token(调试)
+
+
curl 示例
+
+
+
+
+
@@ -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 || "
";
+ 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 @@
'' + escapeHTML(dependency.badgeText) + '
',
'' + escapeHTML(dependency.lineCountText) + '
',
'' + escapeHTML(dependency.actionHint) + '
',
+ '首次成功标准:POST /v1/chat/completions = 200
',
(!state.accessToken ? '当前未登录。登录后才能真正发起 Key 创建请求。
' : '')
].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 (
'' +
'' +
- '
' + (item.name || "未命名 Key") + '
' +
+ '
' + escapeHTML(item.display_name || item.key_id || "未命名 Key") + '
' +
'
' +
- '' + (item.status || "--") + '' +
- '' +
+ '' + escapeHTML(item.admin_status || "--") + '' +
+ 'quota ' + escapeHTML(item.quota_status || "--") + '' +
+ '' +
'
' +
'
' +
'' +
''
@@ -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();
diff --git a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md
index 07feb1c4..587cb9bc 100644
--- a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md
+++ b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md
@@ -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 完成
diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md
index b6b2162f..99d7294d 100644
--- a/docs/EXECUTION_BOARD.md
+++ b/docs/EXECUTION_BOARD.md
@@ -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:` / `portal-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 当前真相
diff --git a/scripts/test/test_tksea_portal_assets.sh b/scripts/test/test_tksea_portal_assets.sh
index 0b23006f..c9d896c0 100755
--- a/scripts/test/test_tksea_portal_assets.sh
+++ b/scripts/test/test_tksea_portal_assets.sh
@@ -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" "逻辑分组目录"