2026-09-13 19:27:30 +08:00
|
|
|
|
#!/usr/bin/env node
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const cfg = require('../config/index.cjs')
|
2026-09-13 19:27:30 +08:00
|
|
|
|
/**
|
2026-09-13 22:27:49 +08:00
|
|
|
|
* verify-platform-admin-section.mjs —— 「系统管理」分区的**无浏览器**渲染验收(档案 82)
|
2026-09-13 19:27:30 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 为什么需要:本机 `agent-browser` 的 daemon 起不来(2026-09-13),浏览器截图验收受阻;
|
|
|
|
|
|
* 而 `06-工作台UI规范 §7.3` 三段式在 bundle **按需动态加载** 时也拿不到带 rev 的完整 URL。
|
|
|
|
|
|
* 做法:打桩 `react` / `react/jsx-runtime` + 最小 hooks 循环 + **真执行** client.js,
|
2026-09-13 22:27:49 +08:00
|
|
|
|
* 断言分区被注册、admin 渲染出门户同款 **6 个功能页**、非 admin 被门禁挡住、
|
|
|
|
|
|
* 以及 zh/en 词典**键集一一对应**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 第 3 轮(2026-09-13):口径从「5 项只读摘要」改为「门户 6 个功能页同名同构」,
|
|
|
|
|
|
* 因此断言同步换成门户的功能名与各页表头。
|
|
|
|
|
|
*
|
2026-09-13 19:27:30 +08:00
|
|
|
|
* 用法: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 = {
|
2026-09-13 22:27:49 +08:00
|
|
|
|
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; }]; },
|
2026-09-13 19:27:30 +08:00
|
|
|
|
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; } },
|
2026-09-19 15:12:19 +08:00
|
|
|
|
location: { hostname: "admin.example.net", protocol: "https:", origin: "https://admin.example.net" },
|
2026-09-13 19:27:30 +08:00
|
|
|
|
open: (u) => { globalThis.__OPENED = u; },
|
2026-09-13 22:27:49 +08:00
|
|
|
|
document: {
|
|
|
|
|
|
createElement: () => {
|
|
|
|
|
|
const o = { setAttribute() {}, remove() {} };
|
|
|
|
|
|
Object.defineProperty(o, "textContent", { set(v) { CSS += v; }, get() { return ""; } });
|
|
|
|
|
|
return o;
|
|
|
|
|
|
},
|
|
|
|
|
|
head: { appendChild() {} },
|
|
|
|
|
|
},
|
2026-09-13 19:27:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
let ROLE = "admin";
|
2026-09-13 22:27:49 +08:00
|
|
|
|
let CSS = "";
|
2026-09-13 19:27:30 +08:00
|
|
|
|
const DATA = {
|
2026-09-13 22:27:49 +08:00
|
|
|
|
"/api/auth/me": null, // 由 fetch 桩按 ROLE 生成
|
2026-09-13 19:27:30 +08:00
|
|
|
|
"/api/dsh/status": { running: true, instance: { port: 43095, restarts: 2 }, breaker: null },
|
2026-09-13 22:27:49 +08:00
|
|
|
|
"/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" },
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// 档案 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",
|
2026-09-19 13:51:01 +08:00
|
|
|
|
// `granted`(档案 138)= **管理员**有没有给这个人开;`enabled` = 用户自己的偏好。
|
|
|
|
|
|
shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, granted: true, count: 1 },
|
2026-09-14 00:00:15 +08:00
|
|
|
|
sharedModelEnabled: true,
|
2026-09-19 13:51:01 +08:00
|
|
|
|
sharedModelGranted: true,
|
2026-09-14 00:00:15 +08:00
|
|
|
|
protocols: ["openai-completions", "openai-responses", "anthropic-messages"],
|
|
|
|
|
|
},
|
2026-09-14 00:15:17 +08:00
|
|
|
|
// 档案 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"] },
|
|
|
|
|
|
],
|
2026-09-14 04:52:35 +08:00
|
|
|
|
catalog: { dir: "/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@earendil-works/pi-ai/dist/providers/data", readable: true, count: 2 },
|
2026-09-14 00:15:17 +08:00
|
|
|
|
},
|
2026-09-13 19:27:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
const sandbox = {
|
|
|
|
|
|
console, setTimeout, clearTimeout,
|
|
|
|
|
|
JSON, Object, Promise, Buffer, URL, globalThis: undefined,
|
2026-09-13 22:27:49 +08:00
|
|
|
|
Math, Date, encodeURIComponent, decodeURIComponent,
|
2026-09-13 19:27:30 +08:00
|
|
|
|
window: win, document: win.document,
|
|
|
|
|
|
fetch: async (url) => {
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const p = String(url).replace("https://example.net", "");
|
2026-09-13 19:27:30 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-09-13 22:27:49 +08:00
|
|
|
|
let DICT = {}, ZH = {}, EN = {}, regs = [];
|
2026-09-13 19:27:30 +08:00
|
|
|
|
const ctx = {
|
|
|
|
|
|
effect: (fn, name) => { try { fn(); } catch (e) { console.log(" effect 抛错:", name, e.message); } },
|
2026-09-13 22:27:49 +08:00
|
|
|
|
locale: {
|
|
|
|
|
|
register: (ns, d) => { ZH = d.zh || {}; EN = d.en || {}; DICT = Object.assign({}, ZH); },
|
|
|
|
|
|
bind: () => (k) => DICT[k] ?? k,
|
|
|
|
|
|
},
|
2026-09-13 19:27:30 +08:00
|
|
|
|
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);
|
2026-09-13 20:19:01 +08:00
|
|
|
|
// ⚠️ apply 里「仅 admin 注册」是**异步**的(先 fetch /api/auth/me 判角色)⇒ 必须等一拍
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 80));
|
2026-09-13 19:27:30 +08:00
|
|
|
|
console.log(" 已注册 section:", regs.map(r => r.meta.id + " / order=" + r.meta.order + " / label=" + r.meta.label()).join(" | "));
|
|
|
|
|
|
|
|
|
|
|
|
function text(n, out = [], depth = 0) {
|
2026-09-13 22:27:49 +08:00
|
|
|
|
if (n == null || depth > 24) return out;
|
2026-09-13 19:27:30 +08:00
|
|
|
|
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; }
|
2026-09-13 22:27:49 +08:00
|
|
|
|
if (n.props) {
|
|
|
|
|
|
// 第 3 轮:功能页正文走 `dangerouslySetInnerHTML`(门户原文),必须单独收集
|
|
|
|
|
|
const dsi = n.props.dangerouslySetInnerHTML;
|
|
|
|
|
|
if (dsi && dsi.__html) out.push(dsi.__html);
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// 表单控件的占位符也是**用户可见文案**(输入框没有 children)⇒ 一并收集,
|
|
|
|
|
|
// 否则「新增表单有哪些栏位」这类断言只能查到可见标签,漏掉真正的引导文案。
|
|
|
|
|
|
if (typeof n.props.placeholder === "string") out.push(n.props.placeholder);
|
2026-09-14 00:15:17 +08:00
|
|
|
|
// `<optgroup label="…">` 同理:分组名是 prop,不收就会漏掉「中国大陆 / 国际」这类分组断言。
|
|
|
|
|
|
if (n.type === "optgroup" && typeof n.props.label === "string") out.push(n.props.label);
|
2026-09-13 22:27:49 +08:00
|
|
|
|
text(n.props.children, out, depth + 1);
|
|
|
|
|
|
}
|
2026-09-13 19:27:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
async function render(Comp) {
|
|
|
|
|
|
cursor = 0; slots = [];
|
|
|
|
|
|
let tree = Comp();
|
2026-09-13 22:27:49 +08:00
|
|
|
|
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
|
2026-09-13 19:27:30 +08:00
|
|
|
|
return tree;
|
|
|
|
|
|
}
|
2026-09-13 22:27:49 +08:00
|
|
|
|
/** 首帧渲染完成后改一个 state(patch 直接写 slots),再渲染一次 —— 用来把某个功能页「点开」。 */
|
2026-09-13 20:45:36 +08:00
|
|
|
|
async function renderWith(Comp, patch) {
|
|
|
|
|
|
let tree = await render(Comp);
|
|
|
|
|
|
patch();
|
|
|
|
|
|
cursor = 0; dirty = false;
|
|
|
|
|
|
tree = Comp();
|
2026-09-13 22:27:49 +08:00
|
|
|
|
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
|
2026-09-13 20:45:36 +08:00
|
|
|
|
return tree;
|
|
|
|
|
|
}
|
|
|
|
|
|
function classes(n, out = [], depth = 0) {
|
2026-09-13 22:27:49 +08:00
|
|
|
|
if (n == null || depth > 16) return out;
|
2026-09-13 20:45:36 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-13 22:27:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 收集整棵树的 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];
|
2026-09-13 19:27:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 22:27:49 +08:00
|
|
|
|
// ── 词典(第 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(","));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
|
2026-09-15 21:17:12 +08:00
|
|
|
|
// 档案 87 起 = 3 个:模型设置(全角色)+ 能力管理(全角色,原「功能管理」)+ 系统管理(仅 admin)
|
2026-09-15 18:54:02 +08:00
|
|
|
|
// 档案 81 · R5(2026-09-15)新增「偏好设置」= 语言切换(全角色,order 99)⇒ 共 **4** 个
|
2026-09-15 21:17:12 +08:00
|
|
|
|
// 2026-09-15:「偏好设置」撤除 ⇒ admin 侧 4 → 3(模型设置 + 能力管理 + 系统管理)
|
|
|
|
|
|
ok("admin 视角下注册了三个 settings.section", regs.length === 3, "-> " + regs.map(r => r.meta.id).join(", "));
|
2026-09-13 19:27:30 +08:00
|
|
|
|
const pa = regs.find(r => r.meta.id === "platform-admin");
|
|
|
|
|
|
ok("存在 platform-admin 分区", !!pa);
|
|
|
|
|
|
if (pa) ok("其 order = 102", pa.meta.order === 102);
|
2026-09-13 22:27:49 +08:00
|
|
|
|
|
2026-09-14 00:00:15 +08:00
|
|
|
|
const PORTAL_ITEMS = ["实例管理", "模型管理", "用户管理", "技能管理", "插件管理", "运行环境"];
|
2026-09-13 22:27:49 +08:00
|
|
|
|
ROLE = "admin";
|
2026-09-13 19:27:30 +08:00
|
|
|
|
const tAdmin = text(await render(pa.Comp)).join(" | ");
|
2026-09-13 19:46:23 +08:00
|
|
|
|
ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理"));
|
2026-09-13 22:27:49 +08:00
|
|
|
|
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)));
|
|
|
|
|
|
|
2026-09-13 19:27:30 +08:00
|
|
|
|
ROLE = "active";
|
|
|
|
|
|
const tUser = text(await render(pa.Comp)).join(" | ");
|
|
|
|
|
|
ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示"));
|
2026-09-13 22:27:49 +08:00
|
|
|
|
ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.includes(k)));
|
|
|
|
|
|
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// ── 档案 87:角色与分区注册 ────────────────────────────────────────────────────
|
|
|
|
|
|
// 「模型设置」**全角色**都要有(官方「设置 → 模型」页在平台环境必然报错 ⇒ 平台自建入口
|
|
|
|
|
|
// 是用户自配模型的**唯一**入口,不能只给 admin);「系统管理」仍仅 admin。
|
2026-09-13 20:19:01 +08:00
|
|
|
|
{
|
|
|
|
|
|
regs = []; ROLE = "active";
|
|
|
|
|
|
mod.apply(ctx);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 80));
|
2026-09-13 22:27:49 +08:00
|
|
|
|
const ids = regs.map(r => r.meta.id).sort();
|
2026-09-15 21:17:12 +08:00
|
|
|
|
// 2026-09-15:「偏好设置」分区已撤除(语言切换搬到 portal-entry 的「用户设置」)⇒ 3 → 2
|
|
|
|
|
|
ok("非 admin:注册 2 个分区(能力管理 + 模型设置;不含系统管理,也不再有偏好设置)",
|
|
|
|
|
|
ids.length === 2 && ids[0] === "business-plugins" && ids[1] === "model-settings",
|
2026-09-13 22:27:49 +08:00
|
|
|
|
"-> " + ids.join(", "));
|
2026-09-13 20:19:01 +08:00
|
|
|
|
regs = []; ROLE = "admin";
|
2026-09-14 00:00:15 +08:00
|
|
|
|
mod.apply(ctx);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 21:17:12 +08:00
|
|
|
|
// ── 2026-09-15(用户要求):**「偏好设置」分区已撤除** ────────────────────────────
|
|
|
|
|
|
// 语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(该 bundle 的断言见
|
|
|
|
|
|
// `scripts/verify-portal-entry.mjs`)。这里只断言"它真的没了"—— 防止将来又把重复入口加回来。
|
2026-09-15 18:54:02 +08:00
|
|
|
|
{
|
|
|
|
|
|
regs = []; ROLE = "active";
|
|
|
|
|
|
mod.apply(ctx);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 80));
|
2026-09-15 21:17:12 +08:00
|
|
|
|
ok("preferences 分区已撤除(不再注册)", regs.every(r => r.meta.id !== "preferences"),
|
|
|
|
|
|
"-> " + regs.map(r => r.meta.id).join(", "));
|
2026-09-15 18:54:02 +08:00
|
|
|
|
regs = []; ROLE = "admin";
|
|
|
|
|
|
mod.apply(ctx);
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// ── 档案 87「模型设置」分区:三条口径必须体现在界面上 ─────────────────────────
|
|
|
|
|
|
{
|
|
|
|
|
|
const ms = regs.find(r => r.meta.id === "model-settings");
|
|
|
|
|
|
ok("存在 model-settings 分区", !!ms);
|
|
|
|
|
|
if (ms) {
|
2026-09-15 21:17:12 +08:00
|
|
|
|
ok("其 order = 100(排在能力管理之前)", ms.meta.order === 100, "-> " + ms.meta.order);
|
2026-09-14 00:00:15 +08:00
|
|
|
|
const tMs = text(await render(ms.Comp)).join(" | ");
|
|
|
|
|
|
ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器"));
|
|
|
|
|
|
// 口径②:共享模型也列入且可开关
|
|
|
|
|
|
ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型"));
|
2026-09-19 13:51:01 +08:00
|
|
|
|
// ── 档案 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("平台共享模型"));
|
|
|
|
|
|
}
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」
|
|
|
|
|
|
ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用"));
|
2026-09-15 21:17:12 +08:00
|
|
|
|
// 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。
|
|
|
|
|
|
// 顺带断言那个误导性的「(平台共享)」后缀不再出现 —— 该选项走内置通道,但仍**要填
|
|
|
|
|
|
// 用户自己的 API 密钥**,写成「平台共享」会让人以为不必填 key。
|
|
|
|
|
|
ok("模型设置:条目名显示为「DeepSeek」(不再有「内置 DeepSeek」)",
|
|
|
|
|
|
tMs.includes("DeepSeek") && !tMs.includes("内置 DeepSeek") && tMs.includes("my-gw"));
|
2026-09-14 00:15:17 +08:00
|
|
|
|
ok("模型设置:自定义条目副行显示 endpoint · 协议",
|
|
|
|
|
|
tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
|
2026-09-14 21:13:32 +08:00
|
|
|
|
// ── 档案 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 04:52:35 +08:00
|
|
|
|
// 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("模型设置:目录不可读时如实提示(含目录路径,不静默空列表)",
|
2026-09-14 21:13:32 +08:00
|
|
|
|
tBad.includes("未能读取官方提供方目录") && tBad.includes("/opt/nope/pi-ai"), tBad.slice(0, 0) + "命中");
|
2026-09-14 04:52:35 +08:00
|
|
|
|
DATA["/api/me/model-providers"] = { providers: [], catalog: { dir: "/x", readable: true, count: 0 } };
|
|
|
|
|
|
const tEmpty = text(await render(ms.Comp)).join(" | ");
|
|
|
|
|
|
ok("模型设置:目录为空的文案与「读不到」不同",
|
2026-09-14 21:13:32 +08:00
|
|
|
|
tEmpty.includes("官方提供方目录为空") && !tEmpty.includes("未能读取官方提供方目录"));
|
2026-09-14 04:52:35 +08:00
|
|
|
|
DATA["/api/me/model-providers"] = saved;
|
|
|
|
|
|
}
|
2026-09-14 21:13:32 +08:00
|
|
|
|
ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型 / 两个入口)",
|
|
|
|
|
|
!!ZH["ms.keyHintCatalog"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] &&
|
|
|
|
|
|
!!ZH["ms.addProvider"] && !!ZH["ms.addCustom"] && !!ZH["ms.customized"]);
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
|
|
|
|
|
|
ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效"));
|
2026-09-14 21:13:32 +08:00
|
|
|
|
// 档案 91:视觉层从「门户表格家族」换成**官方 CSS 模块家族**(`.ms-*`)——
|
|
|
|
|
|
// 换掉表格的原因见档案 91:4 列 auto 列宽会把名称/按钮挤到折行。
|
2026-09-14 00:00:15 +08:00
|
|
|
|
const cMs = classes(await render(ms.Comp));
|
2026-09-14 21:13:32 +08:00
|
|
|
|
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"));
|
2026-09-14 00:00:15 +08:00
|
|
|
|
}
|
2026-09-13 20:19:01 +08:00
|
|
|
|
}
|
2026-09-13 22:27:49 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 首页视觉:门户 `.nav-grid` / `.nav-card` 家族 ─────────────────────────────
|
2026-09-13 20:45:36 +08:00
|
|
|
|
{
|
|
|
|
|
|
ROLE = "admin";
|
|
|
|
|
|
const clsHome = classes(await render(pa.Comp));
|
2026-09-13 22:27:49 +08:00
|
|
|
|
ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub"));
|
|
|
|
|
|
ok("首页:分组标题取门户 .home-section-title 值", clsHome.includes("pa-sec"));
|
2026-09-13 20:45:36 +08:00
|
|
|
|
ok("首页:卡片网格取门户 .nav-grid 值", clsHome.includes("pa-grid"));
|
|
|
|
|
|
ok("首页:卡片取门户 .nav-card 值(.pa-card)", clsHome.includes("pa-card"));
|
|
|
|
|
|
|
2026-09-13 22:27:49 +08:00
|
|
|
|
// ── 6 个功能页逐个点开:**弹窗外壳 + 门户该页的表格/操作** ──────────────────
|
|
|
|
|
|
const EXPECT = {
|
2026-09-14 00:00:15 +08:00
|
|
|
|
files: ["实例管理", "启动 DSH", "新建文件夹", "上传文件", "修改时间", "根"],
|
|
|
|
|
|
keys: ["模型管理", "平台共享密钥", "sk-..."],
|
2026-09-13 22:27:49 +08:00
|
|
|
|
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(","));
|
2026-09-13 20:45:36 +08:00
|
|
|
|
}
|
2026-09-13 22:27:49 +08:00
|
|
|
|
ok("6 个功能页全部 = 门户弹窗外壳(遮罩/大面板/页头/滚动体)", badShell.length === 0,
|
|
|
|
|
|
badShell.length ? "-> " + badShell.join(" ; ") : "-> files/keys/users/skills/plugins/runtime 均通过");
|
|
|
|
|
|
ok("6 个功能页正文 = 门户该页的表格与操作", badBody.length === 0,
|
|
|
|
|
|
badBody.length ? "-> " + badBody.join(" ; ") : "-> 表头 / 按钮 / 说明文本均在");
|
2026-09-13 20:45:36 +08:00
|
|
|
|
|
2026-09-13 22:27:49 +08:00
|
|
|
|
// 插件页:双页签(门户 .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)));
|
2026-09-13 20:45:36 +08:00
|
|
|
|
|
2026-09-13 22:27:49 +08:00
|
|
|
|
// 用户页:表格 + 徽章 + 危险按钮
|
|
|
|
|
|
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]));
|
|
|
|
|
|
|
2026-09-14 00:00:15 +08:00
|
|
|
|
// 实例管理页:门户 .dsh-bar / .pathbar / .table-wrap + **目标用户切换器**(档案 87)
|
2026-09-13 22:27:49 +08:00
|
|
|
|
const clsF = clsAll(await renderWith(pa.Comp, () => { slots[1] = "files"; }));
|
2026-09-14 00:00:15 +08:00
|
|
|
|
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"'));
|
2026-09-13 22:27:49 +08:00
|
|
|
|
|
|
|
|
|
|
slots[1] = null; cursor = 0; dirty = false;
|
2026-09-13 20:45:36 +08:00
|
|
|
|
}
|
2026-09-13 19:27:30 +08:00
|
|
|
|
console.log(failed === 0 ? "\n结论:全绿 ✅" : "\n结论:有 " + failed + " 项失败 ❌");
|
|
|
|
|
|
process.exit(failed === 0 ? 0 : 1);
|