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