Files
s2f/frontend/components/mapping/FieldMappingTable.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

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;