修改前端

This commit is contained in:
2026-03-27 01:54:55 +08:00
parent 4e178477fe
commit 091c9db0da
3 changed files with 783 additions and 215 deletions

View File

@@ -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<any[]>([]);
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [expandedDoc, setExpandedDoc] = useState<string | null>(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 <FileSpreadsheet size={28} />;
case 'docx':
case 'doc':
return <FileText size={28} />;
default:
return <File size={28} />;
}
};
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>
<p className="text-muted-foreground">docx/md/txt</p>
</div>
<Button variant="outline" className="rounded-xl gap-2" onClick={loadDocuments}>
<RefreshCcw size={18} />
<span></span>
</Button>
</section>
{/* Upload Zone */}
<div
{...getRootProps()}
{/* Upload Zone - For non-Excel documents */}
<div
{...getRootProps()}
className={cn(
"relative border-2 border-dashed rounded-3xl p-12 transition-all duration-300 flex flex-col items-center justify-center text-center cursor-pointer group",
isDragActive ? "border-primary bg-primary/5 scale-[1.01]" : "border-muted-foreground/20 hover:border-primary/50 hover:bg-primary/5",
@@ -132,41 +179,36 @@ const Documents: React.FC = () => {
{isDragActive ? '释放以开始上传' : '点击或拖拽文件到这里'}
</p>
<p className="text-sm text-muted-foreground">
docx, xlsx, md, txt
docx, md, txt MongoDB
</p>
</div>
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/20 backdrop-blur-[2px] rounded-3xl">
<Badge variant="secondary" className="px-4 py-2 gap-2 text-sm shadow-lg border-primary/20">
...
</Badge>
</div>
)}
<div className="mt-4 flex items-center gap-4">
<Badge variant="outline" className="bg-blue-500/10 text-blue-600 border-blue-200">
<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>
</div>
</div>
{/* Filter & Search */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索文档名称..."
<Input
placeholder="搜索文档名称..."
className="pl-9 h-11 bg-card rounded-xl border-none shadow-sm focus-visible:ring-primary"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2">
<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('过滤器暂未启用,请直接搜索。')}>
<Filter size={18} />
<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 className="flex gap-2 items-center text-sm text-muted-foreground">
<Database size={16} />
<span>{documents.length} </span>
</div>
</div>
@@ -178,74 +220,111 @@ const Documents: React.FC = () => {
))
) : filteredDocs.length > 0 ? (
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="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">
<FileText size={28} />
<div className={cn(
"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 className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-3">
<h3 className="font-bold text-lg truncate">{doc.name}</h3>
<Badge variant="secondary" className="bg-muted text-muted-foreground text-[10px] uppercase tracking-widest">{doc.type}</Badge>
<Badge className={cn(
"text-[10px] uppercase font-bold",
doc.status === 'completed' ? "bg-emerald-500 text-white" : "bg-amber-500 text-white"
)}>
{doc.status === 'completed' ? '已解析' : '处理中'}
<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.doc_type}
</Badge>
{doc.metadata?.columns && (
<Badge variant="outline" className="text-[10px]">
{doc.metadata.columns.length}
</Badge>
)}
</div>
<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 text-primary"><Database size={14} /> {doc.extracted_entities?.length || 0} </span>
<span className="flex items-center gap-1">
<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 className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
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} />}
<span>{expandedDoc === doc.id ? '收起详情' : '查看结果'}</span>
{expandedDoc === doc.doc_id ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
<span>{expandedDoc === doc.doc_id ? '收起详情' : '查看详情'}</span>
</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} />
</Button>
</div>
</div>
{/* 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{doc.extracted_entities && doc.extracted_entities.length > 0 ? (
doc.extracted_entities.map((entity: any) => (
<div key={entity.id} className="p-3 bg-muted/30 rounded-xl border border-border/50 flex flex-col gap-1">
<span className="text-[10px] font-bold text-primary uppercase tracking-wider">{entity.entity_type}</span>
<span className="text-sm font-semibold">{entity.entity_value}</span>
{entity.confidence && (
<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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{doc.original_filename}</span>
</div>
))
) : (
<div className="col-span-full py-6 text-center text-muted-foreground italic text-sm">
<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 className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{doc.doc_type.toUpperCase()}</span>
</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>
{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>
)}
</div>
)}
</div>
<div className="mt-6 p-4 bg-muted/50 rounded-xl">
<h4 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-2"></h4>
<p className="text-sm line-clamp-4 text-muted-foreground">
{doc.content_text || '尚未提取原文内容...'}
</p>
<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>
)}
@@ -268,4 +347,4 @@ const Documents: React.FC = () => {
);
};
export default Documents;
export default Documents;