Files
s2f/frontend/app/(dashboard)/tasks/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

257 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}