Files
RAG/frontend/src/features/documents/components/DocumentUploadPanel.tsx
YoVinchen ecdb10c37a
Some checks failed
verify / verify (push) Has been cancelled
Make the governed RAG evidence path executable end to end
Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
2026-07-13 05:58:11 +08:00

163 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from "react";
import { Icon } from "../../../components/Icon";
import { DOCUMENT_ACCEPT, formatBytes, validateDocumentFile } from "../file";
import type { DocumentJob, UploadPhase, UploadWorkflowState } from "../types";
interface DocumentUploadPanelProps {
workflow: UploadWorkflowState;
job: DocumentJob | undefined;
onUpload: (file: File) => void;
onCancel: () => void;
}
const ACTIVE_PHASES: UploadPhase[] = [
"hashing",
"declaring",
"uploading",
"completing",
"parsing",
"indexing",
];
const STEPS = [
{ phases: ["hashing", "declaring", "uploading", "completing"], label: "校验并隔离上传" },
{ phases: ["parsing"], label: "本地安全解析" },
{ phases: ["review"], label: "人工复核出域清单" },
{ phases: ["indexing", "indexed"], label: "向量化与激活" },
] as const;
function stepState(phase: UploadPhase, index: number): "pending" | "active" | "done" {
if (phase === "indexed") return "done";
const activeIndex = STEPS.findIndex((step) => (step.phases as readonly string[]).includes(phase));
if (activeIndex === -1) return "pending";
if (index < activeIndex) return "done";
return index === activeIndex ? "active" : "pending";
}
export function DocumentUploadPanel({
workflow,
job,
onUpload,
onCancel,
}: DocumentUploadPanelProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [selectionError, setSelectionError] = useState<string | null>(null);
const busy = ACTIVE_PHASES.includes(workflow.phase);
function chooseFile(next: File | undefined) {
if (next === undefined) return;
const error = validateDocumentFile(next);
setSelectionError(error);
setFile(error === null ? next : null);
}
return (
<section
className="document-panel document-upload-panel"
aria-labelledby="document-upload-title"
>
<div className="section-heading">
<div>
<span className="eyebrow">INGESTION GATE</span>
<h2 id="document-upload-title"></h2>
</div>
<span className="section-heading__meta"> 100 MiB</span>
</div>
<div
className={`document-dropzone${selectionError === null ? "" : " document-dropzone--error"}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (!busy) chooseFile(event.dataTransfer.files[0]);
}}
>
<Icon name="document" size={27} />
<strong>{file === null ? "选择或拖入地质资料" : file.name}</strong>
<span>
{file === null
? "支持 TXT、Markdown、DOCX、PDF"
: `${formatBytes(file.size)} · 等待本地校验`}
</span>
<button
className="secondary-button document-file-button"
disabled={busy}
onClick={() => inputRef.current?.click()}
type="button"
>
</button>
<input
ref={inputRef}
accept={DOCUMENT_ACCEPT}
aria-label="选择待上传文档"
className="visually-hidden"
disabled={busy}
onChange={(event) => chooseFile(event.target.files?.[0])}
type="file"
/>
</div>
{selectionError !== null && (
<p className="field-error" role="alert">
{selectionError}
</p>
)}
<div className="document-workflow" aria-label="文档处理阶段">
{STEPS.map((step, index) => {
const state = stepState(workflow.phase, index);
return (
<div
className={`document-workflow__step document-workflow__step--${state}`}
key={step.label}
>
<span aria-hidden="true">{state === "done" ? "✓" : index + 1}</span>
<small>{step.label}</small>
</div>
);
})}
</div>
<div className="document-live-status" aria-live="polite">
<div>
<strong>{workflow.filename ?? "尚未启动上传"}</strong>
<span>{workflow.message ?? "文件只在浏览器计算摘要,模型密钥不会进入页面。"}</span>
</div>
{job !== undefined && (
<div className="document-job-progress">
<span>{job.stage}</span>
<strong>{job.progress}%</strong>
</div>
)}
</div>
<div className="document-upload-actions">
<button
className="primary-button"
disabled={file === null || busy}
onClick={() => {
if (file !== null) onUpload(file);
}}
type="button"
>
{busy && <span className="button-spinner" aria-hidden="true" />}
{busy ? "处理中" : "校验并上传"}
</button>
{busy && (
<button className="tertiary-button" onClick={onCancel} type="button">
</button>
)}
</div>
<p className="disabled-note">
<Icon name="shield" size={15} />
PDF OCR_REQUIRED
</p>
</section>
);
}