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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
"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>
|
|
);
|
|
} |