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 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
This commit is contained in:
freedakgmail
2026-07-07 09:04:47 +08:00
parent 8487f6eadf
commit 33b4c734aa
117 changed files with 22070 additions and 295 deletions
@@ -0,0 +1,630 @@
"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>
);
}