33b4c734aa
后端: - 新增认证(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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
import { ChevronDown, ChevronRight, Eye, Check } from "lucide-react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { ConfidenceBadge } from "./ConfidenceBadge";
|
|
import type { FieldMappingResponse } from "@/lib/api/types";
|
|
|
|
interface FieldMappingTableProps {
|
|
mappings: FieldMappingResponse[];
|
|
fileName: string;
|
|
fileType: string;
|
|
onUpdate?: (mappingId: number, standardField: string) => void;
|
|
onSkip?: (mappingId: number) => void;
|
|
onConfirm?: (mappingId: number) => void;
|
|
onConfirmAll?: (mappingIds: number[]) => void;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
const STANDARD_FIELDS = [
|
|
{ value: "员工姓名", label: "员工姓名" },
|
|
{ value: "工号", label: "工号" },
|
|
{ value: "部门", label: "部门" },
|
|
{ value: "岗位", label: "岗位" },
|
|
{ value: "基本工资", label: "基本工资" },
|
|
{ value: "奖金", label: "奖金" },
|
|
{ value: "补贴", label: "补贴" },
|
|
{ value: "加班费", label: "加班费" },
|
|
{ value: "扣款", label: "扣款" },
|
|
{ value: "应发工资", label: "应发工资" },
|
|
{ value: "实发工资", label: "实发工资" },
|
|
{ value: "银行账号", label: "银行账号" },
|
|
{ value: "身份证号", label: "身份证号" },
|
|
{ value: "社保基数", label: "社保基数" },
|
|
{ value: "养老保险", label: "养老保险" },
|
|
{ value: "医疗保险", label: "医疗保险" },
|
|
{ value: "失业保险", label: "失业保险" },
|
|
{ value: "公积金", label: "公积金" },
|
|
{ value: "个税", label: "个税" },
|
|
{ value: "应税收入", label: "应税收入" },
|
|
{ value: "税后收入", label: "税后收入" },
|
|
];
|
|
|
|
export function FieldMappingTable({
|
|
mappings,
|
|
fileName,
|
|
fileType,
|
|
onUpdate,
|
|
onSkip,
|
|
onConfirm,
|
|
onConfirmAll,
|
|
readOnly = false,
|
|
}: FieldMappingTableProps) {
|
|
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [editValue, setEditValue] = useState("");
|
|
|
|
const toggleExpand = (id: number) => {
|
|
const newExpanded = new Set(expandedRows);
|
|
if (newExpanded.has(id)) {
|
|
newExpanded.delete(id);
|
|
} else {
|
|
newExpanded.add(id);
|
|
}
|
|
setExpandedRows(newExpanded);
|
|
};
|
|
|
|
const startEdit = (id: number, currentValue: string) => {
|
|
setEditingId(id);
|
|
setEditValue(currentValue);
|
|
};
|
|
|
|
const saveEdit = (id: number) => {
|
|
if (onUpdate && editValue) {
|
|
onUpdate(id, editValue);
|
|
}
|
|
setEditingId(null);
|
|
setEditValue("");
|
|
};
|
|
|
|
const handleConfirmAll = () => {
|
|
if (onConfirmAll) {
|
|
const unconfirmedIds = mappings
|
|
.filter((m) => !m.confirmed && !m.is_skipped && m.standard_field)
|
|
.map((m) => m.id);
|
|
onConfirmAll(unconfirmedIds);
|
|
}
|
|
};
|
|
|
|
const confirmedCount = mappings.filter((m) => m.confirmed).length;
|
|
const unconfirmedCount = mappings.length - confirmedCount;
|
|
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
{/* 表头 */}
|
|
<div className="bg-muted/50 px-5 py-4 border-b border-border">
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-sm font-medium text-foreground">{fileName}</div>
|
|
<Badge variant="secondary">{fileType}</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<span className="text-muted-foreground">
|
|
已确认: <span className="font-semibold text-emerald-600">{confirmedCount}</span>
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
待确认: <span className="font-semibold text-amber-600">{unconfirmedCount}</span>
|
|
</span>
|
|
{!readOnly && unconfirmedCount > 0 && (
|
|
<Button onClick={handleConfirmAll} size="sm" className="btn-press">
|
|
<Check className="w-4 h-4 mr-1" />
|
|
批量确认
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 表格 */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-muted/30">
|
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground w-10" />
|
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
源字段
|
|
</th>
|
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
标准字段
|
|
</th>
|
|
<th className="px-4 py-3 text-center font-medium text-muted-foreground w-24">
|
|
置信度
|
|
</th>
|
|
<th className="px-4 py-3 text-center font-medium text-muted-foreground w-20">
|
|
状态
|
|
</th>
|
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground w-36">
|
|
操作
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{mappings.map((mapping, index) => (
|
|
<motion.tr
|
|
key={mapping.id}
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
|
className={cn(
|
|
"group hover:bg-muted/30 transition-colors",
|
|
mapping.is_skipped && "opacity-50",
|
|
mapping.confirmed && "bg-emerald-50/30 dark:bg-emerald-950/10"
|
|
)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<button
|
|
onClick={() => toggleExpand(mapping.id)}
|
|
className="p-1 hover:bg-muted rounded-md transition-colors"
|
|
>
|
|
{expandedRows.has(mapping.id) ? (
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-foreground">
|
|
{mapping.source_field}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{editingId === mapping.id ? (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
className="flex-1 px-2 py-1.5 border border-input rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<option value="">选择标准字段</option>
|
|
{STANDARD_FIELDS.map((field) => (
|
|
<option key={field.value} value={field.value}>
|
|
{field.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => saveEdit(mapping.id)}
|
|
className="h-7 w-7 p-0 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : mapping.standard_field ? (
|
|
<span className="text-foreground">{mapping.standard_field}</span>
|
|
) : (
|
|
<span className="text-muted-foreground italic">未识别</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<ConfidenceBadge confidence={mapping.confidence} />
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
{mapping.is_skipped ? (
|
|
<Badge variant="secondary" className="text-xs">已跳过</Badge>
|
|
) : mapping.confirmed ? (
|
|
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-xs font-medium">
|
|
已确认
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs">待确认</Badge>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{!readOnly && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="flex items-center justify-end gap-1"
|
|
>
|
|
{editingId !== mapping.id && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => startEdit(mapping.id, mapping.standard_field)}
|
|
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/30"
|
|
>
|
|
修改
|
|
</Button>
|
|
{!mapping.confirmed && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onConfirm?.(mapping.id)}
|
|
className="h-7 px-2 text-xs text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
|
|
>
|
|
确认
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onSkip?.(mapping.id)}
|
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
跳过
|
|
</Button>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</td>
|
|
</motion.tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{mappings.length === 0 && (
|
|
<div className="px-4 py-12 text-center">
|
|
<p className="text-muted-foreground">暂无字段映射数据</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 展开详情 */}
|
|
<AnimatePresence>
|
|
{Array.from(expandedRows).map((id) => {
|
|
const mapping = mappings.find((m) => m.id === id);
|
|
if (!mapping || !mapping.sample_values) return null;
|
|
return (
|
|
<motion.div
|
|
key={`detail-${id}`}
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-4 py-4 bg-muted/30 border-t border-border">
|
|
<div className="space-y-3 max-w-2xl">
|
|
{mapping.reasoning && (
|
|
<div className="text-sm">
|
|
<span className="font-medium text-muted-foreground">判断依据: </span>
|
|
<span className="text-foreground">{mapping.reasoning}</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-sm font-medium text-muted-foreground">样例值: </span>
|
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
|
{(mapping.sample_values as unknown as string[]).slice(0, 5).map((value, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-background border border-border rounded-md text-xs text-foreground"
|
|
>
|
|
<Eye className="w-3 h-3 text-muted-foreground" />
|
|
{String(value)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default FieldMappingTable; |