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,624 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, CheckCircle, Loader2, FileSpreadsheet } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { ENDPOINTS } from "@/lib/api/endpoints";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FieldMappingTable } from "@/components/mapping/FieldMappingTable";
|
||||
import { AISuggestionPanel } from "@/components/mapping/AISuggestionPanel";
|
||||
import type { TaskMappingOverview } from "@/lib/api/types";
|
||||
|
||||
// 模拟数据(实际应从 API 获取)
|
||||
const mockMappingData: TaskMappingOverview = {
|
||||
task_id: 1,
|
||||
total_files: 3,
|
||||
total_fields: 18,
|
||||
confirmed_fields: 5,
|
||||
needs_review: true,
|
||||
files: [
|
||||
{
|
||||
file_id: 1,
|
||||
file_type: "工资表",
|
||||
file_name: "2024年1月工资表.xlsx",
|
||||
total_fields: 6,
|
||||
confirmed_fields: 2,
|
||||
high_confidence: 4,
|
||||
medium_confidence: 1,
|
||||
low_confidence: 1,
|
||||
mappings: [
|
||||
{
|
||||
id: 1,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "员工姓名",
|
||||
standard_field: "员工姓名",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: ["张三", "李四", "王五"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
confirmed_by: 1,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "工号",
|
||||
standard_field: "工号",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: ["EMP001", "EMP002"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
confirmed_by: 1,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "基本薪资",
|
||||
standard_field: "基本工资",
|
||||
confidence: 0.85,
|
||||
reasoning: "字段名相似,样例值验证为数值类型",
|
||||
sample_values: [8000, 10000, 12000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "岗位津贴",
|
||||
standard_field: "补贴",
|
||||
confidence: 0.75,
|
||||
reasoning: "字段名包含关键词:'津贴'",
|
||||
sample_values: [500, 800, 1000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "实发合计",
|
||||
standard_field: "实发工资",
|
||||
confidence: 0.6,
|
||||
reasoning: "字段名部分匹配,需要确认",
|
||||
sample_values: [8500, 10800, 13000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "备注",
|
||||
standard_field: "",
|
||||
confidence: 0.0,
|
||||
reasoning: "无法识别字段类型",
|
||||
sample_values: ["正常", "补发"],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file_id: 2,
|
||||
file_type: "社保表",
|
||||
file_name: "2024年1月社保表.xlsx",
|
||||
total_fields: 6,
|
||||
confirmed_fields: 2,
|
||||
high_confidence: 3,
|
||||
medium_confidence: 2,
|
||||
low_confidence: 1,
|
||||
mappings: [
|
||||
{
|
||||
id: 7,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "姓名",
|
||||
standard_field: "员工姓名",
|
||||
confidence: 0.95,
|
||||
reasoning: "字段名包含关键词:'姓名'",
|
||||
sample_values: ["张三", "李四"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "养老保险",
|
||||
standard_field: "养老保险",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: [800, 960, 1200],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "医疗",
|
||||
standard_field: "医疗保险",
|
||||
confidence: 0.8,
|
||||
reasoning: "字段名部分匹配",
|
||||
sample_values: [200, 240, 300],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "失业",
|
||||
standard_field: "失业保险",
|
||||
confidence: 0.75,
|
||||
reasoning: "字段名相似",
|
||||
sample_values: [50, 60, 75],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "公积金",
|
||||
standard_field: "公积金",
|
||||
confidence: 0.5,
|
||||
reasoning: "需要确认具体含义",
|
||||
sample_values: [500, 600, 750],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "合计",
|
||||
standard_field: "社保合计",
|
||||
confidence: 0.6,
|
||||
reasoning: "可能是合计字段",
|
||||
sample_values: [1550, 1800, 2325],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file_id: 3,
|
||||
file_type: "个税表",
|
||||
file_name: "2024年1月个税表.xlsx",
|
||||
total_fields: 6,
|
||||
confirmed_fields: 1,
|
||||
high_confidence: 2,
|
||||
medium_confidence: 3,
|
||||
low_confidence: 1,
|
||||
mappings: [
|
||||
{
|
||||
id: 13,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "姓名",
|
||||
standard_field: "员工姓名",
|
||||
confidence: 0.95,
|
||||
reasoning: "字段名包含关键词:'姓名'",
|
||||
sample_values: ["张三", "李四"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "应税工资",
|
||||
standard_field: "应税收入",
|
||||
confidence: 0.9,
|
||||
reasoning: "字段名包含关键词:'应税'",
|
||||
sample_values: [8000, 10000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "专项扣除",
|
||||
standard_field: "税前扣除",
|
||||
confidence: 0.8,
|
||||
reasoning: "可能为税前扣除项",
|
||||
sample_values: [1000, 1500],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "个税",
|
||||
standard_field: "应缴个税",
|
||||
confidence: 0.7,
|
||||
reasoning: "可能为个税字段",
|
||||
sample_values: [200, 300],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "税后工资",
|
||||
standard_field: "税后收入",
|
||||
confidence: 0.85,
|
||||
reasoning: "字段名相似",
|
||||
sample_values: [6800, 8500],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "银行账号",
|
||||
standard_field: "银行账号",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: ["6222021234567890"],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.08 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
export default function MappingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const taskId = params.id as string;
|
||||
|
||||
const [data, setData] = useState<TaskMappingOverview | null>(mockMappingData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeFile, setActiveFile] = useState<number>(mockMappingData?.files[0]?.file_id || 1);
|
||||
|
||||
// 统计
|
||||
const totalHigh = data?.files.reduce((acc, f) => acc + f.high_confidence, 0) || 0;
|
||||
const totalMedium = data?.files.reduce((acc, f) => acc + f.medium_confidence, 0) || 0;
|
||||
const totalLow = data?.files.reduce((acc, f) => acc + f.low_confidence, 0) || 0;
|
||||
|
||||
// 更新映射
|
||||
const handleUpdateMapping = useCallback(
|
||||
async (mappingId: number, standardField: string) => {
|
||||
try {
|
||||
await api.put(ENDPOINTS.MAPPING.UPDATE(mappingId), {
|
||||
standard_field: standardField,
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => ({
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
m.id === mappingId ? { ...m, standard_field: standardField, confidence: 1.0 } : m
|
||||
),
|
||||
}));
|
||||
return { ...prev, files: newFiles };
|
||||
});
|
||||
|
||||
toast.success("字段已更新");
|
||||
} catch {
|
||||
toast.error("更新失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 跳过映射
|
||||
const handleSkipMapping = useCallback(
|
||||
async (mappingId: number) => {
|
||||
try {
|
||||
await api.put(ENDPOINTS.MAPPING.UPDATE(mappingId), {
|
||||
is_skipped: true,
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => ({
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
m.id === mappingId ? { ...m, is_skipped: true } : m
|
||||
),
|
||||
}));
|
||||
return { ...prev, files: newFiles };
|
||||
});
|
||||
|
||||
toast.success("字段已跳过");
|
||||
} catch {
|
||||
toast.error("操作失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 确认单个映射
|
||||
const handleConfirmMapping = useCallback(
|
||||
async (mappingId: number) => {
|
||||
try {
|
||||
await api.post(ENDPOINTS.MAPPING.CONFIRM, {
|
||||
mapping_ids: [mappingId],
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => ({
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
m.id === mappingId
|
||||
? { ...m, confirmed: true, confirmed_at: new Date().toISOString() }
|
||||
: m
|
||||
),
|
||||
confirmed_fields: file.mappings.filter(
|
||||
(m) => m.id === mappingId || m.confirmed
|
||||
).length,
|
||||
}));
|
||||
const totalConfirmed = newFiles.reduce((acc, f) => acc + f.confirmed_fields, 0);
|
||||
return { ...prev, files: newFiles, confirmed_fields: totalConfirmed };
|
||||
});
|
||||
|
||||
toast.success("字段已确认");
|
||||
} catch {
|
||||
toast.error("确认失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 批量确认
|
||||
const handleConfirmAll = useCallback(
|
||||
async (mappingIds: number[]) => {
|
||||
if (mappingIds.length === 0) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.post(ENDPOINTS.MAPPING.CONFIRM, {
|
||||
mapping_ids: mappingIds,
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => {
|
||||
const fileIds = file.mappings.map((m) => m.id);
|
||||
const confirmedIds = mappingIds.filter((id) => fileIds.includes(id));
|
||||
return {
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
confirmedIds.includes(m.id)
|
||||
? { ...m, confirmed: true, confirmed_at: new Date().toISOString() }
|
||||
: m
|
||||
),
|
||||
confirmed_fields: file.mappings.filter(
|
||||
(m) => m.confirmed || confirmedIds.includes(m.id)
|
||||
).length,
|
||||
};
|
||||
});
|
||||
const totalConfirmed = newFiles.reduce((acc, f) => acc + f.confirmed_fields, 0);
|
||||
return { ...prev, files: newFiles, confirmed_fields: totalConfirmed };
|
||||
});
|
||||
|
||||
toast.success(`已确认 ${mappingIds.length} 个字段`);
|
||||
} catch {
|
||||
toast.error("批量确认失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 确认并进入下一步
|
||||
const handleConfirmAndNext = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
toast.success("字段映射已确认,正在进入对账...");
|
||||
router.push(`/tasks/${taskId}/reconciliation`);
|
||||
} catch {
|
||||
toast.error("操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId, router, toast]);
|
||||
|
||||
const currentFile = data?.files.find((f) => f.file_id === activeFile);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard")} className="hover:bg-muted">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-heading-1 text-foreground">字段映射确认</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
任务 #{taskId} - 确认 AI 识别的字段映射关系
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleConfirmAndNext} disabled={loading} className="btn-press">
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
确认并进入对账
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* AI 建议面板 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<AISuggestionPanel
|
||||
totalFields={data.total_fields}
|
||||
highConfidence={totalHigh}
|
||||
mediumConfidence={totalMedium}
|
||||
lowConfidence={totalLow}
|
||||
confirmedCount={data.confirmed_fields}
|
||||
onAutoConfirm={() => {
|
||||
const ids = data.files
|
||||
.flatMap((f) => f.mappings)
|
||||
.filter((m) => !m.confirmed && !m.is_skipped && m.standard_field)
|
||||
.map((m) => m.id);
|
||||
handleConfirmAll(ids);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 文件标签页 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div className="flex gap-2 border-b border-border overflow-x-auto">
|
||||
{data.files.map((file) => (
|
||||
<button
|
||||
key={file.file_id}
|
||||
onClick={() => setActiveFile(file.file_id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors ${
|
||||
activeFile === file.file_id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
{file.file_type}
|
||||
<Badge variant={activeFile === file.file_id ? "default" : "secondary"} className="ml-1 text-xs">
|
||||
{file.confirmed_fields}/{file.total_fields}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 字段映射表格 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{currentFile && (
|
||||
<FieldMappingTable
|
||||
mappings={currentFile.mappings}
|
||||
fileName={currentFile.file_name}
|
||||
fileType={currentFile.file_type}
|
||||
onUpdate={handleUpdateMapping}
|
||||
onSkip={handleSkipMapping}
|
||||
onConfirm={handleConfirmMapping}
|
||||
onConfirmAll={handleConfirmAll}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 文件概览卡片 */}
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{data.files.map((file) => (
|
||||
<motion.div key={file.file_id} variants={item}>
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover-lift ${
|
||||
activeFile === file.file_id
|
||||
? "ring-2 ring-primary"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveFile(file.file_id)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<FileSpreadsheet className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{file.file_type}
|
||||
</h3>
|
||||
<p className="text-caption text-muted-foreground truncate">
|
||||
{file.file_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption text-muted-foreground">
|
||||
已确认 {file.confirmed_fields}/{file.total_fields}
|
||||
</span>
|
||||
{file.low_confidence > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{file.low_confidence} 个待检查
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* 置信度进度条 */}
|
||||
<div className="mt-3 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(file.confirmed_fields / file.total_fields) * 100}%` }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="h-full bg-primary rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user