新增从文档中心选择源文档功能及删除功能

智能填表模块新增"从文档中心选择"模式,支持选择已上传的文档作为数据源,
同时支持从列表中删除文档。两种模式通过Tab切换。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj
2026-04-09 22:35:13 +08:00
parent 496b96508d
commit 610d475ce0
2 changed files with 246 additions and 58 deletions

View File

@@ -21,6 +21,7 @@ interface TemplateFillState {
templateFields: TemplateField[];
sourceFiles: SourceFile[];
sourceFilePaths: string[];
sourceDocIds: string[];
templateId: string;
filledResult: any;
setStep: (step: Step) => void;
@@ -30,6 +31,9 @@ interface TemplateFillState {
addSourceFiles: (files: SourceFile[]) => void;
removeSourceFile: (index: number) => void;
setSourceFilePaths: (paths: string[]) => void;
setSourceDocIds: (ids: string[]) => void;
addSourceDocId: (id: string) => void;
removeSourceDocId: (id: string) => void;
setTemplateId: (id: string) => void;
setFilledResult: (result: any) => void;
reset: () => void;
@@ -41,6 +45,7 @@ const initialState = {
templateFields: [],
sourceFiles: [],
sourceFilePaths: [],
sourceDocIds: [],
templateId: '',
filledResult: null,
setStep: () => {},
@@ -50,6 +55,9 @@ const initialState = {
addSourceFiles: () => {},
removeSourceFile: () => {},
setSourceFilePaths: () => {},
setSourceDocIds: () => {},
addSourceDocId: () => {},
removeSourceDocId: () => {},
setTemplateId: () => {},
setFilledResult: () => {},
reset: () => {},
@@ -63,6 +71,7 @@ export const TemplateFillProvider: React.FC<{ children: ReactNode }> = ({ childr
const [templateFields, setTemplateFields] = useState<TemplateField[]>([]);
const [sourceFiles, setSourceFiles] = useState<SourceFile[]>([]);
const [sourceFilePaths, setSourceFilePaths] = useState<string[]>([]);
const [sourceDocIds, setSourceDocIds] = useState<string[]>([]);
const [templateId, setTemplateId] = useState<string>('');
const [filledResult, setFilledResult] = useState<any>(null);
@@ -74,12 +83,21 @@ export const TemplateFillProvider: React.FC<{ children: ReactNode }> = ({ childr
setSourceFiles(prev => prev.filter((_, i) => i !== index));
};
const addSourceDocId = (id: string) => {
setSourceDocIds(prev => prev.includes(id) ? prev : [...prev, id]);
};
const removeSourceDocId = (id: string) => {
setSourceDocIds(prev => prev.filter(docId => docId !== id));
};
const reset = () => {
setStep('upload');
setTemplateFile(null);
setTemplateFields([]);
setSourceFiles([]);
setSourceFilePaths([]);
setSourceDocIds([]);
setTemplateId('');
setFilledResult(null);
};
@@ -92,6 +110,7 @@ export const TemplateFillProvider: React.FC<{ children: ReactNode }> = ({ childr
templateFields,
sourceFiles,
sourceFilePaths,
sourceDocIds,
templateId,
filledResult,
setStep,
@@ -101,6 +120,9 @@ export const TemplateFillProvider: React.FC<{ children: ReactNode }> = ({ childr
addSourceFiles,
removeSourceFile,
setSourceFilePaths,
setSourceDocIds,
addSourceDocId,
removeSourceDocId,
setTemplateId,
setFilledResult,
reset,

View File

@@ -60,6 +60,7 @@ const TemplateFill: React.FC = () => {
templateFields, setTemplateFields,
sourceFiles, setSourceFiles, addSourceFiles, removeSourceFile,
sourceFilePaths, setSourceFilePaths,
sourceDocIds, setSourceDocIds, addSourceDocId, removeSourceDocId,
templateId, setTemplateId,
filledResult, setFilledResult,
reset
@@ -68,6 +69,9 @@ const TemplateFill: React.FC = () => {
const [loading, setLoading] = useState(false);
const [previewDoc, setPreviewDoc] = useState<{ name: string; content: string } | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [sourceMode, setSourceMode] = useState<'upload' | 'select'>('upload');
const [uploadedDocuments, setUploadedDocuments] = useState<DocumentItem[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
// 模板拖拽
const onTemplateDrop = useCallback((acceptedFiles: File[]) => {
@@ -109,40 +113,118 @@ const TemplateFill: React.FC = () => {
multiple: true
});
// 加载已上传文档
const loadUploadedDocuments = useCallback(async () => {
setDocsLoading(true);
try {
const result = await backendApi.getDocuments(undefined, 100);
if (result.success) {
// 过滤可作为数据源的文档类型
const docs = (result.documents || []).filter((d: DocumentItem) =>
['docx', 'md', 'txt', 'xlsx', 'xls'].includes(d.doc_type)
);
setUploadedDocuments(docs);
}
} catch (err: any) {
console.error('加载文档失败:', err);
} finally {
setDocsLoading(false);
}
}, []);
// 删除文档
const handleDeleteDocument = async (docId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('确定要删除该文档吗?')) return;
try {
const result = await backendApi.deleteDocument(docId);
if (result.success) {
setUploadedDocuments(prev => prev.filter(d => d.doc_id !== docId));
removeSourceDocId(docId);
toast.success('文档已删除');
} else {
toast.error(result.message || '删除失败');
}
} catch (err: any) {
toast.error('删除失败: ' + (err.message || '未知错误'));
}
};
useEffect(() => {
if (sourceMode === 'select') {
loadUploadedDocuments();
}
}, [sourceMode, loadUploadedDocuments]);
const handleJointUploadAndFill = async () => {
if (!templateFile) {
toast.error('请先上传模板文件');
return;
}
// 检查是否选择了数据源
if (sourceMode === 'upload' && sourceFiles.length === 0) {
toast.error('请上传源文档或从已上传文档中选择');
return;
}
if (sourceMode === 'select' && sourceDocIds.length === 0) {
toast.error('请选择源文档');
return;
}
setLoading(true);
try {
// 使用联合上传API
const result = await backendApi.uploadTemplateAndSources(
templateFile,
sourceFiles.map(sf => sf.file)
);
if (sourceMode === 'select') {
// 使用已上传文档作为数据源
const result = await backendApi.uploadTemplate(templateFile);
if (result.success) {
setTemplateFields(result.fields || []);
setTemplateId(result.template_id);
setSourceFilePaths(result.source_file_paths || []);
toast.success('文档上传成功,开始智能填表');
setStep('filling');
if (result.success) {
setTemplateFields(result.fields || []);
setTemplateId(result.template_id || 'temp');
toast.success('开始智能填表');
setStep('filling');
// 自动开始填表
const fillResult = await backendApi.fillTemplate(
result.template_id,
result.fields || [],
[], // 使用 source_file_paths 而非 source_doc_ids
result.source_file_paths || [],
'请从以下文档中提取相关信息填写表格'
// 使用 source_doc_ids 进行填表
const fillResult = await backendApi.fillTemplate(
result.template_id || 'temp',
result.fields || [],
sourceDocIds,
[],
'请从以下文档中提取相关信息填写表格'
);
setFilledResult(fillResult);
setStep('preview');
toast.success('表格填写完成');
}
} else {
// 使用联合上传API
const result = await backendApi.uploadTemplateAndSources(
templateFile,
sourceFiles.map(sf => sf.file)
);
setFilledResult(fillResult);
setStep('preview');
toast.success('表格填写完成');
if (result.success) {
setTemplateFields(result.fields || []);
setTemplateId(result.template_id);
setSourceFilePaths(result.source_file_paths || []);
toast.success('文档上传成功,开始智能填表');
setStep('filling');
// 自动开始填表
const fillResult = await backendApi.fillTemplate(
result.template_id,
result.fields || [],
[],
result.source_file_paths || [],
'请从以下文档中提取相关信息填写表格'
);
setFilledResult(fillResult);
setStep('preview');
toast.success('表格填写完成');
}
}
} catch (err: any) {
toast.error('处理失败: ' + (err.message || '未知错误'));
@@ -264,47 +346,131 @@ const TemplateFill: React.FC = () => {
</CardTitle>
<CardDescription>
</CardDescription>
{/* Source Mode Tabs */}
<div className="flex gap-2 mt-2">
<Button
variant={sourceMode === 'upload' ? 'default' : 'outline'}
size="sm"
onClick={() => setSourceMode('upload')}
>
<Upload size={14} className="mr-1" />
</Button>
<Button
variant={sourceMode === 'select' ? 'default' : 'outline'}
size="sm"
onClick={() => setSourceMode('select')}
>
<Files size={14} className="mr-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<div
{...getSourceProps()}
className={cn(
"border-2 border-dashed rounded-2xl p-8 transition-all duration-300 flex flex-col items-center justify-center text-center cursor-pointer group min-h-[200px]",
isSourceDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/20 hover:border-primary/50 hover:bg-primary/5"
)}
>
<input {...getSourceInputProps()} />
<div className="w-14 h-14 rounded-xl bg-blue-500/10 text-blue-500 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
{loading ? <Loader2 className="animate-spin" size={28} /> : <Upload size={28} />}
</div>
<p className="font-medium">
{isSourceDragActive ? '释放以上传' : '点击或拖拽上传源文档'}
</p>
<p className="text-xs text-muted-foreground mt-1">
.xlsx .xls .docx .md .txt
</p>
</div>
{/* Selected Source Files */}
{sourceFiles.length > 0 && (
<div className="mt-4 space-y-2">
{sourceFiles.map((sf, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-muted/50 rounded-xl">
{getFileIcon(sf.file.name)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{sf.file.name}</p>
<p className="text-xs text-muted-foreground">
{(sf.file.size / 1024).toFixed(1)} KB
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeSourceFile(idx)}>
<Trash2 size={14} className="text-red-500" />
</Button>
{sourceMode === 'upload' ? (
<>
<div
{...getSourceProps()}
className={cn(
"border-2 border-dashed rounded-2xl p-8 transition-all duration-300 flex flex-col items-center justify-center text-center cursor-pointer group min-h-[200px]",
isSourceDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/20 hover:border-primary/50 hover:bg-primary/5"
)}
>
<input {...getSourceInputProps()} />
<div className="w-14 h-14 rounded-xl bg-blue-500/10 text-blue-500 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
{loading ? <Loader2 className="animate-spin" size={28} /> : <Upload size={28} />}
</div>
))}
</div>
<p className="font-medium">
{isSourceDragActive ? '释放以上传' : '点击或拖拽上传源文档'}
</p>
<p className="text-xs text-muted-foreground mt-1">
.xlsx .xls .docx .md .txt
</p>
</div>
{/* Selected Source Files */}
{sourceFiles.length > 0 && (
<div className="mt-4 space-y-2">
{sourceFiles.map((sf, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-muted/50 rounded-xl">
{getFileIcon(sf.file.name)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{sf.file.name}</p>
<p className="text-xs text-muted-foreground">
{(sf.file.size / 1024).toFixed(1)} KB
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeSourceFile(idx)}>
<Trash2 size={14} className="text-red-500" />
</Button>
</div>
))}
</div>
)}
</>
) : (
<>
{/* Uploaded Documents Selection */}
{docsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
) : uploadedDocuments.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{uploadedDocuments.map((doc) => (
<div
key={doc.doc_id}
className={cn(
"flex items-center gap-3 p-3 rounded-xl border-2 transition-all cursor-pointer",
sourceDocIds.includes(doc.doc_id)
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/30"
)}
onClick={() => {
if (sourceDocIds.includes(doc.doc_id)) {
removeSourceDocId(doc.doc_id);
} else {
addSourceDocId(doc.doc_id);
}
}}
>
<div className={cn(
"w-6 h-6 rounded-md border-2 flex items-center justify-center transition-all shrink-0",
sourceDocIds.includes(doc.doc_id)
? "border-primary bg-primary text-white"
: "border-muted-foreground/30"
)}>
{sourceDocIds.includes(doc.doc_id) && <CheckCircle2 size={14} />}
</div>
{getFileIcon(doc.original_filename)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{doc.original_filename}</p>
<p className="text-xs text-muted-foreground">
{doc.doc_type.toUpperCase()} {format(new Date(doc.created_at), 'yyyy-MM-dd')}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteDocument(doc.doc_id, e)}
className="shrink-0"
>
<Trash2 size={14} className="text-red-500" />
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Files size={32} className="mx-auto mb-2 opacity-30" />
<p className="text-sm"></p>
</div>
)}
</>
)}
</CardContent>
</Card>