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,397 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
|
||||
interface TaskStats {
|
||||
monthly_tasks: number;
|
||||
pending_exceptions: number;
|
||||
completed_tasks: number;
|
||||
match_rate: number;
|
||||
current_period: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
period: string;
|
||||
status: string;
|
||||
total_employees: number;
|
||||
matched_count: number;
|
||||
exception_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
PENDING: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||||
PROCESSING: { label: "处理中", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" },
|
||||
MAPPING_COMPLETED: { label: "映射完成", color: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300" },
|
||||
COMPLETED: { label: "已完成", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300" },
|
||||
FAILED: { label: "失败", color: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300" },
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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 DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState<TaskStats | null>(null);
|
||||
const [recentTasks, setRecentTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadDashboardData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 并行获取统计数据和任务列表
|
||||
const [statsRes, tasksRes] = await Promise.all([
|
||||
api.get<TaskStats>(ENDPOINTS.TASK.STATS),
|
||||
api.get<Task[]>(ENDPOINTS.TASK.LIST, { params: { limit: 5 } }),
|
||||
]);
|
||||
|
||||
setStats(statsRes.data);
|
||||
setRecentTasks(tasksRes.data);
|
||||
} catch (error) {
|
||||
console.error("加载数据失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, [loadDashboardData]);
|
||||
|
||||
const formatPeriod = (period: string) => {
|
||||
const [year, month] = period.split("-");
|
||||
return `${year}年${parseInt(month)}月`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h1 className="text-heading-1 text-foreground mb-2">工作台</h1>
|
||||
<p className="text-muted-foreground text-body">
|
||||
持续追踪您的对账任务
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="relative overflow-hidden hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||||
本月任务
|
||||
</p>
|
||||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
{stats?.monthly_tasks ?? 0}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-caption text-muted-foreground">
|
||||
{stats?.current_period ? formatPeriod(stats.current_period) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-blue-50 dark:bg-blue-950/30 opacity-50" />
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="relative overflow-hidden hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||||
待处理异常
|
||||
</p>
|
||||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
{stats?.pending_exceptions ?? 0}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-500" />
|
||||
<span className="text-caption text-muted-foreground">
|
||||
需要人工处理
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-amber-50 dark:bg-amber-950/30 opacity-50" />
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="relative overflow-hidden hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||||
已完成
|
||||
</p>
|
||||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
{stats?.completed_tasks ?? 0}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-caption text-muted-foreground">
|
||||
本月累计
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-emerald-50 dark:bg-emerald-950/30 opacity-50" />
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="relative overflow-hidden hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||||
匹配率
|
||||
</p>
|
||||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
{stats?.match_rate ?? 0}%
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<TrendingUp className="w-3 h-3 text-violet-500" />
|
||||
<span className="text-caption text-muted-foreground">
|
||||
本月平均
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
|
||||
<TrendingUp className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-violet-50 dark:bg-violet-950/30 opacity-50" />
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Recent Tasks */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-heading-3 text-foreground">最近任务</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/tasks")}
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recentTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
|
||||
<FileText className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-body text-muted-foreground mb-2">
|
||||
暂无对账任务
|
||||
</p>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
创建您的第一个对账任务开始使用
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 btn-press"
|
||||
onClick={() => router.push("/tasks/new")}
|
||||
>
|
||||
新建任务
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTasks.map((task, index) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.1 + index * 0.05,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="cursor-pointer hover-lift transition-all"
|
||||
onClick={() => router.push(`/tasks/${task.id}/result`)}
|
||||
>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">
|
||||
{formatPeriod(task.period)} 对账任务
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusLabels[task.status]?.color || statusLabels.PENDING.color}`}>
|
||||
{statusLabels[task.status]?.label || task.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{task.total_employees} 名员工 · {task.matched_count} 已匹配 · {task.exception_count} 异常
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<h2 className="text-heading-3 text-foreground mb-4">快捷操作</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card
|
||||
className="group cursor-pointer hover-lift"
|
||||
onClick={() => router.push("/tasks/new")}
|
||||
>
|
||||
<CardContent className="p-5 flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
|
||||
<FileText className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground mb-1 group-hover:text-primary transition-colors">
|
||||
新建对账任务
|
||||
</h3>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
上传工资表、社保表进行对账
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all self-center" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="group cursor-pointer hover-lift"
|
||||
onClick={() => router.push("/exceptions")}
|
||||
>
|
||||
<CardContent className="p-5 flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-amber-500/10 transition-colors">
|
||||
<AlertTriangle className="w-5 h-5 text-muted-foreground group-hover:text-amber-500 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground mb-1 group-hover:text-amber-500 transition-colors">
|
||||
查看异常
|
||||
</h3>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
处理待解决的异常记录
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-amber-500 group-hover:translate-x-1 transition-all self-center" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="group cursor-pointer hover-lift"
|
||||
onClick={() => router.push("/settings/rules")}
|
||||
>
|
||||
<CardContent className="p-5 flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-violet-500/10 transition-colors">
|
||||
<TrendingUp className="w-5 h-5 text-muted-foreground group-hover:text-violet-500 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground mb-1 group-hover:text-violet-500 transition-colors">
|
||||
对账规则
|
||||
</h3>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
配置对账规则和映射关系
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-violet-500 group-hover:translate-x-1 transition-all self-center" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
Search,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
task_id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: "pending" | "processing" | "resolved" | "ignored";
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
description: string;
|
||||
detail: string | null;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
bank_amount: number | null;
|
||||
difference_amount: number | null;
|
||||
resolution: string | null;
|
||||
handler: string | null;
|
||||
note: string | null;
|
||||
suggested_action: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
by_status: Record<string, number>;
|
||||
by_severity: Record<string, number>;
|
||||
by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
low: { label: "低", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", icon: "bg-blue-500" },
|
||||
medium: { label: "中", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", icon: "bg-yellow-500" },
|
||||
high: { label: "高", color: "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300", icon: "bg-orange-500" },
|
||||
critical: { label: "严重", color: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", icon: "bg-red-500" },
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
pending: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||||
processing: { label: "处理中", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300" },
|
||||
confirmed: { label: "已确认", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
||||
resolved: { label: "已解决", color: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" },
|
||||
ignored: { label: "已忽略", color: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500" },
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
amount_mismatch: "金额不一致",
|
||||
missing_record: "记录缺失",
|
||||
duplicate_record: "重复记录",
|
||||
format_error: "格式错误",
|
||||
logic_error: "逻辑错误",
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
function ExceptionsContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||||
const [status, setStatus] = useState(searchParams.get("status") || "");
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
|
||||
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId, status, severity, exceptionType, page]);
|
||||
|
||||
async function fetchExceptions() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ items: ExceptionItem[]; total: number }>("/api/exceptions/", {
|
||||
params: {
|
||||
task_id: taskId || undefined,
|
||||
status: status || undefined,
|
||||
severity: severity || undefined,
|
||||
exception_type: exceptionType || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
});
|
||||
setExceptions(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("加载异常列表失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSummary() {
|
||||
try {
|
||||
const res = await api.get<Summary>("/api/exceptions/summary", {
|
||||
params: { task_id: taskId || undefined },
|
||||
});
|
||||
setSummary(res.data);
|
||||
} catch (error) {
|
||||
console.error("加载汇总失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
setPage(1);
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}
|
||||
|
||||
function viewDetail(exception: ExceptionItem) {
|
||||
setSelectedException(exception);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, newStatus: string) {
|
||||
try {
|
||||
await api.patch(`/api/exceptions/${id}`, { status: newStatus });
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
} catch (error) {
|
||||
console.error("更新状态失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-heading-1 text-foreground">异常处理</h1>
|
||||
<p className="text-muted-foreground mt-1">管理和处理对账过程中发现的异常</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">待处理</p>
|
||||
<p className="text-2xl font-semibold mt-1">{summary.by_status?.pending || 0}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-gray-100 dark:bg-gray-800">
|
||||
<Clock className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">处理中</p>
|
||||
<p className="text-2xl font-semibold mt-1 text-amber-600">{summary.by_status?.processing || 0}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">已解决</p>
|
||||
<p className="text-2xl font-semibold mt-1 text-emerald-600">{summary.by_status?.resolved || 0}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">严重异常</p>
|
||||
<p className="text-2xl font-semibold mt-1 text-red-600">{summary.by_severity?.critical || 0}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-red-50 dark:bg-red-950/30">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Filter Card */}
|
||||
<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] }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">任务ID</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="输入任务ID"
|
||||
value={taskId}
|
||||
onChange={(e) => setTaskId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[150px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">状态</label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="resolved">已解决</SelectItem>
|
||||
<SelectItem value="ignored">已忽略</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[150px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">严重程度</label>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部程度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部程度</SelectItem>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="critical">严重</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[180px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">异常类型</label>
|
||||
<Select value={exceptionType} onValueChange={setExceptionType}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部类型</SelectItem>
|
||||
<SelectItem value="amount_mismatch">金额不一致</SelectItem>
|
||||
<SelectItem value="missing_record">记录缺失</SelectItem>
|
||||
<SelectItem value="duplicate_record">重复记录</SelectItem>
|
||||
<SelectItem value="format_error">格式错误</SelectItem>
|
||||
<SelectItem value="logic_error">逻辑错误</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSearch} className="h-9 btn-press">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Exceptions Table */}
|
||||
<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] }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">异常列表</CardTitle>
|
||||
<Badge variant="secondary">{total} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>严重程度</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">差异金额</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
加载中...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : exceptions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-foreground font-medium">暂无异常记录</p>
|
||||
<p className="text-caption text-muted-foreground mt-1">所有记录都匹配成功</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
exceptions.map((exception, index) => (
|
||||
<motion.div
|
||||
key={exception.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
||||
>
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<Badge className={statusConfig[exception.status]?.color || "bg-gray-100 text-gray-700"}>
|
||||
{statusConfig[exception.status]?.label || exception.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exception.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{exception.employee_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exception.exception_type] || exception.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${severityConfig[exception.severity]?.icon || "bg-gray-500"}`} />
|
||||
<Badge className={severityConfig[exception.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||||
{severityConfig[exception.severity]?.label || exception.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<span className="truncate block">{exception.description}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{exception.difference_amount != null && (
|
||||
<span className={exception.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
||||
{exception.difference_amount > 0 ? "+" : ""}
|
||||
{exception.difference_amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="sm" onClick={() => viewDetail(exception)} className="h-8 w-8 p-0">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{exception.status === "pending" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => updateStatus(exception.id, "processing")} className="h-8 px-2">
|
||||
处理
|
||||
</Button>
|
||||
)}
|
||||
{exception.status === "processing" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => updateStatus(exception.id, "resolved")} className="h-8 px-2">
|
||||
完成
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
第 {page} 页,共 {totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="btn-press"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="btn-press"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>异常详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedException && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工姓名</p>
|
||||
<p className="font-medium">{selectedException.employee_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工ID</p>
|
||||
<p className="font-medium">{selectedException.employee_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">异常类型</p>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${severityConfig[selectedException.severity]?.icon || "bg-gray-500"}`} />
|
||||
<Badge className={severityConfig[selectedException.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||||
{severityConfig[selectedException.severity]?.label || selectedException.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-3">金额详情</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{selectedException.salary_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">工资</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.salary_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.social_security_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">社保</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.social_security_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.tax_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">个税</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.tax_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.bank_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">银行</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.bank_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedException.difference_amount != null && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">差异金额</p>
|
||||
<p className={`text-2xl font-mono font-semibold mt-1 ${selectedException.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{selectedException.difference_amount > 0 ? "+" : ""}
|
||||
{selectedException.difference_amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">描述</p>
|
||||
<p>{selectedException.description}</p>
|
||||
</div>
|
||||
{selectedException.detail && (
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">详细信息</p>
|
||||
<pre className="text-xs bg-muted p-3 rounded-lg overflow-auto">
|
||||
{JSON.stringify(JSON.parse(selectedException.detail), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.suggested_action && (
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">建议操作</p>
|
||||
<p>{selectedException.suggested_action}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExceptionsPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<ExceptionsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-[calc(100vh-3.5rem)]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { ENDPOINTS } from "@/lib/api/endpoints";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import type { CompanyRuleResponse } from "@/lib/api/types";
|
||||
|
||||
// 模拟数据
|
||||
const mockRules: CompanyRuleResponse[] = [
|
||||
{
|
||||
id: 1,
|
||||
company_id: 1,
|
||||
rule_type: "FIELD_MAPPING",
|
||||
match_condition: { source_field: "员工姓名" },
|
||||
target_value: "员工姓名",
|
||||
priority: 0,
|
||||
status: "ACTIVE",
|
||||
description: "工资表员工姓名字段映射",
|
||||
match_count: 15,
|
||||
last_used_at: "2024-01-15T10:30:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
company_id: 1,
|
||||
rule_type: "FIELD_MAPPING",
|
||||
match_condition: { source_field: "基本薪资" },
|
||||
target_value: "基本工资",
|
||||
priority: 0,
|
||||
status: "ACTIVE",
|
||||
description: "工资表基本薪资字段映射",
|
||||
match_count: 12,
|
||||
last_used_at: "2024-01-15T10:30:00Z",
|
||||
created_at: "2024-01-05T00:00:00Z",
|
||||
updated_at: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
company_id: 1,
|
||||
rule_type: "FIELD_MAPPING",
|
||||
match_condition: { source_field: "实发合计" },
|
||||
target_value: "实发工资",
|
||||
priority: 1,
|
||||
status: "INACTIVE",
|
||||
description: "工资表实发合计字段映射(已停用)",
|
||||
match_count: 8,
|
||||
last_used_at: "2024-01-10T00:00:00Z",
|
||||
created_at: "2024-01-08T00:00:00Z",
|
||||
updated_at: "2024-01-12T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const RULE_TYPE_LABELS: Record<string, string> = {
|
||||
FIELD_MAPPING: "字段映射",
|
||||
ACCOUNT_MAPPING: "科目映射",
|
||||
DEPARTMENT_MAPPING: "部门映射",
|
||||
};
|
||||
|
||||
export default function RulesPage() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const [rules, setRules] = useState<CompanyRuleResponse[]>(mockRules);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
|
||||
// 过滤规则
|
||||
const filteredRules = rules.filter((rule) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
rule.match_condition.source_field?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
rule.target_value.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
rule.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter || rule.rule_type === typeFilter;
|
||||
const matchesStatus = !statusFilter || rule.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
// 切换规则状态
|
||||
const handleToggleStatus = useCallback(
|
||||
async (ruleId: number, currentStatus: string) => {
|
||||
const newStatus = currentStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
|
||||
try {
|
||||
await api.patch(ENDPOINTS.MAPPING.RULE_STATUS(ruleId), {
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
setRules((prev) =>
|
||||
prev.map((rule) =>
|
||||
rule.id === ruleId ? { ...rule, status: newStatus } : rule
|
||||
)
|
||||
);
|
||||
|
||||
toast.success(`规则已${newStatus === "ACTIVE" ? "启用" : "停用"}`);
|
||||
} catch {
|
||||
toast.error("状态更新失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 删除规则
|
||||
const handleDeleteRule = useCallback(
|
||||
async (ruleId: number) => {
|
||||
if (!confirm("确定要删除这条规则吗?")) return;
|
||||
|
||||
try {
|
||||
await api.delete(ENDPOINTS.MAPPING.RULE_DELETE(ruleId));
|
||||
|
||||
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
|
||||
toast.success("规则已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
const activeCount = rules.filter((r) => r.status === "ACTIVE").length;
|
||||
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
规则管理
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
管理企业字段映射规则,实现自动识别
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{rules.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">总规则数</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">启用中</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-gray-400">{inactiveCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">已停用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索规则..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="FIELD_MAPPING">字段映射</option>
|
||||
<option value="ACCOUNT_MAPPING">科目映射</option>
|
||||
<option value="DEPARTMENT_MAPPING">部门映射</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">启用</option>
|
||||
<option value="INACTIVE">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
规则类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
匹配条件
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
目标字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
匹配次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
最近使用
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{filteredRules.map((rule) => (
|
||||
<tr
|
||||
key={rule.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-xs">
|
||||
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{rule.target_value}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
|
||||
{rule.match_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
rule.status === "ACTIVE"
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? "启用" : "停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
|
||||
{rule.last_used_at
|
||||
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(rule.id, rule.status)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
rule.status === "ACTIVE"
|
||||
? "text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30"
|
||||
: "text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
title={rule.status === "ACTIVE" ? "停用" : "启用"}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredRules.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
暂无规则数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="font-medium text-blue-800 dark:text-blue-300 mb-2">
|
||||
规则说明
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<li>• 字段映射规则会在上传同类文件时自动应用</li>
|
||||
<li>• 精确匹配的规则(完全相同的字段名)会优先于模糊匹配</li>
|
||||
<li>• 停用规则不会在自动识别中应用,但不会删除</li>
|
||||
<li>• 删除规则后,下次上传相同格式文件需要重新确认映射</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
Users,
|
||||
AlertOctagon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TaskDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
period: string;
|
||||
status: string;
|
||||
total_employees: number;
|
||||
matched_count: number;
|
||||
exception_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface ReconciliationResult {
|
||||
task_id: number;
|
||||
period: string;
|
||||
total_employees: number;
|
||||
matched_employees: number;
|
||||
exception_count: number;
|
||||
exceptions_by_type: Record<string, number>;
|
||||
exceptions_by_severity: Record<string, number>;
|
||||
execution_time_ms: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: string;
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
description: string;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
bank_amount: number | null;
|
||||
difference_amount: number | null;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
low: { label: "低", bg: "bg-blue-100 dark:bg-blue-900/40", text: "text-blue-700 dark:text-blue-300" },
|
||||
medium: { label: "中", bg: "bg-yellow-100 dark:bg-yellow-900/40", text: "text-yellow-700 dark:text-yellow-300" },
|
||||
high: { label: "高", bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300" },
|
||||
critical: { label: "严重", bg: "bg-red-100 dark:bg-red-900/40", text: "text-red-700 dark:text-red-300" },
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
amount_mismatch: "金额不一致",
|
||||
missing_record: "记录缺失",
|
||||
duplicate_record: "重复记录",
|
||||
format_error: "格式错误",
|
||||
logic_error: "逻辑错误",
|
||||
rule_violation: "规则违规",
|
||||
threshold_exceeded: "阈值超限",
|
||||
};
|
||||
|
||||
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: taskId } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [result, setResult] = useState<ReconciliationResult | null>(null);
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchResultRef = useRef<() => Promise<void>>();
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<TaskDetail>(`/api/tasks/${taskId}`);
|
||||
setTask(res.data);
|
||||
|
||||
if (res.data.status === "COMPLETED" && fetchResultRef.current) {
|
||||
fetchResultRef.current();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务详情失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchResult = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ReconciliationResult>(`/api/reconciliation/result/${taskId}`);
|
||||
setResult(res.data);
|
||||
} catch (error) {
|
||||
console.error("获取对账结果失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
// Store fetchResult in ref to avoid circular dependency
|
||||
useEffect(() => {
|
||||
fetchResultRef.current = fetchResult;
|
||||
}, [fetchResult]);
|
||||
|
||||
const fetchExceptions = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<{ items: ExceptionItem[] }>(`/api/exceptions/`, {
|
||||
params: { task_id: taskId, page_size: 100 },
|
||||
});
|
||||
setExceptions(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error("获取异常列表失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
fetchTaskDetail();
|
||||
fetchExceptions();
|
||||
}
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions]);
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await api.get<Blob>(`/api/exports/task/${taskId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `对账结果_${task?.period || taskId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("导出失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportKingdee() {
|
||||
try {
|
||||
const res = await api.get<Blob>(`/api/exports/kingdee/${taskId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `金蝶凭证_${task?.period || taskId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("导出金蝶凭证失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">任务不存在</p>
|
||||
<Button className="mt-4 btn-press" onClick={() => router.back()}>
|
||||
返回
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const matchRate = task.total_employees > 0
|
||||
? ((task.matched_count / task.total_employees) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<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.back()} className="hover:bg-muted">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-heading-1 text-foreground">{task.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
对账期间: {task.period}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={handleExport} className="btn-press">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出结果
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportKingdee} className="btn-press">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
导出金蝶凭证
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">总人数</p>
|
||||
<p className="text-3xl font-semibold mt-1">{task.total_employees}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">匹配人数</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-emerald-600">{task.matched_count}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">异常数量</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-red-600">{task.exception_count}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-red-50 dark:bg-red-950/30">
|
||||
<AlertOctagon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">匹配率</p>
|
||||
<p className="text-3xl font-semibold mt-1">{matchRate}%</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
|
||||
<TrendingUp className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${matchRate}%` }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tabs */}
|
||||
<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] }}
|
||||
>
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList className="bg-muted/50">
|
||||
<TabsTrigger value="overview" className="data-[state=active]:bg-background">概览统计</TabsTrigger>
|
||||
<TabsTrigger value="exceptions" className="data-[state=active]:bg-background">
|
||||
异常列表
|
||||
{task.exception_count > 0 && (
|
||||
<Badge variant="destructive" className="ml-2">
|
||||
{task.exception_count}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="matched" className="data-[state=active]:bg-background">已匹配列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
{/* Exception Types */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">异常类型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result?.exceptions_by_type && Object.keys(result.exceptions_by_type).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(result.exceptions_by_type).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{typeLabels[type] || type}
|
||||
</span>
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Severity Distribution */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">严重程度分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result?.exceptions_by_severity && Object.keys(result.exceptions_by_severity).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["critical", "high", "medium", "low"].map((severity) => {
|
||||
const count = result.exceptions_by_severity[severity] || 0;
|
||||
const config = severityConfig[severity as keyof typeof severityConfig];
|
||||
return (
|
||||
<div key={severity} className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg}`}>
|
||||
<div className={`w-2 h-2 rounded-full bg-current ${config.text}`} />
|
||||
<span className={`text-sm font-medium ${config.text}`}>
|
||||
{config.label}: {count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Execution Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">执行信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">创建时间</p>
|
||||
<p className="text-sm font-medium mt-1">{new Date(task.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">完成时间</p>
|
||||
<p className="text-sm font-medium mt-1">
|
||||
{task.completed_at ? new Date(task.completed_at).toLocaleString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
{result && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">执行时间</p>
|
||||
<p className="text-sm font-medium mt-1">{result.execution_time_ms?.toFixed(0) || '-'}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">结果生成时间</p>
|
||||
<p className="text-sm font-medium mt-1">
|
||||
{result.completed_at ? new Date(result.completed_at).toLocaleString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Exceptions Tab */}
|
||||
<TabsContent value="exceptions">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">异常列表</CardTitle>
|
||||
<Badge variant="secondary">{exceptions.length} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{exceptions.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-50 dark:bg-emerald-950/30 mx-auto mb-4 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">完美匹配!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">所有记录都匹配成功</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>严重程度</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">差异金额</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exceptions.map((exc) => (
|
||||
<TableRow key={exc.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exc.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{exc.employee_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exc.exception_type] || exc.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${severityConfig[exc.severity].bg} ${severityConfig[exc.severity].text}`}>
|
||||
{severityConfig[exc.severity].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[250px]">
|
||||
<span className="truncate block">{exc.description}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{exc.difference_amount != null && (
|
||||
<span className={exc.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
||||
{exc.difference_amount > 0 ? "+" : ""}
|
||||
{exc.difference_amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Matched Tab */}
|
||||
<TabsContent value="matched">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">已匹配列表</CardTitle>
|
||||
<Badge variant="secondary">{task.matched_count} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-50 dark:bg-emerald-950/30 mx-auto mb-4 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">所有记录都匹配成功!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {task.matched_count} 条记录通过所有对账规则
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
FileText,
|
||||
Filter,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
period: string;
|
||||
status: string;
|
||||
total_employees: number;
|
||||
matched_count: number;
|
||||
exception_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
PENDING: { label: "待处理", color: "text-gray-700 dark:text-gray-300", bgColor: "bg-gray-100 dark:bg-gray-800" },
|
||||
PROCESSING: { label: "处理中", color: "text-blue-700 dark:text-blue-300", bgColor: "bg-blue-100 dark:bg-blue-900/50" },
|
||||
MAPPING_COMPLETED: { label: "映射完成", color: "text-amber-700 dark:text-amber-300", bgColor: "bg-amber-100 dark:bg-amber-900/50" },
|
||||
COMPLETED: { label: "已完成", color: "text-emerald-700 dark:text-emerald-300", bgColor: "bg-emerald-100 dark:bg-emerald-900/50" },
|
||||
FAILED: { label: "失败", color: "text-red-700 dark:text-red-300", bgColor: "bg-red-100 dark:bg-red-900/50" },
|
||||
};
|
||||
|
||||
// 生成可选月份列表(近12个月)
|
||||
const getAvailablePeriods = () => {
|
||||
const periods = [];
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
periods.push(`${year}-${month}`);
|
||||
}
|
||||
return periods;
|
||||
};
|
||||
|
||||
const formatPeriod = (period: string) => {
|
||||
const [year, month] = period.split("-");
|
||||
return `${year}年${parseInt(month)}月`;
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const router = useRouter();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string>("");
|
||||
const [availablePeriods] = useState(getAvailablePeriods);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = selectedPeriod ? { period: selectedPeriod } : {};
|
||||
const res = await api.get<Task[]>(ENDPOINTS.TASK.LIST, { params });
|
||||
setTasks(res.data);
|
||||
} catch (error) {
|
||||
console.error("加载任务列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedPeriod]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
const getMatchRate = (task: Task) => {
|
||||
if (task.total_employees === 0) return "0%";
|
||||
const rate = Math.round((task.matched_count / task.total_employees) * 100);
|
||||
return `${rate}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h1 className="text-heading-1 text-foreground mb-2">对账任务</h1>
|
||||
<p className="text-muted-foreground text-body">
|
||||
管理所有对账任务,查看对账结果
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
className="mb-6 flex items-center gap-4 flex-wrap"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-body-small text-muted-foreground">筛选月份:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={selectedPeriod === "" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod("")}
|
||||
className="text-body-small"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
|
||||
{availablePeriods.slice(0, 6).map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
variant={selectedPeriod === period ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
className="text-body-small"
|
||||
>
|
||||
{formatPeriod(period)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedPeriod && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod("")}
|
||||
className="text-caption"
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Tasks List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
|
||||
<FileText className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-heading-3 text-foreground mb-2">暂无任务</h3>
|
||||
<p className="text-body text-muted-foreground mb-6">
|
||||
{selectedPeriod
|
||||
? `没有 ${formatPeriod(selectedPeriod)} 的对账任务`
|
||||
: "当前没有任何对账任务"
|
||||
}
|
||||
</p>
|
||||
<Button onClick={() => router.push("/tasks/new")}>
|
||||
新建对账任务
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task, index) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: index * 0.05,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="cursor-pointer hover-lift transition-all group"
|
||||
onClick={() => router.push(`/tasks/${task.id}/result`)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-heading-3 text-foreground">
|
||||
{formatPeriod(task.period)} 对账任务
|
||||
</h3>
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusLabels[task.status]?.bgColor} ${statusLabels[task.status]?.color}`}>
|
||||
{statusLabels[task.status]?.label || task.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-body-small text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">{task.total_employees}</span> 名员工
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-emerald-600">{task.matched_count}</span> 已匹配
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`font-medium ${task.exception_count > 0 ? "text-amber-600" : "text-muted-foreground"}`}>
|
||||
{task.exception_count}
|
||||
</span> 异常
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
匹配率: <span className="font-medium text-foreground">{getMatchRate(task)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-caption text-muted-foreground">
|
||||
{task.completed_at
|
||||
? new Date(task.completed_at).toLocaleDateString("zh-CN")
|
||||
: new Date(task.created_at).toLocaleDateString("zh-CN")
|
||||
}
|
||||
</span>
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination hint */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2 text-caption text-muted-foreground">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>显示最近 50 条记录</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user