feat: UI优化和功能完善
- 添加公共分页组件,支持上边显示、分页大小10 - 侧边栏/Header按Liner风格优化,添加动画效果 - 异常处理列表支持分页和明细弹窗 - 任务结果页面支持已匹配列表分页 - 修复Dialog弹窗背景透明问题 - 新增dropdown-menu组件 - 添加parsed_file_record模型和回填脚本 - 前端枚举中文化
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user