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:
2026-09-29 18:14:14 +08:00
co-authored by Claude Code
parent ff7894ae1f
commit 985e2f6a53
5 changed files with 344 additions and 2 deletions
@@ -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>;
}