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
+125
View File
@@ -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",
}));
}