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>
This commit is contained in:
@@ -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, /<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");
|
||||
@@ -215,6 +215,7 @@ def main() -> 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),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import BusinessAssistant from "@/components/xingbang/BusinessAssistant";
|
||||
|
||||
/** 业务助手(原 `/` 落地页内容),保留为独立入口。
|
||||
*
|
||||
* AC-53 要求首页呈现三个模块入口而不是只保留旧版落地页布局;助手本身没有被删掉,
|
||||
* 只是不再是默认首页。
|
||||
*/
|
||||
export default function AssistantPage() {
|
||||
return <BusinessAssistant/>;
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
+78
-3
@@ -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 <BusinessAssistant/>;
|
||||
/** 业务工作区首页(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<any>(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 <div className="ra-app">
|
||||
<AgentNav active="home" project={projectId || undefined} identity={identity || undefined}/>
|
||||
<main className="ra-main">
|
||||
<div className="ra-heading">
|
||||
<div>
|
||||
<div className="ra-eyebrow">业务工作区</div>
|
||||
<h1>兴邦金租 · 智审工作台</h1>
|
||||
<p>三个模块各有独立入口;项目上下文在模块之间保持。所有数值与结论都标明来源,未接入的能力会如实显示未完成。</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div role="alert" className="ra-alert error">{error}</div>}
|
||||
{projectId && <div className="ra-alert">当前项目上下文:<b>{projectId}</b>(在模块之间保持)</div>}
|
||||
|
||||
<div className="xb-project-grid">
|
||||
{MODULES.map((item) => <a className="xb-project-card" key={item.key} href={withProject(item.href)}>
|
||||
<span>{item.detail}</span>
|
||||
<h2><item.icon size={18} aria-hidden="true"/> {item.name}</h2>
|
||||
<p>{item.summary}</p>
|
||||
<footer><small>进入模块</small><ArrowRight size={15} aria-hidden="true"/></footer>
|
||||
</a>)}
|
||||
</div>
|
||||
|
||||
<section className="xb-panel xb-wide">
|
||||
<h2>项目工作台</h2>
|
||||
<p>需要按项目办理材料、报告与评审结论时,进入项目工作台。</p>
|
||||
<div className="xb-actions"><a className="ra-button" href={withProject("/xingbang-workbench")}>进入项目工作台</a></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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<any>(null);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [project, setProject] = useState<any>(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 <div className="ra-app">
|
||||
<AgentNav active="prescreen" project={projectId || undefined} identity={identity || undefined}/>
|
||||
<main className="ra-main">
|
||||
<div className="ra-heading">
|
||||
<div>
|
||||
<div className="ra-eyebrow">PRESCREEN / AI 预审</div>
|
||||
<h1>AI 预审工作台</h1>
|
||||
<p>按已发布方案执行受限编排。概率状态、动态维度、证据依据与推进沙盘都在下方;没有依据的一律按缺证或未完成显示。</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div role="alert" className="ra-alert error">{error}</div>}
|
||||
{notice && <div role="status" className="ra-alert">{notice}</div>}
|
||||
|
||||
{!loaded && <p>正在读取项目…</p>}
|
||||
{loaded && !projects.length && <div className="ra-empty">
|
||||
<h2>当前身份没有可访问的项目</h2>
|
||||
<p>AI 预审以项目为上下文;请先确认身份与项目范围。</p>
|
||||
</div>}
|
||||
|
||||
{projects.length > 0 && <>
|
||||
<label className="ra-field"><span>预审哪个项目</span>
|
||||
<select aria-label="选择项目" value={projectId} disabled={busy}
|
||||
onChange={async (e) => {setProjectId(e.target.value); await open(e.target.value);}}>
|
||||
{projects.map((item) => <option key={item.id} value={item.id}>{item.id} · {item.customer || ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{project && <>
|
||||
<section className="xb-task">
|
||||
<label className="ra-field"><span>本次预审要求(可选,只影响本次运行,不改变正式规则)</span>
|
||||
<textarea value={requirement} disabled={busy} onChange={(e) => setRequirement(e.target.value)}/>
|
||||
</label>
|
||||
<label className="ra-field"><span>评审方案</span>
|
||||
<select aria-label="评审方案" value={plan} disabled={busy} onChange={(e) => setPlan(e.target.value)}>
|
||||
{(project.plans || []).map((item: any) => <option key={item.id} value={item.id}>{item.name} · {item.version}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="xb-actions">
|
||||
<button className="ra-button primary" disabled={busy || running || !plan} onClick={launch}>开始 AI 预审</button>
|
||||
{running && <span className="ra-badge">预审执行中,结果会自动刷新</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImportBatchPanel project={project} request={request} base={`/projects/${encodeURIComponent(project.id)}`}
|
||||
headers={headers} busy={busy} setBusy={setBusy} setError={setError}
|
||||
setNotice={setNotice} onChanged={refresh}/>
|
||||
<PrescreenPanel project={project} request={request} busy={busy} setBusy={setBusy}
|
||||
setError={setError} setNotice={setNotice}/>
|
||||
</>}
|
||||
</>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
import {useEffect, useState} from "react";
|
||||
import AgentNav from "@/components/xingbang/AgentNav";
|
||||
import TrajectoryPanel from "@/components/xingbang/TrajectoryPanel";
|
||||
import {messageOf} from "@/lib/prescreen";
|
||||
import "../data-access/workspace.css";
|
||||
|
||||
/** 轨迹复盘模块(AC-53 的三个入口之一)。
|
||||
*
|
||||
* 原 `/trajectory` 是合并后的重定向桩,本页是**新路由**,不复用也不复活那个旧页
|
||||
* (AC-55 的"防重复页面")。只负责项目上下文与请求助手,回放逻辑在 TrajectoryPanel。
|
||||
*/
|
||||
export default function TrajectoryCenterPage() {
|
||||
const [identity, setIdentity] = useState<any>(null);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [project, setProject] = useState<any>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
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(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(messageOf(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
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);
|
||||
setProject(await request(`/projects/${encodeURIComponent(wanted)}`));
|
||||
}
|
||||
} catch (e: any) {setError(e.message);} finally {setLoaded(true);}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function open(id: string) {
|
||||
setProjectId(id); setError("");
|
||||
try {
|
||||
setProject(await request(`/projects/${encodeURIComponent(id)}`));
|
||||
history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
|
||||
} catch (e: any) {setError(e.message);}
|
||||
}
|
||||
|
||||
return <div className="ra-app">
|
||||
<AgentNav active="trajectory" project={projectId || undefined} identity={identity || undefined}/>
|
||||
<main className="ra-main">
|
||||
<div className="ra-heading">
|
||||
<div>
|
||||
<div className="ra-eyebrow">TRAJECTORY / 轨迹复盘</div>
|
||||
<h1>轨迹复盘</h1>
|
||||
<p>回放每一次运行的步骤、组件版本与证据引用,并与会议结果对照。失败的步骤同样列出;成本缺失时留空而不是填 0。</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div role="alert" className="ra-alert error">{error}</div>}
|
||||
|
||||
{!loaded && <p>正在读取项目…</p>}
|
||||
{loaded && !projects.length && <div className="ra-empty">
|
||||
<h2>当前身份没有可访问的项目</h2>
|
||||
<p>轨迹复盘以项目为上下文;请先确认身份与项目范围。</p>
|
||||
</div>}
|
||||
|
||||
{projects.length > 0 && <>
|
||||
<label className="ra-field"><span>复盘哪个项目</span>
|
||||
<select aria-label="选择项目" value={projectId} onChange={(e) => open(e.target.value)}>
|
||||
{projects.map((item) => <option key={item.id} value={item.id}>{item.id} · {item.customer || ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{project && <TrajectoryPanel key={project.id} project={project} request={request}/>}
|
||||
</>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
import {BarChart3, Bot, History, Inbox, LayoutDashboard, Settings2} from "lucide-react";
|
||||
import {BarChart3, Bot, GitBranch, History, Inbox, LayoutDashboard, Settings2, ShieldCheck} from "lucide-react";
|
||||
import IdentitySelector from "./IdentitySelector";
|
||||
import "./navigation.css";
|
||||
|
||||
@@ -19,6 +19,12 @@ function identityHeaders(){
|
||||
return {...(token?{Authorization:`Bearer ${token}`}:{}) ,...(role?{"X-Xingbang-Demo-Role":role}:{})};
|
||||
}
|
||||
|
||||
/** 深色业务导航(BR-50 / AC-53)。
|
||||
*
|
||||
* 三个模块各有**独立入口**:AI 预审 / 历史经验加工 / 轨迹复盘。它们指向各自的路由,
|
||||
* 不是旧页里的页签——BR-50 明确"不得只在旧页下叠加面板"。
|
||||
* 权限仍按角色裁剪(只读访客与客户经理看不到配置治理),数据域由后端每次重验。
|
||||
*/
|
||||
export default function AgentNav({active,project,identity:provided}:{active:string;project?:string;identity?:any}){
|
||||
const [loaded,setLoaded]=useState<any>(null),[roleHint,setRoleHint]=useState("");
|
||||
useEffect(()=>{
|
||||
@@ -38,18 +44,21 @@ export default function AgentNav({active,project,identity:provided}:{active:stri
|
||||
const canConfigure=identity?.preview||role==="admin"||role==="reviewer";
|
||||
const canUsePool=role==="manager"||role==="reviewer";
|
||||
return <nav className="xb-agent-nav" aria-label="兴邦对公业务导航">
|
||||
<a href={projectLink("/xingbang-workbench",project)} className="xb-brand" title="兴邦金租项目工作台">
|
||||
<a href={projectLink("/",project)} className="xb-brand" title="兴邦金租智审工作台">
|
||||
<span className="xb-brand-mark" aria-hidden="true">兴</span>
|
||||
<span><b>兴邦金租</b><small>对公智能评审</small></span>
|
||||
<span><b>兴邦金租</b><small>智审工作台</small></span>
|
||||
</a>
|
||||
<a href="/" className={active==="assistant"?"selected":""}><Bot size={16}/>业务助手</a>
|
||||
<a href={projectLink("/xingbang-workbench",project)} className={active==="overview"?"selected":""}><LayoutDashboard size={16}/>项目工作台</a>
|
||||
{canUsePool&&<a href="/review-pool" className={active==="pool"?"selected":""}><Inbox size={16}/>项目评审池</a>}
|
||||
<span className="xb-nav-group">业务工作区</span>
|
||||
<a href={projectLink("/prescreen-workbench",project)} className={active==="prescreen"?"selected":""}><ShieldCheck size={16}/>AI 预审</a>
|
||||
<a href={projectLink("/experience-center",project)} className={active==="experience"?"selected":""}><History size={16}/>历史经验加工</a>
|
||||
<a href={projectLink("/trajectory-center",project)} className={active==="trajectory"?"selected":""}><GitBranch size={16}/>轨迹复盘</a>
|
||||
<a href={projectLink("/xingbang-workbench",project)} className={active==="overview"?"selected":""}><LayoutDashboard size={16}/>项目工作台</a>
|
||||
{canUsePool&&<a href={projectLink("/review-pool",project)} className={active==="pool"?"selected":""}><Inbox size={16}/>项目评审池</a>}
|
||||
<a href={projectLink("/operations-center",project)} className={active==="time"?"selected":""}><BarChart3 size={16}/>业务时效</a>
|
||||
{canConfigure&&<a href="/data-access" className={active==="risk"?"selected":""}><Settings2 size={16}/>配置治理</a>}
|
||||
<a href="/assistant" className={active==="assistant"?"selected":""}><Bot size={16}/>业务助手</a>
|
||||
<span className="xb-nav-spacer"/>
|
||||
{project&&<span className="xb-project-context">项目 · {project}</span>}
|
||||
<IdentitySelector identity={identity||{role:roleHint}} pending={!identity}/>
|
||||
<IdentitySelector identity={identity||{role:roleHint}} pending={!identity} dark/>
|
||||
</nav>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
import {useEffect, useState} from "react";
|
||||
import {AlertTriangle, Clock, FileText, RefreshCw} from "lucide-react";
|
||||
import {PRESCREEN} from "@/lib/prescreen";
|
||||
|
||||
/** 轨迹复盘(AC-54 的"可回放轨迹";T10 的只读回放语义在界面上落地)。
|
||||
*
|
||||
* 三条界面纪律,直接对应后端已有的规则:
|
||||
* 1. **只读**:这里没有任何写操作;回放不改运行、不改历史结果。
|
||||
* 2. **失败保留**:failed / skipped 步骤照常列出并带错误码,不因为"看起来不好"被过滤。
|
||||
* 3. **成本未知留空**:没有成本口径时显示"未记录",**不填 0**——0 会被读成"零成本"。
|
||||
*
|
||||
* 不展示模型内部推理:轨迹只记录可观察动作与版本,界面也不假装能看到思维链。
|
||||
* 会议结果的**录入**属第四批;本页只读展示已保存的记录。
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
queued: "排队中", running: "执行中", completed: "已完成",
|
||||
skipped: "已跳过", failed: "未完成", cancelled: "已取消",
|
||||
};
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
plan: "方案", skill: "Skill", tool: "工具", rule: "规则",
|
||||
judgment: "专项判断", synthesis: "综合",
|
||||
};
|
||||
|
||||
export default function TrajectoryPanel({project, request}: any) {
|
||||
const [runId, setRunId] = useState("");
|
||||
const [trajectory, setTrajectory] = useState<any>(null);
|
||||
const [outcomes, setOutcomes] = useState<any[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const runs = (project?.runs || []).filter((item: any) => item.id);
|
||||
const path = `/projects/${encodeURIComponent(project.id)}`;
|
||||
|
||||
async function load(id: string, cursor?: string) {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const page = await request(`${PRESCREEN}${path}/runs/${encodeURIComponent(id)}/trajectory`
|
||||
+ (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""));
|
||||
setTrajectory((current: any) => cursor && current
|
||||
? {...page, steps: [...current.steps, ...page.steps]}
|
||||
: page);
|
||||
} catch (e: any) {setError(e.message);} finally {setBusy(false); setLoaded(true);}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!runs.length) {setTrajectory(null); setLoaded(true); return;}
|
||||
const first = runs[0].id;
|
||||
setRunId(first);
|
||||
load(first);
|
||||
request(`${PRESCREEN}${path}/review-outcomes`)
|
||||
.then((data: any) => setOutcomes(data.items || []))
|
||||
.catch(() => setOutcomes([]));
|
||||
}, [project.id]);
|
||||
|
||||
const steps = trajectory?.steps || [];
|
||||
return <section className="xb-panel xb-wide">
|
||||
<h2>运行轨迹回放</h2>
|
||||
<p>一次运行是一条轨迹,多次工具与模型调用是其中的步骤。这里只记录**可观察的动作与版本**,不包含模型内部推理;回放是只读的,不会改动这次运行。</p>
|
||||
|
||||
{!loaded && <p>正在读取轨迹…</p>}
|
||||
{loaded && !runs.length && <div className="ra-empty">
|
||||
<h2>该项目还没有运行</h2>
|
||||
<p>轨迹来自真实运行;没有运行时不显示示例步骤。</p>
|
||||
</div>}
|
||||
|
||||
{runs.length > 1 && <label className="ra-field"><span>查看哪一次运行</span>
|
||||
<select aria-label="选择运行" value={runId} disabled={busy}
|
||||
onChange={(e) => {setRunId(e.target.value); load(e.target.value);}}>
|
||||
{runs.map((item: any) => <option key={item.id} value={item.id}>
|
||||
{new Date(item.created_at).toLocaleString()} · {STATUS_LABEL[item.status] || item.status} · {item.run_kind}
|
||||
</option>)}
|
||||
</select></label>}
|
||||
|
||||
{error && <div role="alert" className="ra-alert error">{error}</div>}
|
||||
|
||||
{trajectory && <>
|
||||
<div className="xb-finding">
|
||||
<div>
|
||||
<b>运行 {trajectory.run_id} · {STATUS_LABEL[trajectory.status] || trajectory.status} · {steps.length} 个步骤</b>
|
||||
<p>输入快照 {trajectory.snapshot_id} · 只读回放{trajectory.read_only ? "(服务端已标记)" : ""}</p>
|
||||
<p><small>{trajectory.note}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{steps.map((step: any) => <div className="xb-finding" key={step.id}>
|
||||
<div>
|
||||
<b>#{step.sequence} · {KIND_LABEL[step.kind] || step.kind} · {STATUS_LABEL[step.status] || step.status}</b>
|
||||
<p>{step.component_ref
|
||||
? <>组件 <code>{step.component_ref.id}@{step.component_ref.version}</code></>
|
||||
: "(无组件引用)"}
|
||||
{step.error_code ? ` · 错误码 ${step.error_code}` : ""}
|
||||
</p>
|
||||
{step.reason_summary && <p className="xb-warning"><AlertTriangle size={13}/> {step.reason_summary}</p>}
|
||||
{(step.evidence_ids || []).length > 0 &&
|
||||
<p><FileText size={13} aria-hidden="true"/> 引用证据 {step.evidence_ids.length} 条:{(step.evidence_ids || []).slice(0, 4).join("、")}{(step.evidence_ids || []).length > 4 ? "…" : ""}</p>}
|
||||
<p><small>
|
||||
成本 {step.cost === null || step.cost === undefined ? "未记录(不填 0)" : `${step.cost} ${step.cost_currency || ""}`}
|
||||
{" · "}耗时 {step.latency_ms === null || step.latency_ms === undefined ? "未记录" : `${step.latency_ms} ms`}
|
||||
{step.started_at ? ` · 开始 ${new Date(step.started_at).toLocaleString()}` : ""}
|
||||
</small></p>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{trajectory.next_cursor && <div className="xb-actions">
|
||||
<button className="ra-button" disabled={busy} onClick={() => load(runId, trajectory.next_cursor)}>
|
||||
<RefreshCw size={15}/>加载更多步骤
|
||||
</button>
|
||||
</div>}
|
||||
</>}
|
||||
|
||||
<h3>会议结果对照(只读)</h3>
|
||||
<p>AI 轨迹与会议记录并列查看。找不到材料版本的结果单列,既不当否决、也不进通过率分母。<b>录入属第四批</b>,这里只读。</p>
|
||||
{!outcomes.length && <p className="ra-note">尚未保存会议结果。</p>}
|
||||
{outcomes.map((item: any) => <div className="xb-finding" key={item.id}>
|
||||
<div>
|
||||
<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}
|
||||
{item.supersedes_id ? ` · 更正自 ${item.supersedes_id}` : ""}</small></p>
|
||||
</div>
|
||||
</div>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
.xb-agent-nav{position:sticky;top:0;z-index:100;display:flex;align-items:center;gap:8px;padding:10px 22px;background:#fffffff5;backdrop-filter:blur(18px);border-bottom:1px solid #dfe7e3;color:#667b6d;box-shadow:0 2px 14px #1e3d2e0f;font-family:Arial,"Microsoft YaHei",sans-serif}.xb-agent-nav a{display:flex;align-items:center;gap:7px;padding:8px 10px;min-height:36px;border-radius:7px;font-size:12px;font-weight:500}.xb-agent-nav a:hover{background:#f2f6f3;color:#216b48}.xb-agent-nav a.selected{background:#e4f1e9;color:#17633f;box-shadow:inset 0 0 0 1px #cce2d4}.xb-agent-nav .xb-brand{gap:8px;padding:2px 14px 2px 0;margin-right:2px;border-right:1px solid #e4eae5;border-radius:0}.xb-brand-mark{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;background:#17634c;color:#fff;font-size:15px;font-weight:700;box-shadow:inset 0 -2px 0 #0002}.xb-brand b{display:block;color:#17634c;font-size:13px;line-height:1.1}.xb-brand small{display:block;margin-top:4px;color:#91a197;font-size:9px;line-height:1.1}.xb-nav-spacer{flex:1}.xb-project-context{font-size:10px;color:#58705f;background:#f2f7f3;border:1px solid #deebe1;border-radius:999px;padding:7px 11px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.xb-identity{display:flex;align-items:center;gap:7px;padding:7px 11px;border-radius:999px;background:#f2f6f3;border:1px solid #dce8e0;font-size:10px;color:#7d8c82;white-space:nowrap}.xb-identity i{width:7px;height:7px;border-radius:50%;background:#55a778;box-shadow:0 0 0 4px #55a77818}.xb-identity b{font-size:11px;color:#244a36}.xb-identity.role-reviewer{background:#eef2fb;border-color:#dce3f3}.xb-identity.role-reviewer i{background:#607bb8}.xb-identity.role-reviewer b{color:#344c82}.xb-identity.role-admin{background:#f8f2e8;border-color:#eadfc9}.xb-identity.role-admin i{background:#bc8541}.xb-identity.role-admin b{color:#76511f}.xb-overview{max-width:1480px;margin:auto;padding:32px}.xb-overview-heading{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:28px}.xb-overview-heading h1{font-size:26px;font-weight:600}.xb-overview-heading p{font-size:12px;color:#84948b;margin-top:9px}.xb-role-switch{display:flex;align-items:center;gap:8px;border:1px solid #cee0d5;background:#fff;padding:9px 12px;border-radius:12px;box-shadow:0 7px 24px #183b2b0b}.xb-role-switch>span{font-size:10px;color:#87978f}.xb-role-switch svg{color:#27805a}.xb-role-switch select{font-weight:700;color:#245c43;border:0;background:transparent;outline:none}.xb-role-switch.dark{border-color:#ffffff1f;background:#ffffff0d;box-shadow:none}.xb-role-switch.dark>span{color:#8daf9e}.xb-role-switch.dark svg{color:#55d49b}.xb-role-switch.dark select{color:#e1f5eb}.xb-role-switch.dark select option{color:#17251e}@media(max-width:760px){.xb-agent-nav{padding:8px 10px;gap:3px;flex-wrap:wrap}.xb-agent-nav a{font-size:10px;padding:6px}.xb-agent-nav .xb-brand{border:0;padding-right:5px}.xb-brand small,.xb-nav-spacer,.xb-identity>span{display:none}.xb-identity{margin-left:auto}.xb-project-context{order:10;width:100%;max-width:none}.xb-overview{padding:22px 15px}.xb-overview-heading{align-items:flex-start;flex-wrap:wrap}}
|
||||
/* 深色导航(BR-50 / AC-53):token 取自已确认原型 docs/v2/prototypes/prescreen-workbench.html
|
||||
* 与 DESIGN.md —— nav.bg #102B27、nav.active #244D41、nav.text #F3F7F5、nav.muted #B1C6BE。
|
||||
* 画布仍是浅色(canvas #F3F5F4,见 workspace.css):"深色导航+浅色工作区"是两件事,
|
||||
* 不要把整页刷黑。
|
||||
*/
|
||||
.xb-agent-nav{position:sticky;top:0;z-index:100;display:flex;align-items:center;gap:8px;padding:10px 22px;background:#102B27;border-bottom:1px solid #244D41;color:#B1C6BE;box-shadow:0 2px 14px #102b2726;font-family:Arial,"Microsoft YaHei",sans-serif}.xb-agent-nav a{display:flex;align-items:center;gap:7px;padding:8px 10px;min-height:36px;border-radius:7px;font-size:12px;font-weight:500;color:#DCE9E3}.xb-agent-nav a:hover{background:#1B3D36;color:#fff}.xb-agent-nav a.selected{background:#244D41;color:#F3F7F5;box-shadow:inset 0 0 0 1px #3f6d5d}.xb-agent-nav .xb-brand{gap:8px;padding:2px 14px 2px 0;margin-right:2px;border-right:1px solid #244D41;border-radius:0}.xb-agent-nav .xb-brand:hover{background:transparent}.xb-brand-mark{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;background:#17634c;color:#fff;font-size:15px;font-weight:700;box-shadow:inset 0 -2px 0 #0002}.xb-brand b{display:block;color:#F3F7F5;font-size:13px;line-height:1.1}.xb-brand small{display:block;margin-top:4px;color:#B1C6BE;font-size:9px;line-height:1.1}.xb-nav-spacer{flex:1}.xb-nav-group{font-size:10px;color:#8FAEA1;padding:0 8px;white-space:nowrap}.xb-project-context{font-size:10px;color:#D6E5DE;background:#ffffff14;border:1px solid #ffffff26;border-radius:999px;padding:7px 11px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.xb-identity{display:flex;align-items:center;gap:7px;padding:7px 11px;border-radius:999px;background:#ffffff14;border:1px solid #ffffff26;font-size:10px;color:#B1C6BE;white-space:nowrap}.xb-identity i{width:7px;height:7px;border-radius:50%;background:#55d49b;box-shadow:0 0 0 4px #55d49b26}.xb-identity b{font-size:11px;color:#EAF4EF}.xb-identity.role-reviewer{background:#ffffff1a;border-color:#7f9bd6a6}.xb-identity.role-reviewer i{background:#8fa9e2}.xb-identity.role-reviewer b{color:#DCE6FA}.xb-identity.role-admin{background:#ffffff1a;border-color:#d9b478a6}.xb-identity.role-admin i{background:#dcae66}.xb-identity.role-admin b{color:#F5E8CF}.xb-overview{max-width:1480px;margin:auto;padding:32px}.xb-overview-heading{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:28px}.xb-overview-heading h1{font-size:26px;font-weight:600}.xb-overview-heading p{font-size:12px;color:#84948b;margin-top:9px}.xb-role-switch{display:flex;align-items:center;gap:8px;border:1px solid #cee0d5;background:#fff;padding:9px 12px;border-radius:12px;box-shadow:0 7px 24px #183b2b0b}.xb-role-switch>span{font-size:10px;color:#87978f}.xb-role-switch svg{color:#27805a}.xb-role-switch select{font-weight:700;color:#245c43;border:0;background:transparent;outline:none}.xb-role-switch.dark{border-color:#ffffff1f;background:#ffffff0d;box-shadow:none}.xb-role-switch.dark>span{color:#8daf9e}.xb-role-switch.dark svg{color:#55d49b}.xb-role-switch.dark select{color:#e1f5eb}.xb-role-switch.dark select option{color:#17251e}@media(max-width:760px){.xb-agent-nav{padding:8px 10px;gap:3px;flex-wrap:wrap}.xb-agent-nav a{font-size:10px;padding:6px}.xb-agent-nav .xb-brand{border:0;padding-right:5px}.xb-brand small,.xb-nav-spacer,.xb-identity>span,.xb-nav-group{display:none}.xb-identity{margin-left:auto}.xb-project-context{order:10;width:100%;max-width:none}.xb-overview{padding:22px 15px}.xb-overview-heading{align-items:flex-start;flex-wrap:wrap}}
|
||||
|
||||
/* 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}
|
||||
.xb-agent-nav a{min-height:42px;padding:10px 13px;border-radius:9px;font-size:14px;font-weight:600;transition:background-color .16s,color .16s,box-shadow .16s}
|
||||
.xb-agent-nav a.selected{box-shadow:inset 0 0 0 1px #bdd8c8,0 3px 10px #1d563a12}
|
||||
.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}
|
||||
.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}
|
||||
.xb-nav-group{font-size:12px}
|
||||
.xb-project-context{font-size:12px;padding:8px 13px}
|
||||
.xb-role-switch{min-height:42px;padding:8px 13px;border-color:#bcd5c6;box-shadow:0 4px 16px #183b2b12}
|
||||
.xb-role-switch>span{font-size:12px}.xb-role-switch select{font-size:14px;cursor:pointer}
|
||||
|
||||
Reference in New Issue
Block a user