Files
dsh_shenxian/scripts/verify-platform-admin-section.mjs
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

408 lines
27 KiB
JavaScript
Raw Permalink 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
const cfg = require('../config/index.cjs')
/**
* 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.example.net", protocol: "https:", origin: "https://admin.example.net" },
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",
// `granted`(档案 138)= **管理员**有没有给这个人开;`enabled` = 用户自己的偏好。
shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, granted: true, count: 1 },
sharedModelEnabled: true,
sharedModelGranted: 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://example.net", "");
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("停用共享模型"));
// ── 档案 138(2026-09-19 用户口径):**管理员逐用户开启后才有这一块** ──────────────
// 判据 = `shared.granted`。未授权时整块不渲染(用户原话:「admin 在用户列表中开启…
// 用户才能在会话中使用(以及在设置的模型设置页面展示)」)。
// ⚠️ 这条断言必须**正反两腿都跑**:只测"已授权时能看到"会漏掉"未授权时也没隐藏"这种
// 半实现(那就是门禁只做了一半 —— 用户看不到授权状态却仍能看到共享模型)。
{
const keep = DATA["/api/me/keys"].shared;
DATA["/api/me/keys"].shared = Object.assign({}, keep, { granted: false });
const tNo = text(await render(ms.Comp)).join(" | ");
ok("模型设置:**未授权**时「平台共享模型」整块不渲染(档案 138)",
!tNo.includes("平台共享模型") && !tNo.includes("停用共享模型"),
"-> 仍有共享字样 = 门禁没生效");
DATA["/api/me/keys"].shared = keep;
const tYes = text(await render(ms.Comp)).join(" | ");
ok("模型设置:恢复授权后该块回来(正反两腿对照)", tYes.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);