33b4c734aa
后端: - 新增认证(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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
"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>
|
|
);
|
|
} |