Files
dsh_shenxian/scripts/verify-platform-admin-section.mjs
T
admin 918f1d3a51 feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)
- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言

⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
2026-09-14 00:00:15 +08:00

299 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* verify-platform-admin-section.mjs —— 「系统管理」分区的**无浏览器**渲染验收(档案 82)
*
* 为什么需要:本机 `agent-browser` 的 daemon 起不来(2026-09-13),浏览器截图验收受阻;
* 而 `06-工作台UI规范 §7.3` 三段式在 bundle **按需动态加载** 时也拿不到带 rev 的完整 URL。
* 做法:打桩 `react` / `react/jsx-runtime` + 最小 hooks 循环 + **真执行** client.js,
* 断言分区被注册、admin 渲染出门户同款 **6 个功能页**、非 admin 被门禁挡住、
* 以及 zh/en 词典**键集一一对应**。
*
* 第 3 轮(2026-09-13):口径从「5 项只读摘要」改为「门户 6 个功能页同名同构」,
* 因此断言同步换成门户的功能名与各页表头。
*
* 用法:node scripts/verify-platform-admin-section.mjs 退出码 0=全绿 / 1=有失败
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import vm from "node:vm";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const src = readFileSync(join(ROOT, "poc/business-plugins/lib/client.js"), "utf8");
let failed = 0;
const ok = (name, cond, extra = "") => { console.log((cond ? " ✓ " : " ✗ ") + name + (extra ? " " + extra : "")); if (!cond) failed++; };
let slots = [], cursor = 0, dirty = false;
const React = {
useState(init) { const i = cursor++; if (!(i in slots)) slots[i] = typeof init === "function" ? init() : init; return [slots[i], (v) => { slots[i] = typeof v === "function" ? v(slots[i]) : v; dirty = true; }]; },
useEffect(fn) { const i = cursor++; if (!slots[i]) { slots[i] = 1; fn(); } },
};
const jsx = (type, props) => ({ type, props: props || {} });
const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" };
let def = null;
const win = {
__ModuleLoader__: { load: (d) => { def = d; } },
location: { hostname: "admin.alotbuy.com", protocol: "https:", origin: "https://admin.alotbuy.com" },
open: (u) => { globalThis.__OPENED = u; },
document: {
createElement: () => {
const o = { setAttribute() {}, remove() {} };
Object.defineProperty(o, "textContent", { set(v) { CSS += v; }, get() { return ""; } });
return o;
},
head: { appendChild() {} },
},
};
let ROLE = "admin";
let CSS = "";
const DATA = {
"/api/auth/me": null, // 由 fetch 桩按 ROLE 生成
"/api/dsh/status": { running: true, instance: { port: 43095, restarts: 2 }, breaker: null },
"/api/admin/users": { users: [{ id: 1, username: "a", role: "admin", createdAt: 1 }, { id: 2, username: "b", role: "active", createdAt: 2 }] },
"/api/admin/storage": { generatedAt: 1, users: [] },
"/api/plugins/business": { plugins: [{ id: "p1", name: "x", version: "1.0.0" }] },
"/api/admin/runtime": { items: [{ name: "node", group: "g", kind: "k", version: "22", source: "s", script: "sc", removable: false }], note: "n", drift: [], runtimeDir: { path: "/p", bytes: 1, installedAt: 1 }, manifest: "m", baselineScript: "b" },
// 档案 87 ·「模型设置」:内置 + 自定义厂家条目、共享开关、协议枚举
"/api/me/keys": {
keys: [
{ id: "k1", name: "内置 DeepSeek", enabled: true, updatedAt: 1, route: null, baseUrl: null, api: null, models: null },
{ id: "k2", name: "我的中转网关", enabled: false, updatedAt: 2, route: "my-gw", baseUrl: "https://api.example.com/v1", api: "openai-completions", models: '["gpt-4o","gpt-4o-mini"]' },
],
effective: "own",
shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, count: 1 },
sharedModelEnabled: true,
protocols: ["openai-completions", "openai-responses", "anthropic-messages"],
},
};
const sandbox = {
console, setTimeout, clearTimeout,
JSON, Object, Promise, Buffer, URL, globalThis: undefined,
Math, Date, encodeURIComponent, decodeURIComponent,
window: win, document: win.document,
fetch: async (url) => {
const p = String(url).replace("https://alotbuy.com", "");
const body = p === "/api/auth/me" ? { user: { id: "u1", username: ROLE, role: ROLE } } : DATA[p];
return { ok: body !== undefined, status: body === undefined ? 404 : 200, json: async () => body };
},
require: (m) => (m === "react" ? React : m === "react/jsx-runtime" ? jsxRuntime : (() => { throw new Error("require " + m); })()),
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
const mod = def.factory(sandbox.require);
let DICT = {}, ZH = {}, EN = {}, regs = [];
const ctx = {
effect: (fn, name) => { try { fn(); } catch (e) { console.log(" effect 抛错:", name, e.message); } },
locale: {
register: (ns, d) => { ZH = d.zh || {}; EN = d.en || {}; DICT = Object.assign({}, ZH); },
bind: () => (k) => DICT[k] ?? k,
},
slots: { inject: (n, fn) => fn(), register: (meta, Comp) => { regs.push({ meta, Comp }); return () => {}; } },
};
const inj = Array.isArray(mod.inject) ? mod.inject : [];
console.log(" inject 声明:", JSON.stringify(inj));
mod.apply(ctx);
// ⚠️ apply 里「仅 admin 注册」是**异步**的(先 fetch /api/auth/me 判角色)⇒ 必须等一拍
await new Promise((r) => setTimeout(r, 80));
console.log(" 已注册 section:", regs.map(r => r.meta.id + " / order=" + r.meta.order + " / label=" + r.meta.label()).join(" | "));
function text(n, out = [], depth = 0) {
if (n == null || depth > 24) return out;
if (typeof n === "string" || typeof n === "number") { out.push(String(n)); return out; }
if (Array.isArray(n)) { n.forEach(x => text(x, out, depth + 1)); return out; }
if (typeof n === "object") {
if (typeof n.type === "function") { text(n.type(n.props || {}), out, depth + 1); return out; }
if (n.props) {
// 第 3 轮:功能页正文走 `dangerouslySetInnerHTML`(门户原文),必须单独收集
const dsi = n.props.dangerouslySetInnerHTML;
if (dsi && dsi.__html) out.push(dsi.__html);
// 表单控件的占位符也是**用户可见文案**(输入框没有 children)⇒ 一并收集,
// 否则「新增表单有哪些栏位」这类断言只能查到可见标签,漏掉真正的引导文案。
if (typeof n.props.placeholder === "string") out.push(n.props.placeholder);
text(n.props.children, out, depth + 1);
}
}
return out;
}
async function render(Comp) {
cursor = 0; slots = [];
let tree = Comp();
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
return tree;
}
/** 首帧渲染完成后改一个 state(patch 直接写 slots),再渲染一次 —— 用来把某个功能页「点开」。 */
async function renderWith(Comp, patch) {
let tree = await render(Comp);
patch();
cursor = 0; dirty = false;
tree = Comp();
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
return tree;
}
function classes(n, out = [], depth = 0) {
if (n == null || depth > 16) return out;
if (Array.isArray(n)) { n.forEach(x => classes(x, out, depth + 1)); return out; }
if (typeof n === "object") {
if (typeof n.type === "function") { classes(n.type(n.props || {}), out, depth + 1); return out; }
if (n.props) {
if (typeof n.props.className === "string") n.props.className.split(/\s+/).forEach(c => { if (c) out.push(c); });
classes(n.props.children, out, depth + 1);
}
}
return out;
}
/**
* 收集整棵树的 class —— **同时扫 React 节点与内联 HTML**。
* 第 3 轮起功能页正文走 `dangerouslySetInnerHTML`(门户原文),class 只存在于字符串里,
* 只看 React 节点会漏掉全部表格 / 工具条 / 页签类名。
*/
function clsAll(tree) {
const out = new Set(classes(tree));
const s = text(tree).join(" ");
const re = /class="([^"]*)"/g;
let m;
while ((m = re.exec(s))) m[1].split(/\s+/).forEach((c) => { if (c) out.add(c); });
return [...out];
}
// ── 词典(第 3 轮新增断言):zh / en 键集必须一一对应 ─────────────────────────────
{
const zk = Object.keys(ZH).sort(), ek = Object.keys(EN).sort();
const onlyZh = zk.filter(k => !(k in EN));
const onlyEn = ek.filter(k => !(k in ZH));
ok("词典:zh/en 键集一一对应", onlyZh.length === 0 && onlyEn.length === 0,
`zh ${zk.length} / en ${ek.length}` + (onlyZh.length ? " | 仅 zh: " + onlyZh.join(",") : "") + (onlyEn.length ? " | 仅 en: " + onlyEn.join(",") : ""));
const paKeys = zk.filter(k => k.startsWith("pa."));
ok("词典:pa.* 词条齐备(门户 6 页文案)", paKeys.length >= 120, "-> " + paKeys.length + " 条");
const ph = Object.entries(ZH).filter(([k, v]) => String(v).includes("{")).map(([k]) => k).sort();
const phEn = Object.entries(EN).filter(([k, v]) => String(v).includes("{")).map(([k]) => k).sort();
ok("词典:占位符键在两个语言里一致", ph.join(",") === phEn.join(","), "-> " + ph.join(","));
}
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
// 档案 87 起 = 3 个:模型设置(全角色)+ 功能管理(全角色)+ 系统管理(仅 admin)
ok("admin 视角下注册了三个 settings.section", regs.length === 3, "-> " + regs.map(r => r.meta.id).join(", "));
const pa = regs.find(r => r.meta.id === "platform-admin");
ok("存在 platform-admin 分区", !!pa);
if (pa) ok("其 order = 102", pa.meta.order === 102);
const PORTAL_ITEMS = ["实例管理", "模型管理", "用户管理", "技能管理", "插件管理", "运行环境"];
ROLE = "admin";
const tAdmin = text(await render(pa.Comp)).join(" | ");
ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理"));
ok("admin:6 个功能项与门户同名", PORTAL_ITEMS.every(k => tAdmin.includes(k)),
"-> " + PORTAL_ITEMS.filter(k => !tAdmin.includes(k)).join(",") || "");
ok("admin:首页两组标题(服务 / 管理)", tAdmin.includes("服务") && tAdmin.includes("管理"));
ok("admin:不再出现旧的「候选池 / 运行时 / 当前实例」自造项名",
!["候选池", "当前实例"].some(k => tAdmin.includes(k)));
ROLE = "active";
const tUser = text(await render(pa.Comp)).join(" | ");
ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示"));
ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.includes(k)));
// ── 档案 87:角色与分区注册 ────────────────────────────────────────────────────
// 「模型设置」**全角色**都要有(官方「设置 → 模型」页在平台环境必然报错 ⇒ 平台自建入口
// 是用户自配模型的**唯一**入口,不能只给 admin);「系统管理」仍仅 admin。
{
regs = []; ROLE = "active";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
const ids = regs.map(r => r.meta.id).sort();
ok("非 admin:注册 2 个分区(功能管理 + 模型设置;不含系统管理)",
ids.length === 2 && ids[0] === "business-plugins" && ids[1] === "model-settings",
"-> " + ids.join(", "));
regs = []; ROLE = "admin";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
}
// ── 档案 87「模型设置」分区:三条口径必须体现在界面上 ─────────────────────────
{
const ms = regs.find(r => r.meta.id === "model-settings");
ok("存在 model-settings 分区", !!ms);
if (ms) {
ok("其 order = 100(排在功能管理之前)", ms.meta.order === 100, "-> " + ms.meta.order);
const tMs = text(await render(ms.Comp)).join(" | ");
ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器"));
// 口径②:共享模型也列入且可开关
ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型"));
// 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」
ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用"));
ok("模型设置:内置与自定义两类条目都在列表里", tMs.includes("内置 DeepSeek") && tMs.includes("我的中转网关"));
ok("模型设置:自定义条目显示厂家标识 / endpoint / 协议",
tMs.includes("my-gw") && tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
ok("模型设置:新增表单(名称 / API Key / Endpoint / 模型清单栏位齐备)",
tMs.includes("新增模型条目") && tMs.includes("模型 id,一行一个") && tMs.includes("留空 = 内置 DeepSeek"));
// 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效"));
const cMs = classes(await render(ms.Comp));
ok("模型设置:复用门户组件类(.pa-box/.pa-tbl/.pa-sm/.pa-badge/.pa-input)",
["pa-box", "pa-tbl", "pa-sm", "pa-badge", "pa-input"].every(c => cMs.includes(c)));
}
}
// ── 首页视觉:门户 `.nav-grid` / `.nav-card` 家族 ─────────────────────────────
{
ROLE = "admin";
const clsHome = classes(await render(pa.Comp));
ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub"));
ok("首页:分组标题取门户 .home-section-title 值", clsHome.includes("pa-sec"));
ok("首页:卡片网格取门户 .nav-grid 值", clsHome.includes("pa-grid"));
ok("首页:卡片取门户 .nav-card 值(.pa-card)", clsHome.includes("pa-card"));
// ── 6 个功能页逐个点开:**弹窗外壳 + 门户该页的表格/操作** ──────────────────
const EXPECT = {
files: ["实例管理", "启动 DSH", "新建文件夹", "上传文件", "修改时间", "根"],
keys: ["模型管理", "平台共享密钥", "sk-..."],
users: ["用户管理", "注册时间", "操作"],
skills: ["技能管理", "共享技能", "更新时间", "上传 / 替换"],
plugins: ["插件管理", "官方推荐插件", "手动添加 / 管理", "导入到平台", "下载量", "投放时间"],
// runtime 的表格由 init 动态生成(门户同构),静态 html 只有容器 ⇒ 这里只查标题与副标题
runtime: ["运行环境", "实例共享的运行时与工具"],
};
const badShell = [], badBody = [];
for (const [key, wants] of Object.entries(EXPECT)) {
const tree = await renderWith(pa.Comp, () => { slots[1] = key; });
const cls = classes(tree);
const shell = cls.includes("pa-overlay") && cls.includes("pa-modal") && cls.includes("pa-wide")
&& cls.includes("pa-mhead") && cls.includes("pa-mbody") && cls.includes("pa-phead") && cls.includes("pa-sm");
if (!shell) badShell.push(key + "(" + cls.filter(c => c.startsWith("pa-")).join("/") + ")");
const body = text(tree).join(" | ");
const miss = wants.filter(w => !body.includes(w));
if (miss.length) badBody.push(key + " 缺 " + miss.join(","));
}
ok("6 个功能页全部 = 门户弹窗外壳(遮罩/大面板/页头/滚动体)", badShell.length === 0,
badShell.length ? "-> " + badShell.join(" ; ") : "-> files/keys/users/skills/plugins/runtime 均通过");
ok("6 个功能页正文 = 门户该页的表格与操作", badBody.length === 0,
badBody.length ? "-> " + badBody.join(" ; ") : "-> 表头 / 按钮 / 说明文本均在");
// 插件页:双页签(门户 .pg-tabs)+ 门户表格类
const clsPl = clsAll(await renderWith(pa.Comp, () => { slots[1] = "plugins"; }));
ok("插件页:双页签取门户 .pg-tabs/.pg-tab 值", clsPl.includes("pa-tabs") && clsPl.includes("pa-tab"));
ok("插件页:页签计数胶囊取门户 .pg-cnt 值", clsPl.includes("pa-tcnt"));
// 导入方式徽章出现在 init 动态生成的行里(静态 html 无)⇒ 改断言 CSS 已注入门户 .badge 对应值
ok("门户组件 CSS 已注入(.badge / .table-wrap / .btn-sm / .pg-tabs 家族)",
[".pa-badge", ".pa-wrap", ".pa-sm", ".pa-tabs", ".pa-box", ".pa-btn", ".pa-plist"].every(c => CSS.includes(c)));
// 用户页:表格 + 徽章 + 危险按钮
const clsU = clsAll(await renderWith(pa.Comp, () => { slots[1] = "users"; }));
ok("用户页:门户表格(.pa-wrap + .pa-tbl)", clsU.includes("pa-wrap") && clsU.includes("pa-tbl"));
ok("用户页:角色徽章四色文案齐备(词典)",
["pa.role.admin", "pa.role.active", "pa.role.pending", "pa.role.disabled"].every(k => ZH[k]));
// 实例管理页:门户 .dsh-bar / .pathbar / .table-wrap + **目标用户切换器**(档案 87)
const clsF = clsAll(await renderWith(pa.Comp, () => { slots[1] = "files"; }));
ok("实例管理页:门户 .dsh-bar / .pathbar 取值", clsF.includes("pa-dshbar") && clsF.includes("pa-pathbar"));
ok("实例管理页:门户 .table-wrap/table.tbl 取值", clsF.includes("pa-wrap") && clsF.includes("pa-tbl"));
const tF = text(await renderWith(pa.Comp, () => { slots[1] = "files"; })).join(" | ");
ok("实例管理页:含「用户」切换器(admin 可管任意用户)",
tF.includes('id="svcUser"') && tF.includes("用户:"), "-> 命中 svcUser=" + tF.includes('id="svcUser"'));
slots[1] = null; cursor = 0; dirty = false;
}
console.log(failed === 0 ? "\n结论:全绿 ✅" : "\n结论:有 " + failed + " 项失败 ❌");
process.exit(failed === 0 ? 0 : 1);