Files
mini-openclaw-v2/scripts/test_module_shell_ui.cjs
T
qiaoqichaoandClaude Code ae89462756 feat(v2): 纠偏第三批——深色导航、三模块独立入口与轨迹复盘
覆盖 AC-53/54/55(BR-50)。审计 P1-2「新界面未接入、原落地页与导航未改」
由此关闭:导航改为原型 token 的深色壳,三个模块各有独立路由与入口。

界面:
- navigation.css:导航改用原型 token(nav.bg #102B27 / active #244D41 /
  muted #B1C6BE),身份选择器在深色底上改用 dark 变体(否则白底白字);
  工作区画布保持浅色 #f5f7f9——"深色导航+浅色工作区"是两件事。
  保留可读性守卫锁定的两条规则(导航项 min-height:42px/font-size:14px、
  身份选择器 min-height:42px),原有 7 项守卫全部继续通过。
- AgentNav:品牌改为「兴邦金租/智审工作台」,加「业务工作区」分组标签,
  三个模块入口(AI 预审 / 历史经验加工 / 轨迹复盘)指向各自路由;
  权限仍按角色裁剪,身份仍来自服务端。
- 新增 /prescreen-workbench(AI 预审)、/trajectory-center(轨迹复盘)两个模块路由;
  / 从旧聊天落地页改为业务工作区首页,呈现三个模块入口;原落地页内容保留在
  /assistant,没有被删掉(AC-53 要求的是不再"只保留"旧布局)。
- TrajectoryPanel:消费既有只读轨迹端点,失败/跳过步骤照常列出并带错误码,
  成本缺失显示"未记录(不填 0)",并展示服务端对轨迹范围的说明;
  会议结果只读展示(录入属第四批)。

复用而非复制:三个模块路由都 import 既有的 PrescreenPanel / ImportBatchPanel /
ExperienceCenter,没有在路由里再写一份实现。

守卫:新增 module-shell-ui(已登记 verify.py,共 13 项检查),机械核对
深色 token、三入口存在、已合并旧页仍为重定向、按角色裁剪、
?project= 上下文与不硬编码项目 id、轨迹面板不渲染推理字段,
并做前后端路径一致性断言(界面调用的端点必须真的在后端声明过)。
深色底对比度按 WCAG 公式在守卫内**实算**(阈值 AA 4.5:1,实测最低 8.37:1),
不在文档里声称"应该够清楚"。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 00:50:16 +08:00

141 lines
8.8 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.
/**
* 纠偏第三批 · 模块壳与三入口源码守卫(BR-50;AC-53/54/55)。
*
* 这是**源码断言**,不是浏览器端到端测试(docs/v2/06-validation.md §2)。
* 它锁住四类可机械核对的约束:
*
* 1. **深色导航+浅色工作区**(AC-53):导航取原型 token,画布保持浅色。
* 2. **三个模块各有独立入口**(BR-50):AI 预审 / 历史经验加工 / 轨迹复盘,
* 各自有路由、各自渲染**复用**的组件——不是把面板塞进旧页当页签。
* 3. **防重复页面**(AC-55):已合并的旧页必须仍是重定向;不得复活。
* 4. **权限与数据隔离**(AC-55):导航按角色裁剪;模块以 ?project= 为上下文,
* 不把项目 id 硬编码,也不在前端自造授权。
*/
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const navigation = read("src/components/xingbang/navigation.css");
const nav = read("src/components/xingbang/AgentNav.tsx");
const home = read("src/app/page.tsx");
// ---------------------------------------------------------------- 1. 深色导航
assert.match(navigation, /\.xb-agent-nav\{[^}]*background:#102B27/, "主导航应使用原型的深色 nav.bg #102B27");
assert.match(navigation, /\.xb-agent-nav a\.selected\{[^}]*background:#244D41/, "选中项应使用 nav.active #244D41");
assert.match(nav, /智审工作台/, "品牌区应显示「智审工作台」(原型文案)");
// 深色底的文字对比度**算出来**,而不是在文档里声称"应该够清楚"(G-6 机械约束优先)。
// 阈值取 WCAG AA 正文 4.5:1;四组配色都要过。
function luminance(hex) {
const channel = (value) => {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function contrast(foreground, background) {
const [a, b] = [luminance(foreground), luminance(background)];
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
const NAV_BG = "#102B27", NAV_ACTIVE = "#244D41";
for (const [label, fg, bg] of [
["未选中导航项", "#DCE9E3", NAV_BG],
["选中导航项", "#FFFFFF", NAV_ACTIVE],
["次要文字(muted)", "#B1C6BE", NAV_BG],
["品牌标题", "#F3F7F5", NAV_BG],
]) {
assert.ok(contrast(fg, bg) >= 4.5, `深色导航「${label}」对比度应 ≥ 4.5:1,实测 ${contrast(fg, bg).toFixed(2)}:1`);
}
assert.match(nav, /业务工作区/, "导航应保留「业务工作区」分组标签");
// 导航是深色,身份选择器在导航内必须用深色变体,否则白底白字
assert.match(nav, /<IdentitySelector[^>]*dark/, "导航内的身份选择器应使用深色变体");
// 画布仍是浅色:不要把整页刷黑
assert.match(read("src/app/data-access/workspace.css"), /\.ra-app\{min-height:100vh;background:#f5f7f9/, "工作区画布应保持浅色");
// ---------------------------------------------------------------- 2. 三个独立入口
const MODULES = [
{key: "prescreen", href: "/prescreen-workbench", label: "AI 预审", component: "PrescreenPanel"},
{key: "experience", href: "/experience-center", label: "历史经验加工", component: "ExperienceCenter"},
{key: "trajectory", href: "/trajectory-center", label: "轨迹复盘", component: "TrajectoryPanel"},
];
for (const item of MODULES) {
assert.ok(exists(`src/app${item.href}/page.tsx`), `${item.label} 应有独立路由 ${item.href}`);
assert.ok(nav.includes(`"${item.href}"`), `导航应提供 ${item.label} 入口`);
assert.ok(nav.includes(item.label), `导航入口文案应为「${item.label}」`);
const page = read(`src/app${item.href}/page.tsx`);
// 复用组件而不是在路由里再写一份实现:import 自 @/components
assert.match(page, /from "@\/components\/xingbang\//, `${item.label} 路由应复用既有组件`);
assert.match(page, /AgentNav/, `${item.label} 路由应复用深色导航`);
// 数据隔离:项目上下文来自 URL,不硬编码项目 id
assert.match(page, /new URLSearchParams\(location\.search\)\.get\("project"\)/, `${item.label} 的项目上下文应来自 ?project=`);
assert.ok(!/DEMO-[ABC]/.test(page), `${item.label} 不得硬编码演示项目 id`);
}
// 首页必须呈现三个入口(AC-53:不能只保留旧版落地页布局)
for (const item of MODULES) {
assert.ok(home.includes(item.href), `业务首页应提供 ${item.label} 入口卡片`);
}
assert.match(home, /MODULES/, "首页应以模块清单驱动入口卡片,而不是写死一段静态文案");
// 旧版落地页内容保留为独立入口,而不是被删掉
assert.ok(exists("src/app/assistant/page.tsx"), "原落地页内容应保留在 /assistant");
assert.match(read("src/app/assistant/page.tsx"), /BusinessAssistant/, "/assistant 应仍渲染业务助手");
// ---------------------------------------------------------------- 3. 防重复页面(AC-55)
// 已合并的旧页必须仍是重定向,不得被重新实现成独立页面。
for (const merged of ["trajectory", "review-workbench", "report-workbench", "collateral-workbench"]) {
assert.match(read(`src/app/${merged}/page.tsx`), /redirect\(/,
`已合并页 src/app/${merged} 必须保持重定向,不得复活`);
}
// 不得把预审面板搬到与既有禁名冲突的目录(那会把旧的 S1/T09 结构重新引入)
for (const banned of ["prescreen", "sandbox", "simulation", "import-batch"]) {
assert.ok(!exists(`src/app/${banned}`), `不得新建与既有面板重名的路由 src/app/${banned}`);
}
// 同一个面板组件被两个宿主复用时,必须是同一份实现(import 复用),不是复制粘贴的第二份
const hosts = ["src/app/xingbang-workbench/page.tsx", "src/app/prescreen-workbench/page.tsx"];
for (const host of hosts) {
assert.match(read(host), /import PrescreenPanel from "@\/components\/xingbang\/PrescreenPanel"/,
`${host} 应复用同一个 PrescreenPanel 组件`);
}
// ---------------------------------------------------------------- 4. 权限(AC-55)
assert.match(nav, /canConfigure/, "导航应按角色裁剪配置治理入口");
assert.match(nav, /request\.state|identity\?\.role/, "导航的角色判据应来自服务端身份而不是硬编码");
assert.ok(!/localStorage\.setItem\("xingbang-demo-role"/.test(nav), "导航不得自行设定演示身份");
// ---------------------------------------------------------------- 5. 轨迹复盘的可操作与诚实性(AC-54)
const trajectory = read("src/components/xingbang/TrajectoryPanel.tsx");
assert.match(trajectory, /failed/, "轨迹面板必须能显示失败步骤(失败保留)");
assert.match(trajectory, /不填 0|未记录/, "成本缺失时必须留空,不得填 0");
assert.ok(!/cost\s*[:=]\s*0\b/.test(trajectory), "不得把缺失成本写成 0");
assert.match(trajectory, /只读/, "轨迹面板必须声明只读");
// 判据落在"是否渲染推理字段"上,而不是禁用词本身——否则解释规则的注释会把守卫自己绊倒。
assert.ok(!/\.\s*(reasoning|reasoning_content|thoughts?|chain_of_thought)\b/i.test(trajectory),
"不得渲染模型隐藏推理字段");
// 服务端已声明轨迹不含内部推理,界面必须把它显示出来(而不是自己编一句)
assert.match(trajectory, /trajectory\.note|note\}/, "应展示服务端对轨迹范围的说明");
assert.match(trajectory, /review-outcomes|会议结果/, "轨迹面板应与会议结果对照");
// 预审模块必须真的挂载预审面板(否则"AI 预审入口"是空壳)
assert.match(read("src/app/prescreen-workbench/page.tsx"), /<PrescreenPanel /, "AI 预审模块应挂载预审面板");
// ---------------------------------------------------------------- 6. 前后端路径一致
// 界面调用的端点必须在后端真的声明过——否则"入口可点"只是连到 404。
const prescreenApi = read("backend/api/prescreen.py");
for (const route of ['"/projects/{project_id}/runs/{run_id}/trajectory"',
'"/projects/{project_id}/review-outcomes"']) {
assert.ok(prescreenApi.includes(route), `后端应声明路由 ${route}`);
}
assert.ok(read("backend/api/project_assistant.py").includes('"/projects/{project_id}/runs"'),
"后端应声明发起运行的路由");
// ---------------------------------------------------------------- 7. 既有禁令继续有效
for (const [name, source] of [["home", home], ["nav", nav], ["trajectory", trajectory]]) {
assert.ok(!/0\.(42|58|51|64)\b/.test(source), `${name} 不得硬编码演示概率值`);
assert.ok(!/"(00|10|01|11)"\s*:/.test(source), `${name} 不得复制演示情景表`);
}
console.log("module-shell-ui: PASS");