diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b92b0ab..7563304 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,72 +1,95 @@ import React, { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { useNavigate, Link } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { - FileText, - TableProperties, - MessageSquareCode, - TrendingUp, - Clock, - CheckCircle2, +import { + FileText, + TableProperties, + MessageSquareCode, + TrendingUp, + Clock, + CheckCircle2, ArrowRight, UploadCloud, Layers, - Sparkles + Sparkles, + Database, + FileSpreadsheet, + RefreshCcw } from 'lucide-react'; -import { useAuth } from '@/context/AuthContext'; -import { documentApi, taskApi } from '@/db/api'; +import { backendApi } from '@/db/backend-api'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import { cn } from '@/lib/utils'; -type Document = any; -type FillTask = any; +type DocumentItem = { + doc_id: string; + filename: string; + original_filename: string; + doc_type: string; + file_size: number; + created_at: string; + metadata?: { + row_count?: number; + column_count?: number; + columns?: string[]; + }; +}; + +type TaskItem = { + task_id: string; + status: string; + created_at: string; + message?: string; +}; const Dashboard: React.FC = () => { - const navigate = useNavigate(); - const { profile } = useAuth(); - const [stats, setStats] = useState({ docs: 0, entities: 0, tasks: 0 }); - const [recentDocs, setRecentDocs] = useState([]); - const [recentTasks, setRecentTasks] = useState([]); + const [stats, setStats] = useState({ docs: 0, excelFiles: 0, tasks: 0 }); + const [recentDocs, setRecentDocs] = useState([]); + const [recentTasks, setRecentTasks] = useState([]); const [loading, setLoading] = useState(true); - useEffect(() => { - if (!profile) return; - const loadData = async () => { - try { - const docs = await documentApi.listDocuments((profile as any).id); - const tasks = await taskApi.listTasks((profile as any).id); - setRecentDocs(docs.slice(0, 5)); - setRecentTasks(tasks.slice(0, 5)); - - let entityCount = 0; - docs.forEach(d => { - if (d.extracted_entities) entityCount += (d.extracted_entities as any[]).length; - }); + const loadData = async () => { + setLoading(true); + try { + // 获取文档列表 + const docsResult = await backendApi.getDocuments(undefined, 50); + if (docsResult.success && docsResult.documents) { + setRecentDocs(docsResult.documents.slice(0, 5)); + + // 分类统计 + const docxMdTxt = docsResult.documents.filter((d: DocumentItem) => + ['docx', 'md', 'txt'].includes(d.doc_type) + ).length; + const xlsx = docsResult.documents.filter((d: DocumentItem) => + d.doc_type === 'xlsx' + ).length; setStats({ - docs: docs.length, - entities: entityCount, - tasks: tasks.length + docs: docxMdTxt, + excelFiles: xlsx, + tasks: 0 // TODO: 后端任务接口 }); - } catch (err) { - console.error(err); - } finally { - setLoading(false); } - }; + } catch (err) { + console.error('加载数据失败:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); - }, [profile]); + }, []); return (

- 你好, {((profile as any)?.email)?.split('@')[0] || '用户'} 👋 + 欢迎使用 智联文档 系统 👋

-

欢迎使用智联文档,今日已为你处理了多个任务。

+

基于大语言模型的文档理解与多源数据融合系统

- {loading ? ( -
- {[1, 2, 3].map(i =>
)} -
- ) : recentTasks.length > 0 ? ( -
- {recentTasks.map(task => ( -
-
- -
-
-

{task.templates?.name || '未知模板'}

-

- 关联 {task.document_ids?.length || 0} 个文档 • {formatDistanceToNow(new Date(task.created_at!), { addSuffix: true, locale: zhCN })} -

-
- +
+ {[ + { title: '上传文档', desc: '支持 docx/md/txt', icon: FileText, link: '/documents', color: 'bg-blue-500' }, + { title: '解析 Excel', desc: '上传并分析数据', icon: FileSpreadsheet, link: '/excel-parse', color: 'bg-emerald-500' }, + { title: '智能填表', desc: '自动填写表格模板', icon: TableProperties, link: '/form-fill', color: 'bg-indigo-500' }, + { title: 'AI 助手', desc: '自然语言交互', icon: MessageSquareCode, link: '/assistant', color: 'bg-amber-500' } + ].map((item, i) => ( + +
+
- ))} -
- ) : ( -
- -

暂无任务记录

- -
- )} +
+

{item.title}

+

{item.desc}

+
+ + + ))} +
+ + {/* System Status */} + + + + + 系统状态 + + + +
+
+
+
+

MySQL

+

结构化数据存储

+
+
+
+
+
+

MongoDB

+

非结构化数据存储

+
+
+
+
+
+

Faiss + RAG

+

向量检索索引

+
+
+
+ +
); }; -export default Dashboard; +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/pages/Documents.tsx b/frontend/src/pages/Documents.tsx index 6655dbf..283b39c 100644 --- a/frontend/src/pages/Documents.tsx +++ b/frontend/src/pages/Documents.tsx @@ -1,80 +1,110 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; -import { - FileText, - Upload, - Search, - Filter, - Trash2, - RefreshCcw, - CheckCircle2, +import { + FileText, + Upload, + Search, + RefreshCcw, + Trash2, Clock, ChevronDown, ChevronUp, - Database + Database, + FileSpreadsheet, + File } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { useAuth } from '@/context/AuthContext'; -import { documentApi } from '@/db/api'; -import { supabase } from '@/db/supabase'; +import { backendApi } from '@/db/backend-api'; import { format } from 'date-fns'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; -type Document = any; -type ExtractedEntity = any; +type DocumentItem = { + doc_id: string; + filename: string; + original_filename: string; + doc_type: string; + file_size: number; + created_at: string; + metadata?: { + row_count?: number; + column_count?: number; + columns?: string[]; + }; +}; const Documents: React.FC = () => { - const { profile } = useAuth(); - const [documents, setDocuments] = useState([]); + const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [expandedDoc, setExpandedDoc] = useState(null); const [uploading, setUploading] = useState(false); const loadDocuments = useCallback(async () => { - if (!profile) return; + setLoading(true); try { - const data = await documentApi.listDocuments((profile as any).id); - setDocuments(data); + const result = await backendApi.getDocuments(undefined, 100); + if (result.success) { + setDocuments(result.documents || []); + } } catch (err: any) { - toast.error('加载文档失败'); + toast.error('加载文档失败: ' + (err.message || '未知错误')); } finally { setLoading(false); } - }, [profile]); + }, []); useEffect(() => { loadDocuments(); }, [loadDocuments]); const onDrop = async (acceptedFiles: File[]) => { - if (!profile) return; setUploading(true); + const uploaded: string[] = []; + try { for (const file of acceptedFiles) { - const doc = await documentApi.uploadDocument(file, (profile as any).id); - if (doc) { - toast.success(`文件 ${file.name} 上传成功,开始智能提取...`); - // Call Edge Function - supabase.functions.invoke('process-document', { - body: { documentId: doc.id } - }).then(({ error }) => { - if (error) toast.error(`提取失败: ${file.name}`); - else { - toast.success(`提取完成: ${file.name}`); - loadDocuments(); - } - }); + try { + const result = await backendApi.uploadDocument(file); + if (result.task_id) { + uploaded.push(file.name); + + // 轮询任务状态 + const checkStatus = async () => { + let attempts = 0; + 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} 处理失败: ${status.error}`); + return; + } + } catch (e) { + console.error('检查状态失败', e); + } + await new Promise(resolve => setTimeout(resolve, 2000)); + attempts++; + } + toast.error(`文件 ${file.name} 处理超时`); + }; + checkStatus(); + } + } catch (err: any) { + toast.error(`上传失败: ${file.name} - ${err.message}`); } } - loadDocuments(); - } catch (err: any) { - toast.error('上传失败'); + + if (uploaded.length > 0) { + toast.success(`已提交 ${uploaded.length} 个文件进行处理`); + } } finally { setUploading(false); } @@ -84,39 +114,56 @@ const Documents: React.FC = () => { onDrop, accept: { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], 'text/markdown': ['.md'], 'text/plain': ['.txt'] } }); - const handleDelete = async (id: string) => { + const handleDelete = async (docId: string) => { try { - const { error } = await supabase.from('documents').delete().eq('id', id); - if (error) throw error; - setDocuments(prev => prev.filter(d => d.id !== id)); - toast.success('文档已删除'); - } catch (err) { - toast.error('删除失败'); + const result = await backendApi.deleteDocument(docId); + if (result.success) { + setDocuments(prev => prev.filter(d => d.doc_id !== docId)); + toast.success('文档已删除'); + } + } catch (err: any) { + toast.error('删除失败: ' + (err.message || '未知错误')); } }; - const filteredDocs = documents.filter(doc => - doc.name.toLowerCase().includes(search.toLowerCase()) + const filteredDocs = documents.filter(doc => + doc.original_filename.toLowerCase().includes(search.toLowerCase()) ); + const getDocIcon = (docType: string) => { + switch (docType) { + case 'xlsx': + case 'xls': + return ; + case 'docx': + case 'doc': + return ; + default: + return ; + } + }; + return (

文档中心

-

上传并管理您的非结构化文档,系统将自动进行深度理解与提取。

+

上传并管理您的非结构化文档(docx/md/txt),系统将自动进行深度理解与信息提取。

+
- {/* Upload Zone */} -
{ {isDragActive ? '释放以开始上传' : '点击或拖拽文件到这里'}

- 支持 docx, xlsx, md, txt 格式文档,系统将自动识别关键信息 + 支持 docx, md, txt 格式文档,系统将自动识别关键信息并存入 MongoDB

- {uploading && ( -
- - 正在极速上传并分析文档... - -
- )} +
+ + Word 文档 + + + Markdown + + + 文本文件 + +
{/* Filter & Search */}
- setSearch(e.target.value)} />
-
- - +
+ + {documents.length} 个文档
@@ -178,74 +220,111 @@ const Documents: React.FC = () => { )) ) : filteredDocs.length > 0 ? ( filteredDocs.map((doc) => ( - +
-
- +
+ {getDocIcon(doc.doc_type)}
-

{doc.name}

- {doc.type} - - {doc.status === 'completed' ? '已解析' : '处理中'} +

{doc.original_filename}

+ + {doc.doc_type} + {doc.metadata?.columns && ( + + {doc.metadata.columns.length} 列 + + )}
- {format(new Date(doc.created_at!), 'yyyy-MM-dd HH:mm')} - {doc.extracted_entities?.length || 0} 个识别项 + + + {format(new Date(doc.created_at), 'yyyy-MM-dd HH:mm')} + + {(doc.file_size / 1024).toFixed(1)} KB
- -
{/* Expanded Details */} - {expandedDoc === doc.id && ( + {expandedDoc === doc.doc_id && (
-
- {doc.extracted_entities && doc.extracted_entities.length > 0 ? ( - doc.extracted_entities.map((entity: any) => ( -
- {entity.entity_type} - {entity.entity_value} - {entity.confidence && ( -
-
-
- )} +
+
+

文件信息

+
+
+ 文件名 + {doc.original_filename}
- )) - ) : ( -
- 暂无识别到的关键信息,请点击重新提取。 +
+ 文件大小 + {(doc.file_size / 1024).toFixed(2)} KB +
+
+ 文档类型 + {doc.doc_type.toUpperCase()} +
+
+ 创建时间 + {format(new Date(doc.created_at), 'yyyy-MM-dd HH:mm:ss')} +
+
+
+ + {doc.metadata?.columns && doc.metadata.columns.length > 0 && ( +
+

表格结构

+
+ {doc.metadata.columns.map((col: string, idx: number) => ( + + {col} + + ))} +
+ {doc.metadata.row_count && ( +

+ 共 {doc.metadata.row_count} 行数据 +

+ )}
)} -
-
-

文档原文摘要

-

- {doc.content_text || '尚未提取原文内容...'} -

+ +
+

存储位置

+
+ + + {doc.doc_type === 'xlsx' ? 'MySQL 数据库(结构化数据)' : 'MongoDB 数据库(非结构化文档)'} + +
+
)} @@ -268,4 +347,4 @@ const Documents: React.FC = () => { ); }; -export default Documents; +export default Documents; \ No newline at end of file diff --git a/frontend/src/pages/TemplateFill.tsx b/frontend/src/pages/TemplateFill.tsx new file mode 100644 index 0000000..8c330a9 --- /dev/null +++ b/frontend/src/pages/TemplateFill.tsx @@ -0,0 +1,447 @@ +import React, { useState, useEffect } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { + TableProperties, + Upload, + FileSpreadsheet, + FileText, + CheckCircle2, + Download, + Clock, + Sparkles, + X, + FilePlus, + RefreshCcw, + ChevronDown, + ChevronUp, + Loader2 +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { backendApi } from '@/db/backend-api'; +import { format } from 'date-fns'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { Skeleton } from '@/components/ui/skeleton'; + +type DocumentItem = { + doc_id: string; + filename: string; + original_filename: string; + doc_type: string; + file_size: number; + created_at: string; + metadata?: { + row_count?: number; + column_count?: number; + columns?: string[]; + }; +}; + +type TemplateField = { + cell: string; + name: string; + field_type: string; + required: boolean; +}; + +const TemplateFill: React.FC = () => { + const [step, setStep] = useState<'upload-template' | 'select-source' | 'preview' | 'filling'>('upload-template'); + const [templateFile, setTemplateFile] = useState(null); + const [templateFields, setTemplateFields] = useState([]); + const [sourceDocs, setSourceDocs] = useState([]); + const [selectedDocs, setSelectedDocs] = useState([]); + const [loading, setLoading] = useState(false); + const [filling, setFilling] = useState(false); + const [filledResult, setFilledResult] = useState(null); + + // Load available source documents + useEffect(() => { + loadSourceDocuments(); + }, []); + + const loadSourceDocuments = async () => { + setLoading(true); + try { + const result = await backendApi.getDocuments(undefined, 100); + if (result.success) { + // Filter to only non-Excel documents that can be used as data sources + const docs = (result.documents || []).filter((d: DocumentItem) => + ['docx', 'md', 'txt', 'xlsx'].includes(d.doc_type) + ); + setSourceDocs(docs); + } + } catch (err: any) { + toast.error('加载数据源失败'); + } finally { + setLoading(false); + } + }; + + const onTemplateDrop = async (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + if (!file) return; + + const ext = file.name.split('.').pop()?.toLowerCase(); + if (!['xlsx', 'xls', 'docx'].includes(ext || '')) { + toast.error('仅支持 xlsx/xls/docx 格式的模板文件'); + return; + } + + setTemplateFile(file); + setLoading(true); + + try { + const result = await backendApi.uploadTemplate(file); + if (result.success) { + setTemplateFields(result.fields || []); + setStep('select-source'); + toast.success('模板上传成功'); + } + } catch (err: any) { + toast.error('模板上传失败: ' + (err.message || '未知错误')); + } finally { + setLoading(false); + } + }; + + const { getRootProps: getTemplateProps, getInputProps: getTemplateInputProps, isDragActive: isTemplateDragActive } = useDropzone({ + onDrop: onTemplateDrop, + accept: { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'] + }, + maxFiles: 1 + }); + + const handleFillTemplate = async () => { + if (!templateFile || selectedDocs.length === 0) { + toast.error('请选择数据源文档'); + return; + } + + setFilling(true); + setStep('filling'); + + try { + // 调用后端填表接口 + const result = await backendApi.fillTemplate('temp-template-id', templateFields); + setFilledResult(result); + setStep('preview'); + toast.success('表格填写完成'); + } catch (err: any) { + toast.error('填表失败: ' + (err.message || '未知错误')); + setStep('select-source'); + } finally { + setFilling(false); + } + }; + + const handleExport = async () => { + if (!templateFile || !filledResult) return; + + try { + const blob = await backendApi.exportFilledTemplate('temp', filledResult.filled_data || {}, 'xlsx'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `filled_${templateFile.name}`; + a.click(); + URL.revokeObjectURL(url); + toast.success('导出成功'); + } catch (err: any) { + toast.error('导出失败: ' + (err.message || '未知错误')); + } + }; + + const resetFlow = () => { + setStep('upload-template'); + setTemplateFile(null); + setTemplateFields([]); + setSelectedDocs([]); + setFilledResult(null); + }; + + return ( +
+
+
+

智能填表

+

+ 根据您的表格模板,自动聚合多源文档信息进行精准填充 +

+
+ {step !== 'upload-template' && ( + + )} +
+ + {/* Progress Steps */} +
+ {['上传模板', '选择数据源', '填写预览'].map((label, idx) => { + const stepIndex = ['upload-template', 'select-source', 'preview'].indexOf(step); + const isActive = idx <= stepIndex; + const isCurrent = idx === stepIndex; + + return ( + +
+
+ {idx + 1} +
+ {label} +
+ {idx < 2 && ( +
+ )} + + ); + })} +
+ + {/* Step 1: Upload Template */} + {step === 'upload-template' && ( +
+ +
+ {loading ? : } +
+
+

+ {isTemplateDragActive ? '释放以开始上传' : '点击或拖拽上传表格模板'} +

+

+ 支持 Excel (.xlsx, .xls) 或 Word (.docx) 格式的表格模板 +

+
+
+ + Excel 模板 + + + Word 模板 + +
+
+ )} + + {/* Step 2: Select Source Documents */} + {step === 'select-source' && ( +
+ {/* Template Info */} + + + + + 已上传模板 + + + +
+
+ +
+
+

{templateFile?.name}

+

+ {templateFields.length} 个字段待填写 +

+
+ +
+ + {/* Template Fields Preview */} +
+

待填写字段

+
+ {templateFields.map((field, idx) => ( + + {field.name} + + ))} +
+
+
+
+ + {/* Source Documents Selection */} + + + + + 选择数据源文档 + + + 从已上传的文档中选择作为填表的数据来源,支持 Excel 和非结构化文档 + + + + {loading ? ( +
+ {[1, 2, 3].map(i => )} +
+ ) : sourceDocs.length > 0 ? ( +
+ {sourceDocs.map(doc => ( +
{ + setSelectedDocs(prev => + prev.includes(doc.doc_id) + ? prev.filter(id => id !== doc.doc_id) + : [...prev, doc.doc_id] + ); + }} + > +
+ {selectedDocs.includes(doc.doc_id) && } +
+
+ {doc.doc_type === 'xlsx' ? : } +
+
+

{doc.original_filename}

+

+ {doc.doc_type.toUpperCase()} • {format(new Date(doc.created_at), 'yyyy-MM-dd')} +

+
+ {doc.metadata?.columns && ( + + {doc.metadata.columns.length} 列 + + )} +
+ ))} +
+ ) : ( +
+ +

暂无数据源文档,请先上传文档

+
+ )} +
+
+ + {/* Action Button */} +
+ +
+
+ )} + + {/* Step 3: Preview Results */} + {step === 'preview' && filledResult && ( + + + + + 填表完成 + + + 系统已根据 {selectedDocs.length} 份文档自动完成表格填写 + + + + {/* Filled Data Preview */} +
+
+ {templateFields.map((field, idx) => ( +
+
{field.name}
+
+ {(filledResult.filled_data || {})[field.name] || '-'} +
+
+ ))} +
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ )} + + {/* Filling State */} + {step === 'filling' && ( + + +
+ +
+

AI 正在智能分析并填表

+

+ 系统正在从 {selectedDocs.length} 份文档中检索相关信息,生成字段描述,并使用 RAG 增强填写准确性... +

+
+
+ )} +
+ ); +}; + +export default TemplateFill; \ No newline at end of file