feat(v2): T09 预审与推进沙盘——运行只读视图、演示情景与界面守卫
覆盖 AC-11/12/29/30/31/42/44(BR-11/29/30/41/43、E-20/21/22)。 后端: - contracts/simulation.py:Simulation/SimulationAssumption/ScenarioDimensionState。 契约层直接禁止 production 域携带演示估计(E-21);ready 必须有结果、 failed 必须带错误码且不得显示结果、计算中不得预告乐观增幅。 - services/prescreen_sandbox.py:演示情景表(DEMO-TABLE-v1)与推演入口。 组合查表不是单项加和(assert_not_additive 把这条写成可执行断言); 只改被假设的维度,其余维度原样保留;失败保留基线并标未完成。 - services/prescreen_store.py:运行视图只读存储,复用 assistant_objects 新 kind prescreen_run,不新建表;只提供保存与读取,要改结果必须产生新修订。 - api/prescreen.py:新增 3 个只读端点(运行列表、运行视图、可用假设动作) 与 1 个情景端点;请求体里没有任何概率字段。越域返回 E-21、冲突假设 E-22、 过期修订 409。 - contracts/prescreen.py:补 E-20/21/22 三个错误码与其 HTTP 映射。 前端(挂进既有工作台「材料与问题」页,不新建路由、不新增样式表): - src/lib/prescreen-run.ts:客户端契约、呈现判定(同刻度 3–8 维才画雷达)、 演示值域判定、来源与数据域徽标(文字+图标+样式,去色仍可区分)。 - src/components/xingbang/PrescreenPanel.tsx:主审意见、演示值(紧邻固定说明)、 维度雷达与等价文字列表、维度依据抽屉、假设勾选即时推演。 旧请求晚到或结果与当前选择不一致即丢弃;请求失败保留基线并标未完成。 - scripts/test_prescreen_ui.cjs:界面源码守卫,已注册进 scripts/verify.py。 未做:运行装配结果的写入方仍是执行链路(T10 范围),当前由测试写入; 沙盘尚未接入真实执行,界面数值全部为服务端演示表值。 验证:backend 全量 unittest 469 项 OK;前端 npm run build 通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {AlertTriangle} from "lucide-react";
|
||||
import {PRESCREEN} from "@/lib/prescreen";
|
||||
import {
|
||||
EMPTY_DIMENSIONS, RECOMMENDATION_LABELS, RUN_STATUS_LABELS,
|
||||
RunView, STAGE_LEGEND, Simulation, demoValue, domainBadge, presentationFor, stageName,
|
||||
stateLabel,
|
||||
} from "@/lib/prescreen-run";
|
||||
|
||||
/** 预审与推进沙盘面板(T09)。
|
||||
*
|
||||
* 挂在既有 /xingbang-workbench 的「材料与问题」页内,**不新建路由**
|
||||
* (docs/v2/04-ui-spec.md §1)。复用 workspace.css 既有 class,不新增样式表。
|
||||
*
|
||||
* 三条界面铁律:
|
||||
* 1. 数值只来自服务端:情景表在服务端,本组件不复制 42/58/51/64(AC-30/31)。
|
||||
* 2. 真实概率为空时不显示任何百分比:演示值必须紧邻"人工设定"说明,且只在演示域(AC-12)。
|
||||
* 3. 只接受与当前选择一致的响应:旧请求晚到即丢弃;请求失败保留基线并标未完成(AC-31)。
|
||||
*/
|
||||
export default function PrescreenPanel({project, request, busy, setBusy, setError, setNotice}: any) {
|
||||
const [runs, setRuns] = useState<RunView[]>([]);
|
||||
const [runId, setRunId] = useState("");
|
||||
const [actions, setActions] = useState<any[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [simulation, setSimulation] = useState<Simulation | null>(null);
|
||||
const [scenarioState, setScenarioState] = useState<"baseline" | "computing" | "ready" | "failed">("baseline");
|
||||
const [openDimension, setOpenDimension] = useState<string>("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const seq = useRef(0);
|
||||
const drawer = useRef<HTMLButtonElement | null>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const projectPath = `/projects/${encodeURIComponent(project.id)}`;
|
||||
|
||||
const run = runs.find((item) => item.id === runId) || null;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const listed = await request(`${PRESCREEN}${projectPath}/runs`);
|
||||
const items: RunView[] = listed.items || [];
|
||||
setRuns(items);
|
||||
const first = items[0] || null;
|
||||
setRunId(first ? first.id : "");
|
||||
setSimulation(null);
|
||||
setSelected([]);
|
||||
setScenarioState("baseline");
|
||||
if (first) {
|
||||
const catalog = await request(`${PRESCREEN}${projectPath}/runs/${encodeURIComponent(first.id)}/assumptions`);
|
||||
setActions(catalog.items || []);
|
||||
} else {
|
||||
setActions([]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
useEffect(() => {load();}, [project.id]);
|
||||
|
||||
function selectRun(id: string) {
|
||||
setRunId(id);
|
||||
setSelected([]);
|
||||
setSimulation(null);
|
||||
setScenarioState("baseline");
|
||||
setOpenDimension("");
|
||||
request(`${PRESCREEN}${projectPath}/runs/${encodeURIComponent(id)}/assumptions`)
|
||||
.then((catalog: any) => setActions(catalog.items || []))
|
||||
.catch((e: any) => setError(e.message));
|
||||
}
|
||||
|
||||
async function applyScenario(keys: string[]) {
|
||||
if (!run) return;
|
||||
const ticket = ++seq.current;
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
setScenarioState(keys.length ? "computing" : "baseline");
|
||||
try {
|
||||
const result: Simulation = await request(
|
||||
`${PRESCREEN}${projectPath}/runs/${encodeURIComponent(run.id)}/simulations`,
|
||||
{assumptions: keys, expected_revision: run.revision});
|
||||
if (ticket !== seq.current) return; // 旧请求晚到:丢弃,不覆盖新结果
|
||||
const returned = (result.assumptions || []).map((item) => item.key).sort().join(",");
|
||||
if (returned !== [...keys].sort().join(",")) return; // 结果与当前选择不一致:丢弃
|
||||
setSimulation(result);
|
||||
setScenarioState(result.status === "failed" ? "failed" : "ready");
|
||||
if (result.status === "failed") setError("该情景未完成;已保留基线与原有意见。");
|
||||
} catch (e: any) {
|
||||
if (ticket !== seq.current) return;
|
||||
setSimulation(null);
|
||||
setScenarioState("failed");
|
||||
setError(`${e.message}(该情景未完成,基线未改变)`);
|
||||
} finally {
|
||||
if (ticket === seq.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
const next = selected.includes(key) ? selected.filter((item) => item !== key) : [...selected, key];
|
||||
setSelected(next);
|
||||
applyScenario(next);
|
||||
}
|
||||
|
||||
function openBasis(id: string, element: HTMLElement | null) {
|
||||
opener.current = element;
|
||||
setOpenDimension(id);
|
||||
setTimeout(() => drawer.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function closeBasis() {
|
||||
setOpenDimension("");
|
||||
opener.current?.focus();
|
||||
}
|
||||
|
||||
const baseline = demoValue(run?.data_domain || "demo", run?.probability_view);
|
||||
const scenario = simulation && simulation.status === "ready"
|
||||
? demoValue(run?.data_domain || "demo", simulation.probability_view) : null;
|
||||
const dimensions = run?.dimension_results || [];
|
||||
// 刻度信息来自呈现层:运行视图只带阶段序数,因此默认同一刻度;方案带 scale_id 时按实际判断。
|
||||
const shown = presentationFor(dimensions.map((item: any) => ({scale_id: item.scale_id || "stage"})));
|
||||
const basis = dimensions.find((item) => item.id === openDimension) || null;
|
||||
const scenarioStages = new Map((simulation?.dimensions || []).map((item) => [item.id, item]));
|
||||
const domain = domainBadge(run?.data_domain || "demo");
|
||||
|
||||
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} onChange={(e) => selectRun(e.target.value)}>
|
||||
{runs.map((item) => <option key={item.id} value={item.id}>{new Date((item as any).created_at).toLocaleString()} · {RUN_STATUS_LABELS[item.status] || item.status}</option>)}
|
||||
</select></label>}
|
||||
|
||||
{run && <>
|
||||
<div className="xb-finding">
|
||||
<div>
|
||||
<b>主审意见:{RECOMMENDATION_LABELS[run.recommendation || ""] || "尚未形成意见"}</b>
|
||||
<p>方案 {run.plan_ref.id} · {run.plan_ref.version} · 运行 {RUN_STATUS_LABELS[run.status] || run.status} · 输入快照 {run.snapshot_id}</p>
|
||||
<p>{domain.text}({run.data_domain === "demo" ? "所有数值为人工演示,未连接真实数据与模型" : "真实数据域不显示任何未校准概率"})</p>
|
||||
{run.missing_inputs.length > 0 && <p className="xb-warning"><AlertTriangle size={13}/> 待补充 {run.missing_inputs.length} 项:{run.missing_inputs.join("、")}</p>}
|
||||
{run.degradations.map((item, index) => <p className="xb-warning" key={index}>未完成项:{item.message}({item.code})</p>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xb-finding" aria-live="polite">
|
||||
<div>
|
||||
<b>{baseline ? `审批通过概率 · 演示值 ${baseline.percent}%` : "审批通过概率:暂不显示"}</b>
|
||||
{baseline
|
||||
? <p>{baseline.label} · 依据表 {baseline.tableVersion} · 条目 {baseline.scenarioKey}。正式审批由人工决定,演示值不得用于任何正式用途。</p>
|
||||
: <p>未完成真实历史校准,因此不显示审批通过概率。当前只给建议、缺证与未完成项;数字缺失本身就是结论的一部分。</p>}
|
||||
{scenario && scenarioState === "ready" && <p>所选情景 · 演示值 {scenario.percent}%({scenario.percent - (baseline?.percent || 0) >= 0 ? "+" : ""}{scenario.percent - (baseline?.percent || 0)} 百分点)· 条目 {scenario.scenarioKey}</p>}
|
||||
{scenarioState === "computing" && <p>正在按当前选择重算;此期间仍显示基线,不会先给一个乐观增幅。</p>}
|
||||
{scenarioState === "failed" && <p className="xb-warning"><AlertTriangle size={13}/> 该情景未完成。基线数值与原有意见保持不变。</p>}
|
||||
<p>证据阶段 ≠ 审批概率:雷达面积不换算成百分比,也不参与任何加总。{STAGE_LEGEND}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>核查维度({dimensions.length})</h3>
|
||||
{shown.kind === "empty" && <div className="ra-empty"><h2>{EMPTY_DIMENSIONS}</h2>
|
||||
<p>方案没有维度时不补占位轴,也不给出任何结论。</p></div>}
|
||||
{shown.kind !== "empty" && <>
|
||||
<p>{shown.reason}</p>
|
||||
{shown.kind === "radar" && <RadarChart dimensions={dimensions} scenarioStages={scenarioStages} />}
|
||||
<div className="xb-finding">
|
||||
<div>
|
||||
{dimensions.map((item) => {
|
||||
const overlay = scenarioStages.get(item.id);
|
||||
const stage = overlay ? overlay.scenario_stage : item.stage;
|
||||
return <p key={item.id}>
|
||||
<b>{item.id}</b> · {stateLabel(item.state)} · 阶段 {stageName(stage)}
|
||||
{overlay?.assumed ? "(本情景假设该维度交叉核验成立;原项目仍待核实)" : ""}
|
||||
{" "}
|
||||
<button className="ra-button" aria-label={`查看 ${item.id} 的依据`}
|
||||
onClick={(e) => openBasis(item.id, e.currentTarget)}>查看依据</button>
|
||||
</p>;
|
||||
})}
|
||||
<p>文字列表与图等价:每个维度都可在此查看阶段与依据,颜色不是唯一区分方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<h3>推进沙盘:假设补充核验的材料</h3>
|
||||
<p>勾选或取消即时重算,不需要再点运行。组合结果由完整选择决定,不是各项增量的简单相加;补件也可能发现新风险,实际判断未必变得乐观。</p>
|
||||
{actions.length === 0 && <div className="ra-empty"><h2>当前方案没有可推演的假设动作</h2>
|
||||
<p>假设动作必须指向本次方案真实存在的维度,因此这里不会给出必然失败的选项。</p></div>}
|
||||
{actions.map((item) => <label className="ra-field" key={item.key}>
|
||||
<span><input type="checkbox" checked={selected.includes(item.key)} disabled={busy}
|
||||
onChange={() => toggle(item.key)}/> {item.label}</span>
|
||||
<span>{item.premise}</span>
|
||||
</label>)}
|
||||
{simulation && <div className="xb-finding">
|
||||
<div>
|
||||
<b>{simulation.status === "ready" ? "所选情景" : "情景未完成"}</b>
|
||||
<p>{simulation.reason}</p>
|
||||
<p>仍需处理:{simulation.remaining_inputs.length ? simulation.remaining_inputs.join("、") : "无(仍需人工复核)"}</p>
|
||||
<p>真实预审保持不变:{run.missing_inputs.length} 项待核实 · {RECOMMENDATION_LABELS[run.recommendation || ""] || "尚未形成意见"}</p>
|
||||
</div>
|
||||
<button className="ra-button" onClick={() => applyScenario([])} disabled={busy}>返回真实预审(恢复基线)</button>
|
||||
</div>}
|
||||
|
||||
{basis && <div role="dialog" aria-modal="false" aria-label={`${basis.id} 的依据`}>
|
||||
<h3>{basis.id} · 依据与版本</h3>
|
||||
<p>状态 {stateLabel(basis.state)} · 阶段 {stageName(basis.stage)} · 数据域 {domain.text}</p>
|
||||
<p>依据引用:{basis.basis_ids.length ? basis.basis_ids.join("、") : "本次没有可用依据,按缺证处理"}</p>
|
||||
<p>读取核验记录不等于该工具亲自完成了核验;维度阶段不换算为概率。</p>
|
||||
<button ref={drawer} className="ra-button" onClick={closeBasis}>关闭依据</button>
|
||||
</div>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** 六到八轴证据雷达。基线虚线、情景实线,旁边始终有等价文字列表。 */
|
||||
function RadarChart({dimensions, scenarioStages}: {dimensions: any[]; scenarioStages: Map<string, any>}) {
|
||||
const radius = 90;
|
||||
const count = dimensions.length;
|
||||
const point = (index: number, stage: number | null) => {
|
||||
const angle = (index * 2 * Math.PI) / count;
|
||||
const ratio = (stage === null || stage === undefined ? 0 : stage) / 4;
|
||||
return [ratio * radius * Math.sin(angle), -ratio * radius * Math.cos(angle)];
|
||||
};
|
||||
const polygon = (stages: (number | null)[]) =>
|
||||
stages.map((stage, index) => point(index, stage).map((value) => value.toFixed(2)).join(",")).join(" ");
|
||||
const baselineStages = dimensions.map((item) => item.stage);
|
||||
const scenarioStagesList = dimensions.map((item) => {
|
||||
const overlay = scenarioStages.get(item.id);
|
||||
return overlay ? overlay.scenario_stage : item.stage;
|
||||
});
|
||||
const hasScenario = scenarioStages.size > 0;
|
||||
return <div>
|
||||
<svg role="group" aria-label="证据核验阶段雷达,详细内容见下方维度按钮" viewBox="-140 -120 280 250">
|
||||
{[1, 2, 3, 4].map((step) => <polygon key={step} className="radar-grid" fill="none" stroke="currentColor" opacity={0.2}
|
||||
points={polygon(Array(count).fill(step))} />)}
|
||||
{dimensions.map((item, index) => {
|
||||
const [x, y] = point(index, 4);
|
||||
return <line key={item.id} stroke="currentColor" opacity={0.2} x1={0} y1={0} x2={x} y2={y} />;
|
||||
})}
|
||||
<polygon data-series="baseline" fill="none" stroke="currentColor" strokeDasharray="4 4"
|
||||
points={polygon(baselineStages)} />
|
||||
{hasScenario && <polygon data-series="scenario" fill="currentColor" fillOpacity={0.15} stroke="currentColor"
|
||||
points={polygon(scenarioStagesList)} />}
|
||||
</svg>
|
||||
<p>虚线为基线,实线为所选假设;两者都只是阶段,不是分数。</p>
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user