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

397 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { motion } from "motion/react";
import {
FileText,
AlertTriangle,
CheckCircle,
TrendingUp,
Clock,
ArrowRight,
Loader2
} 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 TaskStats {
monthly_tasks: number;
pending_exceptions: number;
completed_tasks: number;
match_rate: number;
current_period: string;
}
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 }> = {
PENDING: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
PROCESSING: { label: "处理中", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" },
MAPPING_COMPLETED: { label: "映射完成", color: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300" },
COMPLETED: { label: "已完成", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300" },
FAILED: { label: "失败", color: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300" },
};
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.1,
},
},
};
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 DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<TaskStats | null>(null);
const [recentTasks, setRecentTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const loadDashboardData = useCallback(async () => {
setLoading(true);
try {
// 并行获取统计数据和任务列表
const [statsRes, tasksRes] = await Promise.all([
api.get<TaskStats>(ENDPOINTS.TASK.STATS),
api.get<{ items: Task[] }>(ENDPOINTS.TASK.LIST, { params: { page: 1, page_size: 5 } }),
]);
setStats(statsRes.data);
setRecentTasks(tasksRes.data.items || []);
} catch (error) {
console.error("加载数据失败:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadDashboardData();
}, [loadDashboardData]);
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
return `${year}${parseInt(month)}`;
};
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>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<>
{/* Stats Grid */}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"
>
<motion.div variants={item}>
<Card className="relative overflow-hidden hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
</p>
<p className="text-3xl font-semibold tracking-tight text-foreground">
{stats?.monthly_tasks ?? 0}
</p>
<div className="flex items-center gap-1 mt-2">
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-caption text-muted-foreground">
{stats?.current_period ? formatPeriod(stats.current_period) : "-"}
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
<FileText className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-blue-50 dark:bg-blue-950/30 opacity-50" />
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="relative overflow-hidden hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
</p>
<p className="text-3xl font-semibold tracking-tight text-foreground">
{stats?.pending_exceptions ?? 0}
</p>
<div className="flex items-center gap-1 mt-2">
<AlertTriangle className="w-3 h-3 text-amber-500" />
<span className="text-caption text-muted-foreground">
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
<AlertTriangle className="w-5 h-5 text-amber-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-amber-50 dark:bg-amber-950/30 opacity-50" />
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="relative overflow-hidden hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
</p>
<p className="text-3xl font-semibold tracking-tight text-foreground">
{stats?.completed_tasks ?? 0}
</p>
<div className="flex items-center gap-1 mt-2">
<CheckCircle className="w-3 h-3 text-emerald-500" />
<span className="text-caption text-muted-foreground">
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
<CheckCircle className="w-5 h-5 text-emerald-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-emerald-50 dark:bg-emerald-950/30 opacity-50" />
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="relative overflow-hidden hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
</p>
<p className="text-3xl font-semibold tracking-tight text-foreground">
{stats?.match_rate ?? 0}%
</p>
<div className="flex items-center gap-1 mt-2">
<TrendingUp className="w-3 h-3 text-violet-500" />
<span className="text-caption text-muted-foreground">
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
<TrendingUp className="w-5 h-5 text-violet-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-violet-50 dark:bg-violet-950/30 opacity-50" />
</Card>
</motion.div>
</motion.div>
{/* Recent Tasks */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
className="mb-8"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-heading-3 text-foreground"></h2>
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/tasks")}
>
<ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
{recentTasks.length === 0 ? (
<Card>
<CardContent className="p-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
<FileText className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-body text-muted-foreground mb-2">
</p>
<p className="text-caption text-muted-foreground">
使
</p>
<Button
className="mt-4 btn-press"
onClick={() => router.push("/tasks/new")}
>
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{recentTasks.map((task, index) => (
<motion.div
key={task.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: 0.1 + index * 0.05,
}}
>
<Card
className="cursor-pointer hover-lift transition-all"
onClick={() => router.push(`/tasks/${task.id}/result`)}
>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-muted">
<FileText className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">
{formatPeriod(task.period)}
</h3>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusLabels[task.status]?.color || statusLabels.PENDING.color}`}>
{statusLabels[task.status]?.label || task.status}
</span>
</div>
<p className="text-caption text-muted-foreground">
{task.total_employees} · {task.matched_count} · {task.exception_count}
</p>
</div>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
</CardContent>
</Card>
</motion.div>
))}
</div>
)}
</motion.div>
{/* Quick Actions */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
>
<h2 className="text-heading-3 text-foreground mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/tasks/new")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
<FileText className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-primary transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/exceptions")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-amber-500/10 transition-colors">
<AlertTriangle className="w-5 h-5 text-muted-foreground group-hover:text-amber-500 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-amber-500 transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-amber-500 group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/settings/rules")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-violet-500/10 transition-colors">
<TrendingUp className="w-5 h-5 text-muted-foreground group-hover:text-violet-500 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-violet-500 transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-violet-500 group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
</div>
</motion.div>
</>
)}
</div>
);
}