fix(v2): 浏览器端到端验证——修掉模块页在浏览器里渲染不出内容的缺陷

首次用真实 Chrome 打开 /trajectory-center(新增可选脚本 scripts/browser-e2e.cjs),
暴露 5 个源码守卫与生产构建都看不出的缺陷,全部修复:

1. 三个模块页读 `list.items`,而 /projects 返回的是 {projects, identity}——
   项目列表恒为空,prescreen-workbench / experience-center / trajectory-center
   整页渲染不出任何面板,只显示"当前身份没有可访问的项目"这种看着正常的空态。
   (第二/三批引入)改为 list.projects。
2. TrajectoryPanel 没被传 identity,canRecord 恒假 → 会议结果录入表单永不出现。
3. ImprovementPanel 草稿下拉以组件 id 作 key/value,同一组件多版本草稿时撞 React key,
   且选不准要发布哪一版;改以 `组件id@版本` 为键值。
4. JSX 文本里的 `**强调**` 会原样显示星号(JSX 不渲染 Markdown),共 9 处;
   改为 <b>(字符串上下文处直接去掉标记)。
5. 深色导航模块名被 flex 压缩折行("AI 预审"断成两行);
   加 flex:0 0 auto + white-space:nowrap。

另修一个既有测试脆弱点:test_trajectory.py 的更正用例断言"哪条排前面",
两条记录同刻度时排序翻转,曾让 verify.py 偶发失败;改为断言"两条都在、更正指向旧记录"。

回归防线:第 5 项补进 test_module_shell_ui.cjs 源码断言;
test_ui_readability.cjs 的导航字号断言改为不把 min-height 钉在规则首位。

验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),
证据 test-evidence/20261001-030127-ff59d7e/;scripts/browser-e2e.cjs 19/19 PASS
(截图在 test-evidence/e2e-batch4/)。该 E2E 脚本刻意不接入 verify.py。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 03:04:18 +08:00
co-authored by Claude Code
parent ff59d7e8f1
commit 4720d72965
14 changed files with 294 additions and 44 deletions
+7 -1
View File
@@ -170,7 +170,13 @@ class ReviewOutcomeTests(unittest.TestCase):
with store.transaction(self.service.db_path) as db:
rows = trajectory.listing(db, PROJECT)["items"]
self.assertEqual(len(rows), 2, "更正必须追加,不得覆盖旧记录")
self.assertEqual(rows[0]["raw_code"], "通过")
# 不断言"哪条排前面":两条记录可能落在同一时钟刻度,`listing()` 按 recorded_at 排序
# 时顺序就取决于底层 rowid 顺序。那是与本用例无关的偶然假设,且曾让全量验证偶发失败。
# 本用例要锁的是"旧记录仍在、更正指向它",与排序无关。
by_code = {row["raw_code"]: row for row in rows}
self.assertEqual(sorted(by_code), ["否决", "通过"], "两条结果都必须在,追加不得覆盖")
self.assertEqual(by_code["否决"]["supersedes_id"], first["id"])
self.assertIsNone(by_code["通过"]["supersedes_id"])
def test_correction_must_point_to_an_existing_record_of_the_project(self):
with self.assertRaises(ValueError):
+27 -11
View File
@@ -112,9 +112,14 @@ docs/project-process/README.md、XINGBANG-ADAPTATION.md,执行 git status --sh
- 完成范围:**纠偏第四批「接通评审反馈与改进发布」**——会议结果从页面录入并与材料版本对照;
改进候选接上**真实知识发布入口**,发布后新运行用新版本、旧运行仍可回放。
界面并入「轨迹复盘」模块(`/trajectory-center`),**不新增第四个导航入口**。
本轮另**首次做了真实浏览器端到端验证**(新增可选脚本 `scripts/browser-e2e.cjs`),
并因此发现并修掉 5 个源码守卫与生产构建都看不出的缺陷——其中一个是**三个模块页
在浏览器里根本渲染不出面板**(`/projects` 读错字段),见 17「浏览器验证发现的问题」。
`scripts/verify.py` **14 项检查**全通过(新增 `improvement-panel-ui` 源码守卫)。
- 验证入口:`cd F:/mini-OpenClaw-v2 && backend/venv/Scripts/python.exe scripts/verify.py`;
本轮证据:`test-evidence/20261001-013517-3e15bda/`(14/14 PASS)。
本轮证据:`test-evidence/20261001-030127-ff59d7e/`(14/14 PASS,backend-unit 572 项)。
浏览器验证证据:`node scripts/browser-e2e.cjs` → `test-evidence/e2e-batch4/`(19/19 PASS + 截图)。
**该脚本刻意不接入 verify.py**(verify 不应依赖浏览器),需手动跑。
- 测试规模:全量 **572 项** unittest 无回归(新增 `test_improvement_release.py` 4 项 +
`test_improvements.py` 1 项)。
- 卡片验收对照(详见 [17 四批交付记录](17-batch-delivery-records.md) 第四批):
@@ -131,11 +136,20 @@ docs/project-process/README.md、XINGBANG-ADAPTATION.md,执行 git status --sh
需端点后按 17 的操作步骤补一次真实调用;在此之前不得宣称"真实提炼已跑通"。
- **F18** 例外边界未进入可求值条件语言;**F19** 卡片适用条件不参与运行内逐项判定。
(同第二批,本批未动。)
- **F11** 界面仍未经浏览器验证:`/experience-center`、`/prescreen-workbench`、
`/trajectory-center`(本批新增的会议结果录入与改进面板也在内)都只有
"源码守卫 + 生产构建 + 代码走查"三层证据(本机无 playwright/puppeteer/chromium)。
本批另加了**真实服务器接口冒烟**(走界面同一串 HTTP 请求),但它同样不是浏览器点击。
深色底对比度已改为守卫内按 WCAG 实算(最低 8.37:1),配色达标 ≠ 实机版面已验收。
- **F11 部分关闭**:`/trajectory-center`(含本批的会议结果录入与改进面板)已在**真实 Chrome**
里端到端走过并通过(`scripts/browser-e2e.cjs`,19/19,有截图)。**仍开放的部分**:
① `/experience-center`、`/prescreen-workbench` 只验到"能渲染",交互没有逐项点过;
② 该脚本不在 `verify.py` 里,需手动跑(本机 Chrome + 一次性 `--no-save puppeteer-core`);
③ 无视觉基线比对、无多分辨率、无键盘/读屏检查。
深色底对比度仍是守卫内按 WCAG 实算,不是实测。
- **F27(新)浏览器验证暴露的缺陷已修但防线不全**:本批靠浏览器发现并修了 5 个缺陷
(见 17「浏览器验证发现的问题」)。其中只有"导航名折行"补进了源码守卫;
其余 4 个(读错 `/projects` 字段、漏传 `identity`、重复 React key、JSX 里的字面量 `**`)
目前只有 `browser-e2e.cjs` 覆盖,而它不在强制门禁里——**这类回归没有自动防线**。
是否把浏览器 E2E 接进 `verify.py`、是否把 `puppeteer-core` 写进 `package.json`,需人决策。
- **F28(新)模拟/演示数据随验证累积**:`scripts/xingbang_batch4_smoke.py` 与
`scripts/browser-e2e.cjs` 都**真的写演示库**(新建卡片、会议结果),重复运行会累积脏数据。
两者都在脚本头注明了这一点。
- **F20** 新旧经验入口并存(工作台旧「评审经验」页签);**F21** 首页默认路由已变更
`/` → `/assistant`。两者都需确认后处理。
- **F23(新)改进端点仍无角色门禁**:既有候选/方案端点对评测与发布有 `role()` 门禁,
@@ -153,11 +167,13 @@ docs/project-process/README.md、XINGBANG-ADAPTATION.md,执行 git status --sh
- **F8** Docker 运行时冒烟未实测(本机 Docker Desktop 守护进程不可达)。
- **F6** OQ-06 Q3–Q5:端口冲突核验、运行资源、Gitea CI 是否启用仍 open。
- **F12/F14/F16** 仍有效;**F13** 已由第一批关闭。
- 下一任务:四批纠偏已全部有交付(均记**部分完成**)。继续推进前先定:
补 **F17**(需要你提供模型端点与调用时机)或处理 **F11**(需要可用的浏览器自动化环境);
两者都需要你提供的输入,不要为了推进而把演示当验收。
- 待输入:模型端点与调用时机(F17);浏览器自动化环境(F11);真实数据/制度/会议标签映射与
客户身份权限(M5/M6);Laya 硬件与中文效果需实机探测;Jev 端点与凭据。
- 下一任务:四批纠偏已全部有交付(均记**部分完成**)。**F11 已可自行推进**
(本机有 Chrome,脚本已入库,无需外部环境);继续推进前需你定的两件事:
① 是否把浏览器 E2E 接进 `verify.py` / 写进 `package.json`(F27);
② 是否继续用真实浏览器把 `/experience-center`、`/prescreen-workbench` 也逐项点完(F11 剩余部分)。
补 **F17** 仍需要你提供模型端点与调用时机。不要为了推进而把演示当验收。
- 待输入:模型端点与调用时机(F17);真实数据/制度/会议标签映射与客户身份权限(M5/M6);
Laya 硬件与中文效果需实机探测;Jev 端点与凭据。
- Git:本轮提交 `f775c6c`(`feat(v2): 纠偏第四批——会议结果录入与改进发布接真实知识发布入口`)
在 `codex/xingbang-v2-model-field-fix` 分支,**尚未推送**;
核对命令 `git log --oneline -3`。
+40 -10
View File
@@ -271,11 +271,14 @@ XINGBANG_LOCAL_MODEL_KEY=<你的 DeepSeek API Key>
## 第四批:接通评审反馈与改进发布
状态:**部分完成**。会议结果可从页面录入并与材料版本对照;改进候选走**真实知识发布入口**,
发布后新运行使用新版本、旧运行仍可回放——两条断言都由真实执行链(enqueue + worker)证明。
**未闭合**:界面仍未经浏览器点击/截图验证(同 F11);会议结果是**模拟录入**,未经真实会议系统。
状态:**部分完成**(较本轮初版有实质性推进)。会议结果可从页面录入并与材料版本对照;
改进候选走**真实知识发布入口**,发布后新运行使用新版本、旧运行仍可回放——两条断言都由
真实执行链(enqueue + worker)证明。**本批首次做了真实浏览器端到端验证(F11 对这两个表面已关闭)**,
并因此发现并修掉 5 个源码守卫与生产构建都看不出的缺陷(见下「浏览器验证发现的问题」)。
**未闭合**:会议结果是**模拟录入**,未经真实会议系统;`/experience-center` 与 `/prescreen-workbench`
虽在同一次浏览器验证中被顺带修好,但其自身交互仍未逐项点过。
覆盖:AC-56、AC-57、BR-51。
覆盖:AC-56、AC-57、BR-51;并顺带修复 AC-53/AC-55 的运行时缺陷。
### 页面入口
@@ -336,14 +339,41 @@ AC-53 又要求恰好三个模块入口。`AgentNav.tsx` 未改动。
| 自报版本号被拒(API 层) | `backend/test_improvements.py` `test_client_supplied_released_ref_is_rejected` |
| 界面源码守卫(新增) | `scripts/test_improvement_panel.cjs`(已登记 `scripts/verify.py`,共 **14** 项检查) |
| 轨迹面板守卫(改:区分「回放只读」与「结果只可追加」) | `scripts/test_module_shell_ui.cjs` |
| 全量验证 | `test-evidence/20261001-013517-3e15bda/`(`scripts/verify.py` **14/14 PASS**,含 `backend-unit` 全量 **572** 项) |
| 全量验证 | `test-evidence/20261001-030127-ff59d7e/`(`scripts/verify.py` **14/14 PASS**,含 `backend-unit` 全量 **572** 项) |
| 真实服务器冒烟 | `scripts/xingbang_batch4_smoke.py`(对 127.0.0.1:8012 实跑,退出码 0) |
| **真实浏览器端到端** | `scripts/browser-e2e.cjs`(系统 Chrome;**19/19 PASS**);截图与 `result.json` 在 `test-evidence/e2e-batch4/` |
### 浏览器验证发现的问题(源码守卫与生产构建都查不出来)
首次用真实 Chrome 打开 `/trajectory-center` 后暴露的缺陷。「看起来正常的空态」是它们的共同点——
`/projects` 读错了字段,页面安静地显示"当前身份没有可访问的项目",构建与守卫全都通过。
| # | 问题 | 归属 | 处理 |
|---|---|---|---|
| 1 | 三个模块页读 `list.items`,而 `/projects` 返回的是 `{projects, identity}` → 项目列表恒为空,**整个模块页渲染不出任何面板** | 第二/三批引入(prescreen-workbench、experience-center、trajectory-center 三处) | 改为 `list.projects` |
| 2 | `TrajectoryPanel` 没被传 `identity`,`canRecord` 恒假 → **会议结果录入表单永远不出现** | 本批 | 传入 `identity` |
| 3 | `ImprovementPanel` 的草稿下拉以组件 id 作 key/value,同一组件有多个版本草稿时**撞 React key**,也选不准要发布哪一版 | 本批 | 改以 `组件id@版本` 为键值 |
| 4 | JSX 文本里写 `**强调**` 会**原样显示星号**(JSX 不渲染 Markdown),共 9 处 | 第一/二批 5 处、本批 4 处 | 改为 `<b>`(字符串上下文处直接去掉标记) |
| 5 | 深色导航的模块名被 flex 压缩**折行**("AI 预审"断成两行),三个入口读不清 | 第三批 | `flex:0 0 auto; white-space:nowrap` |
此外还修掉一个**既有测试脆弱点**:`test_trajectory.py` 的更正用例断言"哪条排前面",
而两条记录可能落在同一时钟刻度、排序随之翻转,曾让 `scripts/verify.py` 偶发失败。
改为断言"两条都在、更正指向旧记录",与排序无关;连跑 5 次稳定通过。
回归防线:第 5 项已补进 `scripts/test_module_shell_ui.cjs` 的源码断言;
`scripts/test_ui_readability.cjs` 的导航字号断言改为不把 `min-height` 钉在规则首位
(否则它会因新增属性而误报)。第 1–4 项属于"只有浏览器能看见"的一类,
目前只由 `scripts/browser-e2e.cjs` 覆盖——该脚本**刻意不接入 verify.py**。
### 剩余缺口
- **界面仍未经浏览器验证(F11 未变)**:本批新增的两个表面只有「源码守卫 + 生产构建
(`/trajectory-center` 3.87 → 8.86 kB)+ 真实服务器接口冒烟」三层证据,**没有浏览器点击或截图**;
表单交互、按钮、权限隐藏这些路径没有端到端证明。
- **F11 对本批两个表面已关闭,但未全面关闭**:`/trajectory-center` 已由 `scripts/browser-e2e.cjs`
在真实 Chrome 里走过(19/19),有截图;`/experience-center` 与 `/prescreen-workbench`
只验证到"能被本次路由改动修好、能渲染",其自身交互没有逐项点过,**仍记 F11 未闭合**。
另外该脚本**不在 `verify.py` 里**,也就是说这条证据不会随强制门禁自动重跑——
它需要本机 Chrome 与一次性 `npm i --no-save puppeteer-core`。
- **浏览器验证只覆盖到"能走通"这一层**:没有做视觉回归(无基线截图比对)、没有多分辨率、
没有键盘可达性与屏幕阅读器检查。深色导航对比度仍是守卫内按 WCAG 实算,不是实测。
- **会议结果是模拟录入**:AC-56 验的是"录入后与材料版本对照"的机制,不是真实会议系统对接;
真实标签映射与客户身份权限仍待确认(M5/M6)。
- **对照样本是人工录入的 JSON**,不是真实保留集;`evaluation.independent` 仍是**客户端声明**
@@ -354,5 +384,5 @@ AC-53 又要求恰好三个模块入口。`AgentNav.tsx` 未改动。
(现有 API 测试以 `manager` 身份调用),**未擅自扩大改动**,留待确认。安全上真正关键的
"不自批"已由服务端强制(`must_not_self_approve` + `registry.release()` 的 `edited_by == actor`)。
- **题库/编排无真实发布入口**:已按 409 诚实拒绝,非假接通;需要时另行设计。
- **顺带发现、未修**:`backend/test_trajectory.py:173` 依赖 `recorded_at` 排序,两条记录落在
同一时钟刻度时顺序会翻转(本次全量跑中出现过一次,重跑即通过)。属既有测试脆弱性,非本批引入。
- **浏览器 E2E 尚未固化为项目门禁**:是否把它接进 `verify.py`、是否把 `puppeteer-core`
写进 `package.json`,是产品/工程决策,本批**没有替用户决定**,只提供可选脚本。
+178
View File
@@ -0,0 +1,178 @@
/**
* 真实浏览器端到端验证(纠偏第四批新增;用于关闭 F11)
*
* 用系统已安装的 Chrome 驱动真实界面:深色导航 → 轨迹复盘 → 会议结果录入 → 改进对照面板。
* 它补的是 `scripts/test_*.cjs` 那批**源码字符串断言**补不上的那一层——第四批靠它抓到若干
* 源码守卫与生产构建都看不出的缺陷(详见 17 四批交付记录「浏览器验证发现的问题」)。
*
* **刻意不接入 `scripts/verify.py`**:verify 是本机随时可跑的强制门禁,不应依赖浏览器
* 是否存在。本脚本是可选证据,需要时手动跑。
*
* 依赖与用法:
* 1. 本机有 Chrome(或用 CHROME_BIN 指定路径)
* 2. npm i --no-save puppeteer-core # 不写入 package.json
* 3. 起后端:cd backend && XINGBANG_DEMO_ROLE_LOGIN=1 \
* venv/Scripts/python.exe -m uvicorn app:app --host 127.0.0.1 --port 8012
* 4. 起前端:RISK_AGENT_BACKEND_URL=http://127.0.0.1:8012 npm run dev -- -p 3212
* 5. node scripts/browser-e2e.cjs # 截图与 result.json 落在 E2E_OUT
*
* 注意:脚本会**真的写入**演示库(新增会议结果),不是只读冒烟。
*/
const fs = require("node:fs");
const path = require("node:path");
const puppeteer = require("puppeteer-core");
const CHROME = process.env.CHROME_BIN || "C:/Program Files/Google/Chrome/Application/chrome.exe";
const APP = process.env.E2E_APP || "http://localhost:3212";
const OUT = process.env.E2E_OUT || path.join(__dirname, "..", "test-evidence", "browser-e2e");
const ROLE = process.env.E2E_ROLE || "manager";
const PROJECT = process.env.E2E_PROJECT || "DEMO-B";
const results = [];
function check(label, ok, detail = "") {
results.push({ label, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
}
(async () => {
fs.mkdirSync(OUT, {recursive: true});
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: "new",
args: ["--no-proxy-server", "--window-size=1440,1200"],
});
const page = await browser.newPage();
await page.setViewport({width: 1440, height: 1200});
const consoleErrors = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
page.on("pageerror", (err) => consoleErrors.push("pageerror: " + err.message));
// 先落到同源页面,写入演示身份,再进目标页(应用从 sessionStorage 读身份)
await page.goto(`${APP}/experience-center`, {waitUntil: "domcontentloaded"});
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
await page.goto(`${APP}/trajectory-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
// 面板是异步取数后渲染的:必须等它真的出现,否则断言抓的是渲染前的空壳
await page.waitForFunction(
() => document.body.innerText.includes("运行轨迹回放")
&& document.body.innerText.includes("改进对照与发布"),
{timeout: 30000});
// ---------------------------------------------------------------- 深色导航与三入口(AC-53)
const nav = await page.evaluate(() => {
const el = document.querySelector("nav.xb-agent-nav");
if (!el) return null;
return {bg: getComputedStyle(el).backgroundColor,
items: [...el.querySelectorAll("a")].map((a) => a.textContent.trim())};
});
check("深色导航存在且为深色底", Boolean(nav) && nav.bg === "rgb(16, 43, 39)",
nav ? `bg=${nav.bg}` : "未找到 nav.xb-agent-nav");
for (const label of ["AI 预审", "历史经验加工", "轨迹复盘"]) {
check(`导航含「${label}」入口`, Boolean(nav) && nav.items.includes(label));
}
// 模块名不得被 flex 压缩折行(折行后 "AI 预审" 会断成两行,三个入口读不清)
const navHeights = await page.evaluate(() =>
[...document.querySelectorAll("nav.xb-agent-nav a")]
.map((a) => ({text: a.textContent.trim(), h: Math.round(a.getBoundingClientRect().height)})));
const wrapped = navHeights.filter((item) => item.h > 50);
check("导航项文字不折行", wrapped.length === 0, JSON.stringify(wrapped));
// ---------------------------------------------------------------- 轨迹复盘面板(AC-54)
const heading = await page.evaluate(() =>
document.querySelector("main h1") && document.querySelector("main h1").textContent.trim());
check("轨迹复盘标题渲染", heading === "轨迹复盘", `h1=${heading}`);
const panelText = await page.evaluate(() => document.body.innerText);
check("轨迹面板声明回放只读", /回放是只读的|只读回放/.test(panelText));
check("会议结果区声明更正只追加不覆盖", /不覆盖/.test(panelText));
check("未匹配标注不计入分母", /不计入分母/.test(panelText));
check("改进对照面板渲染", /改进对照与发布/.test(panelText));
check("改进面板声明版本号不由界面提交", /版本号不由界面提交/.test(panelText));
// ---------------------------------------------------------------- AC-56:真实录入一条会议结果
await page.evaluate(() => {
const node = [...document.querySelectorAll("h4")].find((h) => h.textContent.includes("追加会议结果"));
if (node) node.scrollIntoView({block: "center"});
});
const stamp = Date.now().toString().slice(-6);
const meetingId = `E2E-${stamp}`;
await page.type('input[aria-label="原始结果码"]', "附条件通过");
await page.type('input[aria-label="会议编号"]', meetingId);
await page.screenshot({path: path.join(OUT, "01-outcome-form.png")});
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => b.textContent.includes("保存会议结果"));
button.click();
});
await page.waitForFunction(
(id) => document.body.innerText.includes(id), {timeout: 15000}, meetingId
).catch(() => {});
const afterSave = await page.evaluate(() => document.body.innerText);
check("录入的会议结果出现在列表里", afterSave.includes(meetingId), `meeting_id=${meetingId}`);
// 只看 role="status" 的提示条:整页文本里到处都有"未匹配",用它判断会把任何页面都判成通过
const notice = await page.evaluate(() => {
const el = document.querySelector('[role="status"]');
return el ? el.textContent.trim() : "";
});
check("服务端返回未匹配时给出提示", /未匹配/.test(notice), `notice=${notice}`);
// ---------------------------------------------------------------- AC-57:改进面板的权限门
const publishButtons = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("审核发布")).length);
const releasedBlocks = await page.evaluate(() =>
(document.body.innerText.match(/已发布真实版本/g) || []).length);
check("改进面板渲染(有候选或空态)", /尚未改进候选|改进候选|拟发布/.test(afterSave));
const buttonTexts = await page.evaluate(() =>
[...document.querySelectorAll("button")].map((b) => b.textContent));
check("不存在自动上线类按钮",
!buttonTexts.some((text) => /自动上线|一键|自动发布/.test(text)));
console.log(` (当前「审核发布」按钮 ${publishButtons} 个;已发布标记 ${releasedBlocks} 处)`);
// ---------------------------------------------------------------- 结论:先结算已登录阶段
const noisy = (list) => list.filter((text) => !/favicon|404 \(Not Found\)/i.test(text));
const authenticatedErrors = noisy(consoleErrors).slice();
check("已登录阶段控制台无脚本错误", authenticatedErrors.length === 0,
authenticatedErrors.slice(0, 2).map((t) => t.slice(0, 90)).join(" | "));
// ---------------------------------------------------------------- 无有效身份:界面不假装可写
consoleErrors.length = 0;
await page.evaluate(() => sessionStorage.setItem("xingbang-demo-role", "preview-none"));
await page.goto(`${APP}/trajectory-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
await new Promise((r) => setTimeout(r, 1500));
const anonymousText = await page.evaluate(() => document.body.innerText);
const anonymousInputs = await page.evaluate(() =>
[...document.querySelectorAll("input")].map((i) => i.getAttribute("aria-label")).filter(Boolean));
check("无有效身份时不显示会议结果录入表单",
!anonymousInputs.includes("原始结果码"), `可见输入框=${JSON.stringify(anonymousInputs)}`);
// 无效身份下项目列表也取不到,面板根本不挂载——这与"显示只读说明"都是不假装可写
check("无有效身份时给出如实说明(只读提示或无可访问项目空态)",
/只读查看|需要记录人身份|没有可访问的项目/.test(anonymousText));
check("后端对无效演示身份返回 401(界面没有绕过)",
consoleErrors.some((text) => /401/.test(text)));
await page.screenshot({path: path.join(OUT, "02-anonymous.png")});
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
await page.goto(`${APP}/trajectory-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(() => document.body.innerText.includes("运行轨迹回放"), {timeout: 30000});
await page.screenshot({path: path.join(OUT, "03-trajectory-center-full.png"), fullPage: true});
await browser.close();
const failed = results.filter((item) => !item.ok);
fs.writeFileSync(path.join(OUT, "result.json"),
JSON.stringify({role: ROLE, project: PROJECT, meetingId,
authenticatedConsoleErrors: authenticatedErrors.map((t) => t.slice(0, 300)),
results}, null, 2));
console.log(`\n截图与结果写入 ${OUT}`);
console.log(`总计 ${results.length} 项,失败 ${failed.length} 项`);
process.exit(failed.length ? 1 : 0);
})().catch((err) => {
console.error("E2E 运行失败:", err);
process.exit(2);
});
+5
View File
@@ -56,6 +56,11 @@ assert.match(nav, /业务工作区/, "导航应保留「业务工作区」分组
assert.match(nav, /<IdentitySelector[^>]*dark/, "导航内的身份选择器应使用深色变体");
// 画布仍是浅色:不要把整页刷黑
assert.match(read("src/app/data-access/workspace.css"), /\.ra-app\{min-height:100vh;background:#f5f7f9/, "工作区画布应保持浅色");
// 模块名不得被 flex 压缩折行:折行后「AI 预审」会断成两行,三个入口读不清。
// 这类缺陷只在浏览器里可见(源码与构建都正常),是第四批浏览器验证发现的,这里补锁。
assert.match(read("src/components/xingbang/navigation.css"),
/\.xb-agent-nav a\{[^}]*flex:0 0 auto[^}]*white-space:nowrap/,
"导航项必须 flex:0 0 auto + white-space:nowrap,避免模块名折行");
// ---------------------------------------------------------------- 2. 三个独立入口
const MODULES = [
+3 -1
View File
@@ -14,7 +14,9 @@ const operations = read("src/app/operations-center/operations.css");
assert.match(globals, /font-size:\s*16px/, "全局正文应提供清晰的基础字号");
assert.match(globals, /:focus-visible/, "键盘操作应有明显焦点提示");
assert.match(navigation, /\.xb-agent-nav a\{min-height:42px[^}]*font-size:14px/, "主导航应扩大文字和点击区域");
// 不把 min-height 钉在规则首位:第四批为防模块名折行在前面加了 flex/white-space,
// 断言的是"这条规则仍设了 42px 点击区域与 14px 字号"这个意图。
assert.match(navigation, /\.xb-agent-nav a\{[^}]*min-height:42px[^}]*font-size:14px/, "主导航应扩大文字和点击区域");
assert.match(navigation, /\.xb-role-switch\{min-height:42px/, "身份选择器应保留足够大的点击区域");
assert.match(workspace, /\.ra-button\{min-height:42px[^}]*font-size:14px[^}]*font-weight:700/, "工作台按钮应清晰且易点击");
assert.match(workspace, /\.ra-button\.primary\{background:#17634c/, "主操作按钮应采用稳定的主色强调");
+4 -2
View File
@@ -43,9 +43,11 @@ export default function ExperienceCenterPage() {
send("GET", "/projects"),
]);
setIdentity(context);
setProjects(list.items || list || []);
// 同上:`/projects` 返回 {projects, identity},读 `list.items || list` 会拿不到数组。
const items = list.projects || [];
setProjects(items);
const requested = new URLSearchParams(location.search).get("project");
const first = (list.items || list || [])[0];
const first = items[0];
if (requested) setProjectId(requested);
else if (first) setProjectId(first.id);
} catch (e: any) {setError(e.message);} finally {setLoaded(true);}
+4 -1
View File
@@ -64,7 +64,10 @@ export default function PrescreenWorkbenchPage() {
request("/projects"),
]);
setIdentity(context);
const items = list.items || list || [];
// `/projects` 返回 {projects, identity},不是 {items}。读 `list.items || list` 拿不到数组,
// 项目列表恒为空、整页只剩"没有可访问的项目"空态——而空态看着是合理页面,
// 所以源码守卫与生产构建都查不出来(第四批浏览器验证才发现)。
const items = list.projects || [];
setProjects(items);
const wanted = new URLSearchParams(location.search).get("project") || items[0]?.id || "";
if (wanted) {setProjectId(wanted); await open(wanted);}
+3 -2
View File
@@ -49,7 +49,8 @@ export default function TrajectoryCenterPage() {
send("GET", "/projects"),
]);
setIdentity(context);
const items = list.items || list || [];
// 同上:`/projects` 返回 {projects, identity},读 `list.items || list` 会拿不到数组。
const items = list.projects || [];
setProjects(items);
const wanted = new URLSearchParams(location.search).get("project") || items[0]?.id || "";
if (wanted) {
@@ -92,7 +93,7 @@ export default function TrajectoryCenterPage() {
{projects.map((item) => <option key={item.id} value={item.id}>{item.id} · {item.customer || ""}</option>)}
</select>
</label>
{project && <TrajectoryPanel key={project.id} project={project} send={send}/>}
{project && <TrajectoryPanel key={project.id} project={project} identity={identity} send={send}/>}
{project && <ImprovementPanel key={`imp-${project.id}`} project={project}
identity={identity} send={send}/>}
</>}
+4 -4
View File
@@ -128,7 +128,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
<section className="xb-panel xb-wide">
<h2>① 来源:历史过程时间线</h2>
<p>时间线由项目材料**确定性**恢复,不调用模型、不编造经过。只有最终报告时会标"过程材料不完整",不生成虚构的问询顺序。</p>
<p>时间线由项目材料<b>确定性</b>恢复,不调用模型、不编造经过。只有最终报告时会标"过程材料不完整",不生成虚构的问询顺序。</p>
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={buildTimeline}><RefreshCw size={15}/>从项目材料重建时间线</button>
{timeline && <span className="ra-badge">{timeline.status === "partial" ? "过程材料不完整" : "已恢复"} · {events.length} 个事件</span>}
@@ -153,7 +153,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
<section className="xb-panel xb-wide">
<h2>② 提炼:用真实模型生成候选</h2>
<p>提炼会**实际调用**已配置的模型。模型不可用时明确记为未完成(E-28),并且**不产生任何候选草稿**——这里不会用一个固定返回值冒充提炼成功。</p>
<p>提炼会<b>实际调用</b>已配置的模型。模型不可用时明确记为未完成(E-28),并且<b>不产生任何候选草稿</b>——这里不会用一个固定返回值冒充提炼成功。</p>
<label className="ra-field"><span>提炼要求(可选,只约束本次加工,不成为正式制度)</span>
<input value={requirement} disabled={busy} placeholder="例如:重点整理现金流与利润不一致的核查方法"
onChange={(e) => setRequirement(e.target.value)}/>
@@ -177,7 +177,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
<section className="xb-panel xb-wide">
<h2>③ 候选:专家修订、评测与发布</h2>
<p>修订只产生草稿,不直接生效;发布需要先通过隔离评测,并由**另一位**有发布资格的人操作。已发布版本不可覆盖,修订会升版本并保留父版本。</p>
<p>修订只产生草稿,不直接生效;发布需要先通过隔离评测,并由<b>另一位</b>有发布资格的人操作。已发布版本不可覆盖,修订会升版本并保留父版本。</p>
{!candidates.length && <div className="ra-empty"><h2>尚无知识候选</h2><p>先提炼或人工新增一张候选卡片。</p></div>}
{candidates.map((card: any) => <div className="xb-finding" key={card.id}>
@@ -237,7 +237,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
<section className="xb-panel xb-wide">
<h2>④ 方案:让卡片真正影响后续核查</h2>
<p>卡片只有在被选入**已发布方案**后才会影响运行;运行结果会冻结并记录卡片版本,核查项可追溯到 <code>卡片id@版本</code>。</p>
<p>卡片只有在被选入<b>已发布方案</b>后才会影响运行;运行结果会冻结并记录卡片版本,核查项可追溯到 <code>卡片id@版本</code>。</p>
{!plans.length && <p className="ra-note">还没有项目自建方案。在上方已发布卡片上点"选入新方案"即可装配。</p>}
{plans.map((plan: any) => <div className="xb-finding" key={`${plan.id}@${plan.version}`}>
<div>
+1 -1
View File
@@ -100,7 +100,7 @@ export default function ImportBatchPanel({project, request, base, headers, busy,
const open = batches.find((batch) => batch.id === openId) || null;
return <section className="xb-panel xb-wide">
<h2>材料集合导入</h2>
<p>一次登记一批文件,再逐个上传。登记只生成清单;**未上传或未确认的文件不会进入项目事实**。多来源数值不一致时会保留冲突并要求人工裁定,不会按上传先后覆盖。</p>
<p>一次登记一批文件,再逐个上传。登记只生成清单;<b>未上传或未确认的文件不会进入项目事实</b>。多来源数值不一致时会保留冲突并要求人工裁定,不会按上传先后覆盖。</p>
<label className="ra-field"><span>选择文件集合(PDF / XLSX / CSV / TXT / DOCX / 图片;单文件 ≤ 10 MB,单批 ≤ 20 个文件且合计 ≤ 20 MB)</span>
<input aria-label="选择材料集合" type="file" multiple disabled={busy} onChange={(e) => {pickFiles(e.target.files); e.target.value = "";}}/>
+8 -5
View File
@@ -95,8 +95,11 @@ export default function ImprovementPanel({project, identity, send}: any) {
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
}
const draftCards = cards.filter((card: any) => card.status === "draft");
const proposedVersion = draftCards.find((card: any) => card.id === draft.target_ref);
// 同一个组件可以同时存在多个版本的草稿(如 1.0.0 与 1.0.1),
// 因此下拉必须以 `组件id@版本` 为键值——只用组件 id 会撞 key,也选不准要发布哪一版。
const draftCards = cards.filter((card: any) => card.status === "draft")
.map((card: any) => ({...card, ref: `${card.id}@${card.version}`}));
const proposedVersion = draftCards.find((card: any) => card.ref === draft.target_ref);
// 被改进的是"当前已发布的版本",拟发布的是那张草稿——两者是不同版本,不能混为一谈
const published = proposedVersion && cards.find(
(card: any) => card.status === "published" && card.id === proposedVersion.id);
@@ -148,7 +151,7 @@ export default function ImprovementPanel({project, identity, send}: any) {
return <section className="xb-panel xb-wide">
<h2>改进对照与发布</h2>
<p>从可核验的偏差出发:选来源运行或会议结果 → 提出候选修订 → 固定旧版做对照 → 独立审核 →
**由服务端调用真实知识发布入口**。发布只影响后续新运行;旧运行的冻结版本不变,仍可回放。</p>
<b>由服务端调用真实知识发布入口</b>。发布只影响后续新运行;旧运行的冻结版本不变,仍可回放。</p>
{error && <div role="alert" className="ra-alert error">{error}</div>}
{notice && <div role="status" className="ra-alert">{notice}</div>}
{!loaded && <p>正在读取改进候选…</p>}
@@ -186,7 +189,7 @@ export default function ImprovementPanel({project, identity, send}: any) {
<select aria-label="要发布的草稿版本" value={draft.target_ref} disabled={busy}
onChange={(e) => setDraft({...draft, target_ref: e.target.value})}>
<option value="">选择一张草稿卡片</option>
{draftCards.map((card: any) => <option key={card.id} value={card.id}>
{draftCards.map((card: any) => <option key={card.ref} value={card.ref}>
{card.title} · v{card.version}
</option>)}
</select></label>
@@ -285,7 +288,7 @@ export default function ImprovementPanel({project, identity, send}: any) {
})}
<p className="ra-note">
发布由服务端调用真实知识发布入口,**版本号不由界面提交**;发布失败会保留旧版本,
发布由服务端调用真实知识发布入口,<b>版本号不由界面提交</b>;发布失败会保留旧版本,
候选也不会被标成已发布。这里没有"自动上线",发布后也不会自动发起运行。
</p>
</section>;
+7 -5
View File
@@ -92,7 +92,7 @@ export default function TrajectoryPanel({project, identity, send}: any) {
const saved = await send("POST", `${PRESCREEN}${path}/review-outcomes`, body);
setNotice(saved.status === "matched"
? `已保存并关联材料版本 ${saved.reviewed_snapshot_id};计入分母前仍需口径映射。`
: "已保存为**未匹配**:找不到对应材料版本,单列且不计入通过率分母。");
: "已保存为未匹配:找不到对应材料版本,单列且不计入通过率分母。");
setForm(EMPTY_FORM);
await loadOutcomes();
} catch (e: any) {setError(e.message);} finally {setBusy(false);}
@@ -107,7 +107,7 @@ export default function TrajectoryPanel({project, identity, send}: any) {
return <section className="xb-panel xb-wide">
<h2>运行轨迹回放</h2>
<p>一次运行是一条轨迹,多次工具与模型调用是其中的步骤。这里只记录**可观察的动作与版本**,不包含模型内部推理;回放是只读的,不会改动这次运行。</p>
<p>一次运行是一条轨迹,多次工具与模型调用是其中的步骤。这里只记录<b>可观察的动作与版本</b>,不包含模型内部推理;回放是只读的,不会改动这次运行。</p>
{!loaded && <p>正在读取轨迹…</p>}
{loaded && !runs.length && <div className="ra-empty">
@@ -161,8 +161,8 @@ export default function TrajectoryPanel({project, identity, send}: any) {
</>}
<h3>会议结果对照</h3>
<p>AI 轨迹与人工会议记录并列查看。匹配只基于可核验的项目、材料版本:找不到材料版本的结果**单列**,
既不当否决、也不进已观察通过率的分母。更正只能**追加**并指向旧记录,历史不被覆盖。</p>
<p>AI 轨迹与人工会议记录并列查看。匹配只基于可核验的项目、材料版本:找不到材料版本的结果<b>单列</b>,
既不当否决、也不进已观察通过率的分母。更正只能<b>追加</b>并指向旧记录,历史不被覆盖。</p>
{notice && <div role="status" className="ra-alert">{notice}</div>}
{!outcomes && <p>正在读取会议结果…</p>}
@@ -235,7 +235,9 @@ function OutcomeRow({item}: any) {
<b>{item.raw_code} · {item.status === "matched" ? "已关联材料版本" : "未匹配(单列)"}</b>
<p>{item.normalized ? `归一化结果 ${item.normalized}` : "口径未映射,保留原码"}
{item.label === true ? " · 计入通过" : item.label === false ? " · 计入未通过" : " · 不计入分母"}</p>
<p><small><Clock size={12} aria-hidden="true"/> {new Date(item.occurred_at).toLocaleString()} · 记录人 {item.recorded_by}
<p><small><Clock size={12} aria-hidden="true"/> 会议 {item.meeting_id}
{item.reviewed_snapshot_id ? ` · 材料版本 ${item.reviewed_snapshot_id}` : ""}
{" · "}{new Date(item.occurred_at).toLocaleString()} · 记录人 {item.recorded_by}
{item.supersedes_id ? ` · 更正自 ${item.supersedes_id}` : ""}</small></p>
</div>
</div>;
+3 -1
View File
@@ -7,7 +7,9 @@
/* Business UI readability: keep navigation and identity obvious at a glance. */
.xb-agent-nav{min-height:64px;padding:11px 24px;gap:10px;font-family:"Microsoft YaHei","PingFang SC",sans-serif;background:#102B27}
.xb-agent-nav a{min-height:42px;padding:10px 13px;border-radius:9px;font-size:14px;font-weight:600;color:#DCE9E3;transition:background-color .16s,color .16s,box-shadow .16s}
/* flex:0 0 auto + nowrap:模块名不得被压缩折行("AI 预审" 会断成两行)。
容器仍保留换行,窄屏时整项换行而不是把标签撕开。 */
.xb-agent-nav a{flex:0 0 auto;white-space:nowrap;min-height:42px;padding:10px 13px;border-radius:9px;font-size:14px;font-weight:600;color:#DCE9E3;transition:background-color .16s,color .16s,box-shadow .16s}
.xb-agent-nav a.selected{background:#244D41;color:#FFFFFF;box-shadow:inset 0 0 0 1px #4b7d6b,0 3px 10px #00000033}
.xb-brand-mark{width:36px;height:36px;font-size:17px}
.xb-brand b{font-size:15px}.xb-brand small{font-size:11px}