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:
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { motion } from "motion/react";
|
||||
import { Eye, EyeOff, Lock, Mail, AlertCircle, UserCircle } from "lucide-react";
|
||||
|
||||
import { useAuthStore } from "@/lib/stores/auth-store";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().min(6, "密码至少6个字符"),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
// 测试用户数据
|
||||
const TEST_USERS = [
|
||||
{ name: "管理员", email: "admin@xingchen.com", password: "admin123", role: "管理员" },
|
||||
{ name: "财务主管", email: "finance@xingchen.com", password: "finance123", role: "财务主管" },
|
||||
{ name: "会计", email: "accountant@xingchen.com", password: "account123", role: "会计" },
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// onSubmit 必须在 handleSubmit 之前定义
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await api.post(ENDPOINTS.AUTH.LOGIN, data);
|
||||
const { access_token, user } = response.data;
|
||||
|
||||
// 保存到 localStorage
|
||||
login(user, access_token);
|
||||
|
||||
// 设置 cookie
|
||||
document.cookie = `auth_token=${access_token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
document.cookie = `company_id=${user.company_id}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
||||
// 直接跳转
|
||||
router.push("/dashboard");
|
||||
} catch (err: unknown) {
|
||||
const error = err as { message?: string };
|
||||
setError(error.message || "登录失败,请检查邮箱和密码");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
// 快速填充测试用户
|
||||
const fillTestUser = (email: string, password: string) => {
|
||||
setValue("email", email);
|
||||
setValue("password", password);
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/30">
|
||||
{/* Background decoration */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-1/4 w-1/2 h-1/2 bg-primary/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 -right-1/4 w-1/2 h-1/2 bg-primary/3 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
>
|
||||
{/* Logo & Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="text-center mb-8"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary/10 mb-4">
|
||||
<Lock className="w-7 h-7 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-heading-1 text-foreground mb-2">财务AI助手</h1>
|
||||
<p className="text-muted-foreground">财务AI助手</p>
|
||||
</motion.div>
|
||||
|
||||
<Card className="shadow-lg border-muted">
|
||||
<CardContent className="p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-heading-2 text-foreground mb-6 text-center">
|
||||
登录账户
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* 测试用户快捷登录 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
className="mb-6 p-4 rounded-lg bg-muted/30 border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserCircle className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-body-small text-muted-foreground">快速登录测试账号</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TEST_USERS.map((user) => (
|
||||
<button
|
||||
key={user.email}
|
||||
type="button"
|
||||
onClick={() => fillTestUser(user.email, user.password)}
|
||||
className="flex flex-col items-center p-2 rounded-lg bg-background hover:bg-primary/5 border border-border hover:border-primary/30 transition-all text-left"
|
||||
>
|
||||
<span className="text-body-small font-medium text-foreground">{user.name}</span>
|
||||
<span className="text-caption text-muted-foreground truncate w-full text-center">{user.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-start gap-3 p-4 rounded-lg bg-destructive/10 border border-destructive/20"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-body-small font-medium text-foreground">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
{...register("email")}
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="your@email.com"
|
||||
className={cn(
|
||||
"pl-10 h-11 transition-all",
|
||||
errors.email && "border-destructive focus:ring-destructive/20"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-caption text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-body-small font-medium text-foreground">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
{...register("password")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
placeholder="输入密码"
|
||||
className={cn(
|
||||
"pl-10 pr-10 h-11 transition-all",
|
||||
errors.password && "border-destructive focus:ring-destructive/20"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-caption text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-11 text-body font-medium btn-press"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
登录中...
|
||||
</span>
|
||||
) : (
|
||||
"登录"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-caption text-muted-foreground text-center mt-6">
|
||||
登录即表示您同意我们的服务条款
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Help Text */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
className="text-caption text-muted-foreground text-center mt-6"
|
||||
>
|
||||
遇到问题?请联系管理员
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
"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<Task[]>(ENDPOINTS.TASK.LIST, { params: { limit: 5 } }),
|
||||
]);
|
||||
|
||||
setStats(statsRes.data);
|
||||
setRecentTasks(tasksRes.data);
|
||||
} 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">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
Search,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
task_id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: "pending" | "processing" | "resolved" | "ignored";
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
description: string;
|
||||
detail: string | null;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
bank_amount: number | null;
|
||||
difference_amount: number | null;
|
||||
resolution: string | null;
|
||||
handler: string | null;
|
||||
note: string | null;
|
||||
suggested_action: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
by_status: Record<string, number>;
|
||||
by_severity: Record<string, number>;
|
||||
by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
low: { label: "低", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", icon: "bg-blue-500" },
|
||||
medium: { label: "中", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", icon: "bg-yellow-500" },
|
||||
high: { label: "高", color: "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300", icon: "bg-orange-500" },
|
||||
critical: { label: "严重", color: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", icon: "bg-red-500" },
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
pending: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||||
processing: { label: "处理中", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300" },
|
||||
confirmed: { label: "已确认", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
||||
resolved: { label: "已解决", color: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" },
|
||||
ignored: { label: "已忽略", color: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500" },
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
amount_mismatch: "金额不一致",
|
||||
missing_record: "记录缺失",
|
||||
duplicate_record: "重复记录",
|
||||
format_error: "格式错误",
|
||||
logic_error: "逻辑错误",
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
function ExceptionsContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||||
const [status, setStatus] = useState(searchParams.get("status") || "");
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
|
||||
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId, status, severity, exceptionType, page]);
|
||||
|
||||
async function fetchExceptions() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ items: ExceptionItem[]; total: number }>("/api/exceptions/", {
|
||||
params: {
|
||||
task_id: taskId || undefined,
|
||||
status: status || undefined,
|
||||
severity: severity || undefined,
|
||||
exception_type: exceptionType || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
});
|
||||
setExceptions(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("加载异常列表失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSummary() {
|
||||
try {
|
||||
const res = await api.get<Summary>("/api/exceptions/summary", {
|
||||
params: { task_id: taskId || undefined },
|
||||
});
|
||||
setSummary(res.data);
|
||||
} catch (error) {
|
||||
console.error("加载汇总失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
setPage(1);
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}
|
||||
|
||||
function viewDetail(exception: ExceptionItem) {
|
||||
setSelectedException(exception);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, newStatus: string) {
|
||||
try {
|
||||
await api.patch(`/api/exceptions/${id}`, { status: newStatus });
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
} catch (error) {
|
||||
console.error("更新状态失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
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] }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-heading-1 text-foreground">异常处理</h1>
|
||||
<p className="text-muted-foreground mt-1">管理和处理对账过程中发现的异常</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">待处理</p>
|
||||
<p className="text-2xl font-semibold mt-1">{summary.by_status?.pending || 0}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-gray-100 dark:bg-gray-800">
|
||||
<Clock className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">处理中</p>
|
||||
<p className="text-2xl font-semibold mt-1 text-amber-600">{summary.by_status?.processing || 0}</p>
|
||||
</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>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">已解决</p>
|
||||
<p className="text-2xl font-semibold mt-1 text-emerald-600">{summary.by_status?.resolved || 0}</p>
|
||||
</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>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">严重异常</p>
|
||||
<p className="text-2xl font-semibold mt-1 text-red-600">{summary.by_severity?.critical || 0}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-red-50 dark:bg-red-950/30">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Filter Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">任务ID</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="输入任务ID"
|
||||
value={taskId}
|
||||
onChange={(e) => setTaskId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[150px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">状态</label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="resolved">已解决</SelectItem>
|
||||
<SelectItem value="ignored">已忽略</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[150px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">严重程度</label>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部程度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部程度</SelectItem>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="critical">严重</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[180px]">
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">异常类型</label>
|
||||
<Select value={exceptionType} onValueChange={setExceptionType}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部类型</SelectItem>
|
||||
<SelectItem value="amount_mismatch">金额不一致</SelectItem>
|
||||
<SelectItem value="missing_record">记录缺失</SelectItem>
|
||||
<SelectItem value="duplicate_record">重复记录</SelectItem>
|
||||
<SelectItem value="format_error">格式错误</SelectItem>
|
||||
<SelectItem value="logic_error">逻辑错误</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSearch} className="h-9 btn-press">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Exceptions Table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">异常列表</CardTitle>
|
||||
<Badge variant="secondary">{total} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>严重程度</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">差异金额</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
加载中...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : exceptions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-foreground font-medium">暂无异常记录</p>
|
||||
<p className="text-caption text-muted-foreground mt-1">所有记录都匹配成功</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
exceptions.map((exception, index) => (
|
||||
<motion.div
|
||||
key={exception.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
||||
>
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<Badge className={statusConfig[exception.status]?.color || "bg-gray-100 text-gray-700"}>
|
||||
{statusConfig[exception.status]?.label || exception.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exception.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{exception.employee_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exception.exception_type] || exception.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${severityConfig[exception.severity]?.icon || "bg-gray-500"}`} />
|
||||
<Badge className={severityConfig[exception.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||||
{severityConfig[exception.severity]?.label || exception.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<span className="truncate block">{exception.description}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{exception.difference_amount != null && (
|
||||
<span className={exception.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
||||
{exception.difference_amount > 0 ? "+" : ""}
|
||||
{exception.difference_amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="sm" onClick={() => viewDetail(exception)} className="h-8 w-8 p-0">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{exception.status === "pending" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => updateStatus(exception.id, "processing")} className="h-8 px-2">
|
||||
处理
|
||||
</Button>
|
||||
)}
|
||||
{exception.status === "processing" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => updateStatus(exception.id, "resolved")} className="h-8 px-2">
|
||||
完成
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
第 {page} 页,共 {totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="btn-press"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="btn-press"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>异常详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedException && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工姓名</p>
|
||||
<p className="font-medium">{selectedException.employee_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工ID</p>
|
||||
<p className="font-medium">{selectedException.employee_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">异常类型</p>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${severityConfig[selectedException.severity]?.icon || "bg-gray-500"}`} />
|
||||
<Badge className={severityConfig[selectedException.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||||
{severityConfig[selectedException.severity]?.label || selectedException.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-3">金额详情</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{selectedException.salary_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">工资</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.salary_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.social_security_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">社保</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.social_security_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.tax_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">个税</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.tax_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.bank_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">银行</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.bank_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedException.difference_amount != null && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">差异金额</p>
|
||||
<p className={`text-2xl font-mono font-semibold mt-1 ${selectedException.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{selectedException.difference_amount > 0 ? "+" : ""}
|
||||
{selectedException.difference_amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">描述</p>
|
||||
<p>{selectedException.description}</p>
|
||||
</div>
|
||||
{selectedException.detail && (
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">详细信息</p>
|
||||
<pre className="text-xs bg-muted p-3 rounded-lg overflow-auto">
|
||||
{JSON.stringify(JSON.parse(selectedException.detail), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.suggested_action && (
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">建议操作</p>
|
||||
<p>{selectedException.suggested_action}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExceptionsPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<ExceptionsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-[calc(100vh-3.5rem)]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { ENDPOINTS } from "@/lib/api/endpoints";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import type { CompanyRuleResponse } from "@/lib/api/types";
|
||||
|
||||
// 模拟数据
|
||||
const mockRules: CompanyRuleResponse[] = [
|
||||
{
|
||||
id: 1,
|
||||
company_id: 1,
|
||||
rule_type: "FIELD_MAPPING",
|
||||
match_condition: { source_field: "员工姓名" },
|
||||
target_value: "员工姓名",
|
||||
priority: 0,
|
||||
status: "ACTIVE",
|
||||
description: "工资表员工姓名字段映射",
|
||||
match_count: 15,
|
||||
last_used_at: "2024-01-15T10:30:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
company_id: 1,
|
||||
rule_type: "FIELD_MAPPING",
|
||||
match_condition: { source_field: "基本薪资" },
|
||||
target_value: "基本工资",
|
||||
priority: 0,
|
||||
status: "ACTIVE",
|
||||
description: "工资表基本薪资字段映射",
|
||||
match_count: 12,
|
||||
last_used_at: "2024-01-15T10:30:00Z",
|
||||
created_at: "2024-01-05T00:00:00Z",
|
||||
updated_at: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
company_id: 1,
|
||||
rule_type: "FIELD_MAPPING",
|
||||
match_condition: { source_field: "实发合计" },
|
||||
target_value: "实发工资",
|
||||
priority: 1,
|
||||
status: "INACTIVE",
|
||||
description: "工资表实发合计字段映射(已停用)",
|
||||
match_count: 8,
|
||||
last_used_at: "2024-01-10T00:00:00Z",
|
||||
created_at: "2024-01-08T00:00:00Z",
|
||||
updated_at: "2024-01-12T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const RULE_TYPE_LABELS: Record<string, string> = {
|
||||
FIELD_MAPPING: "字段映射",
|
||||
ACCOUNT_MAPPING: "科目映射",
|
||||
DEPARTMENT_MAPPING: "部门映射",
|
||||
};
|
||||
|
||||
export default function RulesPage() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const [rules, setRules] = useState<CompanyRuleResponse[]>(mockRules);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
|
||||
// 过滤规则
|
||||
const filteredRules = rules.filter((rule) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
rule.match_condition.source_field?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
rule.target_value.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
rule.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter || rule.rule_type === typeFilter;
|
||||
const matchesStatus = !statusFilter || rule.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
// 切换规则状态
|
||||
const handleToggleStatus = useCallback(
|
||||
async (ruleId: number, currentStatus: string) => {
|
||||
const newStatus = currentStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
|
||||
try {
|
||||
await api.patch(ENDPOINTS.MAPPING.RULE_STATUS(ruleId), {
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
setRules((prev) =>
|
||||
prev.map((rule) =>
|
||||
rule.id === ruleId ? { ...rule, status: newStatus } : rule
|
||||
)
|
||||
);
|
||||
|
||||
toast.success(`规则已${newStatus === "ACTIVE" ? "启用" : "停用"}`);
|
||||
} catch {
|
||||
toast.error("状态更新失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 删除规则
|
||||
const handleDeleteRule = useCallback(
|
||||
async (ruleId: number) => {
|
||||
if (!confirm("确定要删除这条规则吗?")) return;
|
||||
|
||||
try {
|
||||
await api.delete(ENDPOINTS.MAPPING.RULE_DELETE(ruleId));
|
||||
|
||||
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
|
||||
toast.success("规则已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
const activeCount = rules.filter((r) => r.status === "ACTIVE").length;
|
||||
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
规则管理
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
管理企业字段映射规则,实现自动识别
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{rules.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">总规则数</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">启用中</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-gray-400">{inactiveCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">已停用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索规则..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="FIELD_MAPPING">字段映射</option>
|
||||
<option value="ACCOUNT_MAPPING">科目映射</option>
|
||||
<option value="DEPARTMENT_MAPPING">部门映射</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">启用</option>
|
||||
<option value="INACTIVE">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
规则类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
匹配条件
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
目标字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
匹配次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
最近使用
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{filteredRules.map((rule) => (
|
||||
<tr
|
||||
key={rule.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-xs">
|
||||
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{rule.target_value}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
|
||||
{rule.match_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
rule.status === "ACTIVE"
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? "启用" : "停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
|
||||
{rule.last_used_at
|
||||
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(rule.id, rule.status)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
rule.status === "ACTIVE"
|
||||
? "text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30"
|
||||
: "text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
title={rule.status === "ACTIVE" ? "停用" : "启用"}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredRules.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
暂无规则数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="font-medium text-blue-800 dark:text-blue-300 mb-2">
|
||||
规则说明
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<li>• 字段映射规则会在上传同类文件时自动应用</li>
|
||||
<li>• 精确匹配的规则(完全相同的字段名)会优先于模糊匹配</li>
|
||||
<li>• 停用规则不会在自动识别中应用,但不会删除</li>
|
||||
<li>• 删除规则后,下次上传相同格式文件需要重新确认映射</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, CheckCircle, Loader2, FileSpreadsheet } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { ENDPOINTS } from "@/lib/api/endpoints";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FieldMappingTable } from "@/components/mapping/FieldMappingTable";
|
||||
import { AISuggestionPanel } from "@/components/mapping/AISuggestionPanel";
|
||||
import type { TaskMappingOverview } from "@/lib/api/types";
|
||||
|
||||
// 模拟数据(实际应从 API 获取)
|
||||
const mockMappingData: TaskMappingOverview = {
|
||||
task_id: 1,
|
||||
total_files: 3,
|
||||
total_fields: 18,
|
||||
confirmed_fields: 5,
|
||||
needs_review: true,
|
||||
files: [
|
||||
{
|
||||
file_id: 1,
|
||||
file_type: "工资表",
|
||||
file_name: "2024年1月工资表.xlsx",
|
||||
total_fields: 6,
|
||||
confirmed_fields: 2,
|
||||
high_confidence: 4,
|
||||
medium_confidence: 1,
|
||||
low_confidence: 1,
|
||||
mappings: [
|
||||
{
|
||||
id: 1,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "员工姓名",
|
||||
standard_field: "员工姓名",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: ["张三", "李四", "王五"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
confirmed_by: 1,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "工号",
|
||||
standard_field: "工号",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: ["EMP001", "EMP002"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
confirmed_by: 1,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "基本薪资",
|
||||
standard_field: "基本工资",
|
||||
confidence: 0.85,
|
||||
reasoning: "字段名相似,样例值验证为数值类型",
|
||||
sample_values: [8000, 10000, 12000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "岗位津贴",
|
||||
standard_field: "补贴",
|
||||
confidence: 0.75,
|
||||
reasoning: "字段名包含关键词:'津贴'",
|
||||
sample_values: [500, 800, 1000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "实发合计",
|
||||
standard_field: "实发工资",
|
||||
confidence: 0.6,
|
||||
reasoning: "字段名部分匹配,需要确认",
|
||||
sample_values: [8500, 10800, 13000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
company_id: 1,
|
||||
file_id: 1,
|
||||
source_field: "备注",
|
||||
standard_field: "",
|
||||
confidence: 0.0,
|
||||
reasoning: "无法识别字段类型",
|
||||
sample_values: ["正常", "补发"],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file_id: 2,
|
||||
file_type: "社保表",
|
||||
file_name: "2024年1月社保表.xlsx",
|
||||
total_fields: 6,
|
||||
confirmed_fields: 2,
|
||||
high_confidence: 3,
|
||||
medium_confidence: 2,
|
||||
low_confidence: 1,
|
||||
mappings: [
|
||||
{
|
||||
id: 7,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "姓名",
|
||||
standard_field: "员工姓名",
|
||||
confidence: 0.95,
|
||||
reasoning: "字段名包含关键词:'姓名'",
|
||||
sample_values: ["张三", "李四"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "养老保险",
|
||||
standard_field: "养老保险",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: [800, 960, 1200],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "医疗",
|
||||
standard_field: "医疗保险",
|
||||
confidence: 0.8,
|
||||
reasoning: "字段名部分匹配",
|
||||
sample_values: [200, 240, 300],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "失业",
|
||||
standard_field: "失业保险",
|
||||
confidence: 0.75,
|
||||
reasoning: "字段名相似",
|
||||
sample_values: [50, 60, 75],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "公积金",
|
||||
standard_field: "公积金",
|
||||
confidence: 0.5,
|
||||
reasoning: "需要确认具体含义",
|
||||
sample_values: [500, 600, 750],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
company_id: 1,
|
||||
file_id: 2,
|
||||
source_field: "合计",
|
||||
standard_field: "社保合计",
|
||||
confidence: 0.6,
|
||||
reasoning: "可能是合计字段",
|
||||
sample_values: [1550, 1800, 2325],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file_id: 3,
|
||||
file_type: "个税表",
|
||||
file_name: "2024年1月个税表.xlsx",
|
||||
total_fields: 6,
|
||||
confirmed_fields: 1,
|
||||
high_confidence: 2,
|
||||
medium_confidence: 3,
|
||||
low_confidence: 1,
|
||||
mappings: [
|
||||
{
|
||||
id: 13,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "姓名",
|
||||
standard_field: "员工姓名",
|
||||
confidence: 0.95,
|
||||
reasoning: "字段名包含关键词:'姓名'",
|
||||
sample_values: ["张三", "李四"],
|
||||
is_skipped: false,
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "应税工资",
|
||||
standard_field: "应税收入",
|
||||
confidence: 0.9,
|
||||
reasoning: "字段名包含关键词:'应税'",
|
||||
sample_values: [8000, 10000],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "专项扣除",
|
||||
standard_field: "税前扣除",
|
||||
confidence: 0.8,
|
||||
reasoning: "可能为税前扣除项",
|
||||
sample_values: [1000, 1500],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "个税",
|
||||
standard_field: "应缴个税",
|
||||
confidence: 0.7,
|
||||
reasoning: "可能为个税字段",
|
||||
sample_values: [200, 300],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "税后工资",
|
||||
standard_field: "税后收入",
|
||||
confidence: 0.85,
|
||||
reasoning: "字段名相似",
|
||||
sample_values: [6800, 8500],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
company_id: 1,
|
||||
file_id: 3,
|
||||
source_field: "银行账号",
|
||||
standard_field: "银行账号",
|
||||
confidence: 1.0,
|
||||
reasoning: "字段名完全匹配",
|
||||
sample_values: ["6222021234567890"],
|
||||
is_skipped: false,
|
||||
confirmed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.08 },
|
||||
},
|
||||
};
|
||||
|
||||
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 MappingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const taskId = params.id as string;
|
||||
|
||||
const [data, setData] = useState<TaskMappingOverview | null>(mockMappingData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeFile, setActiveFile] = useState<number>(mockMappingData?.files[0]?.file_id || 1);
|
||||
|
||||
// 统计
|
||||
const totalHigh = data?.files.reduce((acc, f) => acc + f.high_confidence, 0) || 0;
|
||||
const totalMedium = data?.files.reduce((acc, f) => acc + f.medium_confidence, 0) || 0;
|
||||
const totalLow = data?.files.reduce((acc, f) => acc + f.low_confidence, 0) || 0;
|
||||
|
||||
// 更新映射
|
||||
const handleUpdateMapping = useCallback(
|
||||
async (mappingId: number, standardField: string) => {
|
||||
try {
|
||||
await api.put(ENDPOINTS.MAPPING.UPDATE(mappingId), {
|
||||
standard_field: standardField,
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => ({
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
m.id === mappingId ? { ...m, standard_field: standardField, confidence: 1.0 } : m
|
||||
),
|
||||
}));
|
||||
return { ...prev, files: newFiles };
|
||||
});
|
||||
|
||||
toast.success("字段已更新");
|
||||
} catch {
|
||||
toast.error("更新失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 跳过映射
|
||||
const handleSkipMapping = useCallback(
|
||||
async (mappingId: number) => {
|
||||
try {
|
||||
await api.put(ENDPOINTS.MAPPING.UPDATE(mappingId), {
|
||||
is_skipped: true,
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => ({
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
m.id === mappingId ? { ...m, is_skipped: true } : m
|
||||
),
|
||||
}));
|
||||
return { ...prev, files: newFiles };
|
||||
});
|
||||
|
||||
toast.success("字段已跳过");
|
||||
} catch {
|
||||
toast.error("操作失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 确认单个映射
|
||||
const handleConfirmMapping = useCallback(
|
||||
async (mappingId: number) => {
|
||||
try {
|
||||
await api.post(ENDPOINTS.MAPPING.CONFIRM, {
|
||||
mapping_ids: [mappingId],
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => ({
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
m.id === mappingId
|
||||
? { ...m, confirmed: true, confirmed_at: new Date().toISOString() }
|
||||
: m
|
||||
),
|
||||
confirmed_fields: file.mappings.filter(
|
||||
(m) => m.id === mappingId || m.confirmed
|
||||
).length,
|
||||
}));
|
||||
const totalConfirmed = newFiles.reduce((acc, f) => acc + f.confirmed_fields, 0);
|
||||
return { ...prev, files: newFiles, confirmed_fields: totalConfirmed };
|
||||
});
|
||||
|
||||
toast.success("字段已确认");
|
||||
} catch {
|
||||
toast.error("确认失败");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 批量确认
|
||||
const handleConfirmAll = useCallback(
|
||||
async (mappingIds: number[]) => {
|
||||
if (mappingIds.length === 0) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.post(ENDPOINTS.MAPPING.CONFIRM, {
|
||||
mapping_ids: mappingIds,
|
||||
});
|
||||
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newFiles = prev.files.map((file) => {
|
||||
const fileIds = file.mappings.map((m) => m.id);
|
||||
const confirmedIds = mappingIds.filter((id) => fileIds.includes(id));
|
||||
return {
|
||||
...file,
|
||||
mappings: file.mappings.map((m) =>
|
||||
confirmedIds.includes(m.id)
|
||||
? { ...m, confirmed: true, confirmed_at: new Date().toISOString() }
|
||||
: m
|
||||
),
|
||||
confirmed_fields: file.mappings.filter(
|
||||
(m) => m.confirmed || confirmedIds.includes(m.id)
|
||||
).length,
|
||||
};
|
||||
});
|
||||
const totalConfirmed = newFiles.reduce((acc, f) => acc + f.confirmed_fields, 0);
|
||||
return { ...prev, files: newFiles, confirmed_fields: totalConfirmed };
|
||||
});
|
||||
|
||||
toast.success(`已确认 ${mappingIds.length} 个字段`);
|
||||
} catch {
|
||||
toast.error("批量确认失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 确认并进入下一步
|
||||
const handleConfirmAndNext = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
toast.success("字段映射已确认,正在进入对账...");
|
||||
router.push(`/tasks/${taskId}/reconciliation`);
|
||||
} catch {
|
||||
toast.error("操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId, router, toast]);
|
||||
|
||||
const currentFile = data?.files.find((f) => f.file_id === activeFile);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard")} className="hover:bg-muted">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-heading-1 text-foreground">字段映射确认</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
任务 #{taskId} - 确认 AI 识别的字段映射关系
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleConfirmAndNext} disabled={loading} className="btn-press">
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
确认并进入对账
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* AI 建议面板 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<AISuggestionPanel
|
||||
totalFields={data.total_fields}
|
||||
highConfidence={totalHigh}
|
||||
mediumConfidence={totalMedium}
|
||||
lowConfidence={totalLow}
|
||||
confirmedCount={data.confirmed_fields}
|
||||
onAutoConfirm={() => {
|
||||
const ids = data.files
|
||||
.flatMap((f) => f.mappings)
|
||||
.filter((m) => !m.confirmed && !m.is_skipped && m.standard_field)
|
||||
.map((m) => m.id);
|
||||
handleConfirmAll(ids);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 文件标签页 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div className="flex gap-2 border-b border-border overflow-x-auto">
|
||||
{data.files.map((file) => (
|
||||
<button
|
||||
key={file.file_id}
|
||||
onClick={() => setActiveFile(file.file_id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors ${
|
||||
activeFile === file.file_id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
{file.file_type}
|
||||
<Badge variant={activeFile === file.file_id ? "default" : "secondary"} className="ml-1 text-xs">
|
||||
{file.confirmed_fields}/{file.total_fields}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 字段映射表格 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{currentFile && (
|
||||
<FieldMappingTable
|
||||
mappings={currentFile.mappings}
|
||||
fileName={currentFile.file_name}
|
||||
fileType={currentFile.file_type}
|
||||
onUpdate={handleUpdateMapping}
|
||||
onSkip={handleSkipMapping}
|
||||
onConfirm={handleConfirmMapping}
|
||||
onConfirmAll={handleConfirmAll}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 文件概览卡片 */}
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{data.files.map((file) => (
|
||||
<motion.div key={file.file_id} variants={item}>
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover-lift ${
|
||||
activeFile === file.file_id
|
||||
? "ring-2 ring-primary"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveFile(file.file_id)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<FileSpreadsheet className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{file.file_type}
|
||||
</h3>
|
||||
<p className="text-caption text-muted-foreground truncate">
|
||||
{file.file_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption text-muted-foreground">
|
||||
已确认 {file.confirmed_fields}/{file.total_fields}
|
||||
</span>
|
||||
{file.low_confidence > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{file.low_confidence} 个待检查
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* 置信度进度条 */}
|
||||
<div className="mt-3 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(file.confirmed_fields / file.total_fields) * 100}%` }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="h-full bg-primary rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
Users,
|
||||
AlertOctagon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TaskDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
period: string;
|
||||
status: string;
|
||||
total_employees: number;
|
||||
matched_count: number;
|
||||
exception_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface ReconciliationResult {
|
||||
task_id: number;
|
||||
period: string;
|
||||
total_employees: number;
|
||||
matched_employees: number;
|
||||
exception_count: number;
|
||||
exceptions_by_type: Record<string, number>;
|
||||
exceptions_by_severity: Record<string, number>;
|
||||
execution_time_ms: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: string;
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
description: string;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
bank_amount: number | null;
|
||||
difference_amount: number | null;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
low: { label: "低", bg: "bg-blue-100 dark:bg-blue-900/40", text: "text-blue-700 dark:text-blue-300" },
|
||||
medium: { label: "中", bg: "bg-yellow-100 dark:bg-yellow-900/40", text: "text-yellow-700 dark:text-yellow-300" },
|
||||
high: { label: "高", bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300" },
|
||||
critical: { label: "严重", bg: "bg-red-100 dark:bg-red-900/40", text: "text-red-700 dark:text-red-300" },
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
amount_mismatch: "金额不一致",
|
||||
missing_record: "记录缺失",
|
||||
duplicate_record: "重复记录",
|
||||
format_error: "格式错误",
|
||||
logic_error: "逻辑错误",
|
||||
rule_violation: "规则违规",
|
||||
threshold_exceeded: "阈值超限",
|
||||
};
|
||||
|
||||
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: taskId } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [result, setResult] = useState<ReconciliationResult | null>(null);
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchResultRef = useRef<() => Promise<void>>();
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<TaskDetail>(`/api/tasks/${taskId}`);
|
||||
setTask(res.data);
|
||||
|
||||
if (res.data.status === "COMPLETED" && fetchResultRef.current) {
|
||||
fetchResultRef.current();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务详情失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchResult = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ReconciliationResult>(`/api/reconciliation/result/${taskId}`);
|
||||
setResult(res.data);
|
||||
} catch (error) {
|
||||
console.error("获取对账结果失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
// Store fetchResult in ref to avoid circular dependency
|
||||
useEffect(() => {
|
||||
fetchResultRef.current = fetchResult;
|
||||
}, [fetchResult]);
|
||||
|
||||
const fetchExceptions = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<{ items: ExceptionItem[] }>(`/api/exceptions/`, {
|
||||
params: { task_id: taskId, page_size: 100 },
|
||||
});
|
||||
setExceptions(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error("获取异常列表失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
fetchTaskDetail();
|
||||
fetchExceptions();
|
||||
}
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions]);
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await api.get<Blob>(`/api/exports/task/${taskId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `对账结果_${task?.period || taskId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("导出失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportKingdee() {
|
||||
try {
|
||||
const res = await api.get<Blob>(`/api/exports/kingdee/${taskId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `金蝶凭证_${task?.period || taskId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("导出金蝶凭证失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">任务不存在</p>
|
||||
<Button className="mt-4 btn-press" onClick={() => router.back()}>
|
||||
返回
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const matchRate = task.total_employees > 0
|
||||
? ((task.matched_count / task.total_employees) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
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] }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} className="hover:bg-muted">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-heading-1 text-foreground">{task.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
对账期间: {task.period}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={handleExport} className="btn-press">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出结果
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportKingdee} className="btn-press">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
导出金蝶凭证
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">总人数</p>
|
||||
<p className="text-3xl font-semibold mt-1">{task.total_employees}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">匹配人数</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-emerald-600">{task.matched_count}</p>
|
||||
</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>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">异常数量</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-red-600">{task.exception_count}</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-red-50 dark:bg-red-950/30">
|
||||
<AlertOctagon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">匹配率</p>
|
||||
<p className="text-3xl font-semibold mt-1">{matchRate}%</p>
|
||||
</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>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${matchRate}%` }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tabs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList className="bg-muted/50">
|
||||
<TabsTrigger value="overview" className="data-[state=active]:bg-background">概览统计</TabsTrigger>
|
||||
<TabsTrigger value="exceptions" className="data-[state=active]:bg-background">
|
||||
异常列表
|
||||
{task.exception_count > 0 && (
|
||||
<Badge variant="destructive" className="ml-2">
|
||||
{task.exception_count}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="matched" className="data-[state=active]:bg-background">已匹配列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
{/* Exception Types */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">异常类型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result?.exceptions_by_type && Object.keys(result.exceptions_by_type).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(result.exceptions_by_type).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{typeLabels[type] || type}
|
||||
</span>
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Severity Distribution */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">严重程度分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result?.exceptions_by_severity && Object.keys(result.exceptions_by_severity).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["critical", "high", "medium", "low"].map((severity) => {
|
||||
const count = result.exceptions_by_severity[severity] || 0;
|
||||
const config = severityConfig[severity as keyof typeof severityConfig];
|
||||
return (
|
||||
<div key={severity} className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg}`}>
|
||||
<div className={`w-2 h-2 rounded-full bg-current ${config.text}`} />
|
||||
<span className={`text-sm font-medium ${config.text}`}>
|
||||
{config.label}: {count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Execution Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">执行信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">创建时间</p>
|
||||
<p className="text-sm font-medium mt-1">{new Date(task.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">完成时间</p>
|
||||
<p className="text-sm font-medium mt-1">
|
||||
{task.completed_at ? new Date(task.completed_at).toLocaleString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
{result && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">执行时间</p>
|
||||
<p className="text-sm font-medium mt-1">{result.execution_time_ms?.toFixed(0) || '-'}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">结果生成时间</p>
|
||||
<p className="text-sm font-medium mt-1">
|
||||
{result.completed_at ? new Date(result.completed_at).toLocaleString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Exceptions Tab */}
|
||||
<TabsContent value="exceptions">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">异常列表</CardTitle>
|
||||
<Badge variant="secondary">{exceptions.length} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{exceptions.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-50 dark:bg-emerald-950/30 mx-auto mb-4 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">完美匹配!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">所有记录都匹配成功</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>严重程度</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">差异金额</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exceptions.map((exc) => (
|
||||
<TableRow key={exc.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exc.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{exc.employee_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exc.exception_type] || exc.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${severityConfig[exc.severity].bg} ${severityConfig[exc.severity].text}`}>
|
||||
{severityConfig[exc.severity].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[250px]">
|
||||
<span className="truncate block">{exc.description}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{exc.difference_amount != null && (
|
||||
<span className={exc.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
||||
{exc.difference_amount > 0 ? "+" : ""}
|
||||
{exc.difference_amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Matched Tab */}
|
||||
<TabsContent value="matched">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">已匹配列表</CardTitle>
|
||||
<Badge variant="secondary">{task.matched_count} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-50 dark:bg-emerald-950/30 mx-auto mb-4 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">所有记录都匹配成功!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {task.matched_count} 条记录通过所有对账规则
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* 通用的 API 代理路由
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
+306
-15
@@ -1,26 +1,317 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/geist/400.css";
|
||||
@import "@fontsource/geist/500.css";
|
||||
@import "@fontsource/geist/600.css";
|
||||
@import "@fontsource/geist/700.css";
|
||||
@import "@fontsource/ibm-plex-sans/400.css";
|
||||
@import "@fontsource/ibm-plex-sans/500.css";
|
||||
@import "@fontsource/ibm-plex-sans/600.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Design System Tokens */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Colors - Professional Blue-Gray Palette */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 220 14% 18%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 14% 18%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 14% 18%;
|
||||
|
||||
/* Primary - Professional Blue */
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary - Cool Gray */
|
||||
--secondary: 220 13% 95%;
|
||||
--secondary-foreground: 220 14% 18%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 220 13% 95%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 220 13% 95%;
|
||||
--accent-foreground: 220 14% 18%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.06), 0 2px 4px -2px rgb(0 0 0 / 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
/* Dark Mode - Professional Dark */
|
||||
.dark {
|
||||
--background: 220 17% 10%;
|
||||
--foreground: 210 17% 95%;
|
||||
--card: 220 17% 12%;
|
||||
--card-foreground: 210 17% 95%;
|
||||
--popover: 220 17% 12%;
|
||||
--popover-foreground: 210 17% 95%;
|
||||
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 220 17% 10%;
|
||||
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 17% 95%;
|
||||
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
|
||||
--accent: 217 33% 17%;
|
||||
--accent-foreground: 210 17% 95%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 17% 95%;
|
||||
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 224 76% 48%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Base Styles */
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: "Geist", "IBM Plex Sans", system-ui, sans-serif;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Smooth Scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Focus Visible */
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Animation Utilities */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Staggered Animation Delays */
|
||||
.stagger-1 { animation-delay: 50ms; }
|
||||
.stagger-2 { animation-delay: 100ms; }
|
||||
.stagger-3 { animation-delay: 150ms; }
|
||||
.stagger-4 { animation-delay: 200ms; }
|
||||
.stagger-5 { animation-delay: 250ms; }
|
||||
|
||||
/* Transition Utilities */
|
||||
.transition-base {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.transition-bounce {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.hover-lift {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Button Active Effect */
|
||||
.btn-press:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Glass Effect */
|
||||
.glass {
|
||||
background: hsl(var(--background) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography Scale */
|
||||
.text-display {
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-heading-1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-heading-2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-heading-3 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.text-body-small {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
+10
-19
@@ -1,20 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "财务AI助手",
|
||||
description: "AI 驱动的工资对账系统",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +13,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased font-sans">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-63
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
redirect('/dashboard');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
interface PermissionGateProps {
|
||||
children: React.ReactNode;
|
||||
permission?: string;
|
||||
role?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限门控组件
|
||||
* 根据权限码或角色控制组件显示
|
||||
*/
|
||||
export function PermissionGate({
|
||||
children,
|
||||
permission,
|
||||
role,
|
||||
fallback = null,
|
||||
}: PermissionGateProps) {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
if (!user) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (permission && !user.permissions?.includes(permission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// 检查角色
|
||||
if (role && user.role !== role) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 受保护路由组件
|
||||
* 检查用户登录状态,未登录则重定向到登录页
|
||||
*/
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, user, router]);
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 调用后端登出接口
|
||||
await api.post(ENDPOINTS.AUTH.LOGOUT);
|
||||
} catch {
|
||||
// 即使后端失败也清除本地状态
|
||||
}
|
||||
|
||||
// 清除本地状态
|
||||
logout();
|
||||
|
||||
// 跳转到登录页
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
{/* 左侧 - Logo 和系统名称 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-shield w-4 h-4 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">财务AI助手</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 用户信息和登出 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted/50">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{user.full_name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{user.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: "工作台",
|
||||
href: "/dashboard",
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
label: "对账任务",
|
||||
href: "/tasks",
|
||||
icon: FileText
|
||||
},
|
||||
{
|
||||
label: "异常处理",
|
||||
href: "/exceptions",
|
||||
icon: AlertTriangle
|
||||
},
|
||||
{
|
||||
label: "规则设置",
|
||||
href: "/settings/rules",
|
||||
icon: Settings
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-background transition-all duration-300 flex flex-col",
|
||||
collapsed ? "w-16" : "w-56"
|
||||
)}
|
||||
>
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all relative group",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeNav"
|
||||
className="absolute inset-0 bg-primary/10 rounded-lg -z-10"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className={cn("w-5 h-5 shrink-0", isActive && "text-primary")} />
|
||||
{!collapsed && (
|
||||
<span className="text-body font-medium">{item.label}</span>
|
||||
)}
|
||||
|
||||
{/* Tooltip for collapsed state */}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full ml-2 px-2 py-1 bg-foreground text-background text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center justify-center h-10 border-t border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { AlertTriangle, CheckCircle, Info, Sparkles } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AISuggestionPanelProps {
|
||||
totalFields: number;
|
||||
highConfidence: number;
|
||||
mediumConfidence: number;
|
||||
lowConfidence: number;
|
||||
confirmedCount: number;
|
||||
onAutoConfirm?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const config = {
|
||||
success: {
|
||||
bg: "bg-emerald-50 dark:bg-emerald-950/30",
|
||||
border: "border-emerald-200 dark:border-emerald-800",
|
||||
iconBg: "bg-emerald-100 dark:bg-emerald-900/50",
|
||||
iconColor: "text-emerald-600 dark:text-emerald-400",
|
||||
titleColor: "text-emerald-800 dark:text-emerald-300",
|
||||
descColor: "text-emerald-700 dark:text-emerald-400",
|
||||
buttonBg: "bg-emerald-600 hover:bg-emerald-700",
|
||||
progressBg: "bg-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-amber-50 dark:bg-amber-950/30",
|
||||
border: "border-amber-200 dark:border-amber-800",
|
||||
iconBg: "bg-amber-100 dark:bg-amber-900/50",
|
||||
iconColor: "text-amber-600 dark:text-amber-400",
|
||||
titleColor: "text-amber-800 dark:text-amber-300",
|
||||
descColor: "text-amber-700 dark:text-amber-400",
|
||||
buttonBg: "bg-amber-600 hover:bg-amber-700",
|
||||
progressBg: "bg-amber-500",
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50 dark:bg-blue-950/30",
|
||||
border: "border-blue-200 dark:border-blue-800",
|
||||
iconBg: "bg-blue-100 dark:bg-blue-900/50",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
titleColor: "text-blue-800 dark:text-blue-300",
|
||||
descColor: "text-blue-700 dark:text-blue-400",
|
||||
buttonBg: "bg-blue-600 hover:bg-blue-700",
|
||||
progressBg: "bg-blue-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function AISuggestionPanel({
|
||||
totalFields,
|
||||
highConfidence,
|
||||
mediumConfidence,
|
||||
lowConfidence,
|
||||
confirmedCount,
|
||||
onAutoConfirm,
|
||||
className,
|
||||
}: AISuggestionPanelProps) {
|
||||
const pendingCount = totalFields - confirmedCount;
|
||||
const allConfirmed = pendingCount === 0;
|
||||
const progressPercent = totalFields > 0 ? (confirmedCount / totalFields) * 100 : 0;
|
||||
|
||||
const getSuggestion = () => {
|
||||
if (allConfirmed) {
|
||||
return {
|
||||
type: "success" as const,
|
||||
icon: CheckCircle,
|
||||
title: "所有字段已确认",
|
||||
description: "字段映射已完成,可以进入下一步",
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowConfidence > 0) {
|
||||
return {
|
||||
type: "warning" as const,
|
||||
icon: AlertTriangle,
|
||||
title: "建议检查低置信度字段",
|
||||
description: `有 ${lowConfidence} 个字段置信度较低(<70%),建议人工确认`,
|
||||
action: "检查低置信度字段",
|
||||
};
|
||||
}
|
||||
|
||||
if (mediumConfidence > 0) {
|
||||
return {
|
||||
type: "info" as const,
|
||||
icon: Info,
|
||||
title: "存在中等置信度字段",
|
||||
description: `有 ${mediumConfidence} 个字段置信度为中等(70%-90%),建议确认`,
|
||||
action: "批量确认",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "success" as const,
|
||||
icon: Sparkles,
|
||||
title: "可直接确认",
|
||||
description: "所有字段置信度较高,可以批量确认",
|
||||
action: "批量确认",
|
||||
};
|
||||
};
|
||||
|
||||
const suggestion = getSuggestion();
|
||||
const Icon = suggestion.icon;
|
||||
const style = config[suggestion.type];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<Card className={cn(style.bg, style.border, className)}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1, type: "spring", stiffness: 200 }}
|
||||
className={cn("p-2.5 rounded-xl", style.iconBg)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5", style.iconColor)} />
|
||||
</motion.div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className={cn("font-semibold text-base", style.titleColor)}>
|
||||
{suggestion.title}
|
||||
</h3>
|
||||
<p className={cn("text-sm mt-1", style.descColor)}>
|
||||
{suggestion.description}
|
||||
</p>
|
||||
|
||||
{suggestion.action && !allConfirmed && onAutoConfirm && (
|
||||
<Button
|
||||
onClick={onAutoConfirm}
|
||||
size="sm"
|
||||
className={cn("mt-3 text-white btn-press", style.buttonBg)}
|
||||
>
|
||||
{suggestion.action}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Stats */}
|
||||
<div className="text-right shrink-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-bold text-foreground"
|
||||
>
|
||||
{confirmedCount}
|
||||
<span className="text-lg text-muted-foreground">/{totalFields}</span>
|
||||
</motion.div>
|
||||
<p className="text-caption text-muted-foreground">已确认</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between text-caption text-muted-foreground mb-1.5">
|
||||
<span>确认进度</span>
|
||||
<span>{Math.round(progressPercent)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progressPercent}%` }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className={cn("h-full rounded-full", style.progressBg)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence Distribution */}
|
||||
<div className="mt-5 pt-4 border-t border-border/50">
|
||||
<p className="text-caption text-muted-foreground mb-3">置信度分布</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
高置信度: <span className="font-semibold text-foreground">{highConfidence}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
中置信度: <span className="font-semibold text-foreground">{mediumConfidence}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
低置信度: <span className="font-semibold text-foreground">{lowConfidence}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AISuggestionPanel;
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfidenceBadgeProps {
|
||||
confidence: number;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfidenceBadge({
|
||||
confidence,
|
||||
showLabel = true,
|
||||
className,
|
||||
}: ConfidenceBadgeProps) {
|
||||
const getLevel = (value: number) => {
|
||||
if (value > 0.9) return "high";
|
||||
if (value >= 0.7) return "medium";
|
||||
return "low";
|
||||
};
|
||||
|
||||
const getLabel = (value: number) => {
|
||||
if (value > 0.9) return "高";
|
||||
if (value >= 0.7) return "中";
|
||||
return "低";
|
||||
};
|
||||
|
||||
const level = getLevel(confidence);
|
||||
const label = getLabel(confidence);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
level === "high" && "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||
level === "medium" && "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
|
||||
level === "low" && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showLabel ? label : `${(confidence * 100).toFixed(0)}%`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfidenceBadge;
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { ChevronDown, ChevronRight, Eye, Check } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||
import type { FieldMappingResponse } from "@/lib/api/types";
|
||||
|
||||
interface FieldMappingTableProps {
|
||||
mappings: FieldMappingResponse[];
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
onUpdate?: (mappingId: number, standardField: string) => void;
|
||||
onSkip?: (mappingId: number) => void;
|
||||
onConfirm?: (mappingId: number) => void;
|
||||
onConfirmAll?: (mappingIds: number[]) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const STANDARD_FIELDS = [
|
||||
{ value: "员工姓名", label: "员工姓名" },
|
||||
{ value: "工号", label: "工号" },
|
||||
{ value: "部门", label: "部门" },
|
||||
{ value: "岗位", label: "岗位" },
|
||||
{ value: "基本工资", label: "基本工资" },
|
||||
{ value: "奖金", label: "奖金" },
|
||||
{ value: "补贴", label: "补贴" },
|
||||
{ value: "加班费", label: "加班费" },
|
||||
{ value: "扣款", label: "扣款" },
|
||||
{ value: "应发工资", label: "应发工资" },
|
||||
{ value: "实发工资", label: "实发工资" },
|
||||
{ value: "银行账号", label: "银行账号" },
|
||||
{ value: "身份证号", label: "身份证号" },
|
||||
{ value: "社保基数", label: "社保基数" },
|
||||
{ value: "养老保险", label: "养老保险" },
|
||||
{ value: "医疗保险", label: "医疗保险" },
|
||||
{ value: "失业保险", label: "失业保险" },
|
||||
{ value: "公积金", label: "公积金" },
|
||||
{ value: "个税", label: "个税" },
|
||||
{ value: "应税收入", label: "应税收入" },
|
||||
{ value: "税后收入", label: "税后收入" },
|
||||
];
|
||||
|
||||
export function FieldMappingTable({
|
||||
mappings,
|
||||
fileName,
|
||||
fileType,
|
||||
onUpdate,
|
||||
onSkip,
|
||||
onConfirm,
|
||||
onConfirmAll,
|
||||
readOnly = false,
|
||||
}: FieldMappingTableProps) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
const newExpanded = new Set(expandedRows);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpandedRows(newExpanded);
|
||||
};
|
||||
|
||||
const startEdit = (id: number, currentValue: string) => {
|
||||
setEditingId(id);
|
||||
setEditValue(currentValue);
|
||||
};
|
||||
|
||||
const saveEdit = (id: number) => {
|
||||
if (onUpdate && editValue) {
|
||||
onUpdate(id, editValue);
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const handleConfirmAll = () => {
|
||||
if (onConfirmAll) {
|
||||
const unconfirmedIds = mappings
|
||||
.filter((m) => !m.confirmed && !m.is_skipped && m.standard_field)
|
||||
.map((m) => m.id);
|
||||
onConfirmAll(unconfirmedIds);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmedCount = mappings.filter((m) => m.confirmed).length;
|
||||
const unconfirmedCount = mappings.length - confirmedCount;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="bg-muted/50 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{fileName}</div>
|
||||
<Badge variant="secondary">{fileType}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
已确认: <span className="font-semibold text-emerald-600">{confirmedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
待确认: <span className="font-semibold text-amber-600">{unconfirmedCount}</span>
|
||||
</span>
|
||||
{!readOnly && unconfirmedCount > 0 && (
|
||||
<Button onClick={handleConfirmAll} size="sm" className="btn-press">
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
批量确认
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground w-10" />
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
源字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
标准字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground w-24">
|
||||
置信度
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground w-20">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground w-36">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{mappings.map((mapping, index) => (
|
||||
<motion.tr
|
||||
key={mapping.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className={cn(
|
||||
"group hover:bg-muted/30 transition-colors",
|
||||
mapping.is_skipped && "opacity-50",
|
||||
mapping.confirmed && "bg-emerald-50/30 dark:bg-emerald-950/10"
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleExpand(mapping.id)}
|
||||
className="p-1 hover:bg-muted rounded-md transition-colors"
|
||||
>
|
||||
{expandedRows.has(mapping.id) ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{mapping.source_field}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === mapping.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 border border-input rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">选择标准字段</option>
|
||||
{STANDARD_FIELDS.map((field) => (
|
||||
<option key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => saveEdit(mapping.id)}
|
||||
className="h-7 w-7 p-0 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : mapping.standard_field ? (
|
||||
<span className="text-foreground">{mapping.standard_field}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">未识别</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<ConfidenceBadge confidence={mapping.confidence} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{mapping.is_skipped ? (
|
||||
<Badge variant="secondary" className="text-xs">已跳过</Badge>
|
||||
) : mapping.confirmed ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-xs font-medium">
|
||||
已确认
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">待确认</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!readOnly && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-end gap-1"
|
||||
>
|
||||
{editingId !== mapping.id && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(mapping.id, mapping.standard_field)}
|
||||
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/30"
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
{!mapping.confirmed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onConfirm?.(mapping.id)}
|
||||
className="h-7 px-2 text-xs text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSkip?.(mapping.id)}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
跳过
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{mappings.length === 0 && (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="text-muted-foreground">暂无字段映射数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
<AnimatePresence>
|
||||
{Array.from(expandedRows).map((id) => {
|
||||
const mapping = mappings.find((m) => m.id === id);
|
||||
if (!mapping || !mapping.sample_values) return null;
|
||||
return (
|
||||
<motion.div
|
||||
key={`detail-${id}`}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-4 bg-muted/30 border-t border-border">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
{mapping.reasoning && (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-muted-foreground">判断依据: </span>
|
||||
<span className="text-foreground">{mapping.reasoning}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">样例值: </span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
{(mapping.sample_values as unknown as string[]).slice(0, 5).map((value, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-background border border-border rounded-md text-xs text-foreground"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-muted-foreground" />
|
||||
{String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldMappingTable;
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 主题提供者组件
|
||||
* 由于 next-themes 在 React 19 下有兼容性问题,暂时使用简化的实现
|
||||
* 仅传递 children,不进行主题切换
|
||||
*/
|
||||
export function ThemeProvider({ children, ..._props }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// SSR 时返回 children,避免 hydration mismatch
|
||||
if (!mounted) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const cardVariants = cva(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
destructive: "border-red-200 bg-red-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof cardVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -5,6 +5,12 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
// 禁用过度严格的 setState 规则,允许在 effect 中调用数据加载函数
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
import type { ApiError, ApiResponse } from './types';
|
||||
|
||||
// 创建 Axios 实例
|
||||
const createApiClient = (): AxiosInstance => {
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 自动添加 JWT Token
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 添加企业 ID
|
||||
const companyId = localStorage.getItem('company_id');
|
||||
if (companyId && config.headers) {
|
||||
config.headers['X-Company-ID'] = companyId;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError<ApiError>) => {
|
||||
// 统一错误处理
|
||||
const apiError: ApiError = {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: '未知错误',
|
||||
};
|
||||
|
||||
if (error.response) {
|
||||
// 服务器返回错误
|
||||
const { data, status } = error.response;
|
||||
|
||||
if (data && typeof data === 'object' && 'error' in data) {
|
||||
const errorData = data as { error: { code: string; message: string; details?: unknown } };
|
||||
apiError.code = errorData.error.code;
|
||||
apiError.message = errorData.error.message;
|
||||
apiError.details = errorData.error.details;
|
||||
} else {
|
||||
apiError.code = `HTTP_${status}`;
|
||||
apiError.message = error.message;
|
||||
}
|
||||
|
||||
// 401 未授权 - 跳转登录
|
||||
if (status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('company_id');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求发送但无响应
|
||||
apiError.code = 'NETWORK_ERROR';
|
||||
apiError.message = '网络错误,请检查连接';
|
||||
} else {
|
||||
// 其他错误
|
||||
apiError.message = error.message;
|
||||
}
|
||||
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
);
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
// API 客户端单例
|
||||
export const apiClient = createApiClient();
|
||||
|
||||
// 通用请求方法封装
|
||||
export const api = {
|
||||
get: async <T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> => {
|
||||
const response = await apiClient.get<T>(url, config);
|
||||
return { data: response.data, status: response.status };
|
||||
},
|
||||
|
||||
post: async <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> => {
|
||||
const response = await apiClient.post<T>(url, data, config);
|
||||
return { data: response.data, status: response.status };
|
||||
},
|
||||
|
||||
put: async <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> => {
|
||||
const response = await apiClient.put<T>(url, data, config);
|
||||
return { data: response.data, status: response.status };
|
||||
},
|
||||
|
||||
patch: async <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> => {
|
||||
const response = await apiClient.patch<T>(url, data, config);
|
||||
return { data: response.data, status: response.status };
|
||||
},
|
||||
|
||||
delete: async <T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> => {
|
||||
const response = await apiClient.delete<T>(url, config);
|
||||
return { data: response.data, status: response.status };
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* API 端点常量
|
||||
*/
|
||||
export const ENDPOINTS = {
|
||||
// 健康检查
|
||||
HEALTH: '/api/health',
|
||||
|
||||
// 认证相关
|
||||
AUTH: {
|
||||
LOGIN: '/api/auth/login',
|
||||
LOGOUT: '/api/auth/logout',
|
||||
ME: '/api/auth/me',
|
||||
REFRESH: '/api/auth/refresh',
|
||||
},
|
||||
|
||||
// 企业相关
|
||||
COMPANY: {
|
||||
LIST: '/api/companies',
|
||||
DETAIL: (id: number | string) => `/api/companies/${id}`,
|
||||
CREATE: '/api/companies',
|
||||
UPDATE: (id: number | string) => `/api/companies/${id}`,
|
||||
DELETE: (id: number | string) => `/api/companies/${id}`,
|
||||
},
|
||||
|
||||
// 用户相关
|
||||
USER: {
|
||||
LIST: '/api/users',
|
||||
DETAIL: (id: number | string) => `/api/users/${id}`,
|
||||
CREATE: '/api/users',
|
||||
UPDATE: (id: number | string) => `/api/users/${id}`,
|
||||
DELETE: (id: number | string) => `/api/users/${id}`,
|
||||
UPDATE_ROLE: (id: number | string) => `/api/users/${id}/role`,
|
||||
},
|
||||
|
||||
// 文件上传
|
||||
FILE: {
|
||||
UPLOAD: '/api/files/upload',
|
||||
DOWNLOAD: (id: number | string) => `/api/files/${id}/download`,
|
||||
DELETE: (id: number | string) => `/api/files/${id}`,
|
||||
},
|
||||
|
||||
// 任务相关
|
||||
TASK: {
|
||||
LIST: '/api/tasks',
|
||||
STATS: '/api/tasks/stats',
|
||||
DETAIL: (id: number | string) => `/api/tasks/${id}`,
|
||||
CREATE: '/api/tasks',
|
||||
UPDATE: (id: number | string) => `/api/tasks/${id}`,
|
||||
DELETE: (id: number | string) => `/api/tasks/${id}`,
|
||||
RECONCILE: (id: number | string) => `/api/reconciliation/execute/${id}`,
|
||||
RESULT: (id: number | string) => `/api/reconciliation/result/${id}`,
|
||||
EXPORT: (id: number | string) => `/api/exports/task/${id}`,
|
||||
},
|
||||
|
||||
// 异常相关
|
||||
ANOMALY: {
|
||||
LIST: '/api/anomalies',
|
||||
DETAIL: (id: number | string) => `/api/anomalies/${id}`,
|
||||
CONFIRM: (id: number | string) => `/api/anomalies/${id}/confirm`,
|
||||
IGNORE: (id: number | string) => `/api/anomalies/${id}/ignore`,
|
||||
},
|
||||
|
||||
// 分析相关
|
||||
ANALYSIS: {
|
||||
LABOR_COST: (taskId: number | string) => `/api/analysis/labor-cost/${taskId}`,
|
||||
TREND: '/api/analysis/trend',
|
||||
SUMMARY: '/api/analysis/summary',
|
||||
},
|
||||
|
||||
// 凭证相关
|
||||
VOUCHER: {
|
||||
GENERATE: (taskId: number | string) => `/api/vouchers/generate/${taskId}`,
|
||||
EXPORT: (taskId: number | string) => `/api/vouchers/export/${taskId}`,
|
||||
},
|
||||
|
||||
// 审计日志
|
||||
AUDIT: {
|
||||
LIST: '/api/audit-logs',
|
||||
},
|
||||
|
||||
// 字段映射
|
||||
MAPPING: {
|
||||
LIST: '/api/mappings',
|
||||
TASK_MAPPINGS: (taskId: number | string) => `/api/mappings/task/${taskId}`,
|
||||
RECOGNIZE: '/api/mappings/recognize',
|
||||
CONFIRM: '/api/mappings/confirm',
|
||||
DETAIL: (mappingId: number | string) => `/api/mappings/${mappingId}`,
|
||||
UPDATE: (mappingId: number | string) => `/api/mappings/${mappingId}`,
|
||||
SAVE_AS_RULE: (mappingId: number | string) => `/api/mappings/${mappingId}/save-as-rule`,
|
||||
RULES_LIST: '/api/mappings/rules/list',
|
||||
RULE_STATUS: (ruleId: number | string) => `/api/mappings/rules/${ruleId}/status`,
|
||||
RULE_DELETE: (ruleId: number | string) => `/api/mappings/rules/${ruleId}`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default ENDPOINTS;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* API 响应类型
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data: T;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 错误类型
|
||||
*/
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应类型
|
||||
*/
|
||||
export interface PaginatedResponse<T = unknown> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 ID 参数
|
||||
*/
|
||||
export interface IdParams {
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
// ===== 字段映射类型 =====
|
||||
|
||||
export interface FieldMappingResponse {
|
||||
id: number;
|
||||
company_id: number;
|
||||
file_id: number;
|
||||
source_field: string;
|
||||
standard_field: string;
|
||||
confidence: number;
|
||||
reasoning?: string;
|
||||
sample_values?: unknown[];
|
||||
is_skipped: boolean;
|
||||
confirmed: boolean;
|
||||
confirmed_by?: number;
|
||||
confirmed_at?: string;
|
||||
}
|
||||
|
||||
export interface FileMappingSummary {
|
||||
file_id: number;
|
||||
file_type: string;
|
||||
file_name: string;
|
||||
total_fields: number;
|
||||
confirmed_fields: number;
|
||||
high_confidence: number;
|
||||
medium_confidence: number;
|
||||
low_confidence: number;
|
||||
mappings: FieldMappingResponse[];
|
||||
}
|
||||
|
||||
export interface TaskMappingOverview {
|
||||
task_id: number;
|
||||
total_files: number;
|
||||
total_fields: number;
|
||||
confirmed_fields: number;
|
||||
needs_review: boolean;
|
||||
files: FileMappingSummary[];
|
||||
}
|
||||
|
||||
export interface CompanyRuleResponse {
|
||||
id: number;
|
||||
company_id: number;
|
||||
rule_type: string;
|
||||
match_condition: Record<string, unknown>;
|
||||
target_value: string;
|
||||
priority: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
match_count: number;
|
||||
last_used_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConfirmMappingsRequest {
|
||||
mapping_ids: number[];
|
||||
}
|
||||
|
||||
export interface ConfirmMappingsResponse {
|
||||
confirmed_count: number;
|
||||
mappings: FieldMappingResponse[];
|
||||
}
|
||||
|
||||
export interface SaveAsRuleResponse {
|
||||
rule_id: number;
|
||||
source_field: string;
|
||||
target_field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ===== 任务类型 =====
|
||||
|
||||
export interface ReconciliationTask {
|
||||
id: number;
|
||||
company_id: number;
|
||||
name: string;
|
||||
period: string;
|
||||
status: string;
|
||||
file_ids?: Record<string, number>;
|
||||
mapping_completed: boolean;
|
||||
mapping_confirmed_at?: string;
|
||||
mapping_confirmed_by?: number;
|
||||
total_employees?: number;
|
||||
matched_count?: number;
|
||||
exception_count?: number;
|
||||
reconciliation_result?: unknown;
|
||||
error_message?: string;
|
||||
created_by?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 异步请求状态
|
||||
*/
|
||||
export interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步请求 Hook
|
||||
* 用于管理异步请求的加载状态、数据和错误
|
||||
*/
|
||||
export function useAsync<T = unknown>() {
|
||||
const [state, setState] = useState<AsyncState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const execute = useCallback(async (asyncFunction: () => Promise<T>) => {
|
||||
setState({ data: null, loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await asyncFunction();
|
||||
setState({ data: result, loading: false, error: null });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
setState({ data: null, loading: false, error: err });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ data: null, loading: false, error: null });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAsync;
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 确认对话框配置
|
||||
*/
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: 'info' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认对话框状态
|
||||
*/
|
||||
interface ConfirmState {
|
||||
isOpen: boolean;
|
||||
options: ConfirmOptions | null;
|
||||
resolve: ((value: boolean) => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认对话框 Hook
|
||||
* 用于显示确认对话框并等待用户响应
|
||||
*/
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
isOpen: false,
|
||||
options: null,
|
||||
resolve: null,
|
||||
});
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setState({
|
||||
isOpen: true,
|
||||
options: {
|
||||
title: '确认',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
type: 'info',
|
||||
...options,
|
||||
},
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (state.resolve) {
|
||||
state.resolve(true);
|
||||
}
|
||||
setState({ isOpen: false, options: null, resolve: null });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.resolve]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (state.resolve) {
|
||||
state.resolve(false);
|
||||
}
|
||||
setState({ isOpen: false, options: null, resolve: null });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.resolve]);
|
||||
|
||||
return {
|
||||
confirm,
|
||||
isOpen: state.isOpen,
|
||||
options: state.options,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
};
|
||||
}
|
||||
|
||||
export default useConfirm;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 防抖 Hook
|
||||
* 延迟执行函数,直到停止调用一段时间后才执行
|
||||
*
|
||||
* @param value - 要防抖的值
|
||||
* @param delay - 延迟时间(毫秒)
|
||||
* @returns 防抖后的值
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖回调 Hook
|
||||
* 延迟执行回调函数
|
||||
*
|
||||
* @param callback - 要防抖的回调函数
|
||||
* @param delay - 延迟时间(毫秒)
|
||||
* @returns 防抖后的回调函数
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number = 500
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const callbackRef = useRef<T>(callback);
|
||||
|
||||
// 保持最新的 callback 引用
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
}, delay);
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
}
|
||||
|
||||
export default useDebounce;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
/**
|
||||
* 权限检查 Hook
|
||||
*/
|
||||
export function usePermission() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
/**
|
||||
* 检查用户是否有指定权限
|
||||
*/
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
if (!user) return false;
|
||||
return user.permissions?.includes(permission) ?? false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否有指定角色
|
||||
*/
|
||||
const hasRole = (role: string): boolean => {
|
||||
if (!user) return false;
|
||||
return user.role === role;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否有任一权限
|
||||
*/
|
||||
const hasAnyPermission = (permissions: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
return permissions.some((permission) => hasPermission(permission));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否拥有所有权限
|
||||
*/
|
||||
const hasAllPermissions = (permissions: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
return permissions.every((permission) => hasPermission(permission));
|
||||
};
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
hasRole,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePermission;
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Toast 消息类型
|
||||
*/
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
/**
|
||||
* Toast 消息接口
|
||||
*/
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Hook
|
||||
* 用于显示全局通知消息
|
||||
*/
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string, duration: number = 3000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random()}`;
|
||||
const toast: Toast = { id, type, message, duration };
|
||||
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
const success = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
return addToast('success', message, duration);
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const error = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
return addToast('error', message, duration);
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const info = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
return addToast('info', message, duration);
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const warning = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
return addToast('warning', message, duration);
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
export default useToast;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* 用户信息接口
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
company_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证状态接口
|
||||
*/
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// Actions
|
||||
login: (user: User, token: string) => void;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
updateUser: (userData: Partial<User>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证状态 Store
|
||||
* 使用 localStorage 持久化
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: (user, token) => {
|
||||
// 保存到 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('company_id', String(user.company_id));
|
||||
}
|
||||
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
// 清除 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('company_id');
|
||||
// 清除 cookie
|
||||
document.cookie = 'auth_token=; path=/; max-age=0; SameSite=Lax';
|
||||
document.cookie = 'company_id=; path=/; max-age=0; SameSite=Lax';
|
||||
}
|
||||
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
set({ user, isAuthenticated: true });
|
||||
},
|
||||
|
||||
updateUser: (userData) => {
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...userData } : null,
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* 企业信息接口
|
||||
*/
|
||||
export interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
plan: string;
|
||||
status: string;
|
||||
max_users: number;
|
||||
is_trial: boolean;
|
||||
data_retention_months: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业状态接口
|
||||
*/
|
||||
interface CompanyState {
|
||||
currentCompany: Company | null;
|
||||
companies: Company[];
|
||||
|
||||
// Actions
|
||||
setCompany: (company: Company) => void;
|
||||
setCompanies: (companies: Company[]) => void;
|
||||
addCompany: (company: Company) => void;
|
||||
updateCompany: (companyId: number, companyData: Partial<Company>) => void;
|
||||
removeCompany: (companyId: number) => void;
|
||||
clearCompanies: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业状态 Store
|
||||
* 使用 localStorage 持久化
|
||||
*/
|
||||
export const useCompanyStore = create<CompanyState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentCompany: null,
|
||||
companies: [],
|
||||
|
||||
setCompany: (company) => {
|
||||
// 保存当前企业 ID 到 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('company_id', String(company.id));
|
||||
}
|
||||
|
||||
set({ currentCompany: company });
|
||||
},
|
||||
|
||||
setCompanies: (companies) => {
|
||||
set({ companies });
|
||||
},
|
||||
|
||||
addCompany: (company) => {
|
||||
set((state) => ({
|
||||
companies: [...state.companies, company],
|
||||
}));
|
||||
},
|
||||
|
||||
updateCompany: (companyId, companyData) => {
|
||||
set((state) => ({
|
||||
companies: state.companies.map((c) =>
|
||||
c.id === companyId ? { ...c, ...companyData } : c
|
||||
),
|
||||
currentCompany:
|
||||
state.currentCompany?.id === companyId
|
||||
? { ...state.currentCompany, ...companyData }
|
||||
: state.currentCompany,
|
||||
}));
|
||||
},
|
||||
|
||||
removeCompany: (companyId) => {
|
||||
set((state) => ({
|
||||
companies: state.companies.filter((c) => c.id !== companyId),
|
||||
currentCompany:
|
||||
state.currentCompany?.id === companyId ? null : state.currentCompany,
|
||||
}));
|
||||
},
|
||||
|
||||
clearCompanies: () => {
|
||||
set({ companies: [], currentCompany: null });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'company-storage',
|
||||
partialize: (state) => ({
|
||||
currentCompany: state.currentCompany,
|
||||
companies: state.companies,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* UI 状态接口
|
||||
*/
|
||||
interface UIState {
|
||||
// 侧边栏状态
|
||||
sidebarOpen: boolean;
|
||||
|
||||
// 主题
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
loadingMessage: string;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
setLoading: (loading: boolean, message?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 状态 Store
|
||||
* 使用 localStorage 持久化部分状态
|
||||
*/
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarOpen: true,
|
||||
theme: 'light',
|
||||
loading: false,
|
||||
loadingMessage: '',
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen }));
|
||||
},
|
||||
|
||||
setSidebarOpen: (open) => {
|
||||
set({ sidebarOpen: open });
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
// 更新 DOM 元素的主题类
|
||||
if (typeof window !== 'undefined') {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
}
|
||||
}
|
||||
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
setLoading: (loading, message = '') => {
|
||||
set({ loading, loadingMessage: message });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
partialize: (state) => ({
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
theme: state.theme,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig;
|
||||
Generated
+880
-2
@@ -8,12 +8,19 @@
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.9",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"axios": "^1.18.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -21,6 +28,7 @@
|
||||
"react-hook-form": "^7.81.0",
|
||||
"recharts": "^3.9.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -465,6 +473,62 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource/geist": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmmirror.com/@fontsource/geist/-/geist-5.2.9.tgz",
|
||||
"integrity": "sha512-6QMur8g+3/9Uvfv/5chLJGBc9bYVzWijFrWEl0uZnitxp6wEPqKCJd9/GBlOk4dg/QSe96a3TUKEgmscFpE+4g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||
@@ -1330,6 +1394,605 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.2.tgz",
|
||||
"integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.4.tgz",
|
||||
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
|
||||
"integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.11.tgz",
|
||||
"integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-slot": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
|
||||
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.4.tgz",
|
||||
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz",
|
||||
"integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.14",
|
||||
"@radix-ui/react-focus-guards": "1.1.4",
|
||||
"@radix-ui/react-focus-scope": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-portal": "1.1.13",
|
||||
"@radix-ui/react-presence": "1.1.6",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
|
||||
"integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz",
|
||||
"integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||
"@radix-ui/react-use-effect-event": "0.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
|
||||
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz",
|
||||
"integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz",
|
||||
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
|
||||
"integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.11",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2",
|
||||
"@radix-ui/react-use-rect": "1.1.2",
|
||||
"@radix-ui/react-use-size": "1.1.2",
|
||||
"@radix-ui/rect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
|
||||
"integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
|
||||
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz",
|
||||
"integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz",
|
||||
"integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-collection": "1.1.11",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-direction": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.3.2.tgz",
|
||||
"integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.2",
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-collection": "1.1.11",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-direction": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.14",
|
||||
"@radix-ui/react-focus-guards": "1.1.4",
|
||||
"@radix-ui/react-focus-scope": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.3.2",
|
||||
"@radix-ui/react-portal": "1.1.13",
|
||||
"@radix-ui/react-presence": "1.1.6",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2",
|
||||
"@radix-ui/react-use-previous": "1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.7",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
|
||||
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz",
|
||||
"integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.4",
|
||||
"@radix-ui/react-context": "1.1.4",
|
||||
"@radix-ui/react-direction": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-presence": "1.1.6",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-roving-focus": "1.1.14",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
|
||||
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
|
||||
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
|
||||
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
|
||||
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz",
|
||||
"integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
|
||||
"integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
|
||||
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz",
|
||||
"integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.2.tgz",
|
||||
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
@@ -1786,7 +2449,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2522,6 +3185,18 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@@ -3317,6 +3992,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -4201,6 +4882,33 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.42.2.tgz",
|
||||
"integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.42.2",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -4288,6 +4996,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
||||
@@ -5609,6 +6326,47 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmmirror.com/motion/-/motion-12.42.2.tgz",
|
||||
"integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.42.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.42.2.tgz",
|
||||
"integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
@@ -6198,6 +6956,75 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.9.2.tgz",
|
||||
@@ -6879,9 +7706,17 @@
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz",
|
||||
@@ -7242,6 +8077,49 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
@@ -9,12 +9,19 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.9",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"axios": "^1.18.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -22,6 +29,7 @@
|
||||
"react-hook-form": "^7.81.0",
|
||||
"recharts": "^3.9.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
Search,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
task_id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: "pending" | "processing" | "resolved" | "ignored";
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
description: string;
|
||||
detail: string | null;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
bank_amount: number | null;
|
||||
difference_amount: number | null;
|
||||
resolution: string | null;
|
||||
handler: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
by_status: Record<string, number>;
|
||||
by_severity: Record<string, number>;
|
||||
by_type: Record<string, number>;
|
||||
}
|
||||
|
||||
const severityColors = {
|
||||
low: "bg-blue-100 text-blue-800",
|
||||
medium: "bg-yellow-100 text-yellow-800",
|
||||
high: "bg-orange-100 text-orange-800",
|
||||
critical: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
pending: <Clock className="h-4 w-4 text-gray-500" />,
|
||||
processing: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
|
||||
resolved: <CheckCircle className="h-4 w-4 text-green-500" />,
|
||||
ignored: <XCircle className="h-4 w-4 text-gray-400" />,
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
pending: "bg-gray-100 text-gray-800",
|
||||
processing: "bg-yellow-100 text-yellow-800",
|
||||
resolved: "bg-green-100 text-green-800",
|
||||
ignored: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
export default function ExceptionsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||||
const [status, setStatus] = useState(searchParams.get("status") || "");
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
|
||||
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const fetchExceptions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ items: ExceptionItem[]; total: number }>("/api/exceptions/", {
|
||||
params: {
|
||||
task_id: taskId || undefined,
|
||||
status: status || undefined,
|
||||
severity: severity || undefined,
|
||||
exception_type: exceptionType || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
});
|
||||
setExceptions(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("加载异常列表失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId, status, severity, exceptionType, page, pageSize]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<Summary>("/api/exceptions/summary", {
|
||||
params: { task_id: taskId || undefined },
|
||||
});
|
||||
setSummary(res.data);
|
||||
} catch (error) {
|
||||
console.error("加载汇总失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}, [fetchExceptions, fetchSummary]);
|
||||
|
||||
function handleSearch() {
|
||||
setPage(1);
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}
|
||||
|
||||
function viewDetail(exception: ExceptionItem) {
|
||||
setSelectedException(exception);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, newStatus: string) {
|
||||
try {
|
||||
await api.patch(`/api/exceptions/${id}`, { status: newStatus });
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
} catch (error) {
|
||||
console.error("更新状态失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">异常处理</h1>
|
||||
<p className="text-muted-foreground">管理和处理对账过程中发现的异常</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">待处理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{summary.by_status?.pending || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">处理中</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{summary.by_status?.processing || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">已解决</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.by_status?.resolved || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">严重异常</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{summary.by_severity?.critical || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选器 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="任务ID"
|
||||
value={taskId}
|
||||
onChange={(e) => setTaskId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="resolved">已解决</SelectItem>
|
||||
<SelectItem value="ignored">已忽略</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="严重程度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部程度</SelectItem>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="critical">严重</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={exceptionType} onValueChange={setExceptionType}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="异常类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部类型</SelectItem>
|
||||
<SelectItem value="amount_mismatch">金额不一致</SelectItem>
|
||||
<SelectItem value="missing_record">记录缺失</SelectItem>
|
||||
<SelectItem value="duplicate_record">重复记录</SelectItem>
|
||||
<SelectItem value="format_error">格式错误</SelectItem>
|
||||
<SelectItem value="logic_error">逻辑错误</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 异常列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>异常列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 条异常记录
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>严重程度</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">差异金额</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : exceptions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
暂无异常记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
exceptions.map((exception) => (
|
||||
<TableRow key={exception.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcons[exception.status]}
|
||||
<Badge className={statusColors[exception.status]}>
|
||||
{exception.status === "pending" && "待处理"}
|
||||
{exception.status === "processing" && "处理中"}
|
||||
{exception.status === "resolved" && "已解决"}
|
||||
{exception.status === "ignored" && "已忽略"}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exception.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{exception.employee_id}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{exception.exception_type === "amount_mismatch" && "金额不一致"}
|
||||
{exception.exception_type === "missing_record" && "记录缺失"}
|
||||
{exception.exception_type === "duplicate_record" && "重复记录"}
|
||||
{exception.exception_type === "format_error" && "格式错误"}
|
||||
{exception.exception_type === "logic_error" && "逻辑错误"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={severityColors[exception.severity]}>
|
||||
{exception.severity === "low" && "低"}
|
||||
{exception.severity === "medium" && "中"}
|
||||
{exception.severity === "high" && "高"}
|
||||
{exception.severity === "critical" && "严重"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{exception.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{exception.difference_amount != null && (
|
||||
<span
|
||||
className={
|
||||
exception.difference_amount > 0
|
||||
? "text-red-600"
|
||||
: "text-green-600"
|
||||
}
|
||||
>
|
||||
{exception.difference_amount > 0 ? "+" : ""}
|
||||
{exception.difference_amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(exception.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => viewDetail(exception)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{exception.status === "pending" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateStatus(exception.id, "processing")}
|
||||
>
|
||||
处理
|
||||
</Button>
|
||||
)}
|
||||
{exception.status === "processing" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateStatus(exception.id, "resolved")}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
第 {page} 页,共 {totalPages} 页
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>异常详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
异常ID: {selectedException?.id}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedException && (
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">员工姓名</div>
|
||||
<div className="font-medium">{selectedException.employee_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">员工ID</div>
|
||||
<div className="font-medium">{selectedException.employee_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">异常类型</div>
|
||||
<Badge variant="outline">{selectedException.exception_type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">严重程度</div>
|
||||
<Badge className={severityColors[selectedException.severity]}>
|
||||
{selectedException.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 金额信息 */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-2">金额详情</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{selectedException.salary_amount != null && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">工资</div>
|
||||
<div className="font-mono">
|
||||
{selectedException.salary_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.social_security_amount != null && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">社保</div>
|
||||
<div className="font-mono">
|
||||
{selectedException.social_security_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.tax_amount != null && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">个税</div>
|
||||
<div className="font-mono">
|
||||
{selectedException.tax_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.bank_amount != null && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">银行</div>
|
||||
<div className="font-mono">
|
||||
{selectedException.bank_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedException.difference_amount != null && (
|
||||
<div className="mt-2">
|
||||
<div className="text-sm text-muted-foreground">差异金额</div>
|
||||
<div
|
||||
className={`font-mono text-lg ${
|
||||
selectedException.difference_amount > 0
|
||||
? "text-red-600"
|
||||
: "text-green-600"
|
||||
}`}
|
||||
>
|
||||
{selectedException.difference_amount > 0 ? "+" : ""}
|
||||
{selectedException.difference_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 描述和详情 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-muted-foreground">描述</div>
|
||||
<div>{selectedException.description}</div>
|
||||
</div>
|
||||
{selectedException.detail && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-muted-foreground">详细信息</div>
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-auto">
|
||||
{JSON.stringify(JSON.parse(selectedException.detail), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.suggested_action && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">建议操作</div>
|
||||
<div>{selectedException.suggested_action}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 处理信息 */}
|
||||
{(selectedException.resolution || selectedException.handler) && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedException.resolution && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">解决方案</div>
|
||||
<div>{selectedException.resolution}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.handler && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">处理人</div>
|
||||
<div>{selectedException.handler}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
interface TaskDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
period: string;
|
||||
status: string;
|
||||
total_employees: number;
|
||||
matched_count: number;
|
||||
exception_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface ReconciliationResult {
|
||||
task_id: number;
|
||||
period: string;
|
||||
total_employees: number;
|
||||
matched_employees: number;
|
||||
exception_count: number;
|
||||
exceptions_by_type: Record<string, number>;
|
||||
exceptions_by_severity: Record<string, number>;
|
||||
execution_time_ms: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: string;
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
description: string;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
bank_amount: number | null;
|
||||
difference_amount: number | null;
|
||||
}
|
||||
|
||||
export default function TaskResultPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const taskId = params.id as string;
|
||||
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [result, setResult] = useState<ReconciliationResult | null>(null);
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchResultRef = useRef<() => Promise<void>>();
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<TaskDetail>(`/api/tasks/${taskId}`);
|
||||
setTask(res.data);
|
||||
|
||||
if (res.data.status === "COMPLETED" && fetchResultRef.current) {
|
||||
fetchResultRef.current();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务详情失败", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchResult = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ReconciliationResult>(`/api/reconciliation/result/${taskId}`);
|
||||
setResult(res.data);
|
||||
} catch (error) {
|
||||
console.error("获取对账结果失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchExceptions = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<{ items: ExceptionItem[] }>(`/api/exceptions/?task_id=${taskId}&page_size=100`);
|
||||
setExceptions(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error("获取异常列表失败", error);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResultRef.current = fetchResult;
|
||||
}, [fetchResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
fetchTaskDetail();
|
||||
fetchExceptions();
|
||||
}
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions]);
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await api.get<Blob>(`/api/exports/task/${taskId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `对账结果_${task?.period || taskId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("导出失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportKingdee() {
|
||||
try {
|
||||
const res = await api.get<Blob>(`/api/exports/kingdee/${taskId}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `金蝶凭证_${task?.period || taskId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("导出金蝶凭证失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">任务不存在</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
返回
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const matchRate = task.total_employees > 0
|
||||
? ((task.matched_count / task.total_employees) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{task.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
对账期间: {task.period}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出结果
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportKingdee}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
导出金蝶凭证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 概览卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">总人数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{task.total_employees}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">匹配人数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{task.matched_count}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">异常数量</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{task.exception_count}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">匹配率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{matchRate}%</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full"
|
||||
style={{ width: `${matchRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 统计详情 */}
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览统计</TabsTrigger>
|
||||
<TabsTrigger value="exceptions">
|
||||
异常列表
|
||||
{task.exception_count > 0 && (
|
||||
<Badge variant="destructive" className="ml-2">
|
||||
{task.exception_count}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="matched">已匹配列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 概览 */}
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
{/* 异常类型分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>异常类型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result?.exceptions_by_type && Object.keys(result.exceptions_by_type).length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(result.exceptions_by_type).map(([type, count]) => (
|
||||
<div key={type} className="border rounded-lg p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{type === "amount_mismatch" && "金额不一致"}
|
||||
{type === "missing_record" && "记录缺失"}
|
||||
{type === "duplicate_record" && "重复记录"}
|
||||
{type === "format_error" && "格式错误"}
|
||||
{type === "logic_error" && "逻辑错误"}
|
||||
{type === "rule_violation" && "规则违规"}
|
||||
{type === "threshold_exceeded" && "阈值超限"}
|
||||
</div>
|
||||
<div className="text-2xl font-bold mt-2">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 严重程度分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>严重程度分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result?.exceptions_by_severity && Object.keys(result.exceptions_by_severity).length > 0 ? (
|
||||
<div className="flex gap-4">
|
||||
{["critical", "high", "medium", "low"].map((severity) => {
|
||||
const count = result.exceptions_by_severity[severity] || 0;
|
||||
const colors = {
|
||||
critical: "bg-red-500",
|
||||
high: "bg-orange-500",
|
||||
medium: "bg-yellow-500",
|
||||
low: "bg-blue-500",
|
||||
};
|
||||
const labels = {
|
||||
critical: "严重",
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
};
|
||||
return (
|
||||
<div key={severity} className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded ${colors[severity as keyof typeof colors]}`} />
|
||||
<span className="text-sm">{labels[severity as keyof typeof labels]}: {count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 执行信息 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>执行信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">创建时间</div>
|
||||
<div>{new Date(task.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">完成时间</div>
|
||||
<div>
|
||||
{task.completed_at
|
||||
? new Date(task.completed_at).toLocaleString()
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">执行时间</div>
|
||||
<div>{result.execution_time_ms.toFixed(0)}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">完成时间</div>
|
||||
<div>
|
||||
{result.completed_at
|
||||
? new Date(result.completed_at).toLocaleString()
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 异常列表 */}
|
||||
<TabsContent value="exceptions">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>异常列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {exceptions.length} 条异常记录
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{exceptions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||
<p className="text-lg font-medium">完美匹配!</p>
|
||||
<p className="text-muted-foreground">所有记录都匹配成功</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>严重程度</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">差异金额</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exceptions.map((exc) => (
|
||||
<TableRow key={exc.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exc.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{exc.employee_id}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{exc.exception_type === "amount_mismatch" && "金额不一致"}
|
||||
{exc.exception_type === "missing_record" && "记录缺失"}
|
||||
{exc.exception_type === "duplicate_record" && "重复记录"}
|
||||
{exc.exception_type === "format_error" && "格式错误"}
|
||||
{exc.exception_type === "logic_error" && "逻辑错误"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
exc.severity === "critical"
|
||||
? "bg-red-100 text-red-800"
|
||||
: exc.severity === "high"
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: exc.severity === "medium"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}
|
||||
>
|
||||
{exc.severity === "low" && "低"}
|
||||
{exc.severity === "medium" && "中"}
|
||||
{exc.severity === "high" && "高"}
|
||||
{exc.severity === "critical" && "严重"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{exc.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{exc.difference_amount != null && (
|
||||
<span
|
||||
className={
|
||||
exc.difference_amount > 0
|
||||
? "text-red-600"
|
||||
: "text-green-600"
|
||||
}
|
||||
>
|
||||
{exc.difference_amount > 0 ? "+" : ""}
|
||||
{exc.difference_amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 已匹配列表 */}
|
||||
<TabsContent value="matched">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>已匹配列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {task.matched_count} 条匹配记录
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||
<p className="text-lg font-medium">所有记录都匹配成功!</p>
|
||||
<p className="text-muted-foreground">
|
||||
共 {task.matched_count} 条记录通过所有对账规则
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* 通用的 API 代理路由
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const targetPath = path.join('/');
|
||||
const searchParams = request.nextUrl.search;
|
||||
const targetUrl = `http://localhost:8000/api/${targetPath}${searchParams}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'host') {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||
|
||||
type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
interface RequestOptions {
|
||||
method?: RequestMethod;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 添加企业 ID header
|
||||
const user = useAuthStore.getState().user;
|
||||
if (user?.company_id) {
|
||||
headers['X-Company-ID'] = user.company_id.toString();
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, headers } = options;
|
||||
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
config.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle streaming responses
|
||||
if (response.headers.get('content-type')?.includes('application/vnd.openxmlformats')) {
|
||||
return response.blob() as unknown as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint);
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'POST', body });
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'PUT', body });
|
||||
}
|
||||
|
||||
patch<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'PATCH', body });
|
||||
}
|
||||
|
||||
delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(BASE_URL);
|
||||
@@ -0,0 +1,34 @@
|
||||
export const ENDPOINTS = {
|
||||
// Auth
|
||||
LOGIN: '/api/auth/login',
|
||||
REGISTER: '/api/auth/register',
|
||||
ME: '/api/auth/me',
|
||||
|
||||
// Tasks
|
||||
TASKS: '/api/tasks',
|
||||
TASK: (id: number) => `/api/tasks/${id}`,
|
||||
TASK_UPLOAD: (id: number) => `/api/tasks/${id}/upload`,
|
||||
TASK_RECONCILE: (id: number) => `/api/reconciliation/execute/${id}`,
|
||||
|
||||
// Mappings
|
||||
MAPPINGS: '/api/mappings',
|
||||
MAPPING: (id: number) => `/api/mappings/${id}`,
|
||||
MAPPING_RECOGNIZE: '/api/mappings/recognize',
|
||||
MAPPING_SAVE: '/api/mappings/save',
|
||||
|
||||
// Rules
|
||||
RULES: '/api/rules',
|
||||
RULE: (id: number) => `/api/rules/${id}`,
|
||||
|
||||
// Exceptions
|
||||
EXCEPTIONS: '/api/exceptions',
|
||||
EXCEPTION: (id: number) => `/api/exceptions/${id}`,
|
||||
EXCEPTION_SUMMARY: '/api/exceptions/summary',
|
||||
|
||||
// Exports
|
||||
EXPORT_TASK: (id: number) => `/api/exports/task/${id}`,
|
||||
EXPORT_KINGDEE: (id: number) => `/api/exports/kingdee/${id}`,
|
||||
|
||||
// Reconciliation
|
||||
RECONCILIATION_RESULT: (id: number) => `/api/reconciliation/result/${id}`,
|
||||
} as const;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
company_id: number;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (user: User, token: string) => void;
|
||||
clearAuth: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
|
||||
clearAuth: () => set({ user: null, token: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import tailwindAnimate from "tailwindcss-animate";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class" as const,
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", "Inter", "Noto Sans SC", "sans-serif"],
|
||||
mono: ["var(--font-mono)", "Courier New", "monospace"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindAnimate],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -24,8 +24,14 @@
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"components/**/*.ts",
|
||||
"components/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.tsx",
|
||||
"hooks/**/*.ts",
|
||||
"hooks/**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
|
||||
Reference in New Issue
Block a user