Files
dsh_ai1net_server/scripts/verify-platform-admin-section.mjs
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

389 lines
25 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.ai1net.com", protocol: "https:", origin: "https://admin.ai1net.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"],
},
// 档案 87 补做:官方 pi-ai 厂家目录(「新增」选择框的数据源)—— 国内 + 国际各一家代表
"/api/me/model-providers": {
providers: [
{ id: "moonshotai-cn", label: "月之暗面 Moonshot(中国)", api: "openai-completions", baseURL: "https://api.moonshot.cn/v1", cn: true, modelCount: 12, models: ["kimi-k2", "moonshot-v1-8k"] },
{ id: "openai", label: "OpenAI", api: "openai-responses", baseURL: null, cn: false, modelCount: 20, models: ["gpt-4o"] },
],
catalog: { dir: "/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@earendil-works/pi-ai/dist/providers/data", readable: true, count: 2 },
},
};
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://ai1net.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);
// `<optgroup label="…">` 同理:分组名是 prop,不收就会漏掉「中国大陆 / 国际」这类分组断言。
if (n.type === "optgroup" && typeof n.props.label === "string") out.push(n.props.label);
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)
// 档案 81 · R5(2026-09-15)新增「偏好设置」= 语言切换(全角色,order 99)⇒ 共 **4** 个
// 2026-09-15:「偏好设置」撤除 ⇒ admin 侧 4 → 3(模型设置 + 能力管理 + 系统管理)
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();
// 2026-09-15:「偏好设置」分区已撤除(语言切换搬到 portal-entry 的「用户设置」)⇒ 3 → 2
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));
}
// ── 2026-09-15(用户要求):**「偏好设置」分区已撤除** ────────────────────────────
// 语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(该 bundle 的断言见
// `scripts/verify-portal-entry.mjs`)。这里只断言"它真的没了"—— 防止将来又把重复入口加回来。
{
regs = []; ROLE = "active";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
ok("preferences 分区已撤除(不再注册)", regs.every(r => r.meta.id !== "preferences"),
"-> " + regs.map(r => r.meta.id).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("已停用"));
// 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。
// 顺带断言那个误导性的「(平台共享)」后缀不再出现 —— 该选项走内置通道,但仍**要填
// 用户自己的 API 密钥**,写成「平台共享」会让人以为不必填 key。
ok("模型设置:条目名显示为「DeepSeek」(不再有「内置 DeepSeek」)",
tMs.includes("DeepSeek") && !tMs.includes("内置 DeepSeek") && tMs.includes("my-gw"));
ok("模型设置:自定义条目副行显示 endpoint · 协议",
tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
// ── 档案 91(2026-09-14):「新增」改官方**两步式** ──────────────────────────
// 官方口径(`dsh-client-ui-settings-models` README.zh 原文):
// · 「「新增」流程是一张承载休眠目录提供方选择框的卡片」
// · 「编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名」
// 所以默认态**只露两个虚线入口**,点开才出卡片(旧版是常开表单 + 下拉里混一项
// 「自定义厂家(OpenAI 兼容网关)…」,两者形态不同 ⇒ 断言必须跟着换)。
ok("模型设置:新增默认态只有两个虚线入口(添加提供方 / 添加自定义提供方)",
tMs.includes("添加提供方") && tMs.includes("添加自定义提供方"));
ok("模型设置:默认态**不再**出现厂家下拉与旧「自定义厂家…」选项",
!tMs.includes("厂家:") && !tMs.includes("自定义厂家(OpenAI 兼容网关)"));
// 点开「添加提供方」:出现「提供方」下拉(中国大陆 / 国际分组 + 目录厂家)+ 单一「API 密钥」主字段
const tAdd = text(await renderWith(ms.Comp, () => { slots[5] = "add"; })).join(" | ");
ok("模型设置:点开后有「提供方」下拉,按中国大陆 / 国际分组并列出目录厂家",
tAdd.includes("提供方") && tAdd.includes("中国大陆") && tAdd.includes("国际") &&
tAdd.includes("月之暗面 Moonshot(中国)") && tAdd.includes("OpenAI"));
ok("模型设置:新增卡片主字段是「API 密钥」(内置回落占位符)",
tAdd.includes("API 密钥") && tAdd.includes("输入你的 DeepSeek API 密钥"));
ok("模型设置:次要字段收进「自定义设置」,并说明其余字段在 settings.yaml",
tAdd.includes("自定义设置") && tAdd.includes("显示名称") && tAdd.includes("其余字段在 settings.yaml 中"));
// 选中一个目录厂家 ⇒ 给出「端点/模型由目录兜底」的说明(档案 87 的核心口径)
const tPick = text(await renderWith(ms.Comp, () => { slots[5] = "add"; slots[6] = "moonshotai-cn"; })).join(" | ");
ok("模型设置:选目录厂家后提示「端点与 N 个模型由官方目录提供」",
tPick.includes("由官方目录提供") && tPick.includes("12 个模型"));
// 点开「添加自定义提供方」:官方字段序 + 模型改**行式编辑器**(旧版是 textarea 一行一个)
const treeCustom = await renderWith(ms.Comp, () => { slots[5] = "custom"; });
const tCustom = text(treeCustom).join(" | ");
const cCustom = classes(treeCustom);
ok("模型设置:自定义卡片含 Provider ID / API 地址 / API 协议 / 模型目录",
tCustom.includes("Provider ID") && tCustom.includes("API 地址") &&
tCustom.includes("API 协议") && tCustom.includes("模型目录"));
// 行式编辑器:逐行条目(ms-modelEntry/ms-modelRow)+「添加模型」按钮;
// 行尾删除按钮的文案只在 `aria-label`(文本收集器不收属性)⇒ 用词典键 + 图标按钮类来断言
ok("模型设置:自定义模型目录是**行式编辑器**(逐行条目 + 添加模型 + 行尾删除)",
cCustom.includes("ms-modelEntry") && cCustom.includes("ms-modelRow") && cCustom.includes("ms-iconBtn") &&
tCustom.includes("添加模型") && !!ZH["ms.removeModel"]);
// 2026-09-14 留痕:目录读不到时要**如实说明**,不能静默给个空列表
{
const saved = DATA["/api/me/model-providers"];
DATA["/api/me/model-providers"] = {
providers: [],
catalog: { dir: "/opt/nope/pi-ai/dist/providers/data", readable: false, count: 0 },
};
const tBad = text(await render(ms.Comp)).join(" | ");
ok("模型设置:目录不可读时如实提示(含目录路径,不静默空列表)",
tBad.includes("未能读取官方提供方目录") && tBad.includes("/opt/nope/pi-ai"), tBad.slice(0, 0) + "命中");
DATA["/api/me/model-providers"] = { providers: [], catalog: { dir: "/x", readable: true, count: 0 } };
const tEmpty = text(await render(ms.Comp)).join(" | ");
ok("模型设置:目录为空的文案与「读不到」不同",
tEmpty.includes("官方提供方目录为空") && !tEmpty.includes("未能读取官方提供方目录"));
DATA["/api/me/model-providers"] = saved;
}
ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型 / 两个入口)",
!!ZH["ms.keyHintCatalog"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] &&
!!ZH["ms.addProvider"] && !!ZH["ms.addCustom"] && !!ZH["ms.customized"]);
// 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效"));
// 档案 91:视觉层从「门户表格家族」换成**官方 CSS 模块家族**(`.ms-*`)——
// 换掉表格的原因见档案 91:4 列 auto 列宽会把名称/按钮挤到折行。
const cMs = classes(await render(ms.Comp));
ok("模型设置:行用官方卡片行类(ms-rows/ms-rowCard/ms-rowHead/ms-rowIdentity/ms-rowActions)",
["ms-rows", "ms-rowCard", "ms-rowHead", "ms-rowIdentity", "ms-rowActions"].every(c => cMs.includes(c)));
ok("模型设置:新增入口用官方虚线按钮类(ms-addActions/ms-addBtn/ms-btn)",
["ms-addActions", "ms-addBtn", "ms-btn"].every(c => cMs.includes(c)));
ok("模型设置:**不再**用 4 列表格(pa-tbl / pa-sm / pa-input 已从本分区移除)",
!cMs.includes("pa-tbl") && !cMs.includes("pa-sm") && !cMs.includes("pa-input"));
ok("模型设置:保留门户 .pa-box 外壳与 .pa-badge 徽章", cMs.includes("pa-box") && cMs.includes("pa-badge"));
}
}
// ── 首页视觉:门户 `.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);