前后端基本架构和完全excel表的解析及统计图表的生成以及excel表的到出

This commit is contained in:
2026-03-19 01:51:34 +08:00
parent c23b93bb70
commit 2f630695ff
194 changed files with 23354 additions and 174 deletions

View File

@@ -0,0 +1,250 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
MessageSquareCode,
Send,
Bot,
User,
Sparkles,
RefreshCcw,
Trash2,
Zap,
FileText,
TableProperties,
ChevronRight,
ArrowRight
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { useAuth } from '@/context/AuthContext';
import { supabase } from '@/db/supabase';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
type ChatMessage = any;
const Assistant: React.FC = () => {
const navigate = useNavigate();
const { profile } = useAuth();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Initial message
if (messages.length === 0) {
setMessages([
{
id: '1',
role: 'assistant',
content: '您好!我是智联文档 AI 助手。您可以告诉我您想对文档进行的操作,例如:\n- "帮我列出最近上传的所有 docx 文档"\n- "从 2026 财报文档中提取出关键的利润数据"\n- "帮我创建一个汇总各部门报销单的填表任务"\n\n请问有什么我可以帮您的',
created_at: new Date().toISOString()
}
]);
}
}, []);
useEffect(() => {
// Scroll to bottom
if (scrollAreaRef.current) {
const scrollElement = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (scrollElement) {
scrollElement.scrollTop = scrollElement.scrollHeight;
}
}
}, [messages]);
const handleSend = async () => {
if (!input.trim() || !profile) return;
const userMessage: ChatMessage = {
id: Math.random().toString(36).substring(7),
role: 'user',
content: input,
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setLoading(true);
try {
const response = await supabase.functions.invoke('chat-assistant', {
body: {
messages: [...messages, userMessage].slice(-6).map(m => ({ role: m.role, content: m.content })),
userId: (profile as any).id
}
});
if (response.error) throw response.error;
const assistantMessage: ChatMessage = {
id: Math.random().toString(36).substring(7),
role: 'assistant',
content: response.data.choices[0].message.content,
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, assistantMessage]);
} catch (err: any) {
toast.error('对话请求失败');
} finally {
setLoading(false);
}
};
const clearChat = () => {
setMessages([messages[0]]);
toast.success('对话已清空');
};
return (
<div className="h-[calc(100vh-8rem)] flex flex-col gap-6 animate-fade-in relative">
<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 flex items-center gap-3">
<Sparkles className="text-primary animate-pulse" />
</h1>
<p className="text-muted-foreground"></p>
</div>
<Button variant="outline" size="sm" className="rounded-xl gap-2 h-10 border-none bg-card shadow-sm hover:bg-destructive/10 hover:text-destructive" onClick={clearChat}>
<Trash2 size={16} />
<span></span>
</Button>
</section>
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Chat Area */}
<Card className="flex-1 flex flex-col border-none shadow-xl overflow-hidden rounded-3xl bg-card/50 backdrop-blur-sm">
<ScrollArea className="flex-1 p-6" ref={scrollAreaRef}>
<div className="space-y-8 pb-4">
{messages.map((m) => (
<div
key={m.id}
className={cn(
"flex gap-4 max-w-[85%]",
m.role === 'user' ? "ml-auto flex-row-reverse" : "mr-auto"
)}
>
<div className={cn(
"w-10 h-10 rounded-2xl flex items-center justify-center shrink-0 shadow-lg",
m.role === 'user' ? "bg-primary text-primary-foreground" : "bg-white text-primary border border-primary/20"
)}>
{m.role === 'user' ? <User size={20} /> : <Bot size={22} />}
</div>
<div className={cn(
"space-y-2 p-5 rounded-3xl",
m.role === 'user'
? "bg-primary text-primary-foreground shadow-xl shadow-primary/20 rounded-tr-none"
: "bg-white border border-border/50 shadow-md rounded-tl-none"
)}>
<p className="text-sm leading-relaxed whitespace-pre-wrap font-medium">
{m.content}
</p>
<span className={cn(
"text-[10px] block opacity-50 font-bold tracking-widest",
m.role === 'user' ? "text-right" : "text-left"
)}>
{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
))}
{loading && (
<div className="flex gap-4 mr-auto max-w-[85%] animate-pulse">
<div className="w-10 h-10 rounded-2xl bg-muted flex items-center justify-center shrink-0 border border-border/50">
<Bot size={22} className="text-muted-foreground" />
</div>
<div className="p-5 rounded-3xl rounded-tl-none bg-muted/50 border border-border/50">
<div className="flex gap-2">
<div className="w-2 h-2 rounded-full bg-primary/40 animate-bounce [animation-delay:-0.3s]" />
<div className="w-2 h-2 rounded-full bg-primary/40 animate-bounce [animation-delay:-0.15s]" />
<div className="w-2 h-2 rounded-full bg-primary/40 animate-bounce" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
<CardFooter className="p-6 bg-white/50 backdrop-blur-xl border-t border-border/50">
<form
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
className="w-full flex gap-3 bg-muted/30 p-2 rounded-2xl border border-border/50 focus-within:border-primary/50 transition-all shadow-inner"
>
<Input
placeholder="尝试输入:帮我从 2026 财报文档中提取..."
className="flex-1 bg-transparent border-none focus-visible:ring-0 shadow-none h-12 text-base font-medium"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<Button
type="submit"
size="icon"
className="w-12 h-12 rounded-xl bg-primary hover:scale-105 transition-all shadow-lg shadow-primary/20"
disabled={loading || !input.trim()}
>
<Send size={20} />
</Button>
</form>
</CardFooter>
</Card>
{/* Quick Actions Panel */}
<aside className="w-full lg:w-80 space-y-6">
<Card className="border-none shadow-lg rounded-3xl bg-gradient-to-br from-primary/5 via-background to-background">
<CardHeader className="p-6">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-primary flex items-center gap-2">
<Zap size={16} />
</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-3">
{[
{ label: '列出所有 Excel', icon: TableProperties, color: 'text-emerald-500' },
{ label: '分析文档库中实体', icon: FileText, color: 'text-blue-500' },
{ label: '自动纠正排版错误', icon: Sparkles, color: 'text-amber-500' },
{ label: '重命名最近上传', icon: ChevronRight, color: 'text-indigo-500' }
].map((action, i) => (
<button
key={i}
className="w-full flex items-center gap-3 p-3 rounded-2xl hover:bg-white hover:shadow-md transition-all group text-left border border-transparent hover:border-primary/10"
onClick={() => setInput(action.label)}
>
<div className={cn("w-8 h-8 rounded-lg bg-current/10 flex items-center justify-center shrink-0", action.color)}>
<action.icon size={16} />
</div>
<span className="text-sm font-semibold truncate group-hover:text-primary transition-colors">{action.label}</span>
<ArrowRight size={14} className="ml-auto opacity-0 group-hover:opacity-100 -translate-x-2 group-hover:translate-x-0 transition-all" />
</button>
))}
</CardContent>
</Card>
<Card className="border-none shadow-lg rounded-3xl bg-gradient-to-br from-indigo-500/10 to-blue-500/10 overflow-hidden relative">
<div className="absolute top-0 right-0 p-4 opacity-20">
<Sparkles size={100} />
</div>
<CardHeader className="p-6 relative z-10">
<CardTitle className="text-lg font-bold">AI </CardTitle>
<CardDescription className="text-indigo-700/70 font-medium"></CardDescription>
</CardHeader>
<CardFooter className="p-6 pt-0 relative z-10">
<Button className="w-full rounded-2xl bg-indigo-600 hover:bg-indigo-700 h-10 shadow-lg shadow-indigo-200" onClick={() => navigate('/form-fill')}>
</Button>
</CardFooter>
</Card>
</aside>
</div>
</div>
);
};
export default Assistant;

View File

@@ -0,0 +1,220 @@
import React, { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useNavigate, Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
FileText,
TableProperties,
MessageSquareCode,
TrendingUp,
Clock,
CheckCircle2,
ArrowRight,
UploadCloud,
Layers,
Sparkles
} from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { documentApi, taskApi } from '@/db/api';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { cn } from '@/lib/utils';
type Document = any;
type FillTask = any;
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const { profile } = useAuth();
const [stats, setStats] = useState({ docs: 0, entities: 0, tasks: 0 });
const [recentDocs, setRecentDocs] = useState<Document[]>([]);
const [recentTasks, setRecentTasks] = useState<any[]>([]);
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;
});
setStats({
docs: docs.length,
entities: entityCount,
tasks: tasks.length
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
loadData();
}, [profile]);
return (
<div className="space-y-8 animate-fade-in">
<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">
, <span className="text-primary">{((profile as any)?.email)?.split('@')[0] || '用户'}</span> 👋
</h1>
<p className="text-muted-foreground">使</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" className="rounded-xl" asChild>
<Link to="/documents"></Link>
</Button>
<Button className="rounded-xl shadow-lg shadow-primary/20" asChild>
<Link to="/assistant" className="gap-2">
<Sparkles size={18} />
<span></span>
</Link>
</Button>
</div>
</section>
{/* Stats Grid */}
<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.entities, icon: Layers, color: 'bg-indigo-500', trend: '+25% 较上周' },
{ label: '生成表格', value: stats.tasks, icon: TableProperties, color: 'bg-emerald-500', trend: '+8% 较上周' }
].map((stat, i) => (
<Card key={i} className="border-none shadow-md overflow-hidden group hover:shadow-xl transition-all duration-300">
<CardContent className="p-0">
<div className="p-6 flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{stat.label}</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">
<TrendingUp size={12} />
<span>{stat.trend}</span>
</div>
</div>
<div className={cn("w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg", stat.color)}>
<stat.icon size={24} />
</div>
</div>
<div className="h-1 bg-muted group-hover:bg-primary transition-colors" />
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Documents */}
<Card className="border-none shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Clock className="text-primary" size={20} />
</CardTitle>
<CardDescription></CardDescription>
</div>
<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>
</Button>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-4 py-4">
{[1, 2, 3].map(i => <div key={i} className="h-12 bg-muted rounded-xl animate-pulse" />)}
</div>
) : recentDocs.length > 0 ? (
<div className="space-y-3">
{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 className="w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center">
<FileText size={20} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(doc.created_at!), { addSuffix: true, locale: zhCN })}
</p>
</div>
<div className={cn(
"px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider",
doc.status === 'completed' ? "bg-emerald-500/10 text-emerald-500" : "bg-amber-500/10 text-amber-500"
)}>
{doc.status === 'completed' ? '已解析' : '处理中'}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center space-y-3">
<UploadCloud 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="/documents"></Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Recent Tasks */}
<Card className="border-none shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<CheckCircle2 className="text-primary" size={20} />
</CardTitle>
<CardDescription></CardDescription>
</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>
<CardContent>
{loading ? (
<div className="space-y-4 py-4">
{[1, 2, 3].map(i => <div key={i} className="h-12 bg-muted rounded-xl animate-pulse" />)}
</div>
) : recentTasks.length > 0 ? (
<div className="space-y-3">
{recentTasks.map(task => (
<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>
) : (
<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>
</Card>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,271 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import {
FileText,
Upload,
Search,
Filter,
Trash2,
RefreshCcw,
CheckCircle2,
Clock,
ChevronDown,
ChevronUp,
Database
} 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 { Badge } from '@/components/ui/badge';
import { useAuth } from '@/context/AuthContext';
import { documentApi } from '@/db/api';
import { supabase } from '@/db/supabase';
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;
const Documents: React.FC = () => {
const { profile } = useAuth();
const [documents, setDocuments] = useState<any[]>([]);
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;
try {
const data = await documentApi.listDocuments((profile as any).id);
setDocuments(data);
} catch (err: any) {
toast.error('加载文档失败');
} finally {
setLoading(false);
}
}, [profile]);
useEffect(() => {
loadDocuments();
}, [loadDocuments]);
const onDrop = async (acceptedFiles: File[]) => {
if (!profile) return;
setUploading(true);
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();
}
});
}
}
loadDocuments();
} catch (err: any) {
toast.error('上传失败');
} finally {
setUploading(false);
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
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) => {
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 filteredDocs = documents.filter(doc =>
doc.name.toLowerCase().includes(search.toLowerCase())
);
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>
</section>
{/* Upload Zone */}
<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",
uploading && "opacity-50 pointer-events-none"
)}
>
<input {...getInputProps()} />
<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 duration-500">
{uploading ? <RefreshCcw className="animate-spin" size={40} /> : <Upload size={40} />}
</div>
<div className="space-y-2 max-w-sm">
<p className="text-xl font-bold tracking-tight">
{isDragActive ? '释放以开始上传' : '点击或拖拽文件到这里'}
</p>
<p className="text-sm text-muted-foreground">
docx, xlsx, md, txt
</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>
{/* 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="搜索文档名称..."
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>
</div>
{/* Document List */}
<div className="grid grid-cols-1 gap-4">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-2xl" />
))
) : filteredDocs.length > 0 ? (
filteredDocs.map((doc) => (
<Card key={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>
<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' ? '已解析' : '处理中'}
</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>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="rounded-xl gap-2 text-primary hover:bg-primary/10"
onClick={() => setExpandedDoc(expandedDoc === doc.id ? null : doc.id)}
>
{expandedDoc === doc.id ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
<span>{expandedDoc === doc.id ? '收起详情' : '查看结果'}</span>
</Button>
<Button variant="ghost" size="icon" className="rounded-lg text-destructive hover:bg-destructive/10" onClick={() => handleDelete(doc.id)}>
<Trash2 size={18} />
</Button>
</div>
</div>
{/* Expanded Details */}
{expandedDoc === 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>
))
) : (
<div className="col-span-full py-6 text-center text-muted-foreground italic text-sm">
</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>
</div>
)}
</div>
</Card>
))
) : (
<div className="py-20 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center text-muted-foreground/30">
<FileText size={48} />
</div>
<div className="space-y-1">
<p className="text-xl font-bold"></p>
<p className="text-muted-foreground"> AI </p>
</div>
</div>
)}
</div>
</div>
);
};
export default Documents;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,603 @@
import React, { useState, useEffect } from 'react';
import {
TableProperties,
Plus,
FilePlus,
CheckCircle2,
Download,
Clock,
RefreshCcw,
Sparkles,
Zap,
FileCheck,
FileSpreadsheet,
Trash2,
ChevronDown,
ChevronUp,
BarChart3,
FileText,
TrendingUp,
Info,
AlertCircle,
Loader2
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/context/AuthContext';
import { templateApi, documentApi, taskApi } from '@/db/api';
import { backendApi, aiApi } from '@/db/backend-api';
import { supabase } from '@/db/supabase';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useDropzone } from 'react-dropzone';
import { Markdown } from '@/components/ui/markdown';
type Template = any;
type Document = any;
type FillTask = any;
const FormFill: React.FC = () => {
const { profile } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [documents, setDocuments] = useState<Document[]>([]);
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// Selection state
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
const [creating, setCreating] = useState(false);
const [openTaskDialog, setOpenTaskDialog] = useState(false);
const [viewingTask, setViewingTask] = useState<any | null>(null);
// Excel upload state
const [excelFile, setExcelFile] = useState<File | null>(null);
const [excelParseResult, setExcelParseResult] = useState<any>(null);
const [excelAnalysis, setExcelAnalysis] = useState<any>(null);
const [excelAnalyzing, setExcelAnalyzing] = useState(false);
const [expandedSheet, setExpandedSheet] = useState<string | null>(null);
const [aiOptions, setAiOptions] = useState({
userPrompt: '请分析这些数据,并提取关键信息用于填表,包括数值、分类、摘要等。',
analysisType: 'general' as 'general' | 'summary' | 'statistics' | 'insights'
});
const loadData = async () => {
if (!profile) return;
try {
const [t, d, ts] = await Promise.all([
templateApi.listTemplates((profile as any).id),
documentApi.listDocuments((profile as any).id),
taskApi.listTasks((profile as any).id)
]);
setTemplates(t);
setDocuments(d);
setTasks(ts);
} catch (err: any) {
toast.error('数据加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [profile]);
// Excel upload handlers
const onExcelDrop = async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
if (!file.name.match(/\.(xlsx|xls)$/i)) {
toast.error('仅支持 .xlsx 和 .xls 格式的 Excel 文件');
return;
}
setExcelFile(file);
setExcelParseResult(null);
setExcelAnalysis(null);
setExpandedSheet(null);
try {
const result = await backendApi.uploadExcel(file);
if (result.success) {
toast.success(`Excel 解析成功: ${file.name}`);
setExcelParseResult(result);
} else {
toast.error(result.error || '解析失败');
}
} catch (error: any) {
toast.error(error.message || '上传失败');
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: onExcelDrop,
accept: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls']
},
maxFiles: 1
});
const handleAnalyzeExcel = async () => {
if (!excelFile || !excelParseResult?.success) {
toast.error('请先上传并解析 Excel 文件');
return;
}
setExcelAnalyzing(true);
setExcelAnalysis(null);
try {
const result = await aiApi.analyzeExcel(excelFile, {
userPrompt: aiOptions.userPrompt,
analysisType: aiOptions.analysisType
});
if (result.success) {
toast.success('AI 分析完成');
setExcelAnalysis(result);
} else {
toast.error(result.error || 'AI 分析失败');
}
} catch (error: any) {
toast.error(error.message || 'AI 分析失败');
} finally {
setExcelAnalyzing(false);
}
};
const handleUseExcelData = () => {
if (!excelParseResult?.success) {
toast.error('请先解析 Excel 文件');
return;
}
// 将 Excel 解析的数据标记为"文档",添加到选择列表
toast.success('Excel 数据已添加到数据源,请在任务对话框中选择');
// 这里可以添加逻辑来将 Excel 数据传递给后端创建任务
};
const handleDeleteExcel = () => {
setExcelFile(null);
setExcelParseResult(null);
setExcelAnalysis(null);
setExpandedSheet(null);
toast.success('Excel 文件已清除');
};
const handleUploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !profile) return;
try {
toast.loading('正在上传模板...');
await templateApi.uploadTemplate(file, (profile as any).id);
toast.dismiss();
toast.success('模板上传成功');
loadData();
} catch (err) {
toast.dismiss();
toast.error('上传模板失败');
}
};
const handleCreateTask = async () => {
if (!profile || !selectedTemplate || selectedDocs.length === 0) {
toast.error('请先选择模板和数据源文档');
return;
}
setCreating(true);
try {
const task = await taskApi.createTask((profile as any).id, selectedTemplate, selectedDocs);
if (task) {
toast.success('任务已创建,正在进行智能填表...');
setOpenTaskDialog(false);
// Invoke edge function
supabase.functions.invoke('fill-template', {
body: { taskId: task.id }
}).then(({ error }) => {
if (error) toast.error('填表任务执行失败');
else {
toast.success('表格填写完成!');
loadData();
}
});
loadData();
}
} catch (err: any) {
toast.error('创建任务失败');
} finally {
setCreating(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-emerald-500 text-white';
case 'failed': return 'bg-destructive text-white';
default: return 'bg-amber-500 text-white';
}
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
return (
<div className="space-y-8 animate-fade-in 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>
<div className="flex items-center gap-3">
<Dialog open={openTaskDialog} onOpenChange={setOpenTaskDialog}>
<DialogTrigger asChild>
<Button className="rounded-xl shadow-lg shadow-primary/20 gap-2 h-11 px-6">
<FilePlus size={18} />
<span></span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden border-none shadow-2xl rounded-3xl">
<DialogHeader className="p-8 pb-4 bg-muted/50">
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Sparkles size={24} className="text-primary" />
</DialogTitle>
<DialogDescription>
AI
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 p-8 pt-4">
<div className="space-y-8">
{/* Step 1: Select Template */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-bold flex items-center gap-2 text-primary uppercase tracking-widest text-xs">
<span className="w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-[10px]">1</span>
</h4>
<label className="cursor-pointer text-xs font-semibold text-primary hover:underline flex items-center gap-1">
<Plus size={12} />
<input type="file" className="hidden" onChange={handleUploadTemplate} accept=".docx,.xlsx" />
</label>
</div>
{templates.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{templates.map(t => (
<div
key={t.id}
className={cn(
"p-4 rounded-2xl border-2 transition-all cursor-pointer flex items-center gap-3 group relative overflow-hidden",
selectedTemplate === t.id ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
)}
onClick={() => setSelectedTemplate(t.id)}
>
<div className={cn(
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0 transition-colors",
selectedTemplate === t.id ? "bg-primary text-white" : "bg-muted text-muted-foreground"
)}>
<TableProperties size={20} />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{t.name}</p>
<p className="text-[10px] text-muted-foreground uppercase">{t.type}</p>
</div>
{selectedTemplate === t.id && (
<div className="absolute top-0 right-0 w-8 h-8 bg-primary text-white flex items-center justify-center rounded-bl-xl">
<CheckCircle2 size={14} />
</div>
)}
</div>
))}
</div>
) : (
<div className="p-8 text-center bg-muted/30 rounded-2xl border border-dashed text-sm italic text-muted-foreground">
</div>
)}
</div>
{/* Step 2: Upload & Analyze Excel */}
<div className="space-y-4">
<h4 className="font-bold flex items-center gap-2 text-primary uppercase tracking-widest text-xs">
<span className="w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-[10px]">1.5</span>
Excel
</h4>
<div className="bg-muted/20 rounded-2xl p-6">
{!excelFile ? (
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-xl p-8 transition-all duration-300 flex flex-col items-center justify-center text-center cursor-pointer group",
isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/20 hover:border-primary/50 hover:bg-muted/30"
)}
>
<input {...getInputProps()} />
<div className="w-12 h-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
<FileSpreadsheet size={24} />
</div>
<p className="font-semibold text-sm">
{isDragActive ? '释放以开始上传' : '点击或拖拽 Excel 文件'}
</p>
<p className="text-xs text-muted-foreground mt-1"> .xlsx .xls </p>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-background rounded-xl">
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
<FileSpreadsheet size={20} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{excelFile.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(excelFile.size)}</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={handleDeleteExcel}
>
<Trash2 size={16} />
</Button>
</div>
</div>
{/* AI Analysis Options */}
{excelParseResult?.success && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="analysis-type" className="text-xs"></Label>
<Select
value={aiOptions.analysisType}
onValueChange={(value: any) => setAiOptions({ ...aiOptions, analysisType: value })}
>
<SelectTrigger id="analysis-type" className="bg-background h-9 text-sm">
<SelectValue placeholder="选择分析类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="general"></SelectItem>
<SelectItem value="summary"></SelectItem>
<SelectItem value="statistics"></SelectItem>
<SelectItem value="insights"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="user-prompt" className="text-xs"></Label>
<Textarea
id="user-prompt"
value={aiOptions.userPrompt}
onChange={(e) => setAiOptions({ ...aiOptions, userPrompt: e.target.value })}
className="bg-background resize-none text-sm"
rows={2}
/>
</div>
<Button
onClick={handleAnalyzeExcel}
disabled={excelAnalyzing}
className="w-full gap-2 h-9"
variant="outline"
>
{excelAnalyzing ? <Loader2 className="animate-spin" size={14} /> : <Sparkles size={14} />}
{excelAnalyzing ? '分析中...' : 'AI 分析'}
</Button>
{excelParseResult?.success && (
<Button
onClick={handleUseExcelData}
className="w-full gap-2 h-9"
>
<CheckCircle2 size={14} />
使
</Button>
)}
</div>
)}
{/* Excel Analysis Result */}
{excelAnalysis && (
<div className="mt-4 p-4 bg-background rounded-xl max-h-60 overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={16} className="text-primary" />
<span className="font-semibold text-sm">AI </span>
</div>
<Markdown content={excelAnalysis.analysis?.analysis || ''} className="text-sm" />
</div>
)}
</div>
)}
</div>
</div>
{/* Step 3: Select Documents */}
<div className="space-y-4">
<h4 className="font-bold flex items-center gap-2 text-primary uppercase tracking-widest text-xs">
<span className="w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-[10px]">2</span>
</h4>
{documents.filter(d => d.status === 'completed').length > 0 ? (
<div className="space-y-2 max-h-40 overflow-y-auto pr-2 custom-scrollbar">
{documents.filter(d => d.status === 'completed').map(doc => (
<div
key={doc.id}
className={cn(
"flex items-center gap-3 p-3 rounded-xl border transition-all cursor-pointer",
selectedDocs.includes(doc.id) ? "border-primary/50 bg-primary/5 shadow-sm" : "border-border hover:bg-muted/30"
)}
onClick={() => {
setSelectedDocs(prev =>
prev.includes(doc.id) ? prev.filter(id => id !== doc.id) : [...prev, doc.id]
);
}}
>
<Checkbox checked={selectedDocs.includes(doc.id)} onCheckedChange={() => {}} />
<div className="w-8 h-8 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center">
<Zap size={16} />
</div>
<span className="font-semibold text-sm truncate">{doc.name}</span>
</div>
))}
</div>
) : (
<div className="p-6 text-center bg-muted/30 rounded-xl border border-dashed text-xs italic text-muted-foreground">
</div>
)}
</div>
</div>
</ScrollArea>
<DialogFooter className="p-8 pt-4 bg-muted/20 border-t border-dashed">
<Button variant="outline" className="rounded-xl h-12 px-6" onClick={() => setOpenTaskDialog(false)}></Button>
<Button
className="rounded-xl h-12 px-8 shadow-lg shadow-primary/20 gap-2"
onClick={handleCreateTask}
disabled={creating || !selectedTemplate || (selectedDocs.length === 0 && !excelParseResult?.success)}
>
{creating ? <RefreshCcw className="animate-spin h-5 w-5" /> : <Zap className="h-5 w-5 fill-current" />}
<span></span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</section>
{/* Task List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full rounded-3xl bg-muted" />
))
) : tasks.length > 0 ? (
tasks.map((task) => (
<Card key={task.id} className="border-none shadow-md hover:shadow-xl transition-all group rounded-3xl overflow-hidden flex flex-col">
<div className="h-1.5 w-full" style={{ backgroundColor: task.status === 'completed' ? '#10b981' : task.status === 'failed' ? '#ef4444' : '#f59e0b' }} />
<CardHeader className="p-6 pb-2">
<div className="flex justify-between items-start mb-2">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center shadow-inner group-hover:scale-110 transition-transform">
<TableProperties size={24} />
</div>
<Badge className={cn("text-[10px] uppercase font-bold tracking-widest", getStatusColor(task.status))}>
{task.status === 'completed' ? '已完成' : task.status === 'failed' ? '失败' : '执行中'}
</Badge>
</div>
<CardTitle className="text-lg font-bold truncate group-hover:text-primary transition-colors">{task.templates?.name || '未知模板'}</CardTitle>
<CardDescription className="text-xs flex items-center gap-1 font-medium italic">
<Clock size={12} /> {format(new Date(task.created_at!), 'yyyy/MM/dd HH:mm')}
</CardDescription>
</CardHeader>
<CardContent className="p-6 pt-2 flex-1">
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-muted/50 border-none text-[10px] font-bold"> {task.document_ids?.length} </Badge>
</div>
{task.status === 'completed' && (
<div className="p-3 bg-emerald-500/5 rounded-2xl border border-emerald-500/10 flex items-center gap-3">
<CheckCircle2 className="text-emerald-500" size={18} />
<span className="text-xs font-semibold text-emerald-700"></span>
</div>
)}
</div>
</CardContent>
<CardFooter className="p-6 pt-0">
<Button
className="w-full rounded-2xl h-11 bg-primary group-hover:shadow-lg group-hover:shadow-primary/30 transition-all gap-2"
disabled={task.status !== 'completed'}
onClick={() => setViewingTask(task)}
>
<Download size={18} />
<span></span>
</Button>
</CardFooter>
</Card>
))
) : (
<div className="col-span-full py-24 flex flex-col items-center justify-center text-center space-y-6">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center text-muted-foreground/30 border-4 border-dashed">
<TableProperties size={48} />
</div>
<div className="space-y-2 max-w-sm">
<p className="text-2xl font-extrabold tracking-tight"></p>
<p className="text-muted-foreground text-sm"></p>
</div>
<Button className="rounded-xl h-12 px-8" onClick={() => setOpenTaskDialog(true)}></Button>
</div>
)}
</div>
{/* Task Result View Modal */}
<Dialog open={!!viewingTask} onOpenChange={(open) => !open && setViewingTask(null)}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden border-none shadow-2xl rounded-3xl">
<DialogHeader className="p-8 pb-4 bg-primary text-primary-foreground">
<div className="flex items-center gap-3 mb-2">
<FileCheck size={28} />
<DialogTitle className="text-2xl font-extrabold"></DialogTitle>
</div>
<DialogDescription className="text-primary-foreground/80 italic">
{viewingTask?.document_ids?.length}
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 p-8 bg-muted/10">
<div className="prose dark:prose-invert max-w-none">
<div className="bg-card p-8 rounded-2xl shadow-sm border min-h-[400px]">
<Badge variant="outline" className="mb-4"></Badge>
<div className="whitespace-pre-wrap font-sans text-sm leading-relaxed">
<h2 className="text-xl font-bold mb-4"></h2>
<p className="text-muted-foreground mb-6"></p>
<div className="p-4 bg-muted/30 rounded-xl border border-dashed border-primary/20 italic">
...
</div>
<div className="mt-8 space-y-4">
<p className="font-semibold text-primary"> </p>
<p className="font-semibold text-primary"> </p>
<p className="font-semibold text-primary"> </p>
</div>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter className="p-8 pt-4 border-t border-dashed">
<Button variant="outline" className="rounded-xl" onClick={() => setViewingTask(null)}></Button>
<Button className="rounded-xl px-8 gap-2 shadow-lg shadow-primary/20" onClick={() => toast.success("正在导出文件...")}>
<Download size={18} />
{viewingTask?.templates?.type?.toUpperCase() || '文件'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default FormFill;

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/context/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FileText, Lock, User, CheckCircle2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { signIn, signUp } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) return toast.error('请输入用户名和密码');
setLoading(true);
try {
const email = `${username}@miaoda.com`;
const { error } = await signIn(email, password);
if (error) throw error;
toast.success('登录成功');
navigate('/');
} catch (err: any) {
toast.error(err.message || '登录失败');
} finally {
setLoading(false);
}
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) return toast.error('请输入用户名和密码');
setLoading(true);
try {
const email = `${username}@miaoda.com`;
const { error } = await signUp(email, password);
if (error) throw error;
toast.success('注册成功,请登录');
} catch (err: any) {
toast.error(err.message || '注册失败');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[radial-gradient(ellipse_at_top_left,_var(--tw-gradient-stops))] from-primary/10 via-background to-background p-4 relative overflow-hidden">
{/* Decorative elements */}
<div className="absolute top-0 left-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
<div className="absolute bottom-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl translate-x-1/3 translate-y-1/3" />
<div className="w-full max-w-md space-y-8 relative animate-fade-in">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary text-primary-foreground shadow-2xl shadow-primary/30 mb-4 animate-slide-in">
<FileText size={32} />
</div>
<h1 className="text-4xl font-extrabold tracking-tight gradient-text"></h1>
<p className="text-muted-foreground"></p>
</div>
<Card className="border-border/50 shadow-2xl backdrop-blur-sm bg-card/95">
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2 rounded-t-xl h-12 bg-muted/50 p-1">
<TabsTrigger value="login" className="rounded-lg data-[state=active]:bg-background data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="signup" className="rounded-lg data-[state=active]:bg-background data-[state=active]:shadow-sm"></TabsTrigger>
</TabsList>
<TabsContent value="login">
<form onSubmit={handleLogin}>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<div className="relative">
<User className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="username"
placeholder="请输入用户名"
className="pl-9 bg-muted/30 border-none focus-visible:ring-primary"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="请输入密码"
className="pl-9 bg-muted/30 border-none focus-visible:ring-primary"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button className="w-full h-11 text-lg font-semibold rounded-xl" type="submit" disabled={loading}>
{loading ? '登录中...' : '立即登录'}
</Button>
</CardFooter>
</form>
</TabsContent>
<TabsContent value="signup">
<form onSubmit={handleSignUp}>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signup-username"></Label>
<div className="relative">
<User className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="signup-username"
placeholder="仅字母、数字和下划线"
className="pl-9 bg-muted/30 border-none focus-visible:ring-primary"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password"></Label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="signup-password"
type="password"
placeholder="不少于 6 位"
className="pl-9 bg-muted/30 border-none focus-visible:ring-primary"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button className="w-full h-11 text-lg font-semibold rounded-xl" type="submit" disabled={loading}>
{loading ? '注册中...' : '注册账号'}
</Button>
</CardFooter>
</form>
</TabsContent>
</Tabs>
</Card>
<div className="grid grid-cols-2 gap-4 text-center text-xs text-muted-foreground">
<div className="flex flex-col items-center gap-1">
<CheckCircle2 size={16} className="text-primary" />
<span></span>
</div>
<div className="flex flex-col items-center gap-1">
<CheckCircle2 size={16} className="text-primary" />
<span></span>
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
&copy; 2026 |
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,39 @@
import { Link } from "react-router-dom";
import PageMeta from "@/components/common/PageMeta";
export default function NotFound() {
return (
<>
<PageMeta title="页面未找到" description="" />
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
</h1>
<img src="/images/error/404.svg" alt="404" className="dark:hidden" />
<img
src="/images/error/404-dark.svg"
alt="404"
className="hidden dark:block"
/>
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
</p>
<Link
to="/"
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
</Link>
</div>
{/* <!-- Footer --> */}
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
&copy; {new Date().getFullYear()}
</p>
</div>
</>
);
}

View File

@@ -0,0 +1,16 @@
/**
* Sample Page
*/
import PageMeta from "../components/common/PageMeta";
export default function SamplePage() {
return (
<>
<PageMeta title="Home" description="Home Page Introduction" />
<div>
<h3>This is a sample page</h3>
</div>
</>
);
}