From ae89462756a8fc61ffaca00a5bfeaefd9d517f8b Mon Sep 17 00:00:00 2001 From: qiaoqichao Date: Thu, 1 Oct 2026 00:46:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20=E7=BA=A0=E5=81=8F=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=89=B9=E2=80=94=E2=80=94=E6=B7=B1=E8=89=B2=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E3=80=81=E4=B8=89=E6=A8=A1=E5=9D=97=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E4=B8=8E=E8=BD=A8=E8=BF=B9=E5=A4=8D=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 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 --- scripts/test_module_shell_ui.cjs | 140 +++++++++++++++++++ scripts/verify.py | 1 + src/app/assistant/page.tsx | 10 ++ src/app/data-access/workspace.css | 2 +- src/app/page.tsx | 81 ++++++++++- src/app/prescreen-workbench/page.tsx | 147 ++++++++++++++++++++ src/app/trajectory-center/page.tsx | 93 +++++++++++++ src/components/xingbang/AgentNav.tsx | 23 ++- src/components/xingbang/TrajectoryPanel.tsx | 126 +++++++++++++++++ src/components/xingbang/navigation.css | 14 +- 10 files changed, 622 insertions(+), 15 deletions(-) create mode 100644 scripts/test_module_shell_ui.cjs create mode 100644 src/app/assistant/page.tsx create mode 100644 src/app/prescreen-workbench/page.tsx create mode 100644 src/app/trajectory-center/page.tsx create mode 100644 src/components/xingbang/TrajectoryPanel.tsx diff --git a/scripts/test_module_shell_ui.cjs b/scripts/test_module_shell_ui.cjs new file mode 100644 index 0000000..5352ea1 --- /dev/null +++ b/scripts/test_module_shell_ui.cjs @@ -0,0 +1,140 @@ +/** + * 纠偏第三批 · 模块壳与三入口源码守卫(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, /]*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"), / int: ("prescreen-panel-ui", [executable("node"), "scripts/test_prescreen_panel.cjs"], ROOT), ("prescreen-ui", [executable("node"), "scripts/test_prescreen_ui.cjs"], ROOT), ("experience-center-ui", [executable("node"), "scripts/test_experience_center_ui.cjs"], ROOT), + ("module-shell-ui", [executable("node"), "scripts/test_module_shell_ui.cjs"], ROOT), ("frontend-build", [npm, "run", "build"], ROOT), ("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT), ("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT), diff --git a/src/app/assistant/page.tsx b/src/app/assistant/page.tsx new file mode 100644 index 0000000..e64fca9 --- /dev/null +++ b/src/app/assistant/page.tsx @@ -0,0 +1,10 @@ +import BusinessAssistant from "@/components/xingbang/BusinessAssistant"; + +/** 业务助手(原 `/` 落地页内容),保留为独立入口。 + * + * AC-53 要求首页呈现三个模块入口而不是只保留旧版落地页布局;助手本身没有被删掉, + * 只是不再是默认首页。 + */ +export default function AssistantPage() { + return ; +} diff --git a/src/app/data-access/workspace.css b/src/app/data-access/workspace.css index 8ee035f..c3ed97f 100644 --- a/src/app/data-access/workspace.css +++ b/src/app/data-access/workspace.css @@ -2,7 +2,7 @@ @media(max-width:760px){.ra-heading{flex-wrap:wrap}.ra-heading>.ra-button{white-space:nowrap;min-width:128px}.ra-heading>div{flex-basis:100%}} .ra-rule-row,.ra-review-row{padding:20px 0;border-bottom:1px solid #dfe5e9;margin-bottom:18px}.ra-review-row h3{font-weight:600;font-size:15px}.ra-rule-row .ra-field{margin-bottom:12px} .ra-alignment{margin:24px 0}.ra-alignment h3{font-size:14px;font-weight:600;margin-bottom:18px} -.xb-project-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}.xb-project-card{text-align:left;border:1px solid #dfe7e2;background:#fff;border-radius:10px;padding:22px;cursor:pointer;color:#334b3a}.xb-project-card:hover{border-color:#82ad91;box-shadow:0 7px 24px rgba(32,92,59,.08)}.xb-project-card>span{font-size:10px;color:#34805a;background:#e8f4ec;padding:4px 7px;border-radius:12px}.xb-project-card h2{font-size:17px;margin:18px 0 8px}.xb-project-card p{font-size:11px;color:#829087}.xb-project-card footer{display:flex;align-items:center;gap:10px;border-top:1px solid #edf1ee;margin-top:20px;padding-top:15px}.xb-project-card footer small{margin-left:auto}.xb-back{border:0;background:transparent;color:#47755a;cursor:pointer;margin-bottom:22px}.xb-tabs{display:flex;gap:4px;border-bottom:1px solid #dfe6e1;margin-bottom:22px;overflow:auto}.xb-tabs button{border:0;background:transparent;padding:13px 17px;color:#788a7e;white-space:nowrap;cursor:pointer}.xb-tabs button.active{color:#17633f;border-bottom:2px solid #17633f}.xb-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.xb-cards article,.xb-panel{background:#fff;border:1px solid #e0e7e2;border-radius:8px;padding:22px}.xb-cards b,.xb-cards small{display:block;color:#829087;font-size:11px}.xb-cards strong{display:block;color:#225f41;font-size:25px;margin:15px 0}.xb-panel{max-width:920px}.xb-panel h2{font-size:18px;margin-bottom:18px}.xb-panel>p{font-size:12px;color:#687b6e;line-height:1.8;margin-bottom:18px}.xb-finding{display:flex;gap:14px;padding:16px 0;border-bottom:1px solid #edf1ee}.xb-finding:last-of-type{margin-bottom:20px}.xb-finding b{font-size:13px}.xb-finding p{font-size:12px;margin:7px 0;color:#627367}.xb-finding small{font-size:10px;color:#94a097}.xb-status{height:23px;white-space:nowrap;font-size:10px;color:#a05e19;background:#fff2df;padding:5px 8px;border-radius:12px}.xb-status.ok{color:#29734c;background:#e6f4eb}.xb-compare{display:flex;align-items:center;gap:20px;margin-top:24px;padding:22px;background:#f3f8f4;border-radius:8px}.xb-compare strong{font-size:27px;color:#276947}.xb-compare small{margin-left:auto;max-width:260px;color:#7b887f}.xb-report{margin-top:20px;border-top:1px solid #e3e9e5;padding-top:20px}.xb-report>small{color:#a36d2d}.xb-report article{margin-top:18px}.xb-report h3{font-size:14px;margin-bottom:8px}.xb-report pre{white-space:pre-wrap;font:12px/1.8 Arial,"Microsoft YaHei",sans-serif;color:#53645a}@media(max-width:760px){.xb-cards{grid-template-columns:1fr}.xb-compare{align-items:flex-start;flex-wrap:wrap}.xb-compare small{margin-left:0;width:100%}} +.xb-project-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}.xb-project-card{text-align:left;border:1px solid #dfe7e2;background:#fff;border-radius:10px;padding:22px;cursor:pointer;color:#334b3a;display:block;text-decoration:none}.xb-project-card:hover{border-color:#82ad91;box-shadow:0 7px 24px rgba(32,92,59,.08)}.xb-project-card>span{font-size:10px;color:#34805a;background:#e8f4ec;padding:4px 7px;border-radius:12px}.xb-project-card h2{font-size:17px;margin:18px 0 8px}.xb-project-card p{font-size:11px;color:#829087}.xb-project-card footer{display:flex;align-items:center;gap:10px;border-top:1px solid #edf1ee;margin-top:20px;padding-top:15px}.xb-project-card footer small{margin-left:auto}.xb-back{border:0;background:transparent;color:#47755a;cursor:pointer;margin-bottom:22px}.xb-tabs{display:flex;gap:4px;border-bottom:1px solid #dfe6e1;margin-bottom:22px;overflow:auto}.xb-tabs button{border:0;background:transparent;padding:13px 17px;color:#788a7e;white-space:nowrap;cursor:pointer}.xb-tabs button.active{color:#17633f;border-bottom:2px solid #17633f}.xb-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.xb-cards article,.xb-panel{background:#fff;border:1px solid #e0e7e2;border-radius:8px;padding:22px}.xb-cards b,.xb-cards small{display:block;color:#829087;font-size:11px}.xb-cards strong{display:block;color:#225f41;font-size:25px;margin:15px 0}.xb-panel{max-width:920px}.xb-panel h2{font-size:18px;margin-bottom:18px}.xb-panel>p{font-size:12px;color:#687b6e;line-height:1.8;margin-bottom:18px}.xb-finding{display:flex;gap:14px;padding:16px 0;border-bottom:1px solid #edf1ee}.xb-finding:last-of-type{margin-bottom:20px}.xb-finding b{font-size:13px}.xb-finding p{font-size:12px;margin:7px 0;color:#627367}.xb-finding small{font-size:10px;color:#94a097}.xb-status{height:23px;white-space:nowrap;font-size:10px;color:#a05e19;background:#fff2df;padding:5px 8px;border-radius:12px}.xb-status.ok{color:#29734c;background:#e6f4eb}.xb-compare{display:flex;align-items:center;gap:20px;margin-top:24px;padding:22px;background:#f3f8f4;border-radius:8px}.xb-compare strong{font-size:27px;color:#276947}.xb-compare small{margin-left:auto;max-width:260px;color:#7b887f}.xb-report{margin-top:20px;border-top:1px solid #e3e9e5;padding-top:20px}.xb-report>small{color:#a36d2d}.xb-report article{margin-top:18px}.xb-report h3{font-size:14px;margin-bottom:8px}.xb-report pre{white-space:pre-wrap;font:12px/1.8 Arial,"Microsoft YaHei",sans-serif;color:#53645a}@media(max-width:760px){.xb-cards{grid-template-columns:1fr}.xb-compare{align-items:flex-start;flex-wrap:wrap}.xb-compare small{margin-left:0;width:100%}} .xb-wide{max-width:none;margin-top:20px}.xb-task{background:#fff;border:1px solid #dfe6e1;border-radius:8px;padding:20px;margin:15px 0 22px}.xb-task .ra-field{margin-bottom:12px}.xb-task small{display:block;color:#66776d;margin-top:12px}.xb-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0}.xb-actions input,.xb-actions select,.xb-panel>select,.xb-report>select{background:#fff;border:1px solid #dbe1e7;border-radius:5px;padding:9px 11px;font-size:12px;max-width:100%}.xb-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px;margin-top:20px}.xb-grow{flex:1;min-width:0}.xb-panel pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:450px;overflow:auto;font:12px/1.7 Consolas,"Microsoft YaHei",monospace;background:#f6f8f7;padding:14px;margin:10px 0}.xb-panel details{font-size:12px;line-height:1.8;margin:12px 0}.xb-panel summary{cursor:pointer;color:#345e49}.xb-panel h3{font-size:15px;font-weight:600;margin:20px 0 12px}.xb-panel p{font-size:12px;line-height:1.8;overflow-wrap:anywhere;margin:8px 0}.xb-panel a{color:#17634c;text-decoration:underline}.xb-step{border-bottom:1px solid #e5ece7;padding:10px 0}.xb-warning{color:#9a5a16}.xb-document{border-top:1px solid #dfe6e1;margin-top:20px;padding-top:12px}.xb-section-text{width:100%;min-height:170px;border:1px solid #dbe1e7;padding:14px;font:13px/1.8 Arial,"Microsoft YaHei",sans-serif;border-radius:5px;resize:vertical}.xb-section-text:disabled{color:#526258;background:#f5f7f5}.xb-scenario{border-top:1px solid #e5ece7;padding:15px 0}.xb-finding{flex-wrap:wrap}.xb-finding small{overflow-wrap:anywhere}.xb-config-list .xb-finding>div{flex:1;min-width:250px}@media(max-width:760px){.xb-form-grid{grid-template-columns:1fr}.xb-task{padding:14px}} .xb-role-banner{display:inline-flex;margin-bottom:12px;padding:6px 9px;border-radius:999px;background:#e7f3eb;color:#17633f;font-size:10px;font-weight:700}.xb-role-banner.role-reviewer{background:#eef0fb;color:#4c5f9c}.xb-role-banner.role-admin{background:#f4efe5;color:#836226}.xb-next{margin-top:16px;padding:12px;background:#f4f8f5;border-radius:7px}.xb-next small{display:block;color:#8a978f;font-size:9px;margin-bottom:5px}.xb-next b{font-size:11px;color:#306348}.xb-tabs a{padding:13px 17px;color:#17633f;font-size:12px;white-space:nowrap}.xb-time-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.xb-time-heading p{font-size:11px;color:#7c8d83}.xb-time-heading .ra-button{color:#fff;text-decoration:none}.xb-timeline{position:relative;margin-top:24px}.xb-timeline:before{content:"";position:absolute;left:7px;top:7px;bottom:7px;width:1px;background:#d9e6de}.xb-timeline article{display:grid;grid-template-columns:15px 1fr auto;gap:15px;align-items:start;padding:0 0 24px}.xb-timeline article>i{width:15px;height:15px;border-radius:50%;background:#3bc58a;border:4px solid #e1f3ea;z-index:1}.xb-timeline article>i.category-queue{background:#7388e6;border-color:#e5e8fa}.xb-timeline article>i.category-external_wait{background:#e5a251;border-color:#faecd8}.xb-timeline article>i.category-system{background:#46aeca;border-color:#dff2f7}.xb-timeline article b{font-size:12px}.xb-timeline article p{font-size:11px;color:#687b70;margin:5px 0}.xb-timeline article small{font-size:9px;color:#9aa59e}.xb-timeline article strong{font-size:12px;color:#2d684a}@media(max-width:760px){.xb-time-heading{align-items:flex-start;flex-direction:column}.xb-timeline article{grid-template-columns:15px 1fr}.xb-timeline article>strong{grid-column:2}} .xb-metric-card{position:relative;text-align:left;background:#fff;border:1px solid #e0e7e2;border-radius:8px;padding:22px;cursor:pointer;color:inherit}.xb-metric-card:hover{border-color:#69a27f;box-shadow:0 8px 24px rgba(33,87,57,.09)}.xb-metric-card svg{position:absolute;right:18px;bottom:18px;color:#28704a}.xb-project-card:disabled{opacity:.68;cursor:wait}.xb-review-clock{display:grid;grid-template-columns:1fr auto;gap:5px 12px;margin:16px 0;padding:14px;border-radius:8px;background:#f3f0fb;border-left:3px solid #6678c8}.xb-review-clock small,.xb-review-clock span{font-size:10px;color:#6e7185}.xb-review-clock b{font-size:11px;color:#30395f}.xb-review-clock strong{font-size:14px;color:#4c5f9c}.xb-review-clock span{text-align:right}.xb-claim-gate{display:flex;align-items:center;justify-content:space-between;gap:20px;background:#f2f1fb;border-color:#d9d9ee}.xb-claim-gate b,.xb-claim-gate small{display:block}.xb-claim-gate small{margin-top:6px}.xb-step-business{border:1px solid #e0e8e3;border-radius:9px;padding:0!important;margin:12px 0!important;overflow:hidden}.xb-step-business>summary{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:16px 18px;background:#f5f9f6;list-style:none}.xb-step-business.attention>summary{background:#fff8ef}.xb-step-business>summary span,.xb-step-business>summary small{display:block}.xb-step-business>summary small{margin-top:4px;color:#7a8a80}.xb-step-business>summary strong{font-size:11px;color:#28704a}.xb-step-content{padding:16px 18px}.xb-ai-answer{padding:13px 15px;border-radius:7px;background:#eef7f2}.xb-business-checks article{display:flex;align-items:flex-start;gap:13px;padding:13px 0;border-bottom:1px solid #edf1ee}.xb-business-checks article>div{flex:1}.xb-business-checks p{margin:3px 0}.xb-business-checks small{color:#75877c}.xb-result{display:inline-flex;align-items:center;justify-content:center;min-width:66px;padding:5px 8px;border-radius:999px;font-size:10px;font-weight:700;background:#e7f3eb;color:#26704a}.xb-result.result-issue,.xb-result.result-error{background:#fde9e5;color:#a34838}.xb-result.result-unknown{background:#fff0d8;color:#98601c}.xb-result.result-not_applicable{background:#eef0f1;color:#66716a}.xb-issue-card{margin:16px 0;padding:20px;border:1px solid #dfe7e2;border-radius:10px;background:#fff}.xb-issue-card>header{display:flex;align-items:flex-start;gap:13px}.xb-issue-card h3{margin:2px 0 4px}.xb-issue-card>header small{color:#7b8b81}.xb-chain{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:17px 0;background:#dde7e1;border:1px solid #dde7e1;border-radius:8px;overflow:hidden}.xb-chain section{padding:14px;background:#f8faf9}.xb-chain section>small{display:block;margin-bottom:10px;color:#5d7768;font-weight:700}.xb-chain p{margin:0 0 7px}.xb-chain p b,.xb-chain p span{display:block}.xb-chain p span{margin-top:4px;color:#6e7d74}.xb-chain .missing{color:#a06422}.xb-solution{display:flex;align-items:center;gap:14px;padding:14px 16px;background:#edf7f1;border-left:3px solid #35a36d;border-radius:7px}.xb-solution small{color:#56816a}.xb-solution b{font-size:12px}.xb-saved-decision{padding:10px 13px!important;background:#f2f1fb;color:#4c5f9c!important;border-radius:7px}.xb-review-final{display:flex;align-items:center;gap:12px;margin-top:22px;padding:17px;border-radius:9px;background:#edf7f1}.xb-review-final>div{display:flex;align-items:center;gap:10px;margin-right:auto}.xb-review-final span,.xb-review-final small{display:block}.xb-review-final small{margin-top:4px;color:#6a7c71}.xb-review-opinion{max-width:560px}.xb-audit{margin-top:18px}.xb-history-counts{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:15px 0}.xb-history-counts button{display:grid;grid-template-columns:auto 1fr auto;gap:8px;text-align:left;padding:14px;border:1px solid #e2e9e4;border-radius:8px;background:#f8faf9;color:#40584a;cursor:pointer}.xb-history-counts button:hover{border-color:#82ad91}.xb-history-counts strong,.xb-history-counts small{display:block}.xb-history-counts strong{font-size:20px;color:#216844}.xb-history-counts small{grid-column:2/-1;margin-top:5px;color:#718278}.xb-event-list{display:grid;gap:8px}.xb-event-list a{display:grid;grid-template-columns:auto 1fr auto;gap:12px;text-align:left;padding:12px 14px;border:1px solid #e4eae6;border-radius:7px;background:#fff;color:#43574a!important;text-decoration:none!important;cursor:pointer}.xb-event-list a:hover{background:#f4f8f5}.xb-event-list span,.xb-event-list small{display:block}.xb-event-list small{margin-top:3px;color:#829087}@media(max-width:760px){.xb-chain{grid-template-columns:1fr}.xb-review-final,.xb-claim-gate{align-items:stretch;flex-direction:column}.xb-history-counts{grid-template-columns:1fr 1fr}.xb-event-list a{grid-template-columns:auto 1fr auto}} diff --git a/src/app/page.tsx b/src/app/page.tsx index c492ac7..ca6fa7e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,80 @@ -import BusinessAssistant from "@/components/xingbang/BusinessAssistant"; +"use client"; +import {useEffect, useState} from "react"; +import {ArrowRight, GitBranch, History, ShieldCheck} from "lucide-react"; +import AgentNav from "@/components/xingbang/AgentNav"; +import {messageOf} from "@/lib/prescreen"; +import "./data-access/workspace.css"; -export default function Home() { - return ; +/** 业务工作区首页(AC-53 / BR-50)。 + * + * 三个模块**各有独立入口**:AI 预审 / 历史经验加工 / 轨迹复盘,指向各自的路由。 + * 深色导航 + 浅色工作区取自已确认原型;不再以旧版聊天落地页作为唯一首屏。 + * 本页只负责导航与项目上下文,不在这里叠加任何模块面板。 + */ + +const MODULES = [ + {key: "prescreen", href: "/prescreen-workbench", icon: ShieldCheck, name: "AI 预审", + summary: "按已发布方案执行受限编排,给出有依据的意见、缺证与未完成项。", + detail: "概率状态与依据 · 动态维度 · 推进沙盘"}, + {key: "experience", href: "/experience-center", icon: History, name: "历史经验加工", + summary: "从历史过程恢复时间线,用真实模型提炼核查方法,经专家修订与独立审核发布。", + detail: "来源时间线 · 候选治理 · 卡片入方案"}, + {key: "trajectory", href: "/trajectory-center", icon: GitBranch, name: "轨迹复盘", + summary: "只读回放每一次运行的步骤与版本依据,与会议结果对照。", + detail: "步骤与依据 · 失败保留 · 会议结果对照"}, +]; + +export default function BusinessHome() { + const [identity, setIdentity] = useState(null); + const [projectId, setProjectId] = useState(""); + const [error, setError] = useState(""); + + function headers(json = true) { + const token = sessionStorage.getItem("xingbang-access-token"); + const role = sessionStorage.getItem("xingbang-demo-role"); + return {...(json ? {"Content-Type": "application/json"} : {}), + ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...(role ? {"X-Xingbang-Demo-Role": role} : {})}; + } + + useEffect(() => { + setProjectId(new URLSearchParams(location.search).get("project") || ""); + fetch("/api/risk-agent/access/context", {headers: headers(false)}) + .then(async (response) => { + if (!response.ok) throw Error(messageOf(await response.json().catch(() => ({})), "无法读取身份")); + setIdentity(await response.json()); + }) + .catch((e: any) => setError(e.message)); + }, []); + + const withProject = (href: string) => projectId ? `${href}?project=${encodeURIComponent(projectId)}` : href; + return
+ +
+
+
+
业务工作区
+

兴邦金租 · 智审工作台

+

三个模块各有独立入口;项目上下文在模块之间保持。所有数值与结论都标明来源,未接入的能力会如实显示未完成。

+
+
+ {error &&
{error}
} + {projectId &&
当前项目上下文:{projectId}(在模块之间保持)
} + + + +
+

项目工作台

+

需要按项目办理材料、报告与评审结论时,进入项目工作台。

+ +
+
+
; } diff --git a/src/app/prescreen-workbench/page.tsx b/src/app/prescreen-workbench/page.tsx new file mode 100644 index 0000000..122e1d5 --- /dev/null +++ b/src/app/prescreen-workbench/page.tsx @@ -0,0 +1,147 @@ +"use client"; +import {useEffect, useRef, useState} from "react"; +import AgentNav from "@/components/xingbang/AgentNav"; +import ImportBatchPanel from "@/components/xingbang/ImportBatchPanel"; +import PrescreenPanel from "@/components/xingbang/PrescreenPanel"; +import {messageOf} from "@/lib/prescreen"; +import {newRequestId} from "@/lib/request-id"; +import "../data-access/workspace.css"; + +/** AI 预审模块(AC-53/AC-54 的"AI 预审"入口)。 + * + * BR-50:模块有自己的入口,不是旧工作台里的一个页签。这里**复用**既有的 + * ImportBatchPanel 与 PrescreenPanel 组件(同一份实现,不复制第二套), + * 只补项目上下文与发起预审的动作。 + * + * 概率状态、动态维度、推进沙盘与"查看依据"都在 PrescreenPanel 内;本页不重复实现。 + */ +export default function PrescreenWorkbenchPage() { + const [identity, setIdentity] = useState(null); + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(""); + const [project, setProject] = useState(null); + const [plan, setPlan] = useState(""); + const [requirement, setRequirement] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [loaded, setLoaded] = useState(false); + const selected = useRef(""); + + function headers(json = true) { + const token = sessionStorage.getItem("xingbang-access-token"); + const role = sessionStorage.getItem("xingbang-demo-role"); + return {...(json ? {"Content-Type": "application/json"} : {}), + ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...(role ? {"X-Xingbang-Demo-Role": role} : {})}; + } + + async function request(path: string, body?: any) { + const response = await fetch(`/api/risk-agent/project-assistant${path}`, { + method: body === undefined ? "GET" : "POST", + headers: {...headers(), ...(/\/runs$/.test(path) && body ? {"Idempotency-Key": newRequestId()} : {})}, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await response.json(); + if (!response.ok) throw Error(messageOf(data)); + return data; + } + + async function open(id: string) { + selected.current = id; + const data = await request(`/projects/${encodeURIComponent(id)}`); + if (selected.current !== id) return; + setProject(data); + setPlan((current) => data.plans?.some((item: any) => item.id === current) ? current : (data.plans?.[0]?.id || "")); + history.replaceState({}, "", `?project=${encodeURIComponent(id)}`); + } + + useEffect(() => { + (async () => { + try { + const [context, list] = await Promise.all([ + fetch("/api/risk-agent/access/context", {headers: headers(false)}).then((r) => r.ok ? r.json() : null).catch(() => null), + request("/projects"), + ]); + setIdentity(context); + const items = list.items || list || []; + setProjects(items); + const wanted = new URLSearchParams(location.search).get("project") || items[0]?.id || ""; + if (wanted) {setProjectId(wanted); await open(wanted);} + } catch (e: any) {setError(e.message);} finally {setLoaded(true);} + })(); + }, []); + + const running = Boolean(project?.runs?.some((r: any) => ["queued", "running"].includes(r.status))); + useEffect(() => { + if (!running || !project?.id) return; + const id = project.id; + const timer = setInterval(() => {if (selected.current === id) open(id).catch((e: any) => setError(e.message));}, 1500); + return () => clearInterval(timer); + }, [project?.id, running]); + + async function launch() { + setBusy(true); setError(""); setNotice(""); + try { + await request(`/projects/${encodeURIComponent(project.id)}/runs`, + {requirement, plan_id: plan, revision: project.revision}); + setNotice("预审已进入队列;离开页面不会丢失任务。"); + await open(project.id); + } catch (e: any) {setError(e.message);} finally {setBusy(false);} + } + + const refresh = () => project && open(project.id); + + return
+ +
+
+
+
PRESCREEN / AI 预审
+

AI 预审工作台

+

按已发布方案执行受限编排。概率状态、动态维度、证据依据与推进沙盘都在下方;没有依据的一律按缺证或未完成显示。

+
+
+ {error &&
{error}
} + {notice &&
{notice}
} + + {!loaded &&

正在读取项目…

} + {loaded && !projects.length &&
+

当前身份没有可访问的项目

+

AI 预审以项目为上下文;请先确认身份与项目范围。

+
} + + {projects.length > 0 && <> + + + {project && <> +
+