feat(v2): S1d 最薄界面——工作台内的材料集合导入面板
T02 收尾子卡。挂在既有 /xingbang-workbench「材料与问题」页内,**不新建路由**, 复用 workspace.css 既有 class(无新增样式表)。 - src/components/xingbang/ImportBatchPanel.tsx:登记清单 → 逐项上传 → 重试/取消 → 冲突并列展示与裁定。浏览器端用 crypto.subtle 算 sha256 生成清单(服务端仍重算核对) - src/lib/prescreen.ts:错误信封解包 + 契约类型(**手工对齐**,OpenAPI→TS 生成链 未建设,已在文件头标注为临时)+ 服务端状态标签 - page.tsx 仅三处小改:导入面板、挂载面板、request() 改用 messageOf (兼容信封与既有 detail 的 string/array/object 三种形态,不丢既有信息量) 面向用户的诚实性约束(有源码守卫锁定):明确写出"未上传或未确认的文件不会进入 项目事实""不会按上传先后覆盖""不会自动挑一个";空批次用既有空状态,不绘制虚构行; 面板不含任何审批概率。 验证: - npx tsc --noEmit 通过;npm run build 通过 - 新增 scripts/test_prescreen_panel.cjs(10 条源码守卫),已加入 verify.py 用例 - scripts/verify.py 10/10 PASS(exit 0) **未验证**:面板未经浏览器端到端验证。它依赖客户端数据加载,SSR HTML 不含它, 且本项目没有浏览器自动化依赖。目前只有类型检查、构建与源码断言三层证据。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* S1d 界面源码守卫。
|
||||
*
|
||||
* 这是**源码断言**,不是浏览器端到端测试(docs/v2/06-validation.md §2 已明确
|
||||
* 现有 UI 脚本的性质)。它锁住的是可机械核对的约束:不新建路由、复用既有样式、
|
||||
* 上传必须带批次标识、冲突裁定必须留痕、不得展示任何审批概率。
|
||||
*/
|
||||
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 panel = read("src/components/xingbang/ImportBatchPanel.tsx");
|
||||
const workbench = read("src/app/xingbang-workbench/page.tsx");
|
||||
const lib = read("src/lib/prescreen.ts");
|
||||
|
||||
// 1. 挂载在既有工作台内,不新建路由(docs/v2/04-ui-spec.md §1:不恢复已合并的旧页面)
|
||||
assert.match(workbench, /import ImportBatchPanel from "@\/components\/xingbang\/ImportBatchPanel"/, "工作台应导入导入面板");
|
||||
assert.match(workbench, /<ImportBatchPanel /, "工作台应渲染导入面板");
|
||||
for (const banned of ["prescreen", "import-batch", "material-import"]) {
|
||||
assert.ok(!fs.existsSync(path.join(root, "src/app", banned)), `不得为 S1 新建路由目录 src/app/${banned}`);
|
||||
}
|
||||
|
||||
// 2. 复用既有样式,不新增样式表
|
||||
assert.ok(!/^import .*\.css";/m.test(panel), "面板应复用 workspace.css 既有 class,不新增样式表");
|
||||
|
||||
// 3. 上传必须带批次标识(否则服务端无法回写条目状态)
|
||||
assert.match(panel, /form\.append\("batch_id", batch\.id\)/, "上传必须提交 batch_id");
|
||||
assert.match(panel, /form\.append\("item_id", itemId\)/, "上传必须提交 item_id");
|
||||
|
||||
// 4. 冲突裁定必须显式选择 + 填写说明,且未填说明时按钮禁用
|
||||
assert.match(panel, /resolution_note: note/, "裁定必须提交 resolution_note");
|
||||
assert.match(panel, /selected_evidence_ids: \[evidenceId\]/, "裁定必须显式选择来源");
|
||||
assert.match(panel, /disabled=\{busy \|\| !note\.trim\(\)\}/, "未填裁定说明时不得可提交");
|
||||
|
||||
// 5. 必须向用户说明"未确认不入事实"与"冲突不覆盖"(BR-01 / BR-23 的用户可见面)
|
||||
assert.match(panel, /未上传或未确认的文件不会进入项目事实/, "必须说明未确认不入事实");
|
||||
assert.match(panel, /不会按上传先后覆盖/, "必须说明冲突不按上传先后覆盖");
|
||||
assert.match(panel, /不会自动挑一个/, "必须说明系统不会自动挑选冲突来源");
|
||||
|
||||
// 6. 不得展示任何审批概率(BR-12 / AC-12:未校准即无概率)
|
||||
assert.ok(!/通过率|probability|概率环/.test(panel), "S1 面板不得展示任何审批概率");
|
||||
assert.ok(!/通过率|probability/.test(lib), "S1 客户端不得引入概率字段");
|
||||
|
||||
// 7. 不得在前端编造判定结果:值必须来自服务端字段
|
||||
assert.ok(!/value:\s*["']?\d/.test(panel), "面板不得硬编码业务数值");
|
||||
|
||||
// 8. 客户端必须同时吃下两种错误形状,且不丢既有 detail 的信息量
|
||||
assert.match(lib, /data\?\.error/, "客户端必须能解包统一错误信封");
|
||||
assert.match(lib, /JSON\.stringify\(detail\)/, "既有 detail 数组形态不得丢信息");
|
||||
|
||||
// 9. 空状态不得绘制虚构行(UI Spec §3)
|
||||
assert.match(panel, /ra-empty/, "空批次应使用既有空状态样式");
|
||||
assert.match(panel, /尚无导入批次/, "空批次应给出明确的空状态文案");
|
||||
|
||||
console.log("prescreen-panel-ui: PASS");
|
||||
@@ -212,6 +212,7 @@ def main() -> int:
|
||||
("identity-recovery-ui", [executable("node"), "scripts/test_identity_recovery.cjs"], ROOT),
|
||||
("cloud-assistant-access-ui", [executable("node"), "scripts/test_cloud_assistant_access.cjs"], ROOT),
|
||||
("ui-readability", [executable("node"), "scripts/test_ui_readability.cjs"], ROOT),
|
||||
("prescreen-panel-ui", [executable("node"), "scripts/test_prescreen_panel.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),
|
||||
|
||||
@@ -4,6 +4,8 @@ import {ArrowRight, CheckCircle2, FileText, RefreshCw, ShieldCheck} from "lucide
|
||||
import AgentNav from "@/components/xingbang/AgentNav";
|
||||
import AssistantHistory from "@/components/xingbang/AssistantHistory";
|
||||
import ReviewerWorkspace from "@/components/xingbang/ReviewerWorkspace";
|
||||
import ImportBatchPanel from "@/components/xingbang/ImportBatchPanel";
|
||||
import {messageOf} from "@/lib/prescreen";
|
||||
import {newRequestId} from "@/lib/request-id";
|
||||
import "../data-access/workspace.css";
|
||||
|
||||
@@ -26,7 +28,7 @@ export default function XingbangWorkspace() {
|
||||
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|reports)$/.test(path) && body ? {"Idempotency-Key": newRequestId()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败"));
|
||||
if (!response.ok) throw Error(messageOf(data));
|
||||
return data;
|
||||
}
|
||||
async function open(id: string) {
|
||||
@@ -90,7 +92,7 @@ export default function XingbangWorkspace() {
|
||||
{tab === "overview" && <><section className="xb-cards"><article><b>当前数据版本</b><strong>v{project.data_revision}</strong><small>{project.documents.length} 份材料</small></article><button className="xb-metric-card" onClick={goToFindings}><b>未处理问题</b><strong>{openFindings.length}</strong><small>{openFindings[0] ? `${openFindings[0].title} · 点击查看怎么解决` : "当前没有待处理问题"}</small><ArrowRight size={17}/></button><article><b>最近运行</b><strong>{statuses[project.runs[0]?.status] || "未检查"}</strong><small>{project.runs[0]?.created_at ? new Date(project.runs[0].created_at).toLocaleString() : "点击检查项目"}</small></article></section>
|
||||
<section className="xb-panel xb-wide"><h2>AI检查结果</h2><p>先看业务结论;数据来源和解决建议可直接展开,技术信息放在最后。</p>{project.runs.length ? <><select aria-label="选择检查历史" value={chosenRun?.id || ""} onChange={e => setRunId(e.target.value)}>{project.runs.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>本次要求:{chosenRun.requirement || "无额外要求"}</p>{["queued", "running"].includes(chosenRun.status) && <button className="ra-button" onClick={() => act(`${base}/runs/${chosenRun.id}/cancel`, {}, "已取消;迟到结果不会发布。")}>取消本次运行</button>}{chosenRun.error && <p role="alert">{chosenRun.error}</p>}{chosenRun.steps.map((step:any)=><BusinessStep key={step.step_id} step={step} evidence={evidence} statuses={statuses}/>)}{chosenRun.data_errors?.map((e:any,i:number)=><p key={i} className="xb-warning">数据读取未完成:{e.message}</p>)}<details><summary>技术追溯信息</summary><pre>{JSON.stringify({snapshot:chosenRun.snapshot_id,versions:chosenRun.component_versions,changes:chosenRun.change_summary},null,2)}</pre></details></> : <p>尚无运行记录。</p>}</section>
|
||||
<details className="xb-panel xb-wide"><summary>业务历史与操作留痕</summary><AssistantHistory key={project.id} projectId={project.id} records={project.legacy_records} events={project.events} request={request}/>{project.migration_note && <p>{project.migration_note};已保留 {project.legacy_records.length} 条历史记录。</p>}</details></>}
|
||||
{tab === "findings" && <><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
|
||||
{tab === "findings" && <><ImportBatchPanel project={project} request={request} base={base} headers={headers} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice} onChanged={() => open(project.id)}/><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
|
||||
{document && <div className="xb-document"><h3>{document.name}</h3>{document.warnings.map((w: string, i: number) => <p className="xb-warning" key={i}>{w}</p>)}<label className="ra-field"><span>来源确认或补件说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明已核对的期间、口径和依据"/></label>{document.candidates.map((c: any) => <div className="xb-finding" key={c.id}><div><b>{c.field} = {String(c.value ?? "缺失")}</b><p>{c.locator} · {c.period || "期间未明"} · {c.unit || "单位未明"} · {c.scope || "口径未明"}</p></div><button className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {candidate_ids: [c.id], revision: project.revision, reason}, "候选值已确认;请重新检查项目。")}>确认该来源</button></div>)}<details><summary>解析原文与定位</summary>{document.chunks.map((c: any, i: number) => <p key={i}><b>{c.locator}</b> {c.text}{c.formula && <small> 公式:{c.formula};缓存:{c.cached_value || "无"}</small>}</p>)}</details></div>}</section>
|
||||
<section className="xb-panel xb-wide" id="project-findings"><h2>{isReviewer?"全链路评审":"问题与解决"}</h2><p>{isReviewer?"逐项查看数据来源、AI判断和客户经理说明,再选择评审结论。":"每个问题都说明影响和下一步;处理完成后重新AI预审。"}</p>{isReviewer&&<label className="ra-field xb-review-opinion"><span>常用评审意见(选择后应用到下方操作)</span><select value={reviewOpinion} onChange={e=>setReviewOpinion(e.target.value)}><option>请补充最新材料并说明数据口径</option><option>证据充分,同意AI判断</option><option>AI判断不适用于本项目,以人工核实为准</option><option>请补充现场核实记录</option></select></label>}{!project.findings.length&&<p>尚无问题记录,请先执行检查。</p>}{[...project.findings].sort((a:any,b:any)=>Number(["已解决","不适用"].includes(a.status))-Number(["已解决","不适用"].includes(b.status))).map((finding:any)=><FindingCard key={finding.id} finding={finding} evidence={evidence} isReviewer={isReviewer} busy={busy||!hasReviewClaim} reason={reason} onManager={(status:string)=>act(`${base}/findings/${finding.id}/actions`,{action:status,revision:finding.revision,note:reason})} onReviewer={(decision:string)=>reviewDecision(finding.id,decision)} decision={project.review_decisions?.find((item:any)=>item.finding_id===finding.id)}/>)}{!isReviewer&&<><div className="xb-form-grid"><label className="ra-field"><span>人工补充字段</span><select value={field} onChange={e => setField(e.target.value)}>{fields.map(f => <option key={f[0]} value={f[0]}>{f[1]}</option>)}</select></label><label className="ra-field"><span>确认值(留空表示资料仍缺失)</span><input value={value} onChange={e => setValue(e.target.value)} placeholder={field === "collection_dependency" ? "true 或 false" : "输入实际核对值"}/></label><label className="ra-field"><span>处理说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明材料、口径或核实情况"/></label></div><button className="ra-button primary" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {changes: {[field]: value === "" ? null : field === "collection_dependency" ? value === "true" ? true : value === "false" ? false : value : value}, finding_ids: [], revision: project.revision, reason}, "补件已保存,原来源已保留;请重新检查确认问题变化。")}>保存补件</button></>}{isReviewer&&<div className="xb-review-final"><div><CheckCircle2 size={21}/><span><b>完成全链路核查</b><small>AI只提供建议,批准或退回由评审员确认。</small></span></div><button className="ra-button" disabled={busy||!hasReviewClaim} onClick={()=>completeReview("退回")}>退回补充材料</button><button className="ra-button primary" disabled={busy||!hasReviewClaim||!currentRun} onClick={()=>completeReview("批准")}>批准并生成评审报告</button></div>}</section></>}
|
||||
{tab === "report" && <section className="xb-panel xb-wide"><h2>{isReviewer ? "独立评审报告" : "AI预审报告"}</h2><p>模板、运行快照、Skills 与规则版本随报告冻结;每个历史版本都可以重新打开。</p><div className="xb-actions"><select aria-label="报告模板" value={template} onChange={e => setTemplate(e.target.value)}>{isReviewer ? <><option value="template.independent-review">独立评审报告</option><option value="template.committee-brief">上会摘要</option></> : <option value="template.ai-pre-review">AI预审意见</option>}</select><select aria-label="报告来源运行" value={reportSourceRun?.id || ""} onChange={e => setRunId(e.target.value)}>{finished.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]}</option>)}</select><button className="ra-button primary" disabled={busy || !currentRun} onClick={async () => {const r = await act(`${base}/reports`, {run_id: reportSourceRun.id, template_id: template}); if (r) setReportId(r.id);}}><FileText size={15}/>生成报告</button></div>{chosenReport && <div className="xb-report"><select aria-label="选择报告版本" value={chosenReport.id} onChange={e => {setReportId(e.target.value); setSectionEdits({});}}>{project.reports.map((r: any) => <option key={r.id} value={r.id}>{r.template.name} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>{chosenReport.disclaimer} · {statuses[chosenReport.status]}</p><small>来源快照:{chosenReport.snapshot_id}</small><div className="xb-actions"><button className="ra-button" onClick={() => download(`${base}/reports/${chosenReport.id}/export`, `${project.id}-${chosenReport.template.name}.docx`)}>导出 Word</button><button className="ra-button" disabled={busy || !currentRun || ["finalized","submitted"].includes(chosenReport.status)} onClick={() => reportAction("regenerate", {run_id: currentRun.id})}>按最新运行更新候选</button>{isManager && <button className="ra-button primary" disabled={busy || chosenReport.status === "submitted"} onClick={() => reportAction("submit")}>提交正式评审</button>}{canReview && <button className="ra-button primary" disabled={busy || chosenReport.status === "finalized"} onClick={() => reportAction("finalize")}>确认评审定稿</button>}</div>{chosenReport.sections.map((s: any) => <article key={s.id}><h3>{s.title} {s.stale ? "· 来源已过期" : ""} {s.locked ? "· 已锁定" : ""} {s.confirmed ? "· 已确认" : ""}</h3><textarea aria-label={`编辑${s.title}`} className="xb-section-text" disabled={s.locked || ["overview", "findings"].includes(s.id) || ["finalized","submitted"].includes(chosenReport.status)} value={sectionEdits[s.id] ?? s.content} onChange={e => setSectionEdits({...sectionEdits, [s.id]: e.target.value})}/>{!["finalized","submitted"].includes(chosenReport.status) && <div className="xb-actions"><button className="ra-button" disabled={busy || s.locked || ["overview", "findings"].includes(s.id)} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: false})}>保存修改</button><button className="ra-button" disabled={busy || s.stale} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: true, locked: true})}>确认并锁定</button>{s.locked && <button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, locked: false, confirmed: false})}>解除锁定</button>}</div>}{s.candidate && <details open><summary>更新候选(未覆盖当前正文)</summary><pre>{s.candidate.content}</pre><button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, accept_candidate: true})}>采纳候选内容</button></details>}</article>)}</div>}</section>}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {AlertTriangle, Upload} from "lucide-react";
|
||||
import {BATCH_LABELS, FactConflict, ImportBatch, ITEM_LABELS, PRESCREEN, fileDigest, manifestOf, messageOf} from "@/lib/prescreen";
|
||||
|
||||
/** 材料集合导入面板(S1d 最薄界面)。
|
||||
*
|
||||
* 挂在既有 /xingbang-workbench 的「材料与问题」页内,**不新建路由**
|
||||
* (docs/v2/04-ui-spec.md §1:不恢复已合并的旧页面)。
|
||||
* 复用 workspace.css 既有 class,因此没有新增样式表。
|
||||
*
|
||||
* 不直接拼 SQL、不绕过服务端:所有写操作都经 prescreen 端点,权限由后端重验。
|
||||
*/
|
||||
export default function ImportBatchPanel({project, request, base, headers, busy, setBusy, setError, setNotice, onChanged}: any) {
|
||||
const [batches, setBatches] = useState<ImportBatch[]>([]);
|
||||
const [conflicts, setConflicts] = useState<FactConflict[]>([]);
|
||||
const [pending, setPending] = useState<File[]>([]);
|
||||
const [openId, setOpenId] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const files = useRef<Record<string, File>>({});
|
||||
const projectPath = `/projects/${encodeURIComponent(project.id)}`;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const list = await request(`${PRESCREEN}${projectPath}/import-batches`);
|
||||
setBatches(list.items || []);
|
||||
const found = await request(`${PRESCREEN}${projectPath}/fact-conflicts?status=unresolved`);
|
||||
setConflicts(found.items || []);
|
||||
} catch (e: any) {setError(e.message);} finally {setLoaded(true);}
|
||||
}
|
||||
useEffect(() => {refresh();}, [project.id]);
|
||||
|
||||
function pickFiles(selected: FileList | null) {
|
||||
if (!selected?.length) return;
|
||||
const chosen = Array.from(selected);
|
||||
chosen.forEach((file) => {files.current[file.name] = file;});
|
||||
setPending(chosen);
|
||||
setError("");
|
||||
setNotice(`已选择 ${chosen.length} 个文件;登记清单后逐个上传,未登记的文件不会被解析。`);
|
||||
}
|
||||
|
||||
async function createBatch() {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const digests: Record<string, string> = {};
|
||||
for (const file of pending) digests[file.name] = await fileDigest(file);
|
||||
const created = await request(`${PRESCREEN}${projectPath}/import-batches`, {manifest: manifestOf(pending, digests)});
|
||||
setOpenId(created.batch.id);
|
||||
setPending([]);
|
||||
setNotice("导入清单已登记。请逐个上传文件;哈希与大小由服务端重算核对。");
|
||||
await refresh();
|
||||
} catch (e: any) {setError(e.message);} finally {setBusy(false);}
|
||||
}
|
||||
|
||||
async function sendItem(batch: ImportBatch, itemId: string) {
|
||||
const item = batch.items.find((row) => row.id === itemId);
|
||||
const file = item ? files.current[item.relative_path] : undefined;
|
||||
if (!file) {setError(`本机找不到 ${item?.relative_path || "该文件"} 的内容;请重新选择该文件后再上传。`); return;}
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("batch_id", batch.id);
|
||||
form.append("item_id", itemId);
|
||||
const response = await fetch(`/api/risk-agent/project-assistant${base}/documents`, {method: "POST", headers: headers(false), body: form});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(messageOf(data, "上传失败"));
|
||||
setNotice("该文件已解析并回写到批次条目。");
|
||||
await refresh();
|
||||
if (onChanged) await onChanged();
|
||||
} catch (e: any) {setError(e.message); await refresh();} finally {setBusy(false);}
|
||||
}
|
||||
|
||||
async function batchAction(batch: ImportBatch, action: "retry" | "cancel") {
|
||||
const failed = batch.items.filter((item) => item.status === "failed").map((item) => item.id);
|
||||
if (action === "retry" && !failed.length) {setError("当前没有失败项可重试。"); return;}
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
await request(`${PRESCREEN}/import-batches/${batch.id}/${action}`,
|
||||
action === "retry" ? {item_ids: failed, expected_revision: batch.revision} : {expected_revision: batch.revision});
|
||||
setNotice(action === "retry" ? `已重置 ${failed.length} 个失败项;已解析项不会被重复处理。` : "批次已取消;迟到结果会被忽略。");
|
||||
await refresh();
|
||||
} catch (e: any) {setError(e.message); await refresh();} finally {setBusy(false);}
|
||||
}
|
||||
|
||||
async function resolveConflict(conflict: FactConflict, evidenceId: string) {
|
||||
if (!note.trim()) {setError("请填写裁定说明:采纳哪个来源、依据是什么。"); return;}
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
await request(`${PRESCREEN}/fact-conflicts/${conflict.id}/resolve`,
|
||||
{selected_evidence_ids: [evidenceId], resolution_note: note, expected_revision: conflict.revision});
|
||||
setNote("");
|
||||
setNotice("已按所选来源确认;该字段此前未被任何来源覆盖。");
|
||||
await refresh();
|
||||
if (onChanged) await onChanged();
|
||||
} catch (e: any) {setError(e.message); await refresh();} finally {setBusy(false);}
|
||||
}
|
||||
|
||||
const open = batches.find((batch) => batch.id === openId) || null;
|
||||
return <section className="xb-panel xb-wide">
|
||||
<h2>材料集合导入</h2>
|
||||
<p>一次登记一批文件,再逐个上传。登记只生成清单;**未上传或未确认的文件不会进入项目事实**。多来源数值不一致时会保留冲突并要求人工裁定,不会按上传先后覆盖。</p>
|
||||
|
||||
<label className="ra-field"><span>选择文件集合(PDF / XLSX / CSV / TXT / DOCX / 图片;单文件 ≤ 10 MB,单批 ≤ 20 个文件且合计 ≤ 20 MB)</span>
|
||||
<input aria-label="选择材料集合" type="file" multiple disabled={busy} onChange={(e) => {pickFiles(e.target.files); e.target.value = "";}}/>
|
||||
</label>
|
||||
{pending.length > 0 && <>
|
||||
<div className="xb-finding"><div><b>待登记 {pending.length} 个文件</b><p>{pending.map((f) => f.name).join("、").slice(0, 160)}</p></div>
|
||||
<button className="ra-button primary" disabled={busy} onClick={createBatch}><Upload size={15}/>登记导入清单</button></div>
|
||||
</>}
|
||||
|
||||
{!loaded && <p>正在读取导入批次…</p>}
|
||||
{loaded && !batches.length && <div className="ra-empty"><h2>尚无导入批次</h2><p>选择文件后登记清单,即可逐个上传并核对解析结果。</p></div>}
|
||||
|
||||
{batches.map((batch) => <div className="xb-finding" key={batch.id}>
|
||||
<div>
|
||||
<b>批次 {batch.id.slice(-8)} · {BATCH_LABELS[batch.status] || batch.status}</b>
|
||||
<p>{batch.items.length} 个文件 · {batch.total_bytes} 字节 · 解析器 {batch.parser_version} · 数据域 {batch.data_domain === "demo" ? "演示数据" : "正式数据"}</p>
|
||||
{batch.items.some((item) => item.status !== "parsed") && <p className="xb-warning"><AlertTriangle size={13}/> {batch.items.filter((i) => i.status !== "parsed").length} 项尚未完成,本批次内容还不算已进入事实。</p>}
|
||||
{batch.warnings.slice(0, 3).map((w, i) => <p className="xb-warning" key={i}>{w}</p>)}
|
||||
</div>
|
||||
<button className="ra-button" onClick={() => setOpenId(openId === batch.id ? "" : batch.id)}>{openId === batch.id ? "收起明细" : "查看明细"}</button>
|
||||
{["partial", "failed"].includes(batch.status) && <button className="ra-button" disabled={busy} onClick={() => batchAction(batch, "retry")}>只重试失败项</button>}
|
||||
{["queued", "parsing"].includes(batch.status) && <button className="ra-button" disabled={busy} onClick={() => batchAction(batch, "cancel")}>取消批次</button>}
|
||||
</div>)}
|
||||
|
||||
{open && <div className="xb-document">
|
||||
<h3>明细 · {open.id.slice(-8)}</h3>
|
||||
{open.items.map((item) => <div className="xb-finding" key={item.id}>
|
||||
<div>
|
||||
<b>{item.relative_path}</b>
|
||||
<p>{ITEM_LABELS[item.status] || item.status} · {item.size_bytes} 字节 · 定位 {item.document_id ? `已关联 ${item.document_id.slice(-8)}` : "尚无"}{item.observed_at ? ` · 材料时间 ${item.observed_at}` : " · 材料时间未提供"}</p>
|
||||
{item.error_code && <p className="xb-warning">未完成原因:{item.error_code}{item.error_code === "INVALID_EVIDENCE" ? "(文件内容与清单声明的哈希或大小不一致)" : ""}</p>}
|
||||
</div>
|
||||
{item.status !== "parsed" && <button className="ra-button primary" disabled={busy} onClick={() => sendItem(open, item.id)}>上传该文件</button>}
|
||||
</div>)}
|
||||
</div>}
|
||||
|
||||
{conflicts.length > 0 && <>
|
||||
<h3>待裁定的数值冲突({conflicts.length})</h3>
|
||||
<p>同一字段存在多个不一致来源。系统不会自动挑一个,也不会按上传先后覆盖;请核对原件后显式选择。</p>
|
||||
{conflicts.map((conflict) => <div className="xb-document" key={conflict.id}>
|
||||
<h3>{conflict.field}</h3>
|
||||
{conflict.candidates.map((candidate) => <div className="xb-finding" key={candidate.evidence_id}>
|
||||
<div><b>{candidate.value === null ? "未知(未提供)" : `${candidate.value}${candidate.unit ? ` ${candidate.unit}` : ""}`}</b>
|
||||
<p>来源:{candidate.source} · 定位:{candidate.locator}{candidate.period ? ` · 期间 ${candidate.period}` : ""}</p></div>
|
||||
<button className="ra-button" disabled={busy || !note.trim()} onClick={() => resolveConflict(conflict, candidate.evidence_id)}>采纳该来源</button>
|
||||
</div>)}
|
||||
<label className="ra-field"><span>裁定说明(必填,会随冲突记录留痕)</span>
|
||||
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="例如:以合同金额为准,银行流水为阶段性数据"/>
|
||||
</label>
|
||||
</div>)}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** S1 预审接口的瘦客户端助手。
|
||||
*
|
||||
* 路径说明:prescreen 端点挂在 project-assistant 之下,因此工作台既有的
|
||||
* request() 助手(前缀 /api/risk-agent/project-assistant)可直接复用,
|
||||
* 不需要新的代理或客户端框架。见 claude_use/T00/04-first-slice-contracts.md §1。
|
||||
*/
|
||||
|
||||
export const PRESCREEN = "/prescreen";
|
||||
|
||||
/** 错误信封解包。
|
||||
*
|
||||
* S1 新端点返回 {error:{code,message,retryable,request_id}};既有端点仍是
|
||||
* {detail}。两种都要能吃下——否则面板会把 "操作失败" 当成真实原因展示给用户。
|
||||
*
|
||||
* detail 有三种形态,都要保留原有信息量:
|
||||
* string(业务文案)/ array(pydantic 校验错误数组)/ object。
|
||||
*/
|
||||
export function messageOf(data: any, fallback = "操作失败"): string {
|
||||
const envelope = data?.error;
|
||||
if (envelope?.message) {
|
||||
return envelope.retryable ? `${envelope.message}(可重试)` : envelope.message;
|
||||
}
|
||||
const detail = data?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (detail !== undefined && detail !== null) return JSON.stringify(detail);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 契约类型手工对齐 backend/contracts/prescreen.py。
|
||||
*
|
||||
* 临时方案:OpenAPI→TS 生成链尚未建设(docs/project-process/XINGBANG-ADAPTATION.md §4),
|
||||
* 契约字段变更时必须同步修改此处。不得据此宣称类型已自动生成。
|
||||
*/
|
||||
export type ImportItemStatus = "queued" | "parsing" | "parsed" | "failed";
|
||||
|
||||
export interface ImportItem {
|
||||
id: string;
|
||||
document_id: string | null;
|
||||
relative_path: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
mime_type: string;
|
||||
status: ImportItemStatus;
|
||||
error_code: string | null;
|
||||
observed_at: string | null;
|
||||
received_at: string;
|
||||
}
|
||||
|
||||
export interface ImportBatch {
|
||||
id: string;
|
||||
project_id: string;
|
||||
data_domain: "demo" | "production";
|
||||
revision: number;
|
||||
status: "queued" | "parsing" | "ready" | "partial" | "failed" | "cancelled";
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
parser_version: string;
|
||||
items: ImportItem[];
|
||||
total_bytes: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ConflictCandidate {
|
||||
evidence_id: string;
|
||||
value: string | null;
|
||||
source: string;
|
||||
locator: string;
|
||||
unit?: string | null;
|
||||
currency?: string | null;
|
||||
period?: string | null;
|
||||
}
|
||||
|
||||
export interface FactConflict {
|
||||
id: string;
|
||||
project_id: string;
|
||||
data_domain: string;
|
||||
revision: number;
|
||||
field: string;
|
||||
candidate_evidence_ids: string[];
|
||||
candidates: ConflictCandidate[];
|
||||
status: "unresolved" | "resolved";
|
||||
resolution_note: string | null;
|
||||
selected_evidence_ids: string[];
|
||||
resolved_by: string | null;
|
||||
resolved_at: string | null;
|
||||
created_at: string;
|
||||
supersedes_id: string | null;
|
||||
}
|
||||
|
||||
export const BATCH_LABELS: Record<string, string> = {
|
||||
queued: "待处理", parsing: "解析中", ready: "全部解析完成",
|
||||
partial: "部分失败", failed: "全部失败", cancelled: "已取消",
|
||||
};
|
||||
|
||||
export const ITEM_LABELS: Record<string, string> = {
|
||||
queued: "待上传", parsing: "解析中", parsed: "已解析", failed: "解析失败",
|
||||
};
|
||||
|
||||
/** 未完成项(partial/failed 时必须突出显示,见 UI Spec §3)。 */
|
||||
export function unfinished(batch: ImportBatch): ImportItem[] {
|
||||
return batch.items.filter((item) => item.status !== "parsed");
|
||||
}
|
||||
|
||||
export function batchBadge(batch: ImportBatch): string {
|
||||
return BATCH_LABELS[batch.status] || batch.status;
|
||||
}
|
||||
|
||||
/** 浏览器端计算 sha256,用于生成清单。清单哈希由**服务端重算核对**,
|
||||
* 这里只是声明值;不一致时服务端会把该条目置为 failed 并拒绝摄入。 */
|
||||
export async function fileDigest(file: File): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** 目录仅表达用户选择的文件集合,不上传路径扫描结果(docs/v2/14 §3)。 */
|
||||
export function manifestOf(files: File[], digests: Record<string, string>) {
|
||||
return files.map((file) => ({
|
||||
relative_path: file.name,
|
||||
sha256: digests[file.name],
|
||||
size_bytes: file.size,
|
||||
mime_type: file.type || "application/octet-stream",
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user