This commit is contained in:
2026-04-10 00:26:57 +08:00
2 changed files with 313 additions and 165 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { import {
FileText, FileText,
@@ -23,7 +23,8 @@ import {
List, List,
MessageSquareCode, MessageSquareCode,
Tag, Tag,
HelpCircle HelpCircle,
Plus
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -72,8 +73,10 @@ const Documents: React.FC = () => {
// 上传相关状态 // 上传相关状态
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null); const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [parseResult, setParseResult] = useState<ExcelParseResult | null>(null); const [parseResult, setParseResult] = useState<ExcelParseResult | null>(null);
const [expandedSheet, setExpandedSheet] = useState<string | null>(null); const [expandedSheet, setExpandedSheet] = useState<string | null>(null);
const [uploadExpanded, setUploadExpanded] = useState(false);
// AI 分析相关状态 // AI 分析相关状态
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
@@ -210,75 +213,119 @@ const Documents: React.FC = () => {
// 文件上传处理 // 文件上传处理
const onDrop = async (acceptedFiles: File[]) => { const onDrop = async (acceptedFiles: File[]) => {
const file = acceptedFiles[0]; if (acceptedFiles.length === 0) return;
if (!file) return;
setUploadedFile(file);
setUploading(true); setUploading(true);
setParseResult(null); let successCount = 0;
setAiAnalysis(null); let failCount = 0;
setAnalysisCharts(null); const successfulFiles: File[] = [];
setExpandedSheet(null);
setMdAnalysis(null);
setMdSections([]);
setMdStreamingContent('');
const ext = file.name.split('.').pop()?.toLowerCase(); // 逐个上传文件
for (const file of acceptedFiles) {
const ext = file.name.split('.').pop()?.toLowerCase();
try { try {
// Excel 文件使用专门的上传接口 if (ext === 'xlsx' || ext === 'xls') {
if (ext === 'xlsx' || ext === 'xls') { const result = await backendApi.uploadExcel(file, {
const result = await backendApi.uploadExcel(file, { parseAllSheets: parseOptions.parseAllSheets,
parseAllSheets: parseOptions.parseAllSheets, headerRow: parseOptions.headerRow
headerRow: parseOptions.headerRow });
}); if (result.success) {
if (result.success) { successCount++;
toast.success(`解析成功: ${file.name}`); successfulFiles.push(file);
setParseResult(result); // 第一个Excel文件设置解析结果供预览
loadDocuments(); // 刷新文档列表 if (successCount === 1) {
if (result.metadata?.sheet_count === 1) { setUploadedFile(file);
setExpandedSheet(Object.keys(result.data?.sheets || {})[0] || null); setParseResult(result);
if (result.metadata?.sheet_count === 1) {
setExpandedSheet(Object.keys(result.data?.sheets || {})[0] || null);
}
}
loadDocuments();
} else {
failCount++;
toast.error(`${file.name}: ${result.error || '解析失败'}`);
}
} else if (ext === 'md' || ext === 'markdown') {
const result = await backendApi.uploadDocument(file);
if (result.task_id) {
successCount++;
successfulFiles.push(file);
if (successCount === 1) {
setUploadedFile(file);
}
// 轮询任务状态
let attempts = 0;
const checkStatus = async () => {
while (attempts < 30) {
try {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success') {
loadDocuments();
return;
} else if (status.status === 'failure') {
return;
}
} catch (e) {
console.error('检查状态失败', e);
}
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
}
};
checkStatus();
} else {
failCount++;
} }
} else { } else {
toast.error(result.error || '解析失败'); // 其他文档使用通用上传接口
} const result = await backendApi.uploadDocument(file);
} else if (ext === 'md' || ext === 'markdown') { if (result.task_id) {
// Markdown 文件:获取大纲 successCount++;
await fetchMdOutline(); successfulFiles.push(file);
} else { if (successCount === 1) {
// 其他文档使用通用上传接口 setUploadedFile(file);
const result = await backendApi.uploadDocument(file);
if (result.task_id) {
toast.success(`文件 ${file.name} 已提交处理`);
// 轮询任务状态
let attempts = 0;
const checkStatus = async () => {
while (attempts < 30) {
try {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success') {
toast.success(`文件 ${file.name} 处理完成`);
loadDocuments();
return;
} else if (status.status === 'failure') {
toast.error(`文件 ${file.name} 处理失败`);
return;
}
} catch (e) {
console.error('检查状态失败', e);
}
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
} }
toast.error(`文件 ${file.name} 处理超时`); // 轮询任务状态
}; let attempts = 0;
checkStatus(); const checkStatus = async () => {
while (attempts < 30) {
try {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success') {
loadDocuments();
return;
} else if (status.status === 'failure') {
return;
}
} catch (e) {
console.error('检查状态失败', e);
}
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
}
};
checkStatus();
} else {
failCount++;
}
} }
} catch (error: any) {
failCount++;
toast.error(`${file.name}: ${error.message || '上传失败'}`);
} }
} catch (error: any) { }
toast.error(error.message || '上传失败');
} finally { setUploading(false);
setUploading(false); loadDocuments();
if (successCount > 0) {
toast.success(`成功上传 ${successCount} 个文件`);
setUploadedFiles(prev => [...prev, ...successfulFiles]);
setUploadExpanded(true);
}
if (failCount > 0) {
toast.error(`${failCount} 个文件上传失败`);
} }
}; };
@@ -291,7 +338,7 @@ const Documents: React.FC = () => {
'text/markdown': ['.md'], 'text/markdown': ['.md'],
'text/plain': ['.txt'] 'text/plain': ['.txt']
}, },
maxFiles: 1 multiple: true
}); });
// AI 分析处理 // AI 分析处理
@@ -449,6 +496,7 @@ const Documents: React.FC = () => {
const handleDeleteFile = () => { const handleDeleteFile = () => {
setUploadedFile(null); setUploadedFile(null);
setUploadedFiles([]);
setParseResult(null); setParseResult(null);
setAiAnalysis(null); setAiAnalysis(null);
setAnalysisCharts(null); setAnalysisCharts(null);
@@ -456,6 +504,17 @@ const Documents: React.FC = () => {
toast.success('文件已清除'); toast.success('文件已清除');
}; };
const handleRemoveUploadedFile = (index: number) => {
setUploadedFiles(prev => {
const newFiles = prev.filter((_, i) => i !== index);
if (newFiles.length === 0) {
setUploadedFile(null);
}
return newFiles;
});
toast.success('文件已从列表移除');
};
const handleDelete = async (docId: string) => { const handleDelete = async (docId: string) => {
try { try {
const result = await backendApi.deleteDocument(docId); const result = await backendApi.deleteDocument(docId);
@@ -615,7 +674,7 @@ const Documents: React.FC = () => {
<h1 className="text-3xl font-extrabold tracking-tight"></h1> <h1 className="text-3xl font-extrabold tracking-tight"></h1>
<p className="text-muted-foreground">使 AI </p> <p className="text-muted-foreground">使 AI </p>
</div> </div>
<Button variant="outline" className="rounded-xl gap-2" onClick={loadDocuments}> <Button variant="outline" className="rounded-xl gap-2" onClick={() => loadDocuments()}>
<RefreshCcw size={18} /> <RefreshCcw size={18} />
<span></span> <span></span>
</Button> </Button>
@@ -640,7 +699,82 @@ const Documents: React.FC = () => {
</CardHeader> </CardHeader>
{uploadPanelOpen && ( {uploadPanelOpen && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!uploadedFile ? ( {uploadedFiles.length > 0 || uploadedFile ? (
<div className="space-y-3">
{/* 文件列表头部 */}
<div
className="flex items-center justify-between p-3 bg-muted/50 rounded-xl cursor-pointer hover:bg-muted/70 transition-colors"
onClick={() => setUploadExpanded(!uploadExpanded)}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Upload size={20} />
</div>
<div>
<p className="font-semibold text-sm">
{(uploadedFiles.length > 0 ? uploadedFiles : [uploadedFile]).length}
</p>
<p className="text-xs text-muted-foreground">
{uploadExpanded ? '点击收起' : '点击展开查看'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile();
}}
className="text-destructive hover:text-destructive"
>
<Trash2 size={14} className="mr-1" />
</Button>
{uploadExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
{/* 展开的文件列表 */}
{uploadExpanded && (
<div className="space-y-2 border rounded-xl p-3">
{(uploadedFiles.length > 0 ? uploadedFiles : [uploadedFile]).filter(Boolean).map((file, index) => (
<div key={index} className="flex items-center gap-3 p-2 bg-background rounded-lg">
<div className={cn(
"w-8 h-8 rounded flex items-center justify-center",
isExcelFile(file?.name || '') ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isExcelFile(file?.name || '') ? <FileSpreadsheet size={16} /> : <FileText size={16} />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{file?.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file?.size || 0)}</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveUploadedFile(index)}
>
<Trash2 size={14} />
</Button>
</div>
))}
{/* 继续添加按钮 */}
<div
{...getRootProps()}
className="flex items-center justify-center gap-2 p-3 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors"
>
<input {...getInputProps()} multiple={true} />
<Plus size={16} className="text-muted-foreground" />
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
)}
</div>
) : (
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
@@ -649,7 +783,7 @@ const Documents: React.FC = () => {
uploading && "opacity-50 pointer-events-none" uploading && "opacity-50 pointer-events-none"
)} )}
> >
<input {...getInputProps()} /> <input {...getInputProps()} multiple={true} />
<div className="w-14 h-14 rounded-xl bg-primary/10 text-primary flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"> <div className="w-14 h-14 rounded-xl bg-primary/10 text-primary flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
{uploading ? <Loader2 className="animate-spin" size={28} /> : <Upload size={28} />} {uploading ? <Loader2 className="animate-spin" size={28} /> : <Upload size={28} />}
</div> </div>
@@ -671,30 +805,6 @@ const Documents: React.FC = () => {
</Badge> </Badge>
</div> </div>
</div> </div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-muted/30 rounded-xl">
<div className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center",
isExcelFile(uploadedFile.name) ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isExcelFile(uploadedFile.name) ? <FileSpreadsheet size={20} /> : <FileText size={20} />}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{uploadedFile.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.size)}</p>
</div>
<Button variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10" onClick={handleDeleteFile}>
<Trash2 size={16} />
</Button>
</div>
{isExcelFile(uploadedFile.name) && (
<Button onClick={() => onDrop([uploadedFile])} className="w-full" disabled={uploading}>
{uploading ? '解析中...' : '重新解析'}
</Button>
)}
</div>
)} )}
</CardContent> </CardContent>
)} )}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { import {
TableProperties, TableProperties,
@@ -18,7 +18,8 @@ import {
Files, Files,
Trash2, Trash2,
Eye, Eye,
File File,
Plus
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@@ -72,6 +73,7 @@ const TemplateFill: React.FC = () => {
const [sourceMode, setSourceMode] = useState<'upload' | 'select'>('upload'); const [sourceMode, setSourceMode] = useState<'upload' | 'select'>('upload');
const [uploadedDocuments, setUploadedDocuments] = useState<DocumentItem[]>([]); const [uploadedDocuments, setUploadedDocuments] = useState<DocumentItem[]>([]);
const [docsLoading, setDocsLoading] = useState(false); const [docsLoading, setDocsLoading] = useState(false);
const sourceFileInputRef = useRef<HTMLInputElement>(null);
// 模板拖拽 // 模板拖拽
const onTemplateDrop = useCallback((acceptedFiles: File[]) => { const onTemplateDrop = useCallback((acceptedFiles: File[]) => {
@@ -93,25 +95,34 @@ const TemplateFill: React.FC = () => {
}); });
// 源文档拖拽 // 源文档拖拽
const onSourceDrop = useCallback((acceptedFiles: File[]) => { const onSourceDrop = useCallback((e: React.DragEvent) => {
const newFiles = acceptedFiles.map(f => ({ e.preventDefault();
file: f, const files = Array.from(e.dataTransfer.files).filter(f => {
preview: f.type.startsWith('text/') || f.name.endsWith('.md') ? undefined : undefined const ext = f.name.split('.').pop()?.toLowerCase();
})); return ['xlsx', 'xls', 'docx', 'md', 'txt'].includes(ext || '');
addSourceFiles(newFiles); });
if (files.length > 0) {
addSourceFiles(files.map(f => ({ file: f })));
}
}, [addSourceFiles]); }, [addSourceFiles]);
const { getRootProps: getSourceProps, getInputProps: getSourceInputProps, isDragActive: isSourceDragActive } = useDropzone({ const handleSourceFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
onDrop: onSourceDrop, const files = Array.from(e.target.files || []);
accept: { if (files.length > 0) {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], addSourceFiles(files.map(f => ({ file: f })));
'application/vnd.ms-excel': ['.xls'], toast.success(`已添加 ${files.length} 个文件`);
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], }
'text/plain': ['.txt'], e.target.value = '';
'text/markdown': ['.md'] };
},
multiple: true // 仅添加源文档不上传
}); const handleAddSourceFiles = () => {
if (sourceFiles.length === 0) {
toast.error('请先选择源文档');
return;
}
toast.success(`已添加 ${sourceFiles.length} 个源文档,可继续添加更多`);
};
// 加载已上传文档 // 加载已上传文档
const loadUploadedDocuments = useCallback(async () => { const loadUploadedDocuments = useCallback(async () => {
@@ -371,23 +382,33 @@ const TemplateFill: React.FC = () => {
<CardContent> <CardContent>
{sourceMode === 'upload' ? ( {sourceMode === 'upload' ? (
<> <>
<div className="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] border-muted-foreground/20 hover:border-primary/50 hover:bg-primary/5">
<input
id="source-file-input"
type="file"
multiple={true}
accept=".xlsx,.xls,.docx,.md,.txt"
onChange={handleSourceFileSelect}
className="hidden"
/>
<label htmlFor="source-file-input" className="cursor-pointer flex flex-col items-center">
<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">
</p>
<p className="text-xs text-muted-foreground mt-1">
.xlsx .xls .docx .md .txt
</p>
</label>
</div>
<div <div
{...getSourceProps()} onDragOver={(e) => { e.preventDefault(); }}
className={cn( onDrop={onSourceDrop}
"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]", className="mt-2 text-center text-xs text-muted-foreground"
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> </div>
{/* Selected Source Files */} {/* Selected Source Files */}
@@ -407,6 +428,12 @@ const TemplateFill: React.FC = () => {
</Button> </Button>
</div> </div>
))} ))}
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={() => document.getElementById('source-file-input')?.click()}>
<Plus size={14} className="mr-1" />
</Button>
</div>
</div> </div>
)} )}
</> </>
@@ -420,49 +447,60 @@ const TemplateFill: React.FC = () => {
))} ))}
</div> </div>
) : uploadedDocuments.length > 0 ? ( ) : uploadedDocuments.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto"> <div className="space-y-2">
{uploadedDocuments.map((doc) => ( {sourceDocIds.length > 0 && (
<div <div className="flex items-center justify-between p-3 bg-primary/5 rounded-xl border border-primary/20">
key={doc.doc_id} <span className="text-sm font-medium"> {sourceDocIds.length} </span>
className={cn( <Button variant="ghost" size="sm" onClick={() => loadUploadedDocuments()}>
"flex items-center gap-3 p-3 rounded-xl border-2 transition-all cursor-pointer", <RefreshCcw size={14} className="mr-1" />
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> </Button>
</div> </div>
))} )}
<div className="max-h-[300px] overflow-y-auto space-y-2">
{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> </div>
) : ( ) : (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">