feebbb10ac
- 使用公共Pagination组件统一各页面的分页逻辑 - 优化exceptions页面异常列表展示 - 简化settings/rules页面规则管理 - 后端tasks.py增加分页参数支持
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
} |