Files
dsh_shenxian/scripts/verify-platform-admin-section.mjs
T
admin b9540a8536 style(ui): 「系统管理」改按门户 portal.html 实际组件照抄(5 个弹窗全部改表格)
用户第 2 次反馈「点击卡片后的弹窗样式还是没变,要用之前门户的里面的对应样式」。

查明两个真因:
1. 上一步改动没出厂 —— client.js mtime 20:33 > 最新产物 tgz 20:17;本次已升到 0.3.3 并铺发。
2. 更要命:上一版只有「用户管理」1 个弹窗是门户表格,另外 4 个
   (候选池/运行时/存储/当前实例)仍是裸 div + 内联样式。

本轮改法:读 web/portal.html 源码,逐值对应(不再照抄 06-规范 的抽象条目)
- .nav-grid / .nav-card(r10 / 18px 16px / hover 上浮-2px + 蓝边 + 0 4px 12px)
- .pg-tab .pg-cnt 计数胶囊;.table-wrap + table.tbl(5 个弹窗全部)
- .badge 四色 / .btn-sm + .danger / .card-h / .page-title / .page-sub
- 弹窗外壳 = .card(r10/18px) + design.css --shadow(门户无弹窗)
仅颜色走 dsh token --dsw-*(须跟随 dsh 主题),字号/间距/圆角/动效与门户逐值一致。

校验:verify 脚本新增结构层断言(8 项全绿)+ 服务器三方 md5 一致
     + 端到端取实例下发 bundle(rev=fd084d6aa33a)新标记命中、旧标记为 0。
顺带修校验脚本自身缺陷:树遍历 depth>12 会截断表格行文本 → 放宽到 20。
2026-09-13 20:45:36 +08:00

182 lines
10 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 —— R2「平台管理」分区的**无浏览器**渲染验收(档案 82)
*
* 为什么需要:本机 `agent-browser` 的 daemon 起不来(2026-09-13),浏览器截图验收受阻;
* 而 `06-工作台UI规范 §7.3` 三段式在 bundle **按需动态加载** 时也拿不到带 rev 的完整 URL。
* 做法:打桩 `react` / `react/jsx-runtime` + 最小 hooks 循环 + **真执行** client.js,
* 断言分区**被注册**、**admin 渲染出 6 张卡**、**非 admin 被门禁挡住**。
* 用法: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] = 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: () => ({ setAttribute() {}, remove() {}, textContent: "" }), head: { appendChild() {} } },
};
let ROLE = "admin";
const DATA = {
"/api/dsh/status": { running: true, instance: { port: 43095, restarts: 2 }, breaker: null },
"/api/admin/users": { users: [{ role: "admin" }, { role: "active" }, { role: "pending" }, { role: "disabled" }] },
"/api/admin/storage": { generatedAt: 1, users: [{}, {}] },
"/api/plugins/business": { plugins: [{}, {}, {}] },
"/api/admin/runtime": { items: [{}, {}, {}, {}, {}] },
};
const sandbox = {
console, setTimeout, clearTimeout,
JSON, Object, Promise, Buffer, URL, globalThis: undefined,
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 = {}, regs = [];
const ctx = {
effect: (fn, name) => { try { fn(); } catch (e) { console.log(" effect 抛错:", name, e.message); } },
locale: { register: (ns, d) => { DICT = Object.assign({}, dICT_zh(d)); }, bind: () => (k) => DICT[k] ?? k },
slots: { inject: (n, fn) => fn(), register: (meta, Comp) => { regs.push({ meta, Comp }); return () => {}; } },
};
function dICT_zh(d) { return d.zh || {}; }
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 > 20) return out; // 表格是「容器>table>tbody>tr>td>文本」6 层,旧上限 12 会把行文本截掉
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) text(n.props.children, out, depth + 1);
}
return out;
}
async function render(Comp) {
cursor = 0; slots = [];
let tree = Comp();
for (let i = 0; i < 6; 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 < 6; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
return tree;
}
/** 收集整棵树上的 className(用来断言「用的是门户那套 class」) */
function classes(n, out = [], depth = 0) {
if (n == null || depth > 14) 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;
}
for (const { meta, Comp } of regs.filter(r => r.meta.id === "platform-admin")) {
for (const role of ["admin", "active"]) {
ROLE = role;
delete globalThis.__OPENED;
const tree = await render(Comp);
const t = text(tree).join(" | ");
console.log("\n ── role=" + role + " ──");
console.log(" 渲染文本:", t.slice(0, 300));
console.log(" 含「平台管理」:", t.includes("平台管理"), "| 含 6 张卡:", ["当前实例","熔断","用户","存储","候选池","运行时"].every(k => t.includes(k)));
if (role === "active") console.log(" 含仅管理员说明:", t.includes("仅对管理员显示"));
}
}
// 注:apply 里「仅 admin 注册」是异步判角色后才 inject;本脚本打桩的是同步路径,
// 因此这里断言「两个分区都可注册」+ 内容层门禁。注册条件由 R2 的运行时口径另验。
ok("admin 视角下注册了两个 settings.section", regs.length === 2, "-> " + 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);
ROLE = "admin"; delete globalThis.__OPENED;
const tAdmin = text(await render(pa.Comp)).join(" | ");
ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理"));
ok("admin:5 个管理项齐全", ["用户管理","候选池","运行时","存储","当前实例"].every(k => tAdmin.includes(k)));
ok("admin:不再是只读(提示改为弹窗内管理)", tAdmin.includes("弹窗"));
ROLE = "active";
const tUser = text(await render(pa.Comp)).join(" | ");
ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示"));
ok("非 admin:不出现任何管理项", !["候选池","运行时"].some(k => tUser.includes(k)));
// ── 关键:**非 admin 时「系统管理」压根不注册**(2026-09-13 用户指出普通用户不该看到这一栏)
{
regs = []; ROLE = "active";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
ok("非 admin 视角下只注册 1 个分区(不含系统管理)",
regs.length === 1 && regs[0].meta.id === "business-plugins",
"-> " + regs.map(r => r.meta.id).join(", "));
regs = []; ROLE = "admin";
}
// ── 视觉层(2026-09-13 第 2 轮):卡片网格 + **5 个弹窗全部**走门户 `portal.html` 的表格样式 ──
{
ROLE = "admin";
const clsHome = classes(await render(pa.Comp));
ok("首页:卡片网格取门户 .nav-grid 值", clsHome.includes("pa-grid"));
ok("首页:卡片取门户 .nav-card 值(.pa-card)", clsHome.includes("pa-card"));
ok("首页:计数胶囊取门户 .pg-cnt 值(.pa-cnt)", clsHome.includes("pa-cnt"));
ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub"));
// 逐个点开 5 个管理项,**每一个**都必须是「门户 .table-wrap + table.tbl」而不是裸 div 列表
const ITEM_IDS = ["users", "plugins", "runtime", "storage", "instance"];
const bad = [];
for (const which of ITEM_IDS) {
const cls = classes(await renderWith(pa.Comp, () => { slots[2] = which; }));
const good = cls.includes("pa-overlay") && cls.includes("pa-modal") && cls.includes("pa-wrap") && cls.includes("pa-tbl")
&& cls.includes("pa-card-h") && cls.includes("pa-sm");
if (!good) bad.push(which + "(" + cls.filter(c => c.startsWith("pa-")).join("/") + ")");
}
ok("5 个弹窗全部 = 门户弹窗外壳 + portal 表格 + .btn-sm", bad.length === 0, bad.length ? "-> " + bad.join(" ; ") : "-> users/plugins/runtime/storage/instance 均通过");
// 用户弹窗:角色徽章 + 危险操作按钮(门户 .badge / .btn-sm.danger)
const clsUsers = classes(await renderWith(pa.Comp, () => { slots[2] = "users"; }));
ok("用户弹窗:角色用门户 .badge 四色", clsUsers.filter(c => c === "pa-badge").length >= 4, "-> " + clsUsers.filter(c => c === "pa-badge").length + " 枚");
ok("用户弹窗:删除按钮用门户 .btn-sm.danger", clsUsers.includes("danger"));
// 实例弹窗:键值对表格,值列不是裸文本
const tInst = text(await renderWith(pa.Comp, () => { slots[2] = "instance"; })).join(" | ");
ok("实例弹窗:键值齐全", ["端口", "自动重启", "熔断"].every(k => tInst.includes(k)));
slots[2] = null; cursor = 0; dirty = false;
}
console.log(failed === 0 ? "\n结论:全绿 ✅" : "\n结论:有 " + failed + " 项失败 ❌");
process.exit(failed === 0 ? 0 : 1);