Files
s2f/frontend/app/(dashboard)/tasks/[id]/mapping/page.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

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>
);
}