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,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>;
|
||||
}
|
||||
Reference in New Issue
Block a user