fix(v2): 修掉预审面板不刷新与候选行同 id 连开多行的缺陷

真实 Chrome 端到端验证第二篇(/prescreen-workbench、/experience-center)发现两个
源码守卫与生产构建都看不见的缺陷:

- 发起 AI 预审、运行结束后 PrescreenPanel 不重载:新运行进不了"查看哪一次运行",
  面板继续显示上一次运行,而徽标写着"结果会自动刷新"。根因是 load() 只依赖
  project.id;补修时又发现 worker 先写 run.status、再另写 prescreen_run 视图
  (assistant_worker.process_next),两者不在同一事务,只读一次会读到视图还没
  落库的中间态,因此改为按运行集合重载并在最新运行进入视图前短时重试。
- 候选区 key={card.id} 撞 React key,且「自然语言修改适用范围」「补充独立测试」
  按 card.id 开合,点一行连开 4 行(同一 card.id 的草稿行与各版本行共享 id)。
  改为行级 rowKey(组件id@版本@状态)。

同时新增可选脚本 scripts/browser-e2e-modules.cjs(42/42 通过),并给
test_prescreen_ui.cjs / test_experience_center_ui.cjs 补上对应源码断言。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:35:29 +08:00
co-authored by Claude Code
parent 8e2473cf3e
commit c880f40e4c
5 changed files with 498 additions and 6 deletions
+439
View File
@@ -0,0 +1,439 @@
/**
* 真实浏览器端到端验证(第二篇):AI 预审 / 历史经验加工
*
* 第四批用 `browser-e2e.cjs` 走过 `/trajectory-center`,并把 F11 记为「部分关闭」:
* `/experience-center` 与 `/prescreen-workbench` 当时只验到「能渲染」,交互没有逐项点过。
* 本脚本补的就是这两个表面——它驱动的都是**真实点击**(发起预审、重建时间线、请求提炼、
* 展开依据抽屉、切换身份),断言取真实 DOM 与真实执行链的结果,不做接口 mock。
*
* **刻意不接入 `scripts/verify.py`**:verify 是本机随时可跑的强制门禁,不应依赖浏览器
* 是否存在。是否把它写进门禁是产品/工程决策(见 15 交接第 7 节 F27)。
*
* 依赖与用法:
* 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-modules.cjs # 截图与 result.json 落在 E2E_OUT
*
* 注意:脚本**真的写演示库**,不是只读冒烟——每次运行会
* ① 新建一次预审运行(`/prescreen-workbench` 的「开始 AI 预审」);
* ② 重建一次 DEMO-B 的历史时间线(同 id 递增 revision,不新建对象);
* ③ 留下一条 `status=failed` 的提炼记录(未配置模型时的真实结果,E-28)。
* 与 F28 记录的现象同类:重复运行会累积。提炼那一条是本脚本要证明的「失败就是失败」,
* 不是脏数据被隐藏。
*/
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-modules");
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: String(detail)});
console.log(`${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
}
const bodyText = (page) => page.evaluate(() => document.body.innerText);
const noisy = (list) => list.filter((text) => !/favicon|404 \(Not Found\)/i.test(text));
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const shot = (page, name, fullPage = false) => page.screenshot({path: path.join(OUT, name), fullPage});
/** 点击第一个满足条件的按钮;找不到或禁用则返回 false。 */
const clickButton = (page, match) => page.evaluate((needle) => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes(needle));
if (!button || button.disabled) return false;
button.click();
return true;
}, match);
// ------------------------------------------------------------------ AI 预审
async function sectionPrescreen(page, consoleErrors) {
console.log("\n=== AI 预审(/prescreen-workbench)===");
await page.goto(`${APP}/prescreen-workbench?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(
() => document.body.innerText.includes("预审与推进沙盘")
|| document.body.innerText.includes("没有可访问的项目"),
{timeout: 30000});
const heading = await page.evaluate(() => {
const node = document.querySelector("main h1");
return node ? node.textContent.trim() : null;
});
check("AI 预审标题渲染", heading === "AI 预审工作台", `h1=${heading}`);
// 这一条正是第四批的缺陷 1:读错 `/projects` 字段时项目列表恒为空、整页只剩空态,
// 而空态看着完全合理。所以断言必须落在"下拉里真的有项目"上。
const projects = await page.evaluate(() => {
const select = document.querySelector('select[aria-label="选择项目"]');
return select ? [...select.options].map((item) => item.value) : null;
});
check("项目下拉有可选项(/projects 字段读取正确)",
Boolean(projects && projects.length), JSON.stringify(projects));
const plans = await page.evaluate(() => {
const select = document.querySelector('select[aria-label="评审方案"]');
return select ? [...select.options].map((item) => item.textContent.trim()) : null;
});
check("评审方案下拉有可选项", Boolean(plans && plans.length), JSON.stringify(plans));
const runOptions = () => page.evaluate(() => {
const select = document.querySelector('select[aria-label="选择预审运行"]');
return select ? [...select.options].map((item) => item.textContent.trim()) : [];
});
const snapshotLine = () => page.evaluate(() => {
const match = document.body.innerText.match(/输入快照 (\S+)/);
return match ? match[1] : "";
});
const runsBefore = await runOptions();
const snapshotBefore = await snapshotLine();
// ---- 真实点击「开始 AI 预审」:这是第一批留下的"界面点击未由自动化驱动"缺口
check("「开始 AI 预审」按钮可点击", await clickButton(page, "开始 AI 预审"));
await page.waitForFunction(() => {
const node = document.querySelector('[role="status"]');
return node && node.textContent.includes("预审已进入队列");
}, {timeout: 20000}).catch(() => {});
const notice = await page.evaluate(() => {
const node = document.querySelector('[role="status"]');
return node ? node.textContent.trim() : "";
});
check("点击后给出入队提示", /预审已进入队列/.test(notice), `notice=${notice}`);
// 顺序很重要:先等页面**看见**这次运行在执行(徽标出现),再等它结束。
// 反过来写会立刻成立——setNotice 先于 open() 完成,那一刻 project.runs 里还没有新运行,
// "徽标不存在"于是为真,后面全部断言都落在上一次运行的面板上(第一次跑就是这样误判的)。
await page.waitForFunction(() => document.body.innerText.includes("预审执行中,结果会自动刷新"),
{timeout: 90000, polling: 300}).catch(() => {});
await page.waitForFunction(() => !document.body.innerText.includes("预审执行中,结果会自动刷新")
&& document.body.innerText.includes("预审与推进沙盘"),
{timeout: 240000, polling: 500}).catch(() => {});
check("新运行执行结束(页面不再显示执行中)",
!(await bodyText(page)).includes("预审执行中,结果会自动刷新"));
// 徽标写着"结果会自动刷新":先不刷新页面,看面板自己有没有跟上新运行。
// 给它一个有上限的宽限:run.status 与预审视图不是同一事务写入的,晚一两拍是正常的;
// 但"永远不刷新"是缺陷——面板原先只在 project.id 变化时 load(),发起的运行根本进不来。
const panelPickedUp = await page.waitForFunction((count) => {
const select = document.querySelector('select[aria-label="选择预审运行"]');
return select && select.options.length > count;
}, {timeout: 12000, polling: 500}, runsBefore.length).then(() => true).catch(() => false);
check("发起预审后结果面板自动刷新(无需手动刷新页面)", panelPickedUp,
`不刷新页面时运行下拉 ${runsBefore.length}→${(await runOptions()).length}`);
// 刷新页面:证明新运行确实在服务端,缺的只是面板的刷新时机
await page.reload({waitUntil: "networkidle2"});
// 面板外壳是静态 JSX,标题一出现就断言会量到"还没取数的空壳"(第一次跑就栽在这里)
await page.waitForFunction((count) => {
const select = document.querySelector('select[aria-label="选择预审运行"]');
return select && select.options.length > count;
}, {timeout: 30000}, runsBefore.length).catch(() => {});
await wait(500);
const afterRun = await bodyText(page);
const runsAfter = await runOptions();
const snapshotAfter = await snapshotLine();
check("刷新页面后能读到本次发起的预审记录",
runsAfter.length > runsBefore.length || (snapshotAfter && snapshotAfter !== snapshotBefore),
`运行下拉 ${runsBefore.length}→${runsAfter.length};输入快照 ${snapshotBefore}→${snapshotAfter}`);
const statusLine = (afterRun.match(/运行 (已完成|部分完成|未完成|已取消|排队中|执行中)/) || [])[1] || "";
check("运行状态为终态", Boolean(statusLine) && !["排队中", "执行中"].includes(statusLine), statusLine);
// ---- 模型不可用时如实标未完成(BR-47)。配了模型时这条降级记录本就不该出现。
const degradation = await page.evaluate(() => {
const node = [...document.querySelectorAll("p")]
.find((p) => p.textContent.includes("未完成项:"));
return node ? node.textContent.trim() : "";
});
if (degradation) {
check("模型不可用时如实显示未完成项与错误码",
/([A-Z0-9_-]+)/.test(degradation), degradation.slice(0, 140));
} else {
check("本次运行无降级记录(已配置模型,非失败路径)", true);
}
// ---- 概率口径:数字要么不出现,要么必须紧邻"演示值/人工设定"
const probability = await page.evaluate(() => {
const node = [...document.querySelectorAll("div.xb-finding")]
.find((d) => d.textContent.includes("审批通过概率"));
return node ? node.innerText : "";
});
const hasPercent = /\d+%/.test(probability);
const labelled = /演示值|人工设定/.test(probability);
// 概率区必须真的渲染出来:空串会让"没有裸数字"变成一句空断言
check("概率数字必带演示值/人工设定说明(AC-12)",
probability.length > 0 && (!hasPercent || labelled),
probability.split("\n").slice(0, 2).join(" / ").slice(0, 140));
check("声明证据阶段不等于审批概率", /证据阶段 ≠ 审批概率/.test(afterRun));
// ---- 依据抽屉:真实打开、真实关闭、焦点回位
const opened = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => /^查看 .+ 的依据$/.test(b.getAttribute("aria-label") || ""));
if (!button) return null;
button.dataset.e2eOpener = "1";
button.click();
return button.getAttribute("aria-label");
});
check("存在「查看依据」按钮", Boolean(opened), opened || "未找到");
await page.waitForFunction(() => Boolean(document.querySelector('[role="dialog"]')), {timeout: 10000})
.catch(() => {});
const dialog = await page.evaluate(() => {
const node = document.querySelector('[role="dialog"]');
return node ? {label: node.getAttribute("aria-label"), text: node.innerText} : null;
});
check("依据抽屉打开并给出依据与版本",
Boolean(dialog) && /依据与版本/.test(dialog.text), dialog ? dialog.label : "未打开");
await shot(page, "03-prescreen-basis-drawer.png");
await clickButton(page, "关闭依据");
await wait(300);
check("关闭依据后抽屉消失",
await page.evaluate(() => !document.querySelector('[role="dialog"]')));
check("关闭依据后焦点回到触发按钮",
await page.evaluate(() => document.activeElement && document.activeElement.dataset.e2eOpener === "1"));
// ---- 推进沙盘:有假设动作就点一个真的重算;没有就断言它是"诚实的空态"
const assumptions = await page.evaluate(() => {
const heading = [...document.querySelectorAll("h3")].find((h) => h.textContent.includes("推进沙盘"));
if (!heading) return null;
return [...heading.parentElement.querySelectorAll('input[type="checkbox"]')]
.map((box) => (box.closest("label") ? box.closest("label").innerText.trim().slice(0, 40) : ""));
});
if (assumptions === null) {
check("存在推进沙盘区", false, "未找到推进沙盘标题");
} else if (!assumptions.length) {
// 假设动作只指向本次方案真实存在的维度(服务端 available_assumptions),
// 演示方案的维度里没有 DC-CASH/DC-TITLE,因此这里给出空态而不是必然失败的选项。
check("推进沙盘无可用动作时给出诚实空态",
/当前方案没有可推演的假设动作/.test(afterRun),
"演示方案的维度不含假设动作目标维度");
} else {
const box = await page.evaluate(() => {
const heading = [...document.querySelectorAll("h3")].find((h) => h.textContent.includes("推进沙盘"));
const input = heading.parentElement.querySelector('input[type="checkbox"]');
input.click();
return true;
});
check("推进沙盘假设动作可勾选", box, JSON.stringify(assumptions));
await page.waitForFunction(
() => /所选情景|情景未完成|该情景未完成/.test(document.body.innerText),
{timeout: 20000}).catch(() => {});
const scenario = await bodyText(page);
check("勾选后给出情景结果或如实标未完成",
/所选情景|情景未完成|该情景未完成/.test(scenario));
check("情景不改写真实预审(仍显示基线待核实项)", /真实预审保持不变/.test(scenario));
await clickButton(page, "返回真实预审");
await wait(500);
check("可返回真实预审(恢复基线)",
!/真实预审保持不变/.test(await bodyText(page)));
}
await shot(page, "04-prescreen-workbench.png", true);
}
// ------------------------------------------------------------------ 历史经验加工
async function sectionExperience(page, consoleErrors) {
console.log("\n=== 历史经验加工(/experience-center)===");
consoleErrors.length = 0;
await page.goto(`${APP}/experience-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(
() => document.body.innerText.includes("① 来源")
|| document.body.innerText.includes("没有可访问的项目"),
{timeout: 30000});
const heading = await page.evaluate(() => {
const node = document.querySelector("main h1");
return node ? node.textContent.trim() : null;
});
check("历史经验加工标题渲染", heading === "历史经验加工中心", `h1=${heading}`);
const loaded = await bodyText(page);
for (const label of ["① 来源", "② 提炼", "③ 候选", "④ 方案"]) {
check(`四段流程含「${label}」`, loaded.includes(label));
}
const projects = await page.evaluate(() => {
const select = document.querySelector('select[aria-label="选择项目"]');
return select ? [...select.options].map((item) => item.value) : null;
});
check("项目下拉有可选项(/projects 字段读取正确)",
Boolean(projects && projects.length), JSON.stringify(projects));
// ---- ① 时间线:真实点击重建
const cardRows = () => page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("自然语言修改适用范围")).length);
check("「从项目材料重建时间线」按钮可点击", await clickButton(page, "从项目材料重建时间线"));
await page.waitForFunction(
() => /已恢复 · \d+ 个事件|过程材料不完整 · \d+ 个事件/.test(document.body.innerText),
{timeout: 30000}).catch(() => {});
const timelineText = await bodyText(page);
const timelineBadge = (timelineText.match(/(已恢复|过程材料不完整) · (\d+) 个事件/) || []);
check("点击后时间线给出状态与事件数", Boolean(timelineBadge[0]), timelineBadge[0] || "未出现徽标");
check("时间线不编造经过(无结构化记录时明确标注不完整)",
!/过程材料不完整/.test(timelineText) || /未发现结构化的问询\/决定记录/.test(timelineText));
// act() 期间 busy=true 会让按钮保持禁用;先等这次重建真正结束,否则量到的是中间态
await page.waitForFunction(() => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("请求提炼"));
return button && !button.disabled;
}, {timeout: 15000}).catch(() => {});
const extractEnabled = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("请求提炼"));
return button ? !button.disabled : null;
});
check("时间线就绪后「请求提炼」可用", extractEnabled === true);
// ---- ② 真实提炼:模型没配就必须是"未完成",且一张候选都不许产生(BR-48)
const before = await cardRows();
// 先走一次"没有来源事件、也没有提炼要求":服务端按契约拒绝,界面必须如实显示这条理由,
// 而不是安静地什么都不发生或者伪造一条草稿。
await clickButton(page, "请求提炼");
await page.waitForFunction(() => Boolean(document.querySelector('[role="alert"]')), {timeout: 15000})
.catch(() => {});
const rejection = await page.evaluate(() => {
const node = document.querySelector('[role="alert"]');
return node ? node.textContent.trim() : "";
});
check("没有来源事件也没有提炼要求时如实拒绝",
/至少给出一个来源事件或一条提炼要求/.test(rejection), rejection.slice(0, 120));
// 再真的发一次能通过的提炼。演示项目的时间线是 0 个事件(材料里没有结构化的问询/决定记录),
// 所以这里用"提炼要求"满足服务端的前置条件——顺带说明来源事件勾选在本数据集下点不到。
await page.type('input[placeholder*="重点整理"]', "重点整理回款依赖的核查方法");
check("「请求提炼」按钮可点击", await clickButton(page, "请求提炼"));
await page.waitForFunction(
() => /提炼未完成|提炼完成/.test(document.body.innerText), {timeout: 90000}).catch(() => {});
const afterExtract = await bodyText(page);
check("提炼记录被渲染出来", /提炼未完成|提炼完成/.test(afterExtract));
if (/提炼未完成/.test(afterExtract)) {
check("模型不可用时显示 E-28 文案与错误码",
/本次未能完成经验提炼/.test(afterExtract) && /EXTRACTION_UNAVAILABLE/.test(afterExtract));
}
const after = await cardRows();
check("提炼未完成时不产生候选草稿(BR-48)", after === before, `候选行 ${before} → ${after}`);
// ---- ③ 候选卡渲染:来源与状态可区分,且来源不是靠颜色单独传意(AC-42)
const cards = await page.evaluate(() => {
const badges = [...document.querySelectorAll(".xb-origin")]
.map((node) => ({text: node.textContent.trim(), icon: Boolean(node.querySelector("svg"))}));
return {count: badges.length, sample: badges.slice(0, 3)};
});
check("候选卡渲染来源徽标(文字+图标)",
cards.count > 0 && cards.sample.every((b) => b.text && b.icon), JSON.stringify(cards.sample));
check("候选卡渲染状态与版本徽标",
/(草稿|已发布)/.test(afterExtract) && /v\d+\.\d+\.\d+ · revision \d+/.test(afterExtract));
// ---- 修订面板:修的是草稿,且不能一键发布
// act() 是"先显示提示、再刷新、最后解除 busy":提示出现时按钮仍全部禁用(实测 76 个),
// 此时点击会被 disabled 吞掉。等它恢复再点,否则量到的是"点了没反应"的假象。
await page.waitForFunction(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => b.textContent.includes("自然语言修改适用范围"));
return button && !button.disabled;
}, {timeout: 15000}).catch(() => {});
const toggled = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => b.textContent.includes("自然语言修改适用范围"));
if (!button) return "未找到按钮";
if (button.disabled) return "按钮仍禁用(busy 未解除)";
button.click();
return "clicked";
});
check("能点开候选行的修订入口", toggled === "clicked", toggled);
await wait(400);
const panels = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("形成草稿修订")).length);
// 同一个 card.id 在列表里有多行(草稿行与各版本行共享 id):
// 面板若按 card.id 记录"展开哪张卡",点一行会连开多行——真实浏览器里量到过 4 个。
check("展开修订面板只影响被点的那一行", panels === 1, `展开的修订面板数=${panels}`);
const saveDisabled = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("形成草稿修订"));
return button ? button.disabled : null;
});
check("未填写修订要求时「形成草稿修订」保持禁用", saveDisabled === true);
check("修订区声明草稿不直接生效", /不会自动发布/.test(afterExtract) || /不直接生效/.test(afterExtract));
// ---- 身份门禁:manager 不该看到发布按钮
const managerPublish = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("审核发布")).length);
check("manager 身份看不到「审核发布」按钮", managerPublish === 0, `数量=${managerPublish}`);
await shot(page, "05-experience-center.png", true);
// ---- 换 admin 身份:评审入口应出现,但发布仍需要 reviewer(BR-42 / AC-43)
// 没有用 reviewer 做这一步:演示身份里 reviewer 的 `/projects` 只授权到 DEMO-C,
// 而 DEMO-C 没有知识候选,浏览器里点不到「审核发布」——这是演示数据的边界,不是门禁本身。
consoleErrors.length = 0;
await page.evaluate(() => sessionStorage.setItem("xingbang-demo-role", "admin"));
await page.goto(`${APP}/experience-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(() => document.body.innerText.includes("③ 候选"), {timeout: 40000});
await wait(600);
const adminEvaluate = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("隔离评测")).length);
const adminPublish = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("审核发布")).length);
check("admin 身份出现「隔离评测」入口", adminEvaluate > 0, `数量=${adminEvaluate}`);
check("admin 身份仍不出现「审核发布」(发布限 reviewer)", adminPublish === 0, `数量=${adminPublish}`);
await shot(page, "06-experience-admin.png", true);
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
// ---- 浏览器控制台:第四批的缺陷 1–4 都是"只有浏览器能看见"的那一类
const errors = noisy(consoleErrors);
check("历史经验加工页控制台无脚本错误", errors.length === 0,
errors.slice(0, 3).map((t) => t.slice(0, 120)).join(" | "));
return errors;
}
(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", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push("pageerror: " + error.message));
// 应用从 sessionStorage 读身份:先落到同源页面再写,否则进目标页时身份还没生效
await page.goto(`${APP}/experience-center`, {waitUntil: "domcontentloaded"});
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
const report = {role: ROLE, project: PROJECT, sections: {}};
try {
consoleErrors.length = 0;
await sectionPrescreen(page, consoleErrors);
report.sections.prescreen = noisy(consoleErrors).map((text) => text.slice(0, 300));
} catch (error) {
check("AI 预审章节未抛异常", false, String(error).slice(0, 200));
}
try {
report.sections.experience = await sectionExperience(page, consoleErrors);
} catch (error) {
check("历史经验加工章节未抛异常", false, String(error).slice(0, 200));
}
await browser.close();
const failed = results.filter((item) => !item.ok);
fs.writeFileSync(path.join(OUT, "result.json"),
JSON.stringify({...report, results}, null, 2));
console.log(`\n截图与结果写入 ${OUT}`);
console.log(`总计 ${results.length} 项,失败 ${failed.length} 项`);
process.exit(failed.length ? 1 : 0);
})().catch((error) => {
console.error("E2E 运行失败:", error);
process.exit(2);
});
+8
View File
@@ -74,4 +74,12 @@ for (const endpoint of ["case-timeline", "knowledge-extractions", "knowledge-can
}
assert.match(panel, /PRESCREEN/, "必须走既有 prescreen 命名空间");
// 7. 候选行的标识必须唯一:列表里同一个 card.id 会出现多行(草稿行与版本行共享 id)
// 只用 card.id 作 key 会撞 React key,作展开标记会一点连开多行——两者都在真实浏览器里量到过
// (见 17 四批交付记录「浏览器验证发现的问题(第二篇)」)。
assert.match(panel, /const rowKey = \(card: any\) =>/, "候选行必须有行级标识");
assert.match(panel, /key=\{rowKey\(card\)\}/, "候选行的 React key 不得只用 card.id");
assert.ok(!/setReviseFor\([^)]*card\.id\b/.test(panel), "修订面板标记不得只用 card.id(会同开多行)");
assert.ok(!/setCasesFor\([^)]*card\.id\b/.test(panel), "测试样本面板标记不得只用 card.id");
console.log("experience-center-ui: PASS");
+15
View File
@@ -99,4 +99,19 @@ assert.match(panel, /domain\.text/, "数据域必须以文字呈现");
assert.match(lib, /雷达面积不换算成百分比|STAGE_LEGEND/, "必须说明阶段与概率无关");
assert.ok(!/probability\s*[:=]\s*[0-9]/.test(panel), "面板不得写入任何概率数值");
// 11. 从本页发起的运行必须能被面板读到(真实浏览器端到端验证发现的缺陷)
// 面板原先只依赖 project.id 重载,于是从本页发起预审、运行跑完后,面板仍停在旧运行上,
// 新运行也进不了"查看哪一次运行"——而徽标写着"结果会自动刷新"。
// 源码守卫与生产构建都看不出这类问题,只有真渲染会暴露;这里锁住修好的依赖关系。
assert.match(panel, /const runKeys = \(project\.runs \|\| \[\]\)/,
"面板必须从项目的运行集合推导重载信号");
assert.match(panel, /\[project\.id, runKeys\]/,
"运行集合变化时面板必须重载,否则发起的预审不会被读到");
assert.ok(!/\[project\.id\]\)/.test(panel),
"不得退回只依赖 project.id 的重载(发起的运行会读不到)");
// run.status 与 prescreen_run 视图不是同一事务写入的:只读一次会读到视图还没落库的中间态
assert.match(panel, /item\.id === newestRun/,
"重载前必须确认最新运行已出现在视图列表里,不能只赌一次");
assert.match(panel, /rounds < 5/, "重试必须有次数上限,不得变成无限轮询");
console.log("prescreen-ui: PASS");
+11 -5
View File
@@ -22,6 +22,12 @@ const ORIGIN_ICONS: Record<string, any> = {
const CARD_STATUS: Record<string, string> = {
draft: "草稿", published: "已发布", rejected: "已拒绝", withdrawn: "已撤回",
};
/** 候选行的稳定标识。列表里**同一个 card.id 会出现多行**(草稿行与各版本行共享 id,
* 见 knowledge_cards.list_cards),所以 React key 与"展开哪一行的面板"都不能只用 card.id:
* 只用 id 会撞 key,点一行的「自然语言修改适用范围」还会连开同 id 的所有行——
* 这两点都是真实浏览器里量到的(见 17 四批交付记录「浏览器验证发现的问题(第二篇)」)。
* (id, version, status) 在注册表里唯一:同一 (id, version) 的草稿行与版本行 status 不同。 */
const rowKey = (card: any) => `${card.id}@${card.version}@${card.status}`;
// 组方案时的基座核查项:与种子方案一致,保证新方案能独立跑通(不是"只放卡片"的半成品方案)。
const BASE_SKILLS = ["corporate.consistency", "corporate.cashflow", "corporate.assets", "corporate.coverage"];
@@ -180,7 +186,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
<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}>
{candidates.map((card: any) => <div className="xb-finding" key={rowKey(card)}>
<div>
<b>{card.title}</b>
<p>
@@ -194,10 +200,10 @@ export default function ExperienceCenter({project, identity, send}: any) {
<p><small>编辑人 {card.edited_by}{card.reviewer_id ? ` · 审核人 ${card.reviewer_id}` : ""}</small></p>
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={() => {setReviseFor(reviseFor === card.id ? "" : card.id); setReviseText(""); setReviseNote("");}}>
<button className="ra-button" disabled={busy} onClick={() => {setReviseFor(reviseFor === rowKey(card) ? "" : rowKey(card)); setReviseText(""); setReviseNote("");}}>
自然语言修改适用范围
</button>
<button className="ra-button" disabled={busy} onClick={() => {setCasesFor(casesFor === card.id ? "" : card.id); setCasesText("");}}>
<button className="ra-button" disabled={busy} onClick={() => {setCasesFor(casesFor === rowKey(card) ? "" : rowKey(card)); setCasesText("");}}>
补充独立测试样本
</button>
{canReview && <button className="ra-button" disabled={busy} onClick={() => transition(card, "evaluate")}>隔离评测</button>}
@@ -212,7 +218,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
{canPublish && card.edited_by === identity?.id &&
<p className="ra-note">本卡由你本人提议或编辑,因此不显示发布按钮:发布需要独立审核人(BR-42 / AC-43)。</p>}
{reviseFor === card.id && <div className="xb-task">
{reviseFor === rowKey(card) && <div className="xb-task">
<label className="ra-field"><span>用自然语言说明要改什么(必须真实调用模型解析)</span>
<input value={reviseText} disabled={busy} placeholder="例如:只适用于设备类租赁,并增加关联交易例外"
onChange={(e) => setReviseText(e.target.value)}/>
@@ -223,7 +229,7 @@ export default function ExperienceCenter({project, identity, send}: any) {
<button className="ra-button" disabled={busy || !reviseText.trim() || !reviseNote.trim()} onClick={() => revise(card)}>形成草稿修订</button>
</div>}
{casesFor === card.id && <div className="xb-task">
{casesFor === rowKey(card) && <div className="xb-task">
<label className="ra-field"><span>独立测试样本(JSON 数组;自定义 Skill 的发布门禁要求)</span>
<textarea className="ra-code" value={casesText} disabled={busy}
placeholder='[{"name":"缺证样本","values":{"financial.operating_cashflow":null},"expected_outputs":{},"expected_checks":{"history.repayment.dependency":"unknown"}}]'
+25 -1
View File
@@ -60,7 +60,31 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
setLoaded(true);
}
}
useEffect(() => {load();}, [project.id]);
// 宿主页在运行期间会轮询项目(见 prescreen-workbench 与 xingbang-workbench 的 running 轮询),
// 但 `load()` 原先只依赖 project.id:**从本页发起的预审跑完后,面板仍停在旧运行上**,
// 新运行也进不了"查看哪一次运行",而顶部徽标写着"结果会自动刷新"——真实浏览器验证发现
// (见 17 四批交付记录「浏览器验证发现的问题(第二篇)」)。
//
// 只在运行集合变化时重载还不够:worker 是**先更新 run.status、再另写 prescreen_run 视图**
// (assistant_worker.process_next 的 put(run) 与 save_prescreen_view),两者不在同一事务里,
// 状态一变就读会读到"视图还没落库"的中间态,新运行照样漏掉。所以先探视图里有没有最新运行,
// 没有就短时重试几次;次数用尽照常渲染已有结果,不做无限轮询。
const newestRun = (project.runs || [])[0]?.id || "";
const runKeys = (project.runs || []).map((item: any) => `${item.id}:${item.status}`).join(",");
useEffect(() => {
let live = true;
let timer: ReturnType<typeof setTimeout> | undefined;
let rounds = 0;
const read = async () => {
const listed: any = await request(`${PRESCREEN}${projectPath}/runs`).catch(() => null);
if (!live) return;
const present = Boolean(listed && (listed.items || []).some((item: any) => item.id === newestRun));
if (newestRun && !present && rounds < 5) {rounds += 1; timer = setTimeout(read, 700); return;}
await load();
};
read();
return () => {live = false; if (timer) clearTimeout(timer);};
}, [project.id, runKeys]);
function selectRun(id: string) {
setRunId(id);