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,624 @@
"use client";
import { useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { motion } from "motion/react";
import { ArrowLeft, CheckCircle, Loader2, FileSpreadsheet } from "lucide-react";
import { api } from "@/lib/api/client";
import { ENDPOINTS } from "@/lib/api/endpoints";
import { useToast } from "@/lib/hooks/useToast";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FieldMappingTable } from "@/components/mapping/FieldMappingTable";
import { AISuggestionPanel } from "@/components/mapping/AISuggestionPanel";
import type { TaskMappingOverview } from "@/lib/api/types";
// 模拟数据(实际应从 API 获取)
const mockMappingData: TaskMappingOverview = {
task_id: 1,
total_files: 3,
total_fields: 18,
confirmed_fields: 5,
needs_review: true,
files: [
{
file_id: 1,
file_type: "工资表",
file_name: "2024年1月工资表.xlsx",
total_fields: 6,
confirmed_fields: 2,
high_confidence: 4,
medium_confidence: 1,
low_confidence: 1,
mappings: [
{
id: 1,
company_id: 1,
file_id: 1,
source_field: "员工姓名",
standard_field: "员工姓名",
confidence: 1.0,
reasoning: "字段名完全匹配",
sample_values: ["张三", "李四", "王五"],
is_skipped: false,
confirmed: true,
confirmed_by: 1,
confirmed_at: new Date().toISOString(),
},
{
id: 2,
company_id: 1,
file_id: 1,
source_field: "工号",
standard_field: "工号",
confidence: 1.0,
reasoning: "字段名完全匹配",
sample_values: ["EMP001", "EMP002"],
is_skipped: false,
confirmed: true,
confirmed_by: 1,
confirmed_at: new Date().toISOString(),
},
{
id: 3,
company_id: 1,
file_id: 1,
source_field: "基本薪资",
standard_field: "基本工资",
confidence: 0.85,
reasoning: "字段名相似,样例值验证为数值类型",
sample_values: [8000, 10000, 12000],
is_skipped: false,
confirmed: false,
},
{
id: 4,
company_id: 1,
file_id: 1,
source_field: "岗位津贴",
standard_field: "补贴",
confidence: 0.75,
reasoning: "字段名包含关键词:'津贴'",
sample_values: [500, 800, 1000],
is_skipped: false,
confirmed: false,
},
{
id: 5,
company_id: 1,
file_id: 1,
source_field: "实发合计",
standard_field: "实发工资",
confidence: 0.6,
reasoning: "字段名部分匹配,需要确认",
sample_values: [8500, 10800, 13000],
is_skipped: false,
confirmed: false,
},
{
id: 6,
company_id: 1,
file_id: 1,
source_field: "备注",
standard_field: "",
confidence: 0.0,
reasoning: "无法识别字段类型",
sample_values: ["正常", "补发"],
is_skipped: false,
confirmed: false,
},
],
},
{
file_id: 2,
file_type: "社保表",
file_name: "2024年1月社保表.xlsx",
total_fields: 6,
confirmed_fields: 2,
high_confidence: 3,
medium_confidence: 2,
low_confidence: 1,
mappings: [
{
id: 7,
company_id: 1,
file_id: 2,
source_field: "姓名",
standard_field: "员工姓名",
confidence: 0.95,
reasoning: "字段名包含关键词:'姓名'",
sample_values: ["张三", "李四"],
is_skipped: false,
confirmed: true,
},
{
id: 8,
company_id: 1,
file_id: 2,
source_field: "养老保险",
standard_field: "养老保险",
confidence: 1.0,
reasoning: "字段名完全匹配",
sample_values: [800, 960, 1200],
is_skipped: false,
confirmed: true,
},
{
id: 9,
company_id: 1,
file_id: 2,
source_field: "医疗",
standard_field: "医疗保险",
confidence: 0.8,
reasoning: "字段名部分匹配",
sample_values: [200, 240, 300],
is_skipped: false,
confirmed: false,
},
{
id: 10,
company_id: 1,
file_id: 2,
source_field: "失业",
standard_field: "失业保险",
confidence: 0.75,
reasoning: "字段名相似",
sample_values: [50, 60, 75],
is_skipped: false,
confirmed: false,
},
{
id: 11,
company_id: 1,
file_id: 2,
source_field: "公积金",
standard_field: "公积金",
confidence: 0.5,
reasoning: "需要确认具体含义",
sample_values: [500, 600, 750],
is_skipped: false,
confirmed: false,
},
{
id: 12,
company_id: 1,
file_id: 2,
source_field: "合计",
standard_field: "社保合计",
confidence: 0.6,
reasoning: "可能是合计字段",
sample_values: [1550, 1800, 2325],
is_skipped: false,
confirmed: false,
},
],
},
{
file_id: 3,
file_type: "个税表",
file_name: "2024年1月个税表.xlsx",
total_fields: 6,
confirmed_fields: 1,
high_confidence: 2,
medium_confidence: 3,
low_confidence: 1,
mappings: [
{
id: 13,
company_id: 1,
file_id: 3,
source_field: "姓名",
standard_field: "员工姓名",
confidence: 0.95,
reasoning: "字段名包含关键词:'姓名'",
sample_values: ["张三", "李四"],
is_skipped: false,
confirmed: true,
},
{
id: 14,
company_id: 1,
file_id: 3,
source_field: "应税工资",
standard_field: "应税收入",
confidence: 0.9,
reasoning: "字段名包含关键词:'应税'",
sample_values: [8000, 10000],
is_skipped: false,
confirmed: false,
},
{
id: 15,
company_id: 1,
file_id: 3,
source_field: "专项扣除",
standard_field: "税前扣除",
confidence: 0.8,
reasoning: "可能为税前扣除项",
sample_values: [1000, 1500],
is_skipped: false,
confirmed: false,
},
{
id: 16,
company_id: 1,
file_id: 3,
source_field: "个税",
standard_field: "应缴个税",
confidence: 0.7,
reasoning: "可能为个税字段",
sample_values: [200, 300],
is_skipped: false,
confirmed: false,
},
{
id: 17,
company_id: 1,
file_id: 3,
source_field: "税后工资",
standard_field: "税后收入",
confidence: 0.85,
reasoning: "字段名相似",
sample_values: [6800, 8500],
is_skipped: false,
confirmed: false,
},
{
id: 18,
company_id: 1,
file_id: 3,
source_field: "银行账号",
standard_field: "银行账号",
confidence: 1.0,
reasoning: "字段名完全匹配",
sample_values: ["6222021234567890"],
is_skipped: false,
confirmed: false,
},
],
},
],
};
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.08 },
},
};
const item = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const } },
};
export default function MappingPage() {
const params = useParams();
const router = useRouter();
const toast = useToast();
const taskId = params.id as string;
const [data, setData] = useState<TaskMappingOverview | null>(mockMappingData);
const [loading, setLoading] = useState(false);
const [activeFile, setActiveFile] = useState<number>(mockMappingData?.files[0]?.file_id || 1);
// 统计
const totalHigh = data?.files.reduce((acc, f) => acc + f.high_confidence, 0) || 0;
const totalMedium = data?.files.reduce((acc, f) => acc + f.medium_confidence, 0) || 0;
const totalLow = data?.files.reduce((acc, f) => acc + f.low_confidence, 0) || 0;
// 更新映射
const handleUpdateMapping = useCallback(
async (mappingId: number, standardField: string) => {
try {
await api.put(ENDPOINTS.MAPPING.UPDATE(mappingId), {
standard_field: standardField,
});
setData((prev) => {
if (!prev) return prev;
const newFiles = prev.files.map((file) => ({
...file,
mappings: file.mappings.map((m) =>
m.id === mappingId ? { ...m, standard_field: standardField, confidence: 1.0 } : m
),
}));
return { ...prev, files: newFiles };
});
toast.success("字段已更新");
} catch {
toast.error("更新失败");
}
},
[toast]
);
// 跳过映射
const handleSkipMapping = useCallback(
async (mappingId: number) => {
try {
await api.put(ENDPOINTS.MAPPING.UPDATE(mappingId), {
is_skipped: true,
});
setData((prev) => {
if (!prev) return prev;
const newFiles = prev.files.map((file) => ({
...file,
mappings: file.mappings.map((m) =>
m.id === mappingId ? { ...m, is_skipped: true } : m
),
}));
return { ...prev, files: newFiles };
});
toast.success("字段已跳过");
} catch {
toast.error("操作失败");
}
},
[toast]
);
// 确认单个映射
const handleConfirmMapping = useCallback(
async (mappingId: number) => {
try {
await api.post(ENDPOINTS.MAPPING.CONFIRM, {
mapping_ids: [mappingId],
});
setData((prev) => {
if (!prev) return prev;
const newFiles = prev.files.map((file) => ({
...file,
mappings: file.mappings.map((m) =>
m.id === mappingId
? { ...m, confirmed: true, confirmed_at: new Date().toISOString() }
: m
),
confirmed_fields: file.mappings.filter(
(m) => m.id === mappingId || m.confirmed
).length,
}));
const totalConfirmed = newFiles.reduce((acc, f) => acc + f.confirmed_fields, 0);
return { ...prev, files: newFiles, confirmed_fields: totalConfirmed };
});
toast.success("字段已确认");
} catch {
toast.error("确认失败");
}
},
[toast]
);
// 批量确认
const handleConfirmAll = useCallback(
async (mappingIds: number[]) => {
if (mappingIds.length === 0) return;
try {
setLoading(true);
await api.post(ENDPOINTS.MAPPING.CONFIRM, {
mapping_ids: mappingIds,
});
setData((prev) => {
if (!prev) return prev;
const newFiles = prev.files.map((file) => {
const fileIds = file.mappings.map((m) => m.id);
const confirmedIds = mappingIds.filter((id) => fileIds.includes(id));
return {
...file,
mappings: file.mappings.map((m) =>
confirmedIds.includes(m.id)
? { ...m, confirmed: true, confirmed_at: new Date().toISOString() }
: m
),
confirmed_fields: file.mappings.filter(
(m) => m.confirmed || confirmedIds.includes(m.id)
).length,
};
});
const totalConfirmed = newFiles.reduce((acc, f) => acc + f.confirmed_fields, 0);
return { ...prev, files: newFiles, confirmed_fields: totalConfirmed };
});
toast.success(`已确认 ${mappingIds.length} 个字段`);
} catch {
toast.error("批量确认失败");
} finally {
setLoading(false);
}
},
[toast]
);
// 确认并进入下一步
const handleConfirmAndNext = useCallback(async () => {
try {
setLoading(true);
toast.success("字段映射已确认,正在进入对账...");
router.push(`/tasks/${taskId}/reconciliation`);
} catch {
toast.error("操作失败");
} finally {
setLoading(false);
}
}, [taskId, router, toast]);
const currentFile = data?.files.find((f) => f.file_id === activeFile);
if (!data) {
return (
<div className="min-h-full flex items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
...
</div>
</div>
);
}
return (
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-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"
>
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard")} className="hover:bg-muted">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-heading-1 text-foreground"></h1>
<p className="text-muted-foreground mt-1">
#{taskId} - AI
</p>
</div>
</div>
<Button onClick={handleConfirmAndNext} disabled={loading} className="btn-press">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
</Button>
</motion.div>
{/* AI 建议面板 */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
>
<AISuggestionPanel
totalFields={data.total_fields}
highConfidence={totalHigh}
mediumConfidence={totalMedium}
lowConfidence={totalLow}
confirmedCount={data.confirmed_fields}
onAutoConfirm={() => {
const ids = data.files
.flatMap((f) => f.mappings)
.filter((m) => !m.confirmed && !m.is_skipped && m.standard_field)
.map((m) => m.id);
handleConfirmAll(ids);
}}
/>
</motion.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] }}
>
<div className="flex gap-2 border-b border-border overflow-x-auto">
{data.files.map((file) => (
<button
key={file.file_id}
onClick={() => setActiveFile(file.file_id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors ${
activeFile === file.file_id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<FileSpreadsheet className="w-4 h-4" />
{file.file_type}
<Badge variant={activeFile === file.file_id ? "default" : "secondary"} className="ml-1 text-xs">
{file.confirmed_fields}/{file.total_fields}
</Badge>
</button>
))}
</div>
</motion.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] }}
>
{currentFile && (
<FieldMappingTable
mappings={currentFile.mappings}
fileName={currentFile.file_name}
fileType={currentFile.file_type}
onUpdate={handleUpdateMapping}
onSkip={handleSkipMapping}
onConfirm={handleConfirmMapping}
onConfirmAll={handleConfirmAll}
/>
)}
</motion.div>
{/* 文件概览卡片 */}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 md:grid-cols-3 gap-4"
>
{data.files.map((file) => (
<motion.div key={file.file_id} variants={item}>
<Card
className={`cursor-pointer transition-all hover-lift ${
activeFile === file.file_id
? "ring-2 ring-primary"
: ""
}`}
onClick={() => setActiveFile(file.file_id)}
>
<CardContent className="p-5">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-lg bg-muted">
<FileSpreadsheet className="w-5 h-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground truncate">
{file.file_type}
</h3>
<p className="text-caption text-muted-foreground truncate">
{file.file_name}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-caption text-muted-foreground">
{file.confirmed_fields}/{file.total_fields}
</span>
{file.low_confidence > 0 && (
<Badge variant="destructive" className="text-xs">
{file.low_confidence}
</Badge>
)}
</div>
{/* 置信度进度条 */}
<div className="mt-3 h-1.5 bg-muted rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(file.confirmed_fields / file.total_fields) * 100}%` }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="h-full bg-primary rounded-full"
/>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,548 @@
"use client";
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 {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
CheckCircle,
FileText,
Download,
RefreshCw,
ArrowLeft,
TrendingUp,
Users,
AlertOctagon,
} from "lucide-react";
interface TaskDetail {
id: number;
name: string;
period: string;
status: string;
total_employees: number;
matched_count: number;
exception_count: number;
created_at: string;
completed_at: string | null;
}
interface ReconciliationResult {
task_id: number;
period: string;
total_employees: number;
matched_employees: number;
exception_count: number;
exceptions_by_type: Record<string, number>;
exceptions_by_severity: Record<string, number>;
execution_time_ms: number;
completed_at: string;
}
interface ExceptionItem {
id: number;
exception_type: string;
severity: "low" | "medium" | "high" | "critical";
status: string;
employee_id: string;
employee_name: string;
description: string;
salary_amount: number | null;
social_security_amount: number | null;
tax_amount: number | null;
bank_amount: number | null;
difference_amount: 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" },
high: { label: "高", bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300" },
critical: { label: "严重", bg: "bg-red-100 dark:bg-red-900/40", text: "text-red-700 dark:text-red-300" },
};
const typeLabels: Record<string, string> = {
amount_mismatch: "金额不一致",
missing_record: "记录缺失",
duplicate_record: "重复记录",
format_error: "格式错误",
logic_error: "逻辑错误",
rule_violation: "规则违规",
threshold_exceeded: "阈值超限",
};
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
const { id: taskId } = use(params);
const router = useRouter();
const [task, setTask] = useState<TaskDetail | null>(null);
const [result, setResult] = useState<ReconciliationResult | null>(null);
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchResultRef = useRef<() => Promise<void>>();
const fetchTaskDetail = useCallback(async () => {
try {
const res = await api.get<TaskDetail>(`/api/tasks/${taskId}`);
setTask(res.data);
if (res.data.status === "COMPLETED" && fetchResultRef.current) {
fetchResultRef.current();
}
} catch (error) {
console.error("获取任务详情失败", error);
} finally {
setLoading(false);
}
}, [taskId]);
const fetchResult = useCallback(async () => {
try {
const res = await api.get<ReconciliationResult>(`/api/reconciliation/result/${taskId}`);
setResult(res.data);
} catch (error) {
console.error("获取对账结果失败", error);
}
}, [taskId]);
// Store fetchResult in ref to avoid circular dependency
useEffect(() => {
fetchResultRef.current = fetchResult;
}, [fetchResult]);
const fetchExceptions = useCallback(async () => {
try {
const res = await api.get<{ items: ExceptionItem[] }>(`/api/exceptions/`, {
params: { task_id: taskId, page_size: 100 },
});
setExceptions(res.data.items || []);
} catch (error) {
console.error("获取异常列表失败", error);
}
}, [taskId]);
useEffect(() => {
if (taskId) {
fetchTaskDetail();
fetchExceptions();
}
}, [taskId, fetchTaskDetail, fetchExceptions]);
async function handleExport() {
try {
const res = await api.get<Blob>(`/api/exports/task/${taskId}`, {
responseType: "blob",
});
const url = window.URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = `对账结果_${task?.period || taskId}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("导出失败", error);
}
}
async function handleExportKingdee() {
try {
const res = await api.get<Blob>(`/api/exports/kingdee/${taskId}`, {
responseType: "blob",
});
const url = window.URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = `金蝶凭证_${task?.period || taskId}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("导出金蝶凭证失败", error);
}
}
if (loading) {
return (
<div className="min-h-full flex items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin" />
...
</div>
</div>
);
}
if (!task) {
return (
<div className="min-h-full flex items-center justify-center">
<Card className="max-w-md">
<CardContent className="py-12 text-center">
<p className="text-muted-foreground"></p>
<Button className="mt-4 btn-press" onClick={() => router.back()}>
</Button>
</CardContent>
</Card>
</div>
);
}
const matchRate = task.total_employees > 0
? ((task.matched_count / task.total_employees) * 100).toFixed(1)
: "0.0";
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 } },
};
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 className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} className="hover:bg-muted">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-heading-1 text-foreground">{task.name}</h1>
<p className="text-muted-foreground mt-1">
: {task.period}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleExport} className="btn-press">
<Download className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleExportKingdee} className="btn-press">
<FileText className="h-4 w-4 mr-2" />
</Button>
</div>
</motion.div>
{/* Stats Cards */}
<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-3xl font-semibold mt-1">{task.total_employees}</p>
</div>
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
<Users className="w-5 h-5 text-blue-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-3xl font-semibold mt-1 text-emerald-600">{task.matched_count}</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-3xl font-semibold mt-1 text-red-600">{task.exception_count}</p>
</div>
<div className="p-2.5 rounded-lg bg-red-50 dark:bg-red-950/30">
<AlertOctagon className="w-5 h-5 text-red-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-3xl font-semibold mt-1">{matchRate}%</p>
</div>
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
<TrendingUp className="w-5 h-5 text-violet-600" />
</div>
</div>
{/* Progress bar */}
<div className="mt-3 h-2 bg-muted rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${matchRate}%` }}
transition={{ duration: 0.8, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400"
/>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
{/* Tabs */}
<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] }}
>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="bg-muted/50">
<TabsTrigger value="overview" className="data-[state=active]:bg-background"></TabsTrigger>
<TabsTrigger value="exceptions" className="data-[state=active]:bg-background">
{task.exception_count > 0 && (
<Badge variant="destructive" className="ml-2">
{task.exception_count}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="matched" className="data-[state=active]:bg-background"></TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-4">
<div className="grid lg:grid-cols-2 gap-4">
{/* Exception Types */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
{result?.exceptions_by_type && Object.keys(result.exceptions_by_type).length > 0 ? (
<div className="space-y-3">
{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}
</span>
<Badge variant="secondary">{count}</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
{/* Severity Distribution */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
{result?.exceptions_by_severity && Object.keys(result.exceptions_by_severity).length > 0 ? (
<div className="flex flex-wrap gap-3">
{["critical", "high", "medium", "low"].map((severity) => {
const count = result.exceptions_by_severity[severity] || 0;
const config = severityConfig[severity as keyof typeof severityConfig];
return (
<div key={severity} className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg}`}>
<div className={`w-2 h-2 rounded-full bg-current ${config.text}`} />
<span className={`text-sm font-medium ${config.text}`}>
{config.label}: {count}
</span>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</div>
{/* Execution Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-caption text-muted-foreground"></p>
<p className="text-sm font-medium mt-1">{new Date(task.created_at).toLocaleString()}</p>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<p className="text-sm font-medium mt-1">
{task.completed_at ? new Date(task.completed_at).toLocaleString() : "-"}
</p>
</div>
{result && (
<>
<div>
<p className="text-caption text-muted-foreground"></p>
<p className="text-sm font-medium mt-1">{result.execution_time_ms?.toFixed(0) || '-'}ms</p>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<p className="text-sm font-medium mt-1">
{result.completed_at ? new Date(result.completed_at).toLocaleString() : "-"}
</p>
</div>
</>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Exceptions Tab */}
<TabsContent value="exceptions">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium"></CardTitle>
<Badge variant="secondary">{exceptions.length} </Badge>
</div>
</CardHeader>
<CardContent className="p-0">
{exceptions.length === 0 ? (
<div 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"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{exceptions.map((exc) => (
<TableRow key={exc.id}>
<TableCell>
<div>
<div className="font-medium">{exc.employee_name}</div>
<div className="text-xs text-muted-foreground">{exc.employee_id}</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{typeLabels[exc.exception_type] || exc.exception_type}
</Badge>
</TableCell>
<TableCell>
<Badge className={`${severityConfig[exc.severity].bg} ${severityConfig[exc.severity].text}`}>
{severityConfig[exc.severity].label}
</Badge>
</TableCell>
<TableCell className="max-w-[250px]">
<span className="truncate block">{exc.description}</span>
</TableCell>
<TableCell className="text-right">
{exc.difference_amount != null && (
<span className={exc.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
{exc.difference_amount > 0 ? "+" : ""}
{exc.difference_amount.toFixed(2)}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* Matched Tab */}
<TabsContent value="matched">
<Card>
<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>
</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>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { motion } from "motion/react";
import {
FileText,
Filter,
ArrowRight,
Loader2,
ChevronLeft,
ChevronRight
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import ENDPOINTS from "@/lib/api/endpoints";
interface Task {
id: number;
period: string;
status: string;
total_employees: number;
matched_count: number;
exception_count: number;
created_at: string;
completed_at: string | null;
}
const statusLabels: Record<string, { label: string; color: string; bgColor: string }> = {
PENDING: { label: "待处理", color: "text-gray-700 dark:text-gray-300", bgColor: "bg-gray-100 dark:bg-gray-800" },
PROCESSING: { label: "处理中", color: "text-blue-700 dark:text-blue-300", bgColor: "bg-blue-100 dark:bg-blue-900/50" },
MAPPING_COMPLETED: { label: "映射完成", color: "text-amber-700 dark:text-amber-300", bgColor: "bg-amber-100 dark:bg-amber-900/50" },
COMPLETED: { label: "已完成", color: "text-emerald-700 dark:text-emerald-300", bgColor: "bg-emerald-100 dark:bg-emerald-900/50" },
FAILED: { label: "失败", color: "text-red-700 dark:text-red-300", bgColor: "bg-red-100 dark:bg-red-900/50" },
};
// 生成可选月份列表(近12个月)
const getAvailablePeriods = () => {
const periods = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
periods.push(`${year}-${month}`);
}
return periods;
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
return `${year}${parseInt(month)}`;
};
export default function TasksPage() {
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPeriod, setSelectedPeriod] = useState<string>("");
const [availablePeriods] = useState(getAvailablePeriods);
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);
} catch (error) {
console.error("加载任务列表失败:", error);
} finally {
setLoading(false);
}
}, [selectedPeriod]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const getMatchRate = (task: Task) => {
if (task.total_employees === 0) return "0%";
const rate = Math.round((task.matched_count / task.total_employees) * 100);
return `${rate}%`;
};
return (
<div className="min-h-full p-6 lg:p-8 max-w-6xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
className="mb-8"
>
<h1 className="text-heading-1 text-foreground mb-2"></h1>
<p className="text-muted-foreground text-body">
</p>
</motion.div>
{/* Filter Bar */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className="mb-6 flex items-center gap-4 flex-wrap"
>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-body-small text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={selectedPeriod === "" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedPeriod("")}
className="text-body-small"
>
</Button>
{availablePeriods.slice(0, 6).map((period) => (
<Button
key={period}
variant={selectedPeriod === period ? "default" : "outline"}
size="sm"
onClick={() => setSelectedPeriod(period)}
className="text-body-small"
>
{formatPeriod(period)}
</Button>
))}
</div>
{selectedPeriod && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedPeriod("")}
className="text-caption"
>
</Button>
)}
</motion.div>
{/* Tasks List */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : tasks.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardContent className="p-12 text-center">
<div className="w-16 h-16 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-heading-3 text-foreground mb-2"></h3>
<p className="text-body text-muted-foreground mb-6">
{selectedPeriod
? `没有 ${formatPeriod(selectedPeriod)} 的对账任务`
: "当前没有任何对账任务"
}
</p>
<Button onClick={() => router.push("/tasks/new")}>
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</CardContent>
</Card>
</motion.div>
) : (
<div className="space-y-4">
{tasks.map((task, index) => (
<motion.div
key={task.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: index * 0.05,
}}
>
<Card
className="cursor-pointer hover-lift transition-all group"
onClick={() => router.push(`/tasks/${task.id}/result`)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
<FileText className="w-6 h-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-heading-3 text-foreground">
{formatPeriod(task.period)}
</h3>
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusLabels[task.status]?.bgColor} ${statusLabels[task.status]?.color}`}>
{statusLabels[task.status]?.label || task.status}
</span>
</div>
<div className="flex items-center gap-6 text-body-small text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{task.total_employees}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="font-medium text-emerald-600">{task.matched_count}</span>
</span>
<span className="flex items-center gap-1.5">
<span className={`font-medium ${task.exception_count > 0 ? "text-amber-600" : "text-muted-foreground"}`}>
{task.exception_count}
</span>
</span>
<span className="flex items-center gap-1.5">
: <span className="font-medium text-foreground">{getMatchRate(task)}</span>
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-caption text-muted-foreground">
{task.completed_at
? new Date(task.completed_at).toLocaleDateString("zh-CN")
: new Date(task.created_at).toLocaleDateString("zh-CN")
}
</span>
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
</div>
</div>
</CardContent>
</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>
);
}