前后端基本架构和完全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

15
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { RouterProvider } from 'react-router-dom';
import { AuthProvider } from '@/context/AuthContext';
import { router } from '@/routes';
import { Toaster } from 'sonner';
function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
<Toaster position="top-right" richColors closeButton />
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Observer } from 'tailwindcss-intersect';
const IntersectObserver = () => {
const location = useLocation();
useEffect(() => {
// When the location changes, we need to restart the observer
// to pick up new elements on the page.
// We use a small timeout to ensure the DOM has updated.
const timer = setTimeout(() => {
Observer.restart();
}, 100);
return () => clearTimeout(timer);
}, [location]);
return null;
};
export default IntersectObserver;

View File

@@ -0,0 +1,25 @@
import { HelmetProvider, Helmet } from "react-helmet-async";
import { TooltipProvider } from "@/components/ui/tooltip";
const PageMeta = ({
title,
description,
}: {
title: string;
description: string;
}) => (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
</Helmet>
);
export const AppWrapper = ({ children }: { children: React.ReactNode }) => (
<HelmetProvider>
<TooltipProvider>
{children}
</TooltipProvider>
</HelmetProvider>
);
export default PageMeta;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/context/AuthContext';
export const RouteGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm font-medium text-muted-foreground">...</p>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,227 @@
import { cn } from '@/lib/utils'
import { type UseSupabaseUploadReturn } from '@/hooks/use-supabase-upload'
import { Button } from '@/components/ui/button'
import { CheckCircle, File, Loader2, Upload, X } from 'lucide-react'
import { createContext, type PropsWithChildren, useCallback, useContext } from 'react'
export const formatBytes = (
bytes: number,
decimals = 2,
size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
) => {
const k = 1000
const dm = decimals < 0 ? 0 : decimals
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
if (bytes === 0 || bytes === undefined) return size !== undefined ? `0 ${size}` : '0 bytes'
const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
type DropzoneContextType = Omit<UseSupabaseUploadReturn, 'getRootProps' | 'getInputProps'>
const DropzoneContext = createContext<DropzoneContextType | undefined>(undefined)
type DropzoneProps = UseSupabaseUploadReturn & {
className?: string
}
const Dropzone = ({
className,
children,
getRootProps,
getInputProps,
...restProps
}: PropsWithChildren<DropzoneProps>) => {
const isSuccess = restProps.isSuccess
const isActive = restProps.isDragActive
const isInvalid =
(restProps.isDragActive && restProps.isDragReject) ||
(restProps.errors.length > 0 && !restProps.isSuccess) ||
restProps.files.some((file) => file.errors.length !== 0)
return (
<DropzoneContext.Provider value={{ ...restProps }}>
<div
{...getRootProps({
className: cn(
'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',
className,
isSuccess ? 'border-solid' : 'border-dashed',
isActive && 'border-primary bg-primary/10',
isInvalid && 'border-destructive bg-destructive/10'
),
})}
>
<input {...getInputProps()} />
{children}
</div>
</DropzoneContext.Provider>
)
}
const DropzoneContent = ({ className }: { className?: string }) => {
const {
files,
setFiles,
onUpload,
loading,
successes,
errors,
maxFileSize,
maxFiles,
isSuccess,
} = useDropzoneContext()
const exceedMaxFiles = files.length > maxFiles
const handleRemoveFile = useCallback(
(fileName: string) => {
setFiles(files.filter((file) => file.name !== fileName))
},
[files, setFiles]
)
if (isSuccess) {
return (
<div className={cn('flex flex-row items-center gap-x-2 justify-center', className)}>
<CheckCircle size={16} className="text-primary" />
<p className="text-primary text-sm">
Successfully uploaded {files.length} file{files.length > 1 ? 's' : ''}
</p>
</div>
)
}
return (
<div className={cn('flex flex-col', className)}>
{files.map((file, idx) => {
const fileError = errors.find((e) => e.name === file.name)
const isSuccessfullyUploaded = !!successes.find((e) => e === file.name)
return (
<div
key={`${file.name}-${idx}`}
className="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4 "
>
{file.type.startsWith('image/') ? (
<div className="h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center">
<img src={file.preview} alt={file.name} className="object-cover" />
</div>
) : (
<div className="h-10 w-10 rounded border bg-muted flex items-center justify-center">
<File size={18} />
</div>
)}
<div className="shrink grow flex flex-col items-start truncate">
<p title={file.name} className="text-sm truncate max-w-full">
{file.name}
</p>
{file.errors.length > 0 ? (
<p className="text-xs text-destructive">
{file.errors
.map((e) =>
e.message.startsWith('File is larger than')
? `File is larger than ${formatBytes(maxFileSize, 2)} (Size: ${formatBytes(file.size, 2)})`
: e.message
)
.join(', ')}
</p>
) : loading && !isSuccessfullyUploaded ? (
<p className="text-xs text-muted-foreground">Uploading file...</p>
) : !!fileError ? (
<p className="text-xs text-destructive">Failed to upload: {fileError.message}</p>
) : isSuccessfullyUploaded ? (
<p className="text-xs text-primary">Successfully uploaded file</p>
) : (
<p className="text-xs text-muted-foreground">{formatBytes(file.size, 2)}</p>
)}
</div>
{!loading && !isSuccessfullyUploaded && (
<Button
size="icon"
variant="link"
className="shrink-0 justify-self-end text-muted-foreground hover:text-foreground"
onClick={() => handleRemoveFile(file.name)}
>
<X />
</Button>
)}
</div>
)
})}
{exceedMaxFiles && (
<p className="text-sm text-left mt-2 text-destructive">
You may upload only up to {maxFiles} files, please remove {files.length - maxFiles} file
{files.length - maxFiles > 1 ? 's' : ''}.
</p>
)}
{files.length > 0 && !exceedMaxFiles && (
<div className="mt-2">
<Button
variant="outline"
onClick={onUpload}
disabled={files.some((file) => file.errors.length !== 0) || loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading...
</>
) : (
<>Upload files</>
)}
</Button>
</div>
)}
</div>
)
}
const DropzoneEmptyState = ({ className }: { className?: string }) => {
const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()
if (isSuccess) {
return null
}
return (
<div className={cn('flex flex-col items-center gap-y-2', className)}>
<Upload size={20} className="text-muted-foreground" />
<p className="text-sm">
Upload{!!maxFiles && maxFiles > 1 ? ` ${maxFiles}` : ''} file
{!maxFiles || maxFiles > 1 ? 's' : ''}
</p>
<div className="flex flex-col items-center gap-y-1">
<p className="text-xs text-muted-foreground">
Drag and drop or{' '}
<a
onClick={() => inputRef.current?.click()}
className="underline cursor-pointer transition hover:text-foreground"
>
select {maxFiles === 1 ? `file` : 'files'}
</a>{' '}
to upload
</p>
{maxFileSize !== Number.POSITIVE_INFINITY && (
<p className="text-xs text-muted-foreground">
Maximum file size: {formatBytes(maxFileSize, 2)}
</p>
)}
</div>
</div>
)
}
const useDropzoneContext = () => {
const context = useContext(DropzoneContext)
if (!context) {
throw new Error('useDropzoneContext must be used within a Dropzone')
}
return context
}
export { Dropzone, DropzoneContent, DropzoneEmptyState, useDropzoneContext }

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Link, useLocation, Outlet, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
FileText,
TableProperties,
MessageSquareCode,
LogOut,
Menu,
X,
ChevronRight,
User,
Sparkles
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/context/AuthContext';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
const navItems = [
{ name: '控制台', path: '/', icon: LayoutDashboard },
{ name: '文档中心', path: '/documents', icon: FileText },
{ name: 'Excel 解析', path: '/excel-parse', icon: Sparkles },
{ name: '智能填表', path: '/form-fill', icon: TableProperties },
{ name: '智能助手', path: '/assistant', icon: MessageSquareCode },
];
const MainLayout: React.FC = () => {
const { user, profile, signOut } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handleSignOut = async () => {
await signOut();
navigate('/login');
};
const SidebarContent = () => (
<div className="flex flex-col h-full bg-sidebar py-6 border-r border-sidebar-border">
<div className="px-6 mb-10 flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-primary flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20">
<FileText size={24} />
</div>
<div className="flex flex-col">
<span className="font-bold text-lg tracking-tight text-sidebar-foreground"></span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
<nav className="flex-1 px-4 space-y-2">
{navItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group",
isActive
? "bg-primary text-primary-foreground shadow-md shadow-primary/20"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<item.icon size={20} className={cn(isActive ? "text-white" : "group-hover:scale-110 transition-transform")} />
<span className="font-medium">{item.name}</span>
{isActive && <ChevronRight size={16} className="ml-auto" />}
</Link>
);
})}
</nav>
<div className="px-4 mt-auto">
<div className="bg-sidebar-accent/50 rounded-2xl p-4 mb-4 border border-sidebar-border/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-secondary flex items-center justify-center border-2 border-primary/10">
<User size={20} className="text-primary" />
</div>
<div className="flex flex-col overflow-hidden">
<span className="font-semibold text-sm truncate">{((profile as any)?.email) || '用户'}</span>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{((profile as any)?.role) || 'User'}</span>
</div>
</div>
</div>
<Button
variant="outline"
className="w-full justify-start gap-3 border-none hover:bg-destructive/10 hover:text-destructive group rounded-xl"
onClick={handleSignOut}
>
<LogOut size={18} className="group-hover:rotate-180 transition-transform duration-300" />
<span>退</span>
</Button>
</div>
</div>
);
return (
<div className="flex min-h-screen w-full bg-background overflow-hidden">
{/* Desktop Sidebar */}
<aside className="hidden lg:block w-72 shrink-0">
<SidebarContent />
</aside>
{/* Mobile Sidebar */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden fixed top-4 left-4 z-50 bg-background/50 backdrop-blur shadow-sm">
<Menu size={24} />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-72 border-none">
<SidebarContent />
</SheetContent>
</Sheet>
<main className="flex-1 overflow-y-auto relative p-6 lg:p-10 pt-20 lg:pt-10">
<div className="max-w-7xl mx-auto space-y-8 animate-fade-in">
<Outlet />
</div>
</main>
</div>
);
};
export default MainLayout;

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,360 @@
import React, { useState } from 'react';
import { Download, Maximize2, X, Table, FileText, TrendingUp, BarChart3, Info, ExternalLink } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface AIChartDisplayProps {
charts?: any;
statistics?: any;
tablePreview?: any;
className?: string;
}
export const AIChartDisplay: React.FC<AIChartDisplayProps> = ({ charts, statistics, tablePreview, className }) => {
const [previewChart, setPreviewChart] = useState<{ image: string; title: string } | null>(null);
const downloadChart = (imageData: string, title: string) => {
try {
const base64Data = imageData.split(',')[1];
const binaryData = atob(base64Data);
const bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'image/png' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('图表已下载');
} catch (error) {
console.error('下载失败:', error);
toast.error('下载失败,请稍后重试');
}
};
const openPreview = (image: string, title: string) => {
setPreviewChart({ image, title });
};
const closePreview = () => {
setPreviewChart(null);
};
const getChartIcon = (type: string) => {
switch (type) {
case 'bar':
return <Table size={20} className="text-blue-500" />;
case 'pie':
return <ExternalLink size={20} className="text-pink-500" />;
case 'barh':
return <FileText size={20} className="text-emerald-500" />;
case 'time_series':
return <TrendingUp size={20} className="text-purple-500" />;
case 'comparison':
return <BarChart3 size={20} className="text-amber-500" />;
default:
return <Table size={20} className="text-gray-500" />;
}
};
return (
<div className={className}>
{/* 数值型数据图表 */}
{charts?.numeric_charts && charts.numeric_charts.length > 0 && (
<div className="space-y-4 mb-6">
<h4 className="font-bold text-lg mb-3 flex items-center gap-2">
<Table size={18} className="text-blue-500" />
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{charts.numeric_charts.map((chart: any, idx: number) => (
<Card key={idx} className="border shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{chart.title}</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => downloadChart(chart.image, chart.title)}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => openPreview(chart.image, chart.title)}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-3 bg-white">
<img src={chart.image} alt={chart.title} className="w-full h-auto object-contain rounded-lg" />
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* 分类数据图表 */}
{charts?.categorical_charts && charts.categorical_charts.length > 0 && (
<div className="space-y-4 mb-6">
<h4 className="font-bold text-lg mb-3 flex items-center gap-2">
<FileText size={18} className="text-emerald-500" />
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{charts.categorical_charts.map((chart: any, idx: number) => (
<Card key={idx} className="border shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{chart.title}</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => downloadChart(chart.image, chart.title)}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => openPreview(chart.image, chart.title)}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-3 bg-white">
<img src={chart.image} alt={chart.title} className="w-full h-auto object-contain rounded-lg" />
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* 时间序列图表 */}
{charts?.time_series_chart && (
<div className="space-y-4 mb-6">
<h4 className="font-bold text-lg mb-3 flex items-center gap-2">
<TrendingUp size={18} className="text-purple-500" />
</h4>
<Card className="border shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{charts.time_series_chart.title}</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => downloadChart(charts.time_series_chart.image, charts.time_series_chart.title)}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => openPreview(charts.time_series_chart.image, charts.time_series_chart.title)}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-3 bg-white">
<img src={charts.time_series_chart.image} alt={charts.time_series_chart.title} className="w-full h-auto object-contain rounded-lg" />
</CardContent>
</Card>
</div>
)}
{/* 对比数据图表 */}
{charts?.comparison_chart && (
<div className="space-y-4 mb-6">
<h4 className="font-bold text-lg mb-3 flex items-center gap-2">
<BarChart3 size={18} className="text-orange-500" />
</h4>
<Card className="border shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{charts.comparison_chart.title}</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => downloadChart(charts.comparison_chart.image, charts.comparison_chart.title)}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => openPreview(charts.comparison_chart.image, charts.comparison_chart.title)}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-3 bg-white">
<img src={charts.comparison_chart.image} alt={charts.comparison_chart.title} className="w-full h-auto object-contain rounded-lg" />
</CardContent>
</Card>
</div>
)}
{/* 数值摘要 */}
{statistics?.numeric_summary && (
<Card className="border shadow-sm mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Info size={18} className="text-primary" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.count}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.sum?.toLocaleString()}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.mean?.toFixed(2)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.median?.toFixed(2)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.min?.toLocaleString()}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.max?.toLocaleString()}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">{statistics.numeric_summary.std?.toFixed(2)}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* 表格预览 */}
{tablePreview && (
<Card className="border shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Badge variant="outline">
{tablePreview.total_rows}
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
{tablePreview.preview_rows} {tablePreview.total_rows}
</p>
<div className="max-h-80 overflow-y-auto border rounded">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="p-2 text-left border"></th>
{tablePreview.columns.map((col: string, idx: number) => (
<th key={idx} className="p-2 text-left border">{col}</th>
))}
</tr>
</thead>
<tbody>
{tablePreview.rows.slice(0, 20).map((row: any, rowIdx: number) => (
<tr key={rowIdx} className="border-b">
<td className="p-2 text-muted-foreground font-medium border">{rowIdx + 1}</td>
{tablePreview.columns.map((col: string, colIdx: number) => (
<td key={colIdx} className="p-2 border">
{row[col] ?? '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* 图表预览对话框 */}
<Dialog open={previewChart !== null} onOpenChange={closePreview}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between w-full pr-8">
<DialogTitle className="text-xl">{previewChart?.title}</DialogTitle>
<Button variant="ghost" size="icon" onClick={closePreview}>
<X size={20} />
</Button>
</div>
</DialogHeader>
<div className="p-4 bg-white rounded-lg">
{previewChart && (
<img
src={previewChart.image}
alt={previewChart.title}
className="w-full h-auto object-contain max-w-full"
style={{ maxHeight: '70vh' }}
/>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,211 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,550 @@
import React, { useState } from 'react';
import { Image as ImageIcon, Maximize2, Download, BarChart3, ScatterChart, Grid3x3, X, ExternalLink } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface ChartDisplayProps {
charts?: any;
className?: string;
}
type ChartType = 'histogram' | 'bar_chart' | 'box_plot' | 'correlation_heatmap' | 'bar' | 'pie' | 'barh' | 'time_series' | 'comparison';
export const ChartDisplay: React.FC<ChartDisplayProps> = ({ charts, className }) => {
const [expandedCharts, setExpandedCharts] = useState<Record<string, boolean>>({});
const [previewChart, setPreviewChart] = useState<{ type: string; data: any; title: string } | null>(null);
const toggleChart = (key: string) => {
setExpandedCharts(prev => ({
...prev,
[key]: !prev[key]
}));
};
const downloadChart = (imageData: string, title: string) => {
try {
// 提取 base64 数据
const base64Data = imageData.split(',')[1];
const binaryData = atob(base64Data);
const bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'image/png' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('图表已下载');
} catch (error) {
console.error('下载失败:', error);
toast.error('下载失败,请稍后重试');
}
};
const openPreview = (type: string, data: any, title: string) => {
setPreviewChart({ type, data, title });
};
const closePreview = () => {
setPreviewChart(null);
};
const getChartIcon = (type: ChartType) => {
switch (type) {
case 'histogram':
case 'bar':
return <BarChart3 size={20} className="text-blue-500" />;
case 'bar_chart':
case 'barh':
return <BarChart3 size={20} className="text-emerald-500" />;
case 'box_plot':
return <Grid3x3 size={20} className="text-purple-500" />;
case 'correlation_heatmap':
return <ScatterChart size={20} className="text-orange-500" />;
case 'pie':
return <ExternalLink size={20} className="text-pink-500" />;
case 'time_series':
return <ScatterChart size={20} className="text-cyan-500" />;
case 'comparison':
return <BarChart3 size={20} className="text-amber-500" />;
default:
return <ImageIcon size={20} className="text-gray-500" />;
}
};
const getChartLabel = (type: ChartType) => {
const labels = {
'histogram': '直方图',
'bar_chart': '条形图',
'box_plot': '箱线图',
'correlation_heatmap': '相关性热力图',
'bar': '柱状图',
'pie': '饼图',
'barh': '水平条形图',
'time_series': '时间序列图',
'comparison': '对比图'
};
return labels[type] || type;
};
const getChartColor = (type: ChartType) => {
const colors = {
'histogram': 'bg-blue-500/10 border-blue-500/20',
'bar_chart': 'bg-emerald-500/10 border-emerald-500/20',
'box_plot': 'bg-purple-500/10 border-purple-500/20',
'correlation_heatmap': 'bg-orange-500/10 border-orange-500/20',
'bar': 'bg-blue-500/10 border-blue-500/20',
'pie': 'bg-pink-500/10 border-pink-500/20',
'barh': 'bg-emerald-500/10 border-emerald-500/20',
'time_series': 'bg-cyan-500/10 border-cyan-500/20',
'comparison': 'bg-amber-500/10 border-amber-500/20'
};
return colors[type] || 'bg-gray-500/10 border-gray-500/20';
};
if (!charts || Object.keys(charts).length === 0) {
return (
<div className={className}>
<Card className="border-none shadow-sm">
<CardContent className="p-12 text-center text-muted-foreground">
<BarChart3 size={48} className="mx-auto mb-4 text-muted-foreground/30" />
<p className="text-sm"></p>
</CardContent>
</Card>
</div>
);
}
return (
<div className={className}>
{/* 直方图 */}
{charts.histograms && charts.histograms.length > 0 && (
<Card className="border-none shadow-md mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<BarChart3 className="text-blue-500" size={20} />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{charts.histograms.map((chart: any, idx: number) => (
<div key={idx} className="space-y-3">
<div className="flex items-center justify-between">
<span className="font-semibold text-sm truncate flex-1" title={chart.column}>
{chart.column}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => downloadChart(chart.image, `${chart.column}_histogram`)}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => openPreview('histogram', chart, `${chart.column} - 直方图`)}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
{chart.stats && (
<div className="flex gap-4 text-xs text-muted-foreground bg-muted/30 px-3 py-2 rounded-md">
<span>: {chart.stats.mean?.toFixed(2)}</span>
<span>: {chart.stats.median?.toFixed(2)}</span>
<span>: {chart.stats.std?.toFixed(2)}</span>
</div>
)}
<div
className={cn(
"border rounded-lg overflow-hidden cursor-pointer transition-all hover:shadow-lg hover:border-blue-400",
expandedCharts[`hist_${idx}`] ? "ring-2 ring-blue-400" : ""
)}
onClick={() => toggleChart(`hist_${idx}`)}
>
{expandedCharts[`hist_${idx}`] ? (
<div className="p-2 bg-white">
<img src={chart.image} alt={chart.column} className="w-full h-auto object-contain" />
</div>
) : (
<div className="h-52 bg-gradient-to-b from-blue-500/20 to-blue-500/5 flex flex-col items-center justify-center gap-2">
<BarChart3 size={40} className="text-blue-500/30" />
<span className="text-xs text-blue-500/50"></span>
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 条形图 */}
{charts.bar_charts && charts.bar_charts.length > 0 && (
<Card className="border-none shadow-md mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<BarChart3 className="text-emerald-500" size={20} />
</CardTitle>
<CardDescription>Top 10</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{charts.bar_charts.map((chart: any, idx: number) => (
<div key={idx} className="space-y-3">
<div className="flex items-center justify-between">
<span className="font-semibold text-sm truncate flex-1" title={chart.column}>
{chart.column}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => downloadChart(chart.image, `${chart.column}_bar_chart`)}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => openPreview('bar_chart', chart, `${chart.column} - 条形图`)}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
<div
className={cn(
"border rounded-lg overflow-hidden cursor-pointer transition-all hover:shadow-lg hover:border-emerald-400",
expandedCharts[`bar_${idx}`] ? "ring-2 ring-emerald-400" : ""
)}
onClick={() => toggleChart(`bar_${idx}`)}
>
{expandedCharts[`bar_${idx}`] ? (
<div className="p-2 bg-white">
<img src={chart.image} alt={chart.column} className="w-full h-auto object-contain" />
</div>
) : (
<div className="h-52 bg-gradient-to-b from-emerald-500/20 to-emerald-500/5 flex flex-col items-center justify-center gap-2">
<BarChart3 size={40} className="text-emerald-500/30" />
<span className="text-xs text-emerald-500/50"></span>
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 箱线图 */}
{charts.box_plots && charts.box_plots.length > 0 && (
<Card className="border-none shadow-md mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Grid3x3 className="text-purple-500" size={20} />
线
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{charts.box_plots.map((boxPlot: any, idx: number) => (
<div key={idx} className="space-y-3">
<div className="flex items-end justify-end">
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => downloadChart(boxPlot.image, 'box_plot')}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => openPreview('box_plot', boxPlot, '箱线图对比')}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
<div
className={cn(
"border rounded-lg overflow-hidden cursor-pointer transition-all hover:shadow-lg hover:border-purple-400",
expandedCharts[`box_plot_${idx}`] ? "ring-2 ring-purple-400" : ""
)}
onClick={() => toggleChart(`box_plot_${idx}`)}
>
{expandedCharts[`box_plot_${idx}`] ? (
<div className="p-2 bg-white">
<img src={boxPlot.image} alt="箱线图" className="w-full h-auto object-contain" />
</div>
) : (
<div className="h-56 bg-gradient-to-b from-purple-500/20 to-purple-500/5 flex flex-col items-center justify-center gap-2">
<Grid3x3 size={48} className="text-purple-500/30" />
<span className="text-xs text-purple-500/50"></span>
</div>
)}
</div>
{boxPlot.columns && (
<div className="flex flex-wrap gap-2">
{boxPlot.columns.map((col: string) => (
<Badge key={col} variant="secondary" className="text-xs">
{col}
</Badge>
))}
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* 相关性热力图 */}
{charts.correlation && (
<Card className="border-none shadow-md mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<ScatterChart className="text-orange-500" size={20} />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end justify-end">
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => downloadChart(charts.correlation.image, 'correlation_heatmap')}
title="下载图表"
>
<Download size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => openPreview('correlation_heatmap', charts.correlation, '相关性热力图')}
title="放大查看"
>
<Maximize2 size={14} />
</Button>
</div>
</div>
<div
className={cn(
"border rounded-lg overflow-hidden cursor-pointer transition-all hover:shadow-lg hover:border-orange-400",
expandedCharts['correlation'] ? "ring-2 ring-orange-400" : ""
)}
onClick={() => toggleChart('correlation')}
>
{expandedCharts['correlation'] ? (
<div className="p-2 bg-white">
<img src={charts.correlation.image} alt="相关性热力图" className="w-full h-auto object-contain" />
</div>
) : (
<div className="h-64 bg-gradient-to-b from-orange-500/20 to-orange-500/5 flex flex-col items-center justify-center gap-2">
<ScatterChart size={56} className="text-orange-500/30" />
<span className="text-xs text-orange-500/50"></span>
</div>
)}
</div>
{charts.correlation.columns && (
<div className="flex flex-wrap gap-2">
{charts.correlation.columns.map((col: string) => (
<Badge key={col} variant="secondary" className="text-xs">
{col}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* 图表预览对话框 */}
<Dialog open={previewChart !== null} onOpenChange={closePreview}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between w-full pr-8">
<DialogTitle className="text-xl">{previewChart?.title}</DialogTitle>
<Button variant="ghost" size="icon" onClick={closePreview}>
<X size={20} />
</Button>
</div>
</DialogHeader>
<div className="p-4 bg-white rounded-lg">
{previewChart && (
<img
src={previewChart.data.image}
alt={previewChart.title}
className="w-full h-auto object-contain max-w-full"
style={{ maxHeight: '70vh' }}
/>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
};
export const StatisticsDisplay: React.FC<{ statistics: any }> = ({ statistics }) => {
if (!statistics) {
return null;
}
return (
<Card className="border-none shadow-md mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<BarChart3 className="text-primary" size={20} />
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="numeric" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="numeric"></TabsTrigger>
<TabsTrigger value="categorical"></TabsTrigger>
</TabsList>
<TabsContent value="numeric" className="mt-4">
{statistics.numeric && Object.keys(statistics.numeric).length > 0 ? (
<ScrollArea className="h-96 pr-2">
<div className="space-y-3">
{Object.entries(statistics.numeric).map(([col, stats]: [string, any]) => (
<div key={col} className="p-4 bg-muted/30 rounded-xl border">
<div className="font-semibold text-sm mb-3 truncate" title={col}>{col}</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div className="flex flex-col">
<span className="text-muted-foreground mb-1"></span>
<span className="font-medium text-base">{stats.count}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1"></span>
<span className="font-medium text-base">{stats.mean?.toFixed(2)}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1"></span>
<span className="font-medium text-base">{stats.median?.toFixed(2)}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1"></span>
<span className="font-medium text-base">{stats.std?.toFixed(2)}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1"></span>
<span className="font-medium text-base">{stats.min}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1"></span>
<span className="font-medium text-base">{stats.max}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1">Q25</span>
<span className="font-medium text-base">{stats.q25?.toFixed(2)}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground mb-1">Q75</span>
<span className="font-medium text-base">{stats.q75?.toFixed(2)}</span>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="p-8 text-center text-muted-foreground text-sm">
</div>
)}
</TabsContent>
<TabsContent value="categorical" className="mt-4">
{statistics.categorical && Object.keys(statistics.categorical).length > 0 ? (
<ScrollArea className="h-96 pr-2">
<div className="space-y-3">
{Object.entries(statistics.categorical).map(([col, stats]: [string, any]) => (
<div key={col} className="p-4 bg-muted/30 rounded-xl border">
<div className="flex items-center justify-between mb-3">
<div className="font-semibold text-sm truncate flex-1 mr-2" title={col}>{col}</div>
<Badge variant="secondary" className="text-xs">
{stats.unique}
</Badge>
</div>
<div className="text-xs space-y-2">
<div className="flex items-center">
<span className="text-muted-foreground w-16 flex-shrink-0">:</span>
<span className="font-medium ml-1 truncate" title={stats.most_common}>
{stats.most_common}
</span>
<span className="text-muted-foreground ml-1 flex-shrink-0">
({stats.most_common_count} )
</span>
</div>
<div className="flex items-center">
<span className="text-muted-foreground w-16 flex-shrink-0">:</span>
<span className={stats.missing > 0 ? "text-destructive font-medium" : "text-muted-foreground"}>
{stats.missing}
</span>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="p-8 text-center text-muted-foreground text-sm">
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,367 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,107 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface MarkdownProps {
content: string;
className?: string;
}
export const Markdown: React.FC<MarkdownProps> = ({ content, className }) => {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-bold mb-3 mt-5">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-semibold mb-2 mt-4">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-base font-semibold mb-2 mt-3">{children}</h4>
),
p: ({ children }) => (
<p className="mb-3 leading-relaxed">{children}</p>
),
ul: ({ children }) => (
<ul className="list-disc list-inside mb-4 space-y-1 ml-4">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside mb-4 space-y-1 ml-4">{children}</ol>
),
li: ({ children }) => (
<li className="text-sm leading-relaxed">{children}</li>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }) => (
<em className="italic">{children}</em>
),
code: ({ children, className }) => {
if (className && className.includes('language-')) {
return (
<code className="block bg-muted/80 p-4 rounded-lg overflow-x-auto mb-4 text-sm font-mono">
{children}
</code>
);
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
);
},
pre: ({ children }) => (
<pre className="bg-muted/80 p-4 rounded-lg overflow-x-auto mb-4 text-sm">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 py-2 my-4 bg-muted/30 italic text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-4 border rounded-lg">
<table className="w-full text-sm border-collapse">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">{children}</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-border">{children}</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-muted/30 transition-colors">{children}</tr>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left font-semibold text-foreground border-b border-border">{children}</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-foreground border-b border-border/30">{children}</td>
),
hr: () => (
<hr className="my-6 border-border" />
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
};

View File

@@ -0,0 +1,254 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,196 @@
/**
* @file Custom multi-select dropdown component
*/
import type React from "react";
import { useEffect, useState, useRef } from "react";
interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
options: Option[];
value?: string[];
defaultSelected?: string[];
onChange?: (selected: string[]) => void;
disabled?: boolean;
}
const MultiSelect: React.FC<MultiSelectProps> = ({
options,
defaultSelected = [],
value,
onChange,
disabled = false,
}) => {
const [selectedOptions, setSelectedOptions] =
useState<string[]>(defaultSelected);
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const toggleDropdown = () => {
if (!disabled) setIsOpen((prev) => !prev);
};
useEffect(() => {
if (selectedOptions.length && value && !value?.length) {
onChange?.(defaultSelected);
}
}, [defaultSelected]);
useEffect(() => {
if (
value?.length &&
(value.length !== selectedOptions.length ||
value.some((val) => !selectedOptions.includes(val)))
) {
setSelectedOptions(value);
}
}, [value, selectedOptions]);
const handleSelect = (optionValue: string) => {
const newSelectedOptions = selectedOptions.includes(optionValue)
? selectedOptions.filter((value) => value !== optionValue)
: [...selectedOptions, optionValue];
setSelectedOptions(newSelectedOptions);
onChange?.(newSelectedOptions);
};
const removeOption = (value: string) => {
const newSelectedOptions = selectedOptions.filter((opt) => opt !== value);
setSelectedOptions(newSelectedOptions);
onChange?.(newSelectedOptions);
};
const selectedValuesText = selectedOptions.map(
(value) => options.find((option) => option.value === value)?.label || ""
);
return (
<div className="relative z-20 inline-block w-full" ref={containerRef}>
<div className="relative flex flex-col items-center">
<div onClick={toggleDropdown} className="w-full">
<div className="mb-2 flex h-11 rounded-lg border border-gray-300 py-1.5 pl-3 pr-3 shadow-theme-xs outline-hidden transition focus:border-brand-300 focus:shadow-focus-ring dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-300">
<div className="flex flex-wrap flex-auto gap-2">
{selectedValuesText.length > 0 ? (
selectedValuesText.map((text, index) => (
<div
key={index}
className="group flex items-center justify-center rounded-full border-[0.7px] border-transparent bg-gray-100 py-1 pl-2.5 pr-2 text-sm text-gray-800 hover:border-gray-200 dark:bg-gray-800 dark:text-white/90 dark:hover:border-gray-800"
>
<span className="flex-initial max-w-full">{text}</span>
<div className="flex flex-row-reverse flex-auto">
<div
onClick={(e) => {
e.stopPropagation();
removeOption(selectedOptions[index]);
}}
className="pl-2 text-gray-500 cursor-pointer group-hover:text-gray-400 dark:text-gray-400"
>
<svg
className="fill-current"
role="button"
width="14"
height="14"
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.40717 4.46881C3.11428 4.17591 3.11428 3.70104 3.40717 3.40815C3.70006 3.11525 4.17494 3.11525 4.46783 3.40815L6.99943 5.93975L9.53095 3.40822C9.82385 3.11533 10.2987 3.11533 10.5916 3.40822C10.8845 3.70112 10.8845 4.17599 10.5916 4.46888L8.06009 7.00041L10.5916 9.53193C10.8845 9.82482 10.8845 10.2997 10.5916 10.5926C10.2987 10.8855 9.82385 10.8855 9.53095 10.5926L6.99943 8.06107L4.46783 10.5927C4.17494 10.8856 3.70006 10.8856 3.40717 10.5927C3.11428 10.2998 3.11428 9.8249 3.40717 9.53201L5.93877 7.00041L3.40717 4.46881Z"
/>
</svg>
</div>
</div>
</div>
))
) : (
<input
placeholder="Please select options..."
className="w-full h-full p-1 pr-2 text-sm bg-transparent border-0 outline-hidden appearance-none placeholder:text-gray-800 focus:border-0 focus:outline-hidden focus:ring-0 dark:placeholder:text-white/90"
readOnly
value="Please select options..."
/>
)}
</div>
<div className="flex items-center py-1 pl-1 pr-1 w-7">
<button
type="button"
onClick={toggleDropdown}
className="w-5 h-5 text-gray-700 outline-hidden cursor-pointer focus:outline-hidden dark:text-gray-400"
>
<svg
className={`stroke-current ${isOpen ? "rotate-180" : ""}`}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.39551L10.0001 12.6038L15.2084 7.39551"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
{isOpen && (
<div
className="absolute left-0 z-40 w-full overflow-y-auto bg-white rounded-lg shadow-sm top-full max-h-select dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col">
{options.map((option, index) => (
<div
key={index}
className={`hover:bg-primary/5 w-full cursor-pointer rounded-t border-b border-gray-200 dark:border-gray-800`}
onClick={() => handleSelect(option.value)}
>
<div
className={`relative flex w-full items-center p-2 pl-2 ${
selectedOptions.includes(option.value)
? "bg-primary/10"
: ""
}`}
>
<div className="mx-2 leading-6 text-gray-800 dark:text-white/90">
{option.label}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default MultiSelect;

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,97 @@
/**
* QR Code Generator Component
*
* React wrapper component based on QRCode.js that can convert any text to QR code image
*
* Usage example:
* import QRCodeDataUrl from './components/qrcodedataurl'
*
* function App() {
* return <QRCodeDataUrl text="https://example.com" /> // Replace with valid URL
* }
*/
import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
interface QRCodeDataUrlProps {
/**
* Text content to be encoded as QR code
* Can be URL, text, contact information, etc.
* Example: "https://example.com" or "CONTACT:1234567890"
*/
text: string;
/**
* QR code image width (pixels)
* @default 128
*/
width?: number;
/**
* QR code foreground color (valid CSS color value)
* @default "#000000" (black)
*/
color?: string;
/**
* QR code background color (valid CSS color value)
* @default "#ffffff" (white)
*/
backgroundColor?: string;
/**
* Custom CSS class name
*/
className?: string;
}
/**
* QR Code Generator Component
* @param {QRCodeDataUrlProps} props - Component properties
*/
const QRCodeDataUrl: React.FC<QRCodeDataUrlProps> = ({
text,
width = 128,
color = '#000000',
backgroundColor = '#ffffff',
className = '',
}) => {
const [dataUrl, setDataUrl] = useState<string>('');
useEffect(() => {
const generateQR = async () => {
try {
const url = await QRCode.toDataURL(text, {
width,
color: {
dark: color,
light: backgroundColor,
},
});
setDataUrl(url);
} catch (err) {
console.error('Failed to generate QR code:', err);
}
};
generateQR();
}, [text, width, color, backgroundColor]);
return (
<div className={`qr-code-container ${className}`}>
{dataUrl ? (
<img
src={dataUrl}
alt={`QR Code: ${text}`}
width={width}
height={width}
/>
) : (
<div>Generating QR code...</div>
)}
</div>
);
};
export default QRCodeDataUrl;

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,111 @@
/**
* Video Player Component
*
* Video player based on video-react wrapper, supports custom poster, autoplay, mute and other features
*
* Usage example:
* <Video
* src="" // Video resource URL, defaults to empty string
* poster="https://internal-amis-res.cdn.bcebos.com/images/2019-12/1577157239810/da6376bf988c.png" // Video poster image
* />
*/
import {
BigPlayButton,
ControlBar,
PlayToggle,
CurrentTimeDisplay,
TimeDivider,
DurationDisplay,
FullscreenToggle,
VolumeMenuButton,
ProgressControl
} from 'video-react';
import 'video-react/dist/video-react.css';
interface VideoProps {
/** Video resource URL */
src: string;
poster?: string; /** Video poster image URL */
className?: string; /** Custom class name */
autoPlay?: boolean; /** Whether to autoplay, defaults to false */
muted?: boolean; /** Whether to mute, defaults to false */
controls?: boolean; /** Whether to show controls, defaults to true */
aspectRatio?: string | 'auto' | '16:9' | '4:3'; /** Video aspect ratio, defaults to 'auto' */
}
export default function Video({
className,
src,
poster,
autoPlay = false,
muted = false,
controls = true,
aspectRatio = 'auto'
}: VideoProps) {
return (
<div className={`min-w-[100px] ${className}`} custom-component="video">
<style>
{`
.video-react-paused .video-react-big-play-button.big-play-button-hide {
display: block;
}
.video-react .video-react-big-play-button {
width: 48px;
height: 40px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-left: -24px !important;
margin-top: -20px !important;
background: rgba(7, 12, 20, 0.6) !important;
}
.video-react .video-react-big-play-button:hover {
background: rgba(7, 12, 20, 0.8) !important;
}
.video-react .video-react-big-play-button:before {
display: block;
content: '';
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAqCAYAAADBNhlmAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGVSURBVHgB7ZnhbYMwEIWPqAN0BHcDRiAbdASyQTpB6QRpJyAjdAO8QbsBbNBs4L6TcYtaUADb +H7kk56cgKJ8sX0RHBmNYIwpMBT92w7RWZZ1lBoWQ1rzny/kGbmnVODLc3OdFikpBWZ85mSI9ku7htbY/RqNXT8WtA6FNJCsEUUR2FEYSqSNIRpK0FGSFT2FEg0t6DiSXfojeRJLkFHIybfiYwo6FFKvFd1C0KHIijZL9ueWgo6CFlR8CkFHSTNEUwo6SuTDTFyMSBBkWKwiK1oOT0gRdCj6rXh+LU7QocjOppIqyPCy15IFmUK6oNg9 +MNN0BMtWfCCHKQKdsiemwXSBHnWniD2gHzygTuSAYu9Ia8QuwxPSBBkseqvmCOloEYO15pSKfagJlsA+zkdsy1nUCMvkNJLPrSFYEdW7EwriCk4WZlLiCEYRMwRWvBMdjk7CoQT9P2lmlYUwGw8GphN7AbmULJdINZuJjYQnDOL3O33bqn5SOZm+jFEZRI8hsjGDkLkEUNO9taPL3veQ/xlrOEbeloBZoEUypwAAAAASUVORK5CYII=)
no-repeat;
background-size: contain;
width: 15px;
height: 16.25px;
margin: 0 auto;
position: relative;
}
.h-full > .video-react.video-react-fluid {
height: 100%;
padding-top: 0 !important;
aspect-ratio: 16 / 9;
}
`}
</style>
<Player
poster={poster}
src={src}
autoPlay={autoPlay}
muted={muted}
aspectRatio={aspectRatio}
>
<ControlBar
disableDefaultControls
autoHide
disableCompletely={!controls}
>
<PlayToggle key="play-toggle" />
<VolumeMenuButton key="volume-menu-button" vertical />
<CurrentTimeDisplay key="current-time-display" />
<TimeDivider key="time-divider" />
<DurationDisplay key="duration-display" />
<ProgressControl key="progress-control" />
<FullscreenToggle key="fullscreen-toggle" />
</ControlBar>
<BigPlayButton position="center" />
</Player>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '@/db/supabase';
import { User } from '@supabase/supabase-js';
import { Profile } from '@/types/types';
interface AuthContextType {
user: User | null;
profile: Profile | null;
signIn: (email: string, password: string) => Promise<{ error: any }>;
signUp: (email: string, password: string) => Promise<{ error: any }>;
signOut: () => Promise<{ error: any }>;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check active sessions and sets the user
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
if (session?.user) fetchProfile(session.user.id);
else setLoading(false);
});
// Listen for changes on auth state (sign in, sign out, etc.)
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
if (session?.user) fetchProfile(session.user.id);
else {
setProfile(null);
setLoading(false);
}
});
return () => subscription.unsubscribe();
}, []);
const fetchProfile = async (uid: string) => {
try {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', uid)
.maybeSingle();
if (error) throw error;
setProfile(data);
} catch (err) {
console.error('Error fetching profile:', err);
} finally {
setLoading(false);
}
};
const signIn = async (email: string, password: string) => {
return await supabase.auth.signInWithPassword({ email, password });
};
const signUp = async (email: string, password: string) => {
return await supabase.auth.signUp({ email, password });
};
const signOut = async () => {
return await supabase.auth.signOut();
};
return (
<AuthContext.Provider value={{ user, profile, signIn, signUp, signOut, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,126 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { supabase } from '@/db/supabase';
import type { User } from '@supabase/supabase-js';
import type { Profile } from '@/types/types';
import { toast } from 'sonner';
export async function getProfile(userId: string): Promise<Profile | null> {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
if (error) {
console.error('获取用户信息失败:', error);
return null;
}
return data;
}
interface AuthContextType {
user: User | null;
profile: Profile | null;
loading: boolean;
signInWithUsername: (username: string, password: string) => Promise<{ error: Error | null }>;
signUpWithUsername: (username: string, password: string) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const refreshProfile = async () => {
if (!user) {
setProfile(null);
return;
}
const profileData = await getProfile(user.id);
setProfile(profileData);
};
useEffect(() => {
supabase
.auth
.getSession()
.then(({ data: { session } }) => {
setUser(session?.user ?? null);
if (session?.user) {
getProfile(session.user.id).then(setProfile);
}
})
.catch(error => {
toast.error(`获取用户信息失败: ${error.message}`);
})
.finally(() => {
setLoading(false);
});
// In this function, do NOT use any await calls. Use `.then()` instead to avoid deadlocks.
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
if (session?.user) {
getProfile(session.user.id).then(setProfile);
} else {
setProfile(null);
}
});
return () => subscription.unsubscribe();
}, []);
const signInWithUsername = async (username: string, password: string) => {
try {
const email = `${username}@miaoda.com`;
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return { error: null };
} catch (error) {
return { error: error as Error };
}
};
const signUpWithUsername = async (username: string, password: string) => {
try {
const email = `${username}@miaoda.com`;
const { error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw error;
return { error: null };
} catch (error) {
return { error: error as Error };
}
};
const signOut = async () => {
await supabase.auth.signOut();
setUser(null);
setProfile(null);
};
return (
<AuthContext.Provider value={{ user, profile, loading, signInWithUsername, signUpWithUsername, signOut, refreshProfile }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

120
frontend/src/db/api.ts Normal file
View File

@@ -0,0 +1,120 @@
import { supabase } from './supabase';
import type { Document, ExtractedEntity, Template, FillTask } from '../types/types';
export const documentApi = {
async listDocuments(userId: string) {
const { data, error } = await supabase
.from('documents')
.select('*, extracted_entities(*)')
.eq('owner_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
async uploadDocument(file: File, userId: string) {
const fileExt = file.name.split('.').pop();
const fileName = `${userId}/${Math.random().toString(36).substring(2, 15)}.${fileExt}`;
const filePath = `documents/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('document_storage')
.upload(filePath, file);
if (uploadError) throw uploadError;
const { data, error } = await supabase
.from('documents')
.insert({
owner_id: userId,
name: file.name,
type: fileExt || 'unknown',
storage_path: filePath,
status: 'pending'
})
.select()
.maybeSingle();
if (error) throw error;
return data;
},
async getDocumentEntities(docId: string) {
const { data, error } = await supabase
.from('extracted_entities')
.select('*')
.eq('document_id', docId);
if (error) throw error;
return data;
}
};
export const templateApi = {
async listTemplates(userId: string) {
const { data, error } = await supabase
.from('templates')
.select('*')
.eq('owner_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
async uploadTemplate(file: File, userId: string) {
const fileExt = file.name.split('.').pop();
const fileName = `${userId}/templates/${Math.random().toString(36).substring(2, 15)}.${fileExt}`;
const filePath = `templates/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('document_storage')
.upload(filePath, file);
if (uploadError) throw uploadError;
const { data, error } = await supabase
.from('templates')
.insert({
owner_id: userId,
name: file.name,
type: fileExt || 'unknown',
storage_path: filePath
})
.select()
.maybeSingle();
if (error) throw error;
return data;
}
};
export const taskApi = {
async listTasks(userId: string) {
const { data, error } = await supabase
.from('fill_tasks')
.select('*, templates(*)')
.eq('owner_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
async createTask(userId: string, templateId: string, documentIds: string[]) {
const { data, error } = await supabase
.from('fill_tasks')
.insert({
owner_id: userId,
template_id: templateId,
document_ids: documentIds,
status: 'pending'
})
.select()
.maybeSingle();
if (error) throw error;
return data;
}
};

View File

@@ -0,0 +1,527 @@
/**
* 后端 FastAPI 调用封装
*/
const BACKEND_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8000/api/v1';
export interface ExcelUploadOptions {
parseAllSheets?: boolean;
sheetName?: string;
headerRow?: number;
}
export interface ExcelParseResult {
success: boolean;
data?: {
columns?: string[];
rows?: Record<string, any>[];
row_count?: number;
column_count?: number;
sheets?: Record<string, any>;
};
error?: string;
metadata?: {
filename?: string;
extension?: string;
sheet_count?: number;
sheet_names?: string[];
row_count?: number;
column_count?: number;
columns?: string[];
file_size?: number;
saved_path?: string;
original_filename?: string;
};
}
export interface ExcelExportOptions {
columns?: string[];
sheetName?: string;
}
export const backendApi = {
/**
* 上传并解析 Excel 文件
*/
async uploadExcel(
file: File,
options: ExcelUploadOptions = {}
): Promise<ExcelParseResult> {
const formData = new FormData();
formData.append('file', file);
const params = new URLSearchParams();
// 只在显式设置为 true 时才发送参数,避免发送 "false" 字符串
if (options.parseAllSheets === true) {
params.append('parse_all_sheets', 'true');
}
if (options.sheetName) {
params.append('sheet_name', options.sheetName);
}
if (options.headerRow !== undefined) {
params.append('header_row', String(options.headerRow));
}
const url = `${BACKEND_BASE_URL}/upload/excel?${params.toString()}`;
try {
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '上传失败');
}
return await response.json();
} catch (error) {
console.error('上传 Excel 文件失败:', error);
throw error;
}
},
/**
* 导出 Excel 文件(基于原始解析结果,选择列导出)
*/
async exportExcel(
filePath: string,
options: ExcelExportOptions = {}
): Promise<Blob> {
const params = new URLSearchParams();
if (options.sheetName) {
params.append('sheet_name', options.sheetName);
}
if (options.columns && options.columns.length > 0) {
params.append('columns', options.columns.join(','));
}
const url = `${BACKEND_BASE_URL}/upload/excel/export/${encodeURIComponent(filePath)}?${params.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '导出失败');
}
return await response.blob();
} catch (error) {
console.error('导出 Excel 文件失败:', error);
throw error;
}
},
/**
* 获取 Excel 文件预览
*/
async getExcelPreview(
filePath: string,
sheetName?: string,
maxRows: number = 10
): Promise<ExcelParseResult> {
const params = new URLSearchParams();
if (sheetName !== undefined) {
params.append('sheet_name', sheetName);
}
params.append('max_rows', String(maxRows));
const url = `${BACKEND_BASE_URL}/upload/excel/preview/${encodeURIComponent(filePath)}?${params.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '获取预览失败');
}
return await response.json();
} catch (error) {
console.error('获取预览失败:', error);
throw error;
}
},
/**
* 删除已上传的文件
*/
async deleteUploadedFile(filePath: string): Promise<{ success: boolean; message: string }> {
const url = `${BACKEND_BASE_URL}/upload/file?file_path=${encodeURIComponent(filePath)}`;
try {
const response = await fetch(url, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '删除失败');
}
return await response.json();
} catch (error) {
console.error('删除文件失败:', error);
throw error;
}
},
/**
* 健康检查
*/
async healthCheck(): Promise<{ status: string; service: string }> {
try {
const response = await fetch(`${BACKEND_BASE_URL.replace('/api/v1', '')}/health`);
if (!response.ok) throw new Error('健康检查失败');
return await response.json();
} catch (error) {
console.error('健康检查失败:', error);
throw error;
}
}
};
/**
* AI 分析相关的 API 调用
*/
export interface AIAnalyzeOptions {
userPrompt?: string;
analysisType?: 'general' | 'summary' | 'statistics' | 'insights';
parseAllSheets?: boolean;
}
export interface ExcelData {
columns?: string[];
rows?: Record<string, any>[];
row_count?: number;
column_count?: number;
}
export interface AIAnalysisResult {
success: boolean;
analysis?: string;
model?: string;
analysisType?: string;
error?: string;
}
export interface AIExcelAnalyzeResult {
success: boolean;
excel?: {
data?: ExcelData;
sheets?: Record<string, ExcelData>;
metadata?: any;
saved_path?: string;
};
analysis?: {
analysis?: string;
model?: string;
analysisType?: string;
is_template?: boolean;
sheets?: Record<string, AIAnalysisResult>;
total_sheets?: number;
successful?: number;
errors?: Record<string, string>;
};
error?: string;
}
export const aiApi = {
/**
* 上传并使用 AI 分析 Excel 文件
*/
async analyzeExcel(
file: File,
options: AIAnalyzeOptions = {}
): Promise<AIExcelAnalyzeResult> {
const formData = new FormData();
formData.append('file', file);
const params = new URLSearchParams();
if (options.userPrompt) {
params.append('user_prompt', options.userPrompt);
}
if (options.analysisType) {
params.append('analysis_type', options.analysisType);
}
// 只在显式设置为 true 时才发送参数
if (options.parseAllSheets === true) {
params.append('parse_all_sheets', 'true');
}
const url = `${BACKEND_BASE_URL}/ai/analyze/excel?${params.toString()}`;
try {
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'AI 分析失败');
}
return await response.json();
} catch (error) {
console.error('AI 分析失败:', error);
throw error;
}
},
/**
* 对已解析的 Excel 数据进行 AI 分析
*/
async analyzeText(
excelData: ExcelData,
userPrompt: string = '',
analysisType: string = 'general'
): Promise<AIAnalysisResult> {
const url = `${BACKEND_BASE_URL}/ai/analyze/text`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
excel_data: excelData,
user_prompt: userPrompt,
analysis_type: analysisType,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '文本分析失败');
}
return await response.json();
} catch (error) {
console.error('文本分析失败:', error);
throw error;
}
},
/**
* 获取支持的分析类型
*/
async getAnalysisTypes(): Promise<{
types: Array<{
value: string;
label: string;
description: string;
}>;
}> {
const url = `${BACKEND_BASE_URL}/ai/analysis/types`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('获取分析类型失败');
return await response.json();
} catch (error) {
console.error('获取分析类型失败:', error);
throw error;
}
}
};
/**
* 可视化相关的 API 调用
*/
export interface VisualizationResult {
success: boolean;
statistics?: {
numeric?: Record<string, any>;
categorical?: Record<string, any>;
};
charts?: {
histograms?: Array<any>;
bar_charts?: Array<any>;
box_plots?: Array<any>;
correlation?: any; // 修复:后端返回的是 correlation不是 relation
};
distributions?: Record<string, any>;
row_count?: number;
column_count?: number;
error?: string;
}
export const visualizationApi = {
/**
* 生成统计信息和图表
*/
async generateStatistics(
excelData: ExcelData,
analysisType: string = 'statistics'
): Promise<VisualizationResult> {
const url = `${BACKEND_BASE_URL}/visualization/statistics`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
excel_data: excelData,
analysis_type: analysisType
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '生成图表失败');
}
return await response.json();
} catch (error) {
console.error('生成图表失败:', error);
throw error;
}
},
/**
* 获取支持的图表类型
*/
async getChartTypes(): Promise<{
chart_types: Array<{
value: string;
label: string;
description: string;
}>;
}> {
const url = `${BACKEND_BASE_URL}/visualization/chart-types`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('获取图表类型失败');
return await response.json();
} catch (error) {
console.error('获取图表类型失败:', error);
throw error;
}
}
};
/**
* 分析结果图表 API - 根据 AI 分析结果生成图表
*/
export interface AnalysisChartRequest {
analysis_text: string;
original_filename?: string;
file_type?: string;
}
export interface AnalysisChartResult {
success: boolean;
charts?: {
numeric_charts?: Array<{
type: string;
title: string;
image: string;
data: Array<{ name: string; value: number }>;
}>;
categorical_charts?: Array<{
type: string;
title: string;
image: string;
data: Array<{ name: string; count: number }>;
}>;
time_series_chart?: {
type: string;
title: string;
image: string;
data: Array<{ name: string; value: number }>;
};
comparison_chart?: {
type: string;
title: string;
image: string;
data: Array<{ name: string; value: number }>;
};
table_preview?: {
columns: string[];
rows: any[];
total_rows: number;
preview_rows: number;
};
};
statistics?: {
numeric_summary?: {
count: number;
sum: number;
mean: number;
median: number;
min: number;
max: number;
std: number;
};
};
metadata?: {
total_items?: number;
data_types?: string[];
};
data_source?: string;
error?: string;
}
export const analysisChartsApi = {
/**
* 从 AI 分析结果中提取数据并生成图表
*/
async extractAndGenerateCharts(request: AnalysisChartRequest): Promise<AnalysisChartResult> {
const url = `${BACKEND_BASE_URL}/analysis/extract-and-chart`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '生成图表失败');
}
return await response.json();
} catch (error) {
console.error('生成分析结果图表失败:', error);
throw error;
}
},
/**
* 仅提取结构化数据(调试用)
*/
async analyzeTextOnly(request: AnalysisChartRequest) {
const url = `${BACKEND_BASE_URL}/analysis/analyze-text`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '分析失败');
}
return await response.json();
} catch (error) {
console.error('分析文本失败:', error);
throw error;
}
}
};

View File

@@ -0,0 +1,8 @@
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

1
frontend/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
// global types

View File

@@ -0,0 +1,15 @@
import * as React from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,17 @@
import { useNavigate } from "react-router-dom";
const useGoBack = () => {
const navigate = useNavigate();
const goBack = () => {
if (window.history.state && window.history.state.idx > 0) {
navigate(-1); // Go back to the previous page
} else {
navigate("/"); // Redirect to home if no history exists
}
};
return goBack;
};
export default useGoBack;

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { type FileError, type FileRejection, useDropzone } from 'react-dropzone'
import {type SupabaseClient} from '@supabase/supabase-js'
interface FileWithPreview extends File {
preview?: string
errors: readonly FileError[]
}
type UseSupabaseUploadOptions = {
/**
* Name of bucket to upload files to in your Supabase project
*/
bucketName: string
/**
* Folder to upload files to in the specified bucket within your Supabase project.
*
* Defaults to uploading files to the root of the bucket
*
* e.g If specified path is `test`, your file will be uploaded as `test/file_name`
*/
path?: string
/**
* Allowed MIME types for each file upload (e.g `image/png`, `text/html`, etc). Wildcards are also supported (e.g `image/*`).
*
* Defaults to allowing uploading of all MIME types.
*/
allowedMimeTypes?: string[]
/**
* Maximum upload size of each file allowed in bytes. (e.g 1000 bytes = 1 KB)
*/
maxFileSize?: number
/**
* Maximum number of files allowed per upload.
*/
maxFiles?: number
/**
* The number of seconds the asset is cached in the browser and in the Supabase CDN.
*
* This is set in the Cache-Control: max-age=<seconds> header. Defaults to 3600 seconds.
*/
cacheControl?: number
/**
* When set to true, the file is overwritten if it exists.
*
* When set to false, an error is thrown if the object already exists. Defaults to `false`
*/
upsert?: boolean
/**
* initialized Supabase client instance
*/
supabase: SupabaseClient
}
type UseSupabaseUploadReturn = ReturnType<typeof useSupabaseUpload>
const useSupabaseUpload = (options: UseSupabaseUploadOptions) => {
const {
bucketName,
path,
allowedMimeTypes = [],
maxFileSize = Number.POSITIVE_INFINITY,
maxFiles = 1,
cacheControl = 3600,
upsert = false,
supabase
} = options
const [files, setFiles] = useState<FileWithPreview[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [errors, setErrors] = useState<{ name: string; message: string }[]>([])
const [successes, setSuccesses] = useState<string[]>([])
const isSuccess = useMemo(() => {
if (errors.length === 0 && successes.length === 0) {
return false
}
if (errors.length === 0 && successes.length === files.length) {
return true
}
return false
}, [errors.length, successes.length, files.length])
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
const validFiles = acceptedFiles
.filter((file) => !files.find((x) => x.name === file.name))
.map((file) => {
;(file as FileWithPreview).preview = URL.createObjectURL(file)
;(file as FileWithPreview).errors = []
return file as FileWithPreview
})
const invalidFiles = fileRejections.map(({ file, errors }) => {
;(file as FileWithPreview).preview = URL.createObjectURL(file)
;(file as FileWithPreview).errors = errors
return file as FileWithPreview
})
const newFiles = [...files, ...validFiles, ...invalidFiles]
setFiles(newFiles)
},
[files, setFiles]
)
const dropzoneProps = useDropzone({
onDrop,
noClick: true,
accept: allowedMimeTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
maxSize: maxFileSize,
maxFiles: maxFiles,
multiple: maxFiles !== 1,
})
const onUpload = useCallback(async () => {
setLoading(true)
// [Joshen] This is to support handling partial successes
// If any files didn't upload for any reason, hitting "Upload" again will only upload the files that had errors
const filesWithErrors = errors.map((x) => x.name)
const filesToUpload =
filesWithErrors.length > 0
? [
...files.filter((f) => filesWithErrors.includes(f.name)),
...files.filter((f) => !successes.includes(f.name)),
]
: files
const responses = await Promise.all(
filesToUpload.map(async (file) => {
const { error } = await supabase.storage
.from(bucketName)
.upload(!!path ? `${path}/${file.name}` : file.name, file, {
cacheControl: cacheControl.toString(),
upsert,
})
if (error) {
return { name: file.name, message: error.message }
} else {
return { name: file.name, message: undefined }
}
})
)
const responseErrors = responses.filter((x) => x.message !== undefined)
// if there were errors previously, this function tried to upload the files again so we should clear/overwrite the existing errors.
setErrors(responseErrors)
const responseSuccesses = responses.filter((x) => x.message === undefined)
const newSuccesses = Array.from(
new Set([...successes, ...responseSuccesses.map((x) => x.name)])
)
setSuccesses(newSuccesses)
setLoading(false)
}, [files, path, bucketName, errors, successes])
useEffect(() => {
if (files.length === 0) {
setErrors([])
}
// If the number of files doesn't exceed the maxFiles parameter, remove the error 'Too many files' from each file
if (files.length <= maxFiles) {
let changed = false
const newFiles = files.map((file) => {
if (file.errors.some((e) => e.code === 'too-many-files')) {
file.errors = file.errors.filter((e) => e.code !== 'too-many-files')
changed = true
}
return file
})
if (changed) {
setFiles(newFiles)
}
}
}, [files.length, setFiles, maxFiles])
return {
files,
setFiles,
successes,
isSuccess,
loading,
errors,
setErrors,
onUpload,
maxFileSize: maxFileSize,
maxFiles: maxFiles,
allowedMimeTypes,
...dropzoneProps,
}
}
export { useSupabaseUpload, type UseSupabaseUploadOptions, type UseSupabaseUploadReturn }

114
frontend/src/index.css Normal file
View File

@@ -0,0 +1,114 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--primary-glow: 221.2 83.2% 70%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 221.2 83.2% 53.3%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--success: 142 76% 36%;
--warning: 38 92% 50%;
--info: 214 95% 93%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary-glow: 217.2 91.2% 80%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
}
}
@layer utilities {
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary-glow)));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.glass-card {
@apply bg-card/80 backdrop-blur-md border border-white/10 shadow-lg;
}
}

39
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,39 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export type Params = Partial<
Record<keyof URLSearchParams, string | number | null | undefined>
>;
export function createQueryString(
params: Params,
searchParams: URLSearchParams
) {
const newSearchParams = new URLSearchParams(searchParams?.toString());
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) {
newSearchParams.delete(key);
} else {
newSearchParams.set(key, String(value));
}
}
return newSearchParams.toString();
}
export function formatDate(
date: Date | string | number,
opts: Intl.DateTimeFormatOptions = {}
) {
return new Intl.DateTimeFormat("zh-CN", {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
year: opts.year ?? "numeric",
...opts,
}).format(new Date(date));
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { AppWrapper } from "./components/common/PageMeta.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppWrapper>
<App />
</AppWrapper>
</StrictMode>
);

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>
</>
);
}

52
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard';
import Documents from '@/pages/Documents';
import FormFill from '@/pages/FormFill';
import Assistant from '@/pages/Assistant';
import ExcelParse from '@/pages/ExcelParse';
import MainLayout from '@/components/layouts/MainLayout';
import { RouteGuard } from '@/components/common/RouteGuard';
export const routes = [
{
path: '/login',
element: <Login />,
},
{
path: '/',
element: (
<RouteGuard>
<MainLayout />
</RouteGuard>
),
children: [
{
path: '/',
element: <Dashboard />,
},
{
path: '/documents',
element: <Documents />,
},
{
path: '/form-fill',
element: <FormFill />,
},
{
path: '/assistant',
element: <Assistant />,
},
{
path: '/excel-parse',
element: <ExcelParse />,
},
],
},
{
path: '*',
element: <Navigate to="/" replace />,
},
];
export const router = createBrowserRouter(routes);

View File

6
frontend/src/svg.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "*.svg?react" {
import React = require("react");
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}

View File

@@ -0,0 +1,6 @@
export interface Option {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
withCount?: boolean;
}

View File

@@ -0,0 +1,75 @@
export interface Database {
public: {
Tables: {
profiles: {
Row: {
id: string;
email: string | null;
phone: string | null;
role: 'user' | 'admin';
created_at: string | null;
};
};
documents: {
Row: {
id: string;
owner_id: string;
name: string;
type: string;
storage_path: string;
content_text: string | null;
metadata: any;
status: string;
created_at: string | null;
extracted_entities?: any[];
};
};
extracted_entities: {
Row: {
id: string;
document_id: string;
entity_type: string;
entity_value: string;
confidence: number | null;
created_at: string | null;
};
};
templates: {
Row: {
id: string;
owner_id: string;
name: string;
type: string;
storage_path: string;
created_at: string | null;
};
};
fill_tasks: {
Row: {
id: string;
owner_id: string;
template_id: string;
document_ids: string[];
status: string;
result_path: string | null;
error_message: string | null;
created_at: string | null;
templates?: any;
};
};
};
};
}
export type Profile = Database['public']['Tables']['profiles']['Row'];
export type Document = Database['public']['Tables']['documents']['Row'];
export type ExtractedEntity = Database['public']['Tables']['extracted_entities']['Row'];
export type Template = Database['public']['Tables']['templates']['Row'];
export type FillTask = Database['public']['Tables']['fill_tasks']['Row'];
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
id: string;
created_at: string;
}

11
frontend/src/types/virtual-modules.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
// src/types/virtual-modules.d.ts
declare module '@/db/supabase' {
export const supabase: ReturnType<typeof import('@supabase/supabase-js').createClient>;
}
declare module '@/types/types' {
export interface Profile {
[key: string]: unknown;
}
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />