feat: UI优化和功能完善

- 添加公共分页组件,支持上边显示、分页大小10
- 侧边栏/Header按Liner风格优化,添加动画效果
- 异常处理列表支持分页和明细弹窗
- 任务结果页面支持已匹配列表分页
- 修复Dialog弹窗背景透明问题
- 新增dropdown-menu组件
- 添加parsed_file_record模型和回填脚本
- 前端枚举中文化
This commit is contained in:
freedakgmail
2026-07-07 12:57:15 +08:00
parent 78bd27a59a
commit b93929eb1a
24 changed files with 3155 additions and 608 deletions
+57 -36
View File
@@ -33,8 +33,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertTriangle,
import { AlertTriangle,
CheckCircle,
Clock,
Eye,
@@ -42,6 +41,7 @@ import {
XCircle,
} from "lucide-react";
import { api } from "@/lib/api/client";
import { Pagination } from "@/components/ui/pagination";
interface ExceptionItem {
id: number;
@@ -93,6 +93,35 @@ const typeLabels: Record<string, string> = {
duplicate_record: "重复记录",
format_error: "格式错误",
logic_error: "逻辑错误",
rule_violation: "规则违规",
threshold_exceeded: "阈值超限",
missing_employee: "员工缺失",
extra_employee: "多余员工",
amount_difference: "金额差异",
zero_amount: "金额为零",
negative_amount: "负数金额",
unusual_amount: "金额异常",
duplicate_employee: "重复员工",
bank_mismatch: "银行不匹配",
bank_missing: "银行记录缺失",
bank_extra: "银行多余记录",
AMOUNT_MISMATCH: "金额不一致",
MISSING_RECORD: "记录缺失",
DUPLICATE_RECORD: "重复记录",
FORMAT_ERROR: "格式错误",
LOGIC_ERROR: "逻辑错误",
RULE_VIOLATION: "规则违规",
THRESHOLD_EXCEEDED: "阈值超限",
MISSING_EMPLOYEE: "员工缺失",
EXTRA_EMPLOYEE: "多余员工",
AMOUNT_DIFFERENCE: "金额差异",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
const container = {
@@ -121,8 +150,8 @@ function ExceptionsContent() {
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 [pageSize, setPageSize] = useState(10);
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
@@ -130,7 +159,16 @@ function ExceptionsContent() {
fetchExceptions();
fetchSummary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId, status, severity, exceptionType, page]);
}, [taskId, status, severity, exceptionType]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
fetchExceptions();
fetchSummary();
}
async function fetchExceptions() {
setLoading(true);
@@ -344,6 +382,10 @@ function ExceptionsContent() {
<SelectItem value="duplicate_record"></SelectItem>
<SelectItem value="format_error"></SelectItem>
<SelectItem value="logic_error"></SelectItem>
<SelectItem value="missing_employee"></SelectItem>
<SelectItem value="duplicate_employee"></SelectItem>
<SelectItem value="zero_amount"></SelectItem>
<SelectItem value="unusual_amount"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -371,6 +413,14 @@ function ExceptionsContent() {
</div>
</CardHeader>
<CardContent className="p-0">
{total > 0 && (
<Pagination
page={page}
pageSize={pageSize}
total={total}
onChange={onPageChange}
/>
)}
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -428,7 +478,7 @@ function ExceptionsContent() {
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{typeLabels[exception.exception_type] || exception.exception_type}
{typeLabels[exception.exception_type] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || exception.exception_type}
</Badge>
</TableCell>
<TableCell>
@@ -473,35 +523,6 @@ function ExceptionsContent() {
)}
</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>
@@ -525,7 +546,7 @@ function ExceptionsContent() {
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
<Badge variant="outline">{typeLabels[selectedException.exception_type] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}</Badge>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
+282 -172
View File
@@ -2,8 +2,26 @@
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
import { motion } from "motion/react";
import {
ArrowLeft,
Trash2,
ToggleLeft,
ToggleRight,
Search,
FileText
} from "lucide-react";
import { Card, CardContent } 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 { api } from "@/lib/api/client";
import { ENDPOINTS } from "@/lib/api/endpoints";
import { useToast } from "@/lib/hooks/useToast";
@@ -61,6 +79,19 @@ const RULE_TYPE_LABELS: Record<string, string> = {
DEPARTMENT_MAPPING: "部门映射",
};
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 } },
};
export default function RulesPage() {
const router = useRouter();
const toast = useToast();
@@ -129,196 +160,275 @@ export default function RulesPage() {
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-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 mb-8"
>
<div className="flex items-center gap-4">
<button
<Button
variant="ghost"
size="icon"
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>
</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">
<h1 className="text-heading-1 text-foreground"></h1>
<p className="text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
</motion.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>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"
>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<p className="text-caption text-muted-foreground uppercase tracking-wide"></p>
<p className="text-3xl font-semibold mt-1">{rules.length}</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<p className="text-caption text-muted-foreground uppercase tracking-wide"></p>
<p className="text-3xl font-semibold mt-1 text-emerald-600">{activeCount}</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<p className="text-caption text-muted-foreground uppercase tracking-wide"></p>
<p className="text-3xl font-semibold mt-1 text-muted-foreground">{inactiveCount}</p>
</CardContent>
</Card>
</motion.div>
</motion.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>
<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-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索规则..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-9 pl-9"
/>
</div>
</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>
<div className="w-[150px]">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="FIELD_MAPPING"></SelectItem>
<SelectItem value="ACCOUNT_MAPPING"></SelectItem>
<SelectItem value="DEPARTMENT_MAPPING"></SelectItem>
</SelectContent>
</Select>
</div>
<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="w-[150px]">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INACTIVE"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</motion.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>
<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] }}
className="mt-6"
>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
使
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredRules.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
<FileText 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>
</td>
</tr>
) : (
filteredRules.map((rule, index) => (
<motion.tr
key={rule.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.02 }}
className="hover:bg-accent/50 transition-colors"
>
<td className="px-4 py-3">
<Badge variant="outline">
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
</Badge>
</td>
<td className="px-4 py-3 font-medium text-foreground">
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{rule.target_value}
</td>
<td className="px-4 py-3 text-center text-muted-foreground">
{rule.match_count}
</td>
<td className="px-4 py-3 text-center">
<Badge className={rule.status === "ACTIVE"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500"
}>
{rule.status === "ACTIVE" ? "启用" : "停用"}
</Badge>
</td>
<td className="px-4 py-3 text-center text-muted-foreground text-caption">
{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-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleStatus(rule.id, rule.status)}
className={`h-8 w-8 ${rule.status === "ACTIVE"
? "text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
: "text-muted-foreground hover:text-foreground"
}`}
title={rule.status === "ACTIVE" ? "停用" : "启用"}
>
{rule.status === "ACTIVE" ? (
<ToggleRight className="w-5 h-5" />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteRule(rule.id)}
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</motion.tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</motion.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>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="mt-6"
>
<Card className="border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="p-5">
<h3 className="font-medium text-foreground mb-3">
</h3>
<ul className="text-body-small text-muted-foreground space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
</ul>
</CardContent>
</Card>
</motion.div>
</div>
);
}
@@ -4,6 +4,7 @@ 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 { Pagination } from "@/components/ui/pagination";
import {
Card,
CardContent,
@@ -26,6 +27,12 @@ import {
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
FileText,
@@ -35,6 +42,7 @@ import {
TrendingUp,
Users,
AlertOctagon,
Eye,
} from "lucide-react";
interface TaskDetail {
@@ -76,6 +84,15 @@ interface ExceptionItem {
difference_amount: number | null;
}
interface MatchedItem {
employee_id: string;
employee_name: string;
salary_amount: number | null;
social_security_amount: number | null;
tax_amount: number | null;
net_salary: 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" },
@@ -91,6 +108,33 @@ const typeLabels: Record<string, string> = {
logic_error: "逻辑错误",
rule_violation: "规则违规",
threshold_exceeded: "阈值超限",
missing_employee: "员工缺失",
extra_employee: "多余员工",
amount_difference: "金额差异",
zero_amount: "金额为零",
negative_amount: "负数金额",
unusual_amount: "金额异常",
duplicate_employee: "重复员工",
bank_mismatch: "银行不匹配",
bank_missing: "银行记录缺失",
bank_extra: "银行多余记录",
AMOUNT_MISMATCH: "金额不一致",
MISSING_RECORD: "记录缺失",
DUPLICATE_RECORD: "重复记录",
FORMAT_ERROR: "格式错误",
LOGIC_ERROR: "逻辑错误",
RULE_VIOLATION: "规则违规",
THRESHOLD_EXCEEDED: "阈值超限",
MISSING_EMPLOYEE: "员工缺失",
EXTRA_EMPLOYEE: "多余员工",
AMOUNT_DIFFERENCE: "金额差异",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
@@ -100,9 +144,18 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
const [task, setTask] = useState<TaskDetail | null>(null);
const [result, setResult] = useState<ReconciliationResult | null>(null);
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
const [matchedRecords, setMatchedRecords] = useState<MatchedItem[]>([]);
const [matchedTotal, setMatchedTotal] = useState(0);
const [matchedPage, setMatchedPage] = useState(1);
const [matchedPageSize, setMatchedPageSize] = useState(10);
const [loading, setLoading] = useState(true);
const [matchedLoading, setMatchedLoading] = useState(false);
const [exceptionDetailOpen, setExceptionDetailOpen] = useState(false);
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
const [matchedDetailOpen, setMatchedDetailOpen] = useState(false);
const [selectedMatched, setSelectedMatched] = useState<MatchedItem | null>(null);
const fetchResultRef = useRef<() => Promise<void>>();
const fetchResultRef = useRef<(() => Promise<void>) | undefined>(undefined);
const fetchTaskDetail = useCallback(async () => {
try {
@@ -144,12 +197,36 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
}
}, [taskId]);
const fetchMatchedRecords = useCallback(async () => {
setMatchedLoading(true);
try {
const res = await api.get<{ items: MatchedItem[]; total: number }>(`/api/reconciliation/matched/${taskId}`, {
params: { page: matchedPage, page_size: matchedPageSize },
});
setMatchedRecords(res.data.items || []);
setMatchedTotal(res.data.total || 0);
} catch (error) {
console.error("获取已匹配列表失败", error);
} finally {
setMatchedLoading(false);
}
}, [taskId, matchedPage, matchedPageSize]);
function onMatchedPageChange(newPage: number, newPageSize: number) {
setMatchedPage(newPage);
if (newPageSize !== matchedPageSize) {
setMatchedPageSize(newPageSize);
}
fetchMatchedRecords();
}
useEffect(() => {
if (taskId) {
fetchTaskDetail();
fetchExceptions();
fetchMatchedRecords();
}
}, [taskId, fetchTaskDetail, fetchExceptions]);
}, [taskId, fetchTaskDetail, fetchExceptions, fetchMatchedRecords]);
async function handleExport() {
try {
@@ -377,7 +454,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
{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}
{typeLabels[type] || typeLabels[type.toLowerCase()] || typeLabels[type.toUpperCase()] || type}
</span>
<Badge variant="secondary">{count}</Badge>
</div>
@@ -484,7 +561,14 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
</TableHeader>
<TableBody>
{exceptions.map((exc) => (
<TableRow key={exc.id}>
<TableRow
key={exc.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => {
setSelectedException(exc);
setExceptionDetailOpen(true);
}}
>
<TableCell>
<div>
<div className="font-medium">{exc.employee_name}</div>
@@ -493,7 +577,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{typeLabels[exc.exception_type] || exc.exception_type}
{typeLabels[exc.exception_type] || typeLabels[exc.exception_type.toLowerCase()] || typeLabels[exc.exception_type.toUpperCase()] || exc.exception_type}
</Badge>
</TableCell>
<TableCell>
@@ -527,22 +611,217 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
<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>
<Badge variant="secondary">{matchedTotal} </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>
{matchedTotal > 0 && (
<div className="mb-4">
<Pagination
total={matchedTotal}
page={matchedPage}
pageSize={matchedPageSize}
onChange={onMatchedPageChange}
/>
</div>
)}
{matchedLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : matchedRecords.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matchedRecords.map((record, index) => (
<TableRow
key={`${record.employee_id}-${index}`}
className="cursor-pointer hover:bg-muted/50"
onClick={() => {
setSelectedMatched(record);
setMatchedDetailOpen(true);
}}
>
<TableCell>
<div>
<div className="font-medium">{record.employee_name}</div>
<div className="text-xs text-muted-foreground">{record.employee_id}</div>
</div>
</TableCell>
<TableCell className="text-right">
{record.salary_amount != null ? record.salary_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right">
{record.social_security_amount != null ? record.social_security_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right">
{record.tax_amount != null ? record.tax_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right font-medium">
{record.net_salary != null ? record.net_salary.toFixed(2) : "-"}
</TableCell>
<TableCell>
<Eye className="h-4 w-4 text-muted-foreground" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
{/* 异常详情弹窗 */}
<Dialog open={exceptionDetailOpen} onOpenChange={setExceptionDetailOpen}>
<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] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}
</Badge>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<Badge className={`${severityConfig[selectedException.severity].bg} ${severityConfig[selectedException.severity].text}`}>
{severityConfig[selectedException.severity].label}
</Badge>
</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>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* 已匹配记录明细弹窗 */}
<Dialog open={matchedDetailOpen} onOpenChange={setMatchedDetailOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedMatched && (
<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">{selectedMatched.employee_name}</p>
</div>
<div>
<p className="text-caption text-muted-foreground">ID</p>
<p className="font-medium">{selectedMatched.employee_id}</p>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-medium mb-3"></h4>
<div className="space-y-3">
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">
{selectedMatched.salary_amount != null ? selectedMatched.salary_amount.toFixed(2) : "-"}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">
{selectedMatched.social_security_amount != null ? selectedMatched.social_security_amount.toFixed(2) : "-"}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">
{selectedMatched.tax_amount != null ? selectedMatched.tax_amount.toFixed(2) : "-"}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-lg bg-primary/10 border border-primary/20">
<span className="font-medium"></span>
<span className="font-mono font-semibold text-lg">
{selectedMatched.net_salary != null ? selectedMatched.net_salary.toFixed(2) : "-"}
</span>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+33 -17
View File
@@ -7,11 +7,10 @@ import {
Filter,
ArrowRight,
Loader2,
ChevronLeft,
ChevronRight
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/ui/pagination";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import ENDPOINTS from "@/lib/api/endpoints";
@@ -59,24 +58,40 @@ export default function TasksPage() {
const [loading, setLoading] = useState(true);
const [selectedPeriod, setSelectedPeriod] = useState<string>("");
const [availablePeriods] = useState(getAvailablePeriods);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
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);
const params = {
page,
page_size: pageSize,
...(selectedPeriod ? { period: selectedPeriod } : {})
};
const res = await api.get<{ items: Task[]; total: number }>(ENDPOINTS.TASK.LIST, { params });
setTasks(res.data.items || []);
setTotal(res.data.total || 0);
} catch (error) {
console.error("加载任务列表失败:", error);
} finally {
setLoading(false);
}
}, [selectedPeriod]);
}, [page, pageSize, selectedPeriod]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
loadTasks();
}
const getMatchRate = (task: Task) => {
if (task.total_employees === 0) return "0%";
const rate = Math.round((task.matched_count / task.total_employees) * 100);
@@ -176,7 +191,16 @@ export default function TasksPage() {
</Card>
</motion.div>
) : (
<div className="space-y-4">
<>
<div className="mb-4">
<Pagination
total={total}
page={page}
pageSize={pageSize}
onChange={onPageChange}
/>
</div>
<div className="space-y-4">
{tasks.map((task, index) => (
<motion.div
key={task.id}
@@ -241,16 +265,8 @@ export default function TasksPage() {
</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>
</>
)}
</div>
);