Files
s2f/frontend/app/(dashboard)/exceptions/page.tsx
T
freedakgmail feebbb10ac refactor: 统一分页组件和UI优化
- 使用公共Pagination组件统一各页面的分页逻辑
- 优化exceptions页面异常列表展示
- 简化settings/rules页面规则管理
- 后端tasks.py增加分页参数支持
2026-07-07 13:53:54 +08:00

653 lines
27 KiB
TypeScript

"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";
import { Pagination } from "@/components/ui/pagination";
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: "逻辑错误",
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 = {
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, setPageSize] = useState(10);
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]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
fetchExceptions();
fetchSummary();
}
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>
<SelectItem value="missing_employee"></SelectItem>
<SelectItem value="duplicate_employee"></SelectItem>
<SelectItem value="zero_amount"></SelectItem>
<SelectItem value="unusual_amount"></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">
{total > 0 && (
<Pagination
page={page}
pageSize={pageSize}
total={total}
onChange={onPageChange}
/>
)}
<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) => (
<TableRow key={exception.id} 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] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || 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>
))
)}
</TableBody>
</Table>
</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] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || 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">
{(() => {
try {
const detail = typeof selectedException.detail === 'string'
? JSON.parse(selectedException.detail)
: selectedException.detail;
return JSON.stringify(detail, null, 2);
} catch {
return String(selectedException.detail);
}
})()}
</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>
);
}