Files
s2f/frontend/app/(dashboard)/exceptions/page.tsx
T
freedakgmail 33b4c734aa 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 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
2026-07-07 09:04:47 +08:00

630 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}