修改前端
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -12,61 +12,84 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
Layers,
|
Layers,
|
||||||
Sparkles
|
Sparkles,
|
||||||
|
Database,
|
||||||
|
FileSpreadsheet,
|
||||||
|
RefreshCcw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { backendApi } from '@/db/backend-api';
|
||||||
import { documentApi, taskApi } from '@/db/api';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Document = any;
|
type DocumentItem = {
|
||||||
type FillTask = any;
|
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 Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const [stats, setStats] = useState({ docs: 0, excelFiles: 0, tasks: 0 });
|
||||||
const { profile } = useAuth();
|
const [recentDocs, setRecentDocs] = useState<DocumentItem[]>([]);
|
||||||
const [stats, setStats] = useState({ docs: 0, entities: 0, tasks: 0 });
|
const [recentTasks, setRecentTasks] = useState<TaskItem[]>([]);
|
||||||
const [recentDocs, setRecentDocs] = useState<Document[]>([]);
|
|
||||||
const [recentTasks, setRecentTasks] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!profile) return;
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const docs = await documentApi.listDocuments((profile as any).id);
|
// 获取文档列表
|
||||||
const tasks = await taskApi.listTasks((profile as any).id);
|
const docsResult = await backendApi.getDocuments(undefined, 50);
|
||||||
setRecentDocs(docs.slice(0, 5));
|
if (docsResult.success && docsResult.documents) {
|
||||||
setRecentTasks(tasks.slice(0, 5));
|
setRecentDocs(docsResult.documents.slice(0, 5));
|
||||||
|
|
||||||
let entityCount = 0;
|
// 分类统计
|
||||||
docs.forEach(d => {
|
const docxMdTxt = docsResult.documents.filter((d: DocumentItem) =>
|
||||||
if (d.extracted_entities) entityCount += (d.extracted_entities as any[]).length;
|
['docx', 'md', 'txt'].includes(d.doc_type)
|
||||||
});
|
).length;
|
||||||
|
const xlsx = docsResult.documents.filter((d: DocumentItem) =>
|
||||||
|
d.doc_type === 'xlsx'
|
||||||
|
).length;
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
docs: docs.length,
|
docs: docxMdTxt,
|
||||||
entities: entityCount,
|
excelFiles: xlsx,
|
||||||
tasks: tasks.length
|
tasks: 0 // TODO: 后端任务接口
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('加载数据失败:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [profile]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in">
|
||||||
<section className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<section className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight">
|
<h1 className="text-3xl font-extrabold tracking-tight">
|
||||||
你好, <span className="text-primary">{((profile as any)?.email)?.split('@')[0] || '用户'}</span> 👋
|
欢迎使用 <span className="text-primary">智联文档</span> 系统 👋
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">欢迎使用智联文档,今日已为你处理了多个任务。</p>
|
<p className="text-muted-foreground">基于大语言模型的文档理解与多源数据融合系统</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" className="rounded-xl" asChild>
|
<Button variant="outline" className="rounded-xl" asChild>
|
||||||
@@ -84,9 +107,9 @@ const Dashboard: React.FC = () => {
|
|||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{[
|
{[
|
||||||
{ label: '已上传文档', value: stats.docs, icon: FileText, color: 'bg-blue-500', trend: '+12% 较上周' },
|
{ label: '已上传文档', value: stats.docs, icon: FileText, color: 'bg-blue-500', trend: '非结构化文档', link: '/documents' },
|
||||||
{ label: '提取实体', value: stats.entities, icon: Layers, color: 'bg-indigo-500', trend: '+25% 较上周' },
|
{ label: 'Excel 文件', value: stats.excelFiles, icon: FileSpreadsheet, color: 'bg-emerald-500', trend: '结构化数据', link: '/excel-parse' },
|
||||||
{ label: '生成表格', value: stats.tasks, icon: TableProperties, color: 'bg-emerald-500', trend: '+8% 较上周' }
|
{ label: '填表任务', value: stats.tasks, icon: TableProperties, color: 'bg-indigo-500', trend: '待实现', link: '/form-fill' }
|
||||||
].map((stat, i) => (
|
].map((stat, i) => (
|
||||||
<Card key={i} className="border-none shadow-md overflow-hidden group hover:shadow-xl transition-all duration-300">
|
<Card key={i} className="border-none shadow-md overflow-hidden group hover:shadow-xl transition-all duration-300">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -94,8 +117,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||||
<p className="text-3xl font-bold tracking-tight">{stat.value}</p>
|
<p className="text-3xl font-bold tracking-tight">{stat.value}</p>
|
||||||
<div className="flex items-center gap-1 text-xs text-emerald-500 font-medium bg-emerald-500/10 px-2 py-1 rounded-full w-fit">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded-full w-fit">
|
||||||
<TrendingUp size={12} />
|
|
||||||
<span>{stat.trend}</span>
|
<span>{stat.trend}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +140,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<Clock className="text-primary" size={20} />
|
<Clock className="text-primary" size={20} />
|
||||||
最近上传
|
最近上传
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>你最近上传并处理的非结构化文档</CardDescription>
|
<CardDescription>您最近上传的文档文件</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-primary hover:text-primary/80 hover:bg-primary/5">
|
<Button variant="ghost" size="sm" asChild className="text-primary hover:text-primary/80 hover:bg-primary/5">
|
||||||
<Link to="/documents">查看全部 <ArrowRight size={14} className="ml-1" /></Link>
|
<Link to="/documents">查看全部 <ArrowRight size={14} className="ml-1" /></Link>
|
||||||
@@ -132,21 +154,18 @@ const Dashboard: React.FC = () => {
|
|||||||
) : recentDocs.length > 0 ? (
|
) : recentDocs.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentDocs.map(doc => (
|
{recentDocs.map(doc => (
|
||||||
<div key={doc.id} className="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:border-border hover:bg-muted/30 transition-all group">
|
<div key={doc.doc_id} className="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:border-border hover:bg-muted/30 transition-all group">
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center">
|
||||||
<FileText size={20} />
|
<FileText size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-sm truncate">{doc.name}</p>
|
<p className="font-semibold text-sm truncate">{doc.original_filename}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDistanceToNow(new Date(doc.created_at!), { addSuffix: true, locale: zhCN })}
|
{doc.doc_type.toUpperCase()} • {formatDistanceToNow(new Date(doc.created_at), { addSuffix: true, locale: zhCN })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className="px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-muted">
|
||||||
"px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider",
|
{doc.doc_type}
|
||||||
doc.status === 'completed' ? "bg-emerald-500/10 text-emerald-500" : "bg-amber-500/10 text-amber-500"
|
|
||||||
)}>
|
|
||||||
{doc.status === 'completed' ? '已解析' : '处理中'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -163,56 +182,79 @@ const Dashboard: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recent Tasks */}
|
{/* Quick Actions */}
|
||||||
<Card className="border-none shadow-md">
|
<Card className="border-none shadow-md">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
<CheckCircle2 className="text-primary" size={20} />
|
<Sparkles className="text-primary" size={20} />
|
||||||
填表记录
|
快速开始
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>自动化生成的表格与汇总结果</CardDescription>
|
<CardDescription>选择您需要的服务开始使用</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-primary hover:text-primary/80 hover:bg-primary/5">
|
|
||||||
<Link to="/form-fill">查看全部 <ArrowRight size={14} className="ml-1" /></Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-4 py-4">
|
{[
|
||||||
{[1, 2, 3].map(i => <div key={i} className="h-12 bg-muted rounded-xl animate-pulse" />)}
|
{ 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) => (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={item.link}
|
||||||
|
className="flex items-center gap-4 p-4 rounded-2xl border border-transparent hover:border-border hover:bg-muted/30 transition-all group"
|
||||||
|
>
|
||||||
|
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg", item.color)}>
|
||||||
|
<item.icon size={24} />
|
||||||
</div>
|
</div>
|
||||||
) : recentTasks.length > 0 ? (
|
<div>
|
||||||
<div className="space-y-3">
|
<p className="font-semibold group-hover:text-primary transition-colors">{item.title}</p>
|
||||||
{recentTasks.map(task => (
|
<p className="text-xs text-muted-foreground">{item.desc}</p>
|
||||||
<div key={task.id} className="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:border-border hover:bg-muted/30 transition-all">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
|
|
||||||
<TableProperties size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-semibold text-sm truncate">{task.templates?.name || '未知模板'}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
关联 {task.document_ids?.length || 0} 个文档 • {formatDistanceToNow(new Date(task.created_at!), { addSuffix: true, locale: zhCN })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" className="text-primary h-8 w-8" onClick={() => navigate('/form-fill')}>
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ArrowRight size={16} className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center space-y-3">
|
|
||||||
<MessageSquareCode size={48} className="text-muted-foreground/30" />
|
|
||||||
<p className="text-muted-foreground italic">暂无任务记录</p>
|
|
||||||
<Button variant="outline" size="sm" asChild className="rounded-xl">
|
|
||||||
<Link to="/form-fill">开始填表</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* System Status */}
|
||||||
|
<Card className="border-none shadow-md">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Database className="text-primary" size={20} />
|
||||||
|
系统状态
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted/30">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">MySQL</p>
|
||||||
|
<p className="text-xs text-muted-foreground">结构化数据存储</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted/30">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">MongoDB</p>
|
||||||
|
<p className="text-xs text-muted-foreground">非结构化数据存储</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted/30">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">Faiss + RAG</p>
|
||||||
|
<p className="text-xs text-muted-foreground">向量检索索引</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,77 +4,107 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Upload,
|
Upload,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
|
||||||
Trash2,
|
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
CheckCircle2,
|
Trash2,
|
||||||
Clock,
|
Clock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Database
|
Database,
|
||||||
|
FileSpreadsheet,
|
||||||
|
File
|
||||||
} 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';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { backendApi } from '@/db/backend-api';
|
||||||
import { documentApi } from '@/db/api';
|
|
||||||
import { supabase } from '@/db/supabase';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
type Document = any;
|
type DocumentItem = {
|
||||||
type ExtractedEntity = any;
|
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 Documents: React.FC = () => {
|
||||||
const { profile } = useAuth();
|
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||||
const [documents, setDocuments] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [expandedDoc, setExpandedDoc] = useState<string | null>(null);
|
const [expandedDoc, setExpandedDoc] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const loadDocuments = useCallback(async () => {
|
const loadDocuments = useCallback(async () => {
|
||||||
if (!profile) return;
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await documentApi.listDocuments((profile as any).id);
|
const result = await backendApi.getDocuments(undefined, 100);
|
||||||
setDocuments(data);
|
if (result.success) {
|
||||||
|
setDocuments(result.documents || []);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error('加载文档失败');
|
toast.error('加载文档失败: ' + (err.message || '未知错误'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDocuments();
|
loadDocuments();
|
||||||
}, [loadDocuments]);
|
}, [loadDocuments]);
|
||||||
|
|
||||||
const onDrop = async (acceptedFiles: File[]) => {
|
const onDrop = async (acceptedFiles: File[]) => {
|
||||||
if (!profile) return;
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
const uploaded: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const file of acceptedFiles) {
|
for (const file of acceptedFiles) {
|
||||||
const doc = await documentApi.uploadDocument(file, (profile as any).id);
|
try {
|
||||||
if (doc) {
|
const result = await backendApi.uploadDocument(file);
|
||||||
toast.success(`文件 ${file.name} 上传成功,开始智能提取...`);
|
if (result.task_id) {
|
||||||
// Call Edge Function
|
uploaded.push(file.name);
|
||||||
supabase.functions.invoke('process-document', {
|
|
||||||
body: { documentId: doc.id }
|
// 轮询任务状态
|
||||||
}).then(({ error }) => {
|
const checkStatus = async () => {
|
||||||
if (error) toast.error(`提取失败: ${file.name}`);
|
let attempts = 0;
|
||||||
else {
|
while (attempts < 30) {
|
||||||
toast.success(`提取完成: ${file.name}`);
|
try {
|
||||||
|
const status = await backendApi.getTaskStatus(result.task_id);
|
||||||
|
if (status.status === 'success') {
|
||||||
|
toast.success(`文件 ${file.name} 处理完成`);
|
||||||
loadDocuments();
|
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();
|
||||||
}
|
}
|
||||||
loadDocuments();
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error('上传失败');
|
toast.error(`上传失败: ${file.name} - ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
toast.success(`已提交 ${uploaded.length} 个文件进行处理`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -84,37 +114,54 @@ const Documents: React.FC = () => {
|
|||||||
onDrop,
|
onDrop,
|
||||||
accept: {
|
accept: {
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
|
||||||
'text/markdown': ['.md'],
|
'text/markdown': ['.md'],
|
||||||
'text/plain': ['.txt']
|
'text/plain': ['.txt']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (docId: string) => {
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.from('documents').delete().eq('id', id);
|
const result = await backendApi.deleteDocument(docId);
|
||||||
if (error) throw error;
|
if (result.success) {
|
||||||
setDocuments(prev => prev.filter(d => d.id !== id));
|
setDocuments(prev => prev.filter(d => d.doc_id !== docId));
|
||||||
toast.success('文档已删除');
|
toast.success('文档已删除');
|
||||||
} catch (err) {
|
}
|
||||||
toast.error('删除失败');
|
} catch (err: any) {
|
||||||
|
toast.error('删除失败: ' + (err.message || '未知错误'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredDocs = documents.filter(doc =>
|
const filteredDocs = documents.filter(doc =>
|
||||||
doc.name.toLowerCase().includes(search.toLowerCase())
|
doc.original_filename.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getDocIcon = (docType: string) => {
|
||||||
|
switch (docType) {
|
||||||
|
case 'xlsx':
|
||||||
|
case 'xls':
|
||||||
|
return <FileSpreadsheet size={28} />;
|
||||||
|
case 'docx':
|
||||||
|
case 'doc':
|
||||||
|
return <FileText size={28} />;
|
||||||
|
default:
|
||||||
|
return <File size={28} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 pb-10">
|
<div className="space-y-8 pb-10">
|
||||||
<section className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<section className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight">文档中心</h1>
|
<h1 className="text-3xl font-extrabold tracking-tight">文档中心</h1>
|
||||||
<p className="text-muted-foreground">上传并管理您的非结构化文档,系统将自动进行深度理解与提取。</p>
|
<p className="text-muted-foreground">上传并管理您的非结构化文档(docx/md/txt),系统将自动进行深度理解与信息提取。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" className="rounded-xl gap-2" onClick={loadDocuments}>
|
||||||
|
<RefreshCcw size={18} />
|
||||||
|
<span>刷新</span>
|
||||||
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Upload Zone */}
|
{/* Upload Zone - For non-Excel documents */}
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -132,16 +179,20 @@ const Documents: React.FC = () => {
|
|||||||
{isDragActive ? '释放以开始上传' : '点击或拖拽文件到这里'}
|
{isDragActive ? '释放以开始上传' : '点击或拖拽文件到这里'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
支持 docx, xlsx, md, txt 格式文档,系统将自动识别关键信息
|
支持 docx, md, txt 格式文档,系统将自动识别关键信息并存入 MongoDB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{uploading && (
|
<div className="mt-4 flex items-center gap-4">
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/20 backdrop-blur-[2px] rounded-3xl">
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-600 border-blue-200">
|
||||||
<Badge variant="secondary" className="px-4 py-2 gap-2 text-sm shadow-lg border-primary/20">
|
<FileText size={14} className="mr-1" /> Word 文档
|
||||||
正在极速上传并分析文档...
|
</Badge>
|
||||||
|
<Badge variant="outline" className="bg-purple-500/10 text-purple-600 border-purple-200">
|
||||||
|
<FileText size={14} className="mr-1" /> Markdown
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="bg-gray-500/10 text-gray-600 border-gray-200">
|
||||||
|
<File size={14} className="mr-1" /> 文本文件
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter & Search */}
|
{/* Filter & Search */}
|
||||||
@@ -155,18 +206,9 @@ const Documents: React.FC = () => {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||||
<Button variant="outline" className="h-11 rounded-xl px-4 gap-2 border-none bg-card shadow-sm hover:bg-primary/5 hover:text-primary" onClick={() => toast.info('过滤器暂未启用,请直接搜索。')}>
|
<Database size={16} />
|
||||||
<Filter size={18} />
|
<span>{documents.length} 个文档</span>
|
||||||
<span>筛选</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 rounded-xl px-4 gap-2 border-none bg-card shadow-sm hover:bg-primary/5 hover:text-primary"
|
|
||||||
onClick={loadDocuments}
|
|
||||||
>
|
|
||||||
<RefreshCcw size={18} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,26 +220,35 @@ const Documents: React.FC = () => {
|
|||||||
))
|
))
|
||||||
) : filteredDocs.length > 0 ? (
|
) : filteredDocs.length > 0 ? (
|
||||||
filteredDocs.map((doc) => (
|
filteredDocs.map((doc) => (
|
||||||
<Card key={doc.id} className="border-none shadow-sm overflow-hidden group hover:shadow-md transition-all">
|
<Card key={doc.doc_id} className="border-none shadow-sm overflow-hidden group hover:shadow-md transition-all">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="p-6 flex flex-col md:flex-row md:items-center gap-6">
|
<div className="p-6 flex flex-col md:flex-row md:items-center gap-6">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-blue-500/10 text-blue-500 flex items-center justify-center shrink-0">
|
<div className={cn(
|
||||||
<FileText size={28} />
|
"w-14 h-14 rounded-2xl flex items-center justify-center shrink-0",
|
||||||
|
doc.doc_type === 'xlsx' ? "bg-emerald-500/10 text-emerald-500" :
|
||||||
|
doc.doc_type === 'docx' ? "bg-blue-500/10 text-blue-500" :
|
||||||
|
"bg-gray-500/10 text-gray-500"
|
||||||
|
)}>
|
||||||
|
{getDocIcon(doc.doc_type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="font-bold text-lg truncate">{doc.name}</h3>
|
<h3 className="font-bold text-lg truncate">{doc.original_filename}</h3>
|
||||||
<Badge variant="secondary" className="bg-muted text-muted-foreground text-[10px] uppercase tracking-widest">{doc.type}</Badge>
|
<Badge variant="secondary" className="bg-muted text-muted-foreground text-[10px] uppercase tracking-widest">
|
||||||
<Badge className={cn(
|
{doc.doc_type}
|
||||||
"text-[10px] uppercase font-bold",
|
|
||||||
doc.status === 'completed' ? "bg-emerald-500 text-white" : "bg-amber-500 text-white"
|
|
||||||
)}>
|
|
||||||
{doc.status === 'completed' ? '已解析' : '处理中'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{doc.metadata?.columns && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{doc.metadata.columns.length} 列
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1"><Clock size={14} /> {format(new Date(doc.created_at!), 'yyyy-MM-dd HH:mm')}</span>
|
<span className="flex items-center gap-1">
|
||||||
<span className="flex items-center gap-1 text-primary"><Database size={14} /> {doc.extracted_entities?.length || 0} 个识别项</span>
|
<Clock size={14} />
|
||||||
|
{format(new Date(doc.created_at), 'yyyy-MM-dd HH:mm')}
|
||||||
|
</span>
|
||||||
|
<span>{(doc.file_size / 1024).toFixed(1)} KB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -205,47 +256,75 @@ const Documents: React.FC = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-xl gap-2 text-primary hover:bg-primary/10"
|
className="rounded-xl gap-2 text-primary hover:bg-primary/10"
|
||||||
onClick={() => setExpandedDoc(expandedDoc === doc.id ? null : doc.id)}
|
onClick={() => setExpandedDoc(expandedDoc === doc.doc_id ? null : doc.doc_id)}
|
||||||
>
|
>
|
||||||
{expandedDoc === doc.id ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
{expandedDoc === doc.doc_id ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||||
<span>{expandedDoc === doc.id ? '收起详情' : '查看结果'}</span>
|
<span>{expandedDoc === doc.doc_id ? '收起详情' : '查看详情'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="rounded-lg text-destructive hover:bg-destructive/10" onClick={() => handleDelete(doc.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-lg text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(doc.doc_id)}
|
||||||
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Details */}
|
{/* Expanded Details */}
|
||||||
{expandedDoc === doc.id && (
|
{expandedDoc === doc.doc_id && (
|
||||||
<div className="px-6 pb-6 pt-2 border-t border-dashed animate-in slide-in-from-top-2 duration-300">
|
<div className="px-6 pb-6 pt-2 border-t border-dashed animate-in slide-in-from-top-2 duration-300">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{doc.extracted_entities && doc.extracted_entities.length > 0 ? (
|
<div className="p-4 bg-muted/30 rounded-xl">
|
||||||
doc.extracted_entities.map((entity: any) => (
|
<h4 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3">文件信息</h4>
|
||||||
<div key={entity.id} className="p-3 bg-muted/30 rounded-xl border border-border/50 flex flex-col gap-1">
|
<div className="space-y-2 text-sm">
|
||||||
<span className="text-[10px] font-bold text-primary uppercase tracking-wider">{entity.entity_type}</span>
|
<div className="flex justify-between">
|
||||||
<span className="text-sm font-semibold">{entity.entity_value}</span>
|
<span className="text-muted-foreground">文件名</span>
|
||||||
{entity.confidence && (
|
<span className="font-medium">{doc.original_filename}</span>
|
||||||
<div className="mt-1 w-full bg-muted h-1 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-primary h-full transition-all duration-1000"
|
|
||||||
style={{ width: `${entity.confidence * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">文件大小</span>
|
||||||
|
<span className="font-medium">{(doc.file_size / 1024).toFixed(2)} KB</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
<div className="flex justify-between">
|
||||||
) : (
|
<span className="text-muted-foreground">文档类型</span>
|
||||||
<div className="col-span-full py-6 text-center text-muted-foreground italic text-sm">
|
<span className="font-medium">{doc.doc_type.toUpperCase()}</span>
|
||||||
暂无识别到的关键信息,请点击重新提取。
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">创建时间</span>
|
||||||
|
<span className="font-medium">{format(new Date(doc.created_at), 'yyyy-MM-dd HH:mm:ss')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 p-4 bg-muted/50 rounded-xl">
|
</div>
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-2">文档原文摘要</h4>
|
</div>
|
||||||
<p className="text-sm line-clamp-4 text-muted-foreground">
|
|
||||||
{doc.content_text || '尚未提取原文内容...'}
|
{doc.metadata?.columns && doc.metadata.columns.length > 0 && (
|
||||||
|
<div className="p-4 bg-muted/30 rounded-xl">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3">表格结构</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{doc.metadata.columns.map((col: string, idx: number) => (
|
||||||
|
<Badge key={idx} variant="outline" className="bg-background">
|
||||||
|
{col}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{doc.metadata.row_count && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
共 {doc.metadata.row_count} 行数据
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-muted/30 rounded-xl col-span-full">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3">存储位置</h4>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Database size={16} className="text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{doc.doc_type === 'xlsx' ? 'MySQL 数据库(结构化数据)' : 'MongoDB 数据库(非结构化文档)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
447
frontend/src/pages/TemplateFill.tsx
Normal file
447
frontend/src/pages/TemplateFill.tsx
Normal file
@@ -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<File | null>(null);
|
||||||
|
const [templateFields, setTemplateFields] = useState<TemplateField[]>([]);
|
||||||
|
const [sourceDocs, setSourceDocs] = useState<DocumentItem[]>([]);
|
||||||
|
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filling, setFilling] = useState(false);
|
||||||
|
const [filledResult, setFilledResult] = useState<any>(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 (
|
||||||
|
<div className="space-y-8 pb-10">
|
||||||
|
<section className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight">智能填表</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
根据您的表格模板,自动聚合多源文档信息进行精准填充
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{step !== 'upload-template' && (
|
||||||
|
<Button variant="outline" className="rounded-xl gap-2" onClick={resetFlow}>
|
||||||
|
<RefreshCcw size={18} />
|
||||||
|
<span>重新开始</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
{['上传模板', '选择数据源', '填写预览'].map((label, idx) => {
|
||||||
|
const stepIndex = ['upload-template', 'select-source', 'preview'].indexOf(step);
|
||||||
|
const isActive = idx <= stepIndex;
|
||||||
|
const isCurrent = idx === stepIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-full transition-all",
|
||||||
|
isActive ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
||||||
|
isCurrent ? "bg-white/20" : ""
|
||||||
|
)}>
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
{idx < 2 && (
|
||||||
|
<div className={cn(
|
||||||
|
"w-12 h-0.5",
|
||||||
|
idx < stepIndex ? "bg-primary" : "bg-muted"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Upload Template */}
|
||||||
|
{step === 'upload-template' && (
|
||||||
|
<div
|
||||||
|
{...getTemplateProps()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-3xl p-16 transition-all duration-300 flex flex-col items-center justify-center text-center cursor-pointer group",
|
||||||
|
isTemplateDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/20 hover:border-primary/50 hover:bg-primary/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getTemplateInputProps()} />
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-primary/10 text-primary flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||||
|
{loading ? <Loader2 className="animate-spin" size={40} /> : <Upload size={40} />}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-w-md">
|
||||||
|
<p className="text-xl font-bold tracking-tight">
|
||||||
|
{isTemplateDragActive ? '释放以开始上传' : '点击或拖拽上传表格模板'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
支持 Excel (.xlsx, .xls) 或 Word (.docx) 格式的表格模板
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
|
||||||
|
<FileSpreadsheet size={14} className="mr-1" /> Excel 模板
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-600 border-blue-200">
|
||||||
|
<FileText size={14} className="mr-1" /> Word 模板
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Select Source Documents */}
|
||||||
|
{step === 'select-source' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Template Info */}
|
||||||
|
<Card className="border-none shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="text-primary" size={20} />
|
||||||
|
已上传模板
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
|
||||||
|
<FileSpreadsheet size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold">{templateFile?.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{templateFields.length} 个字段待填写
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setStep('upload-template')}>
|
||||||
|
重新选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Fields Preview */}
|
||||||
|
<div className="mt-4 p-4 bg-muted/30 rounded-xl">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3">待填写字段</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{templateFields.map((field, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="bg-background">
|
||||||
|
{field.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Source Documents Selection */}
|
||||||
|
<Card className="border-none shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<FileText className="text-primary" size={20} />
|
||||||
|
选择数据源文档
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
从已上传的文档中选择作为填表的数据来源,支持 Excel 和非结构化文档
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map(i => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : sourceDocs.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sourceDocs.map(doc => (
|
||||||
|
<div
|
||||||
|
key={doc.doc_id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-4 p-4 rounded-xl border-2 transition-all cursor-pointer",
|
||||||
|
selectedDocs.includes(doc.doc_id)
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/30"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDocs(prev =>
|
||||||
|
prev.includes(doc.doc_id)
|
||||||
|
? prev.filter(id => id !== doc.doc_id)
|
||||||
|
: [...prev, doc.doc_id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-6 h-6 rounded-md border-2 flex items-center justify-center transition-all",
|
||||||
|
selectedDocs.includes(doc.doc_id)
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-muted-foreground/30"
|
||||||
|
)}>
|
||||||
|
{selectedDocs.includes(doc.doc_id) && <CheckCircle2 size={14} />}
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-lg flex items-center justify-center",
|
||||||
|
doc.doc_type === 'xlsx' ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||||
|
)}>
|
||||||
|
{doc.doc_type === 'xlsx' ? <FileSpreadsheet size={20} /> : <FileText size={20} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold 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>
|
||||||
|
{doc.metadata?.columns && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{doc.metadata.columns.length} 列
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<FileText size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p>暂无数据源文档,请先上传文档</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="rounded-xl px-8 shadow-lg shadow-primary/20 gap-2"
|
||||||
|
disabled={selectedDocs.length === 0 || filling}
|
||||||
|
onClick={handleFillTemplate}
|
||||||
|
>
|
||||||
|
{filling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
<span>AI 正在分析并填表...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles size={20} />
|
||||||
|
<span>开始智能填表</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Preview Results */}
|
||||||
|
{step === 'preview' && filledResult && (
|
||||||
|
<Card className="border-none shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="text-emerald-500" size={20} />
|
||||||
|
填表完成
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
系统已根据 {selectedDocs.length} 份文档自动完成表格填写
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Filled Data Preview */}
|
||||||
|
<div className="p-6 bg-muted/30 rounded-2xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{templateFields.map((field, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-4">
|
||||||
|
<div className="w-32 text-sm font-medium text-muted-foreground">{field.name}</div>
|
||||||
|
<div className="flex-1 p-3 bg-background rounded-xl border">
|
||||||
|
{(filledResult.filled_data || {})[field.name] || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<Button variant="outline" className="rounded-xl gap-2" onClick={resetFlow}>
|
||||||
|
<RefreshCcw size={18} />
|
||||||
|
<span>继续填表</span>
|
||||||
|
</Button>
|
||||||
|
<Button className="rounded-xl gap-2 shadow-lg shadow-primary/20" onClick={handleExport}>
|
||||||
|
<Download size={18} />
|
||||||
|
<span>导出结果</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filling State */}
|
||||||
|
{step === 'filling' && (
|
||||||
|
<Card className="border-none shadow-md">
|
||||||
|
<CardContent className="py-16 flex flex-col items-center justify-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-6">
|
||||||
|
<Loader2 className="animate-spin text-primary" size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-2">AI 正在智能分析并填表</h3>
|
||||||
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
|
系统正在从 {selectedDocs.length} 份文档中检索相关信息,生成字段描述,并使用 RAG 增强填写准确性...
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateFill;
|
||||||
Reference in New Issue
Block a user