feat: 完善前后端核心功能模块
后端: - 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API - 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers - 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等 - 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等 - 添加数据库迁移脚本 前端: - 新增登录页面和仪表盘页面 - 新增任务列表、任务详情、字段映射页面 - 新增异常处理页面和规则设置页面 - 新增 API 代理路由 /api/[...path] - 新增 UI 组件库 (button, card, dialog, input, table 等) - 新增 auth 组件 (ProtectedRoute, PermissionGate) - 新增 layout 组件 (Header, Sidebar) - 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel) - 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等) - 新增状态管理 (auth-store, company-store, ui-store) - 集成 Tailwind CSS 和 shadcn/ui 组件库 其他: - 添加 Alembic 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
interface PermissionGateProps {
|
||||
children: React.ReactNode;
|
||||
permission?: string;
|
||||
role?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限门控组件
|
||||
* 根据权限码或角色控制组件显示
|
||||
*/
|
||||
export function PermissionGate({
|
||||
children,
|
||||
permission,
|
||||
role,
|
||||
fallback = null,
|
||||
}: PermissionGateProps) {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
if (!user) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (permission && !user.permissions?.includes(permission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// 检查角色
|
||||
if (role && user.role !== role) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 受保护路由组件
|
||||
* 检查用户登录状态,未登录则重定向到登录页
|
||||
*/
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, user, router]);
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 调用后端登出接口
|
||||
await api.post(ENDPOINTS.AUTH.LOGOUT);
|
||||
} catch {
|
||||
// 即使后端失败也清除本地状态
|
||||
}
|
||||
|
||||
// 清除本地状态
|
||||
logout();
|
||||
|
||||
// 跳转到登录页
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
{/* 左侧 - Logo 和系统名称 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-shield w-4 h-4 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">财务AI助手</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 用户信息和登出 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted/50">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{user.full_name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{user.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: "工作台",
|
||||
href: "/dashboard",
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
label: "对账任务",
|
||||
href: "/tasks",
|
||||
icon: FileText
|
||||
},
|
||||
{
|
||||
label: "异常处理",
|
||||
href: "/exceptions",
|
||||
icon: AlertTriangle
|
||||
},
|
||||
{
|
||||
label: "规则设置",
|
||||
href: "/settings/rules",
|
||||
icon: Settings
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-background transition-all duration-300 flex flex-col",
|
||||
collapsed ? "w-16" : "w-56"
|
||||
)}
|
||||
>
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all relative group",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeNav"
|
||||
className="absolute inset-0 bg-primary/10 rounded-lg -z-10"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className={cn("w-5 h-5 shrink-0", isActive && "text-primary")} />
|
||||
{!collapsed && (
|
||||
<span className="text-body font-medium">{item.label}</span>
|
||||
)}
|
||||
|
||||
{/* Tooltip for collapsed state */}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full ml-2 px-2 py-1 bg-foreground text-background text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center justify-center h-10 border-t border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { AlertTriangle, CheckCircle, Info, Sparkles } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AISuggestionPanelProps {
|
||||
totalFields: number;
|
||||
highConfidence: number;
|
||||
mediumConfidence: number;
|
||||
lowConfidence: number;
|
||||
confirmedCount: number;
|
||||
onAutoConfirm?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const config = {
|
||||
success: {
|
||||
bg: "bg-emerald-50 dark:bg-emerald-950/30",
|
||||
border: "border-emerald-200 dark:border-emerald-800",
|
||||
iconBg: "bg-emerald-100 dark:bg-emerald-900/50",
|
||||
iconColor: "text-emerald-600 dark:text-emerald-400",
|
||||
titleColor: "text-emerald-800 dark:text-emerald-300",
|
||||
descColor: "text-emerald-700 dark:text-emerald-400",
|
||||
buttonBg: "bg-emerald-600 hover:bg-emerald-700",
|
||||
progressBg: "bg-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-amber-50 dark:bg-amber-950/30",
|
||||
border: "border-amber-200 dark:border-amber-800",
|
||||
iconBg: "bg-amber-100 dark:bg-amber-900/50",
|
||||
iconColor: "text-amber-600 dark:text-amber-400",
|
||||
titleColor: "text-amber-800 dark:text-amber-300",
|
||||
descColor: "text-amber-700 dark:text-amber-400",
|
||||
buttonBg: "bg-amber-600 hover:bg-amber-700",
|
||||
progressBg: "bg-amber-500",
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50 dark:bg-blue-950/30",
|
||||
border: "border-blue-200 dark:border-blue-800",
|
||||
iconBg: "bg-blue-100 dark:bg-blue-900/50",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
titleColor: "text-blue-800 dark:text-blue-300",
|
||||
descColor: "text-blue-700 dark:text-blue-400",
|
||||
buttonBg: "bg-blue-600 hover:bg-blue-700",
|
||||
progressBg: "bg-blue-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function AISuggestionPanel({
|
||||
totalFields,
|
||||
highConfidence,
|
||||
mediumConfidence,
|
||||
lowConfidence,
|
||||
confirmedCount,
|
||||
onAutoConfirm,
|
||||
className,
|
||||
}: AISuggestionPanelProps) {
|
||||
const pendingCount = totalFields - confirmedCount;
|
||||
const allConfirmed = pendingCount === 0;
|
||||
const progressPercent = totalFields > 0 ? (confirmedCount / totalFields) * 100 : 0;
|
||||
|
||||
const getSuggestion = () => {
|
||||
if (allConfirmed) {
|
||||
return {
|
||||
type: "success" as const,
|
||||
icon: CheckCircle,
|
||||
title: "所有字段已确认",
|
||||
description: "字段映射已完成,可以进入下一步",
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowConfidence > 0) {
|
||||
return {
|
||||
type: "warning" as const,
|
||||
icon: AlertTriangle,
|
||||
title: "建议检查低置信度字段",
|
||||
description: `有 ${lowConfidence} 个字段置信度较低(<70%),建议人工确认`,
|
||||
action: "检查低置信度字段",
|
||||
};
|
||||
}
|
||||
|
||||
if (mediumConfidence > 0) {
|
||||
return {
|
||||
type: "info" as const,
|
||||
icon: Info,
|
||||
title: "存在中等置信度字段",
|
||||
description: `有 ${mediumConfidence} 个字段置信度为中等(70%-90%),建议确认`,
|
||||
action: "批量确认",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "success" as const,
|
||||
icon: Sparkles,
|
||||
title: "可直接确认",
|
||||
description: "所有字段置信度较高,可以批量确认",
|
||||
action: "批量确认",
|
||||
};
|
||||
};
|
||||
|
||||
const suggestion = getSuggestion();
|
||||
const Icon = suggestion.icon;
|
||||
const style = config[suggestion.type];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<Card className={cn(style.bg, style.border, className)}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1, type: "spring", stiffness: 200 }}
|
||||
className={cn("p-2.5 rounded-xl", style.iconBg)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5", style.iconColor)} />
|
||||
</motion.div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className={cn("font-semibold text-base", style.titleColor)}>
|
||||
{suggestion.title}
|
||||
</h3>
|
||||
<p className={cn("text-sm mt-1", style.descColor)}>
|
||||
{suggestion.description}
|
||||
</p>
|
||||
|
||||
{suggestion.action && !allConfirmed && onAutoConfirm && (
|
||||
<Button
|
||||
onClick={onAutoConfirm}
|
||||
size="sm"
|
||||
className={cn("mt-3 text-white btn-press", style.buttonBg)}
|
||||
>
|
||||
{suggestion.action}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Stats */}
|
||||
<div className="text-right shrink-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-bold text-foreground"
|
||||
>
|
||||
{confirmedCount}
|
||||
<span className="text-lg text-muted-foreground">/{totalFields}</span>
|
||||
</motion.div>
|
||||
<p className="text-caption text-muted-foreground">已确认</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between text-caption text-muted-foreground mb-1.5">
|
||||
<span>确认进度</span>
|
||||
<span>{Math.round(progressPercent)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progressPercent}%` }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className={cn("h-full rounded-full", style.progressBg)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence Distribution */}
|
||||
<div className="mt-5 pt-4 border-t border-border/50">
|
||||
<p className="text-caption text-muted-foreground mb-3">置信度分布</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
高置信度: <span className="font-semibold text-foreground">{highConfidence}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
中置信度: <span className="font-semibold text-foreground">{mediumConfidence}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
低置信度: <span className="font-semibold text-foreground">{lowConfidence}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AISuggestionPanel;
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfidenceBadgeProps {
|
||||
confidence: number;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfidenceBadge({
|
||||
confidence,
|
||||
showLabel = true,
|
||||
className,
|
||||
}: ConfidenceBadgeProps) {
|
||||
const getLevel = (value: number) => {
|
||||
if (value > 0.9) return "high";
|
||||
if (value >= 0.7) return "medium";
|
||||
return "low";
|
||||
};
|
||||
|
||||
const getLabel = (value: number) => {
|
||||
if (value > 0.9) return "高";
|
||||
if (value >= 0.7) return "中";
|
||||
return "低";
|
||||
};
|
||||
|
||||
const level = getLevel(confidence);
|
||||
const label = getLabel(confidence);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
level === "high" && "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||
level === "medium" && "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
|
||||
level === "low" && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showLabel ? label : `${(confidence * 100).toFixed(0)}%`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfidenceBadge;
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { ChevronDown, ChevronRight, Eye, Check } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||
import type { FieldMappingResponse } from "@/lib/api/types";
|
||||
|
||||
interface FieldMappingTableProps {
|
||||
mappings: FieldMappingResponse[];
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
onUpdate?: (mappingId: number, standardField: string) => void;
|
||||
onSkip?: (mappingId: number) => void;
|
||||
onConfirm?: (mappingId: number) => void;
|
||||
onConfirmAll?: (mappingIds: number[]) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const STANDARD_FIELDS = [
|
||||
{ value: "员工姓名", label: "员工姓名" },
|
||||
{ value: "工号", label: "工号" },
|
||||
{ value: "部门", label: "部门" },
|
||||
{ value: "岗位", label: "岗位" },
|
||||
{ value: "基本工资", label: "基本工资" },
|
||||
{ value: "奖金", label: "奖金" },
|
||||
{ value: "补贴", label: "补贴" },
|
||||
{ value: "加班费", label: "加班费" },
|
||||
{ value: "扣款", label: "扣款" },
|
||||
{ value: "应发工资", label: "应发工资" },
|
||||
{ value: "实发工资", label: "实发工资" },
|
||||
{ value: "银行账号", label: "银行账号" },
|
||||
{ value: "身份证号", label: "身份证号" },
|
||||
{ value: "社保基数", label: "社保基数" },
|
||||
{ value: "养老保险", label: "养老保险" },
|
||||
{ value: "医疗保险", label: "医疗保险" },
|
||||
{ value: "失业保险", label: "失业保险" },
|
||||
{ value: "公积金", label: "公积金" },
|
||||
{ value: "个税", label: "个税" },
|
||||
{ value: "应税收入", label: "应税收入" },
|
||||
{ value: "税后收入", label: "税后收入" },
|
||||
];
|
||||
|
||||
export function FieldMappingTable({
|
||||
mappings,
|
||||
fileName,
|
||||
fileType,
|
||||
onUpdate,
|
||||
onSkip,
|
||||
onConfirm,
|
||||
onConfirmAll,
|
||||
readOnly = false,
|
||||
}: FieldMappingTableProps) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
const newExpanded = new Set(expandedRows);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpandedRows(newExpanded);
|
||||
};
|
||||
|
||||
const startEdit = (id: number, currentValue: string) => {
|
||||
setEditingId(id);
|
||||
setEditValue(currentValue);
|
||||
};
|
||||
|
||||
const saveEdit = (id: number) => {
|
||||
if (onUpdate && editValue) {
|
||||
onUpdate(id, editValue);
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const handleConfirmAll = () => {
|
||||
if (onConfirmAll) {
|
||||
const unconfirmedIds = mappings
|
||||
.filter((m) => !m.confirmed && !m.is_skipped && m.standard_field)
|
||||
.map((m) => m.id);
|
||||
onConfirmAll(unconfirmedIds);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmedCount = mappings.filter((m) => m.confirmed).length;
|
||||
const unconfirmedCount = mappings.length - confirmedCount;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="bg-muted/50 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{fileName}</div>
|
||||
<Badge variant="secondary">{fileType}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
已确认: <span className="font-semibold text-emerald-600">{confirmedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
待确认: <span className="font-semibold text-amber-600">{unconfirmedCount}</span>
|
||||
</span>
|
||||
{!readOnly && unconfirmedCount > 0 && (
|
||||
<Button onClick={handleConfirmAll} size="sm" className="btn-press">
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
批量确认
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground w-10" />
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
源字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
标准字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground w-24">
|
||||
置信度
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground w-20">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground w-36">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{mappings.map((mapping, index) => (
|
||||
<motion.tr
|
||||
key={mapping.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className={cn(
|
||||
"group hover:bg-muted/30 transition-colors",
|
||||
mapping.is_skipped && "opacity-50",
|
||||
mapping.confirmed && "bg-emerald-50/30 dark:bg-emerald-950/10"
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleExpand(mapping.id)}
|
||||
className="p-1 hover:bg-muted rounded-md transition-colors"
|
||||
>
|
||||
{expandedRows.has(mapping.id) ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{mapping.source_field}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === mapping.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 border border-input rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">选择标准字段</option>
|
||||
{STANDARD_FIELDS.map((field) => (
|
||||
<option key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => saveEdit(mapping.id)}
|
||||
className="h-7 w-7 p-0 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : mapping.standard_field ? (
|
||||
<span className="text-foreground">{mapping.standard_field}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">未识别</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<ConfidenceBadge confidence={mapping.confidence} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{mapping.is_skipped ? (
|
||||
<Badge variant="secondary" className="text-xs">已跳过</Badge>
|
||||
) : mapping.confirmed ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-xs font-medium">
|
||||
已确认
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">待确认</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!readOnly && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-end gap-1"
|
||||
>
|
||||
{editingId !== mapping.id && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(mapping.id, mapping.standard_field)}
|
||||
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/30"
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
{!mapping.confirmed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onConfirm?.(mapping.id)}
|
||||
className="h-7 px-2 text-xs text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSkip?.(mapping.id)}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
跳过
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{mappings.length === 0 && (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="text-muted-foreground">暂无字段映射数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
<AnimatePresence>
|
||||
{Array.from(expandedRows).map((id) => {
|
||||
const mapping = mappings.find((m) => m.id === id);
|
||||
if (!mapping || !mapping.sample_values) return null;
|
||||
return (
|
||||
<motion.div
|
||||
key={`detail-${id}`}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-4 bg-muted/30 border-t border-border">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
{mapping.reasoning && (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-muted-foreground">判断依据: </span>
|
||||
<span className="text-foreground">{mapping.reasoning}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">样例值: </span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
{(mapping.sample_values as unknown as string[]).slice(0, 5).map((value, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-background border border-border rounded-md text-xs text-foreground"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-muted-foreground" />
|
||||
{String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldMappingTable;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 主题提供者组件
|
||||
* 由于 next-themes 在 React 19 下有兼容性问题,暂时使用简化的实现
|
||||
* 仅传递 children,不进行主题切换
|
||||
*/
|
||||
export function ThemeProvider({ children, ..._props }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// SSR 时返回 children,避免 hydration mismatch
|
||||
if (!mounted) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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-full 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 hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground 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 }
|
||||
@@ -0,0 +1,56 @@
|
||||
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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 hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
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 }
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const cardVariants = cva(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
destructive: "border-red-200 bg-red-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof cardVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant }), 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<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
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 }
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
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,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background 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-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,160 @@
|
||||
"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-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 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-96 min-w-[8rem] overflow-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",
|
||||
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("py-1.5 pl-8 pr-2 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-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">
|
||||
<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,
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", 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,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
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-10 items-center justify-center rounded-md 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-sm px-3 py-1.5 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-sm",
|
||||
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 }
|
||||
Reference in New Issue
Block a user