Files
s2f/frontend/app/(dashboard)/tasks/page.tsx
T
freedakgmail feebbb10ac refactor: 统一分页组件和UI优化
- 使用公共Pagination组件统一各页面的分页逻辑
- 优化exceptions页面异常列表展示
- 简化settings/rules页面规则管理
- 后端tasks.py增加分页参数支持
2026-07-07 13:53:54 +08:00

272 lines
10 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,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/ui/pagination";
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 [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const loadTasks = useCallback(async () => {
try {
setLoading(true);
const params = {
page,
page_size: pageSize,
...(selectedPeriod ? { period: selectedPeriod } : {})
};
const res = await api.get<{ items: Task[]; total: number }>(ENDPOINTS.TASK.LIST, { params });
setTasks(res.data.items || []);
setTotal(res.data.total || 0);
} catch (error) {
console.error("加载任务列表失败:", error);
} finally {
setLoading(false);
}
}, [page, pageSize, selectedPeriod]);
useEffect(() => {
loadTasks();
}, [loadTasks, page, pageSize, selectedPeriod]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
}
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-7xl mx-auto space-y-6">
{/* 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="mb-4">
<Pagination
total={total}
page={page}
pageSize={pageSize}
onChange={onPageChange}
/>
</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>
</>
)}
</div>
);
}