feat: UI优化和功能完善
- 添加公共分页组件,支持上边显示、分页大小10 - 侧边栏/Header按Liner风格优化,添加动画效果 - 异常处理列表支持分页和明细弹窗 - 任务结果页面支持已匹配列表分页 - 修复Dialog弹窗背景透明问题 - 新增dropdown-menu组件 - 添加parsed_file_record模型和回填脚本 - 前端枚举中文化
This commit is contained in:
@@ -23,6 +23,18 @@ const loginSchema = z.object({
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
company_id: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 测试用户数据
|
||||
const TEST_USERS = [
|
||||
{ name: "管理员", email: "admin@xingchen.com", password: "admin123", role: "管理员" },
|
||||
@@ -43,7 +55,7 @@ export default function LoginPage() {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await api.post(ENDPOINTS.AUTH.LOGIN, data);
|
||||
const response = await api.post<LoginResponse>(ENDPOINTS.AUTH.LOGIN, data);
|
||||
const { access_token, user } = response.data;
|
||||
|
||||
// 保存到 localStorage
|
||||
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertTriangle,
|
||||
import { AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
@@ -93,6 +93,35 @@ const typeLabels: Record<string, string> = {
|
||||
duplicate_record: "重复记录",
|
||||
format_error: "格式错误",
|
||||
logic_error: "逻辑错误",
|
||||
rule_violation: "规则违规",
|
||||
threshold_exceeded: "阈值超限",
|
||||
missing_employee: "员工缺失",
|
||||
extra_employee: "多余员工",
|
||||
amount_difference: "金额差异",
|
||||
zero_amount: "金额为零",
|
||||
negative_amount: "负数金额",
|
||||
unusual_amount: "金额异常",
|
||||
duplicate_employee: "重复员工",
|
||||
bank_mismatch: "银行不匹配",
|
||||
bank_missing: "银行记录缺失",
|
||||
bank_extra: "银行多余记录",
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_RECORD: "记录缺失",
|
||||
DUPLICATE_RECORD: "重复记录",
|
||||
FORMAT_ERROR: "格式错误",
|
||||
LOGIC_ERROR: "逻辑错误",
|
||||
RULE_VIOLATION: "规则违规",
|
||||
THRESHOLD_EXCEEDED: "阈值超限",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
EXTRA_EMPLOYEE: "多余员工",
|
||||
AMOUNT_DIFFERENCE: "金额差异",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
|
||||
const container = {
|
||||
@@ -121,8 +150,8 @@ function ExceptionsContent() {
|
||||
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 [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
@@ -130,7 +159,16 @@ function ExceptionsContent() {
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId, status, severity, exceptionType, page]);
|
||||
}, [taskId, status, severity, exceptionType]);
|
||||
|
||||
function onPageChange(newPage: number, newPageSize: number) {
|
||||
setPage(newPage);
|
||||
if (newPageSize !== pageSize) {
|
||||
setPageSize(newPageSize);
|
||||
}
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}
|
||||
|
||||
async function fetchExceptions() {
|
||||
setLoading(true);
|
||||
@@ -344,6 +382,10 @@ function ExceptionsContent() {
|
||||
<SelectItem value="duplicate_record">重复记录</SelectItem>
|
||||
<SelectItem value="format_error">格式错误</SelectItem>
|
||||
<SelectItem value="logic_error">逻辑错误</SelectItem>
|
||||
<SelectItem value="missing_employee">员工缺失</SelectItem>
|
||||
<SelectItem value="duplicate_employee">重复员工</SelectItem>
|
||||
<SelectItem value="zero_amount">金额为零</SelectItem>
|
||||
<SelectItem value="unusual_amount">金额异常</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -371,6 +413,14 @@ function ExceptionsContent() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{total > 0 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -428,7 +478,7 @@ function ExceptionsContent() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exception.exception_type] || exception.exception_type}
|
||||
{typeLabels[exception.exception_type] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || exception.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -473,35 +523,6 @@ function ExceptionsContent() {
|
||||
)}
|
||||
</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>
|
||||
@@ -525,7 +546,7 @@ function ExceptionsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">异常类型</p>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||||
|
||||
@@ -2,8 +2,26 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Search,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } 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 { api } from "@/lib/api/client";
|
||||
import { ENDPOINTS } from "@/lib/api/endpoints";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
@@ -61,6 +79,19 @@ const RULE_TYPE_LABELS: Record<string, string> = {
|
||||
DEPARTMENT_MAPPING: "部门映射",
|
||||
};
|
||||
|
||||
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 } },
|
||||
};
|
||||
|
||||
export default function RulesPage() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
@@ -129,196 +160,275 @@ export default function RulesPage() {
|
||||
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-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 mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</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">
|
||||
<h1 className="text-heading-1 text-foreground">规则管理</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
管理企业字段映射规则,实现自动识别
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.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>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">总规则数</p>
|
||||
<p className="text-3xl font-semibold mt-1">{rules.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">启用中</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-emerald-600">{activeCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">已停用</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-muted-foreground">{inactiveCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.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>
|
||||
<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-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索规则..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<div className="w-[150px]">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部类型</SelectItem>
|
||||
<SelectItem value="FIELD_MAPPING">字段映射</SelectItem>
|
||||
<SelectItem value="ACCOUNT_MAPPING">科目映射</SelectItem>
|
||||
<SelectItem value="DEPARTMENT_MAPPING">部门映射</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<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="w-[150px]">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部状态</SelectItem>
|
||||
<SelectItem value="ACTIVE">启用</SelectItem>
|
||||
<SelectItem value="INACTIVE">停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.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>
|
||||
<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] }}
|
||||
className="mt-6"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
规则类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
匹配条件
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
目标字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
匹配次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
最近使用
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredRules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||||
<FileText 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>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRules.map((rule, index) => (
|
||||
<motion.tr
|
||||
key={rule.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
||||
className="hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">
|
||||
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{rule.target_value}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">
|
||||
{rule.match_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge className={rule.status === "ACTIVE"
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500"
|
||||
}>
|
||||
{rule.status === "ACTIVE" ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground text-caption">
|
||||
{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-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleStatus(rule.id, rule.status)}
|
||||
className={`h-8 w-8 ${rule.status === "ACTIVE"
|
||||
? "text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title={rule.status === "ACTIVE" ? "停用" : "启用"}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.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>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mt-6"
|
||||
>
|
||||
<Card className="border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-medium text-foreground mb-3">
|
||||
规则说明
|
||||
</h3>
|
||||
<ul className="text-body-small text-muted-foreground space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
字段映射规则会在上传同类文件时自动应用
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
精确匹配的规则(完全相同的字段名)会优先于模糊匹配
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
停用规则不会在自动识别中应用,但不会删除
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
删除规则后,下次上传相同格式文件需要重新确认映射
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { Pagination } from "@/components/ui/pagination";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -26,6 +27,12 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
@@ -35,6 +42,7 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
AlertOctagon,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TaskDetail {
|
||||
@@ -76,6 +84,15 @@ interface ExceptionItem {
|
||||
difference_amount: number | null;
|
||||
}
|
||||
|
||||
interface MatchedItem {
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
net_salary: 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" },
|
||||
@@ -91,6 +108,33 @@ const typeLabels: Record<string, string> = {
|
||||
logic_error: "逻辑错误",
|
||||
rule_violation: "规则违规",
|
||||
threshold_exceeded: "阈值超限",
|
||||
missing_employee: "员工缺失",
|
||||
extra_employee: "多余员工",
|
||||
amount_difference: "金额差异",
|
||||
zero_amount: "金额为零",
|
||||
negative_amount: "负数金额",
|
||||
unusual_amount: "金额异常",
|
||||
duplicate_employee: "重复员工",
|
||||
bank_mismatch: "银行不匹配",
|
||||
bank_missing: "银行记录缺失",
|
||||
bank_extra: "银行多余记录",
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_RECORD: "记录缺失",
|
||||
DUPLICATE_RECORD: "重复记录",
|
||||
FORMAT_ERROR: "格式错误",
|
||||
LOGIC_ERROR: "逻辑错误",
|
||||
RULE_VIOLATION: "规则违规",
|
||||
THRESHOLD_EXCEEDED: "阈值超限",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
EXTRA_EMPLOYEE: "多余员工",
|
||||
AMOUNT_DIFFERENCE: "金额差异",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
|
||||
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -100,9 +144,18 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [result, setResult] = useState<ReconciliationResult | null>(null);
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [matchedRecords, setMatchedRecords] = useState<MatchedItem[]>([]);
|
||||
const [matchedTotal, setMatchedTotal] = useState(0);
|
||||
const [matchedPage, setMatchedPage] = useState(1);
|
||||
const [matchedPageSize, setMatchedPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [matchedLoading, setMatchedLoading] = useState(false);
|
||||
const [exceptionDetailOpen, setExceptionDetailOpen] = useState(false);
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [matchedDetailOpen, setMatchedDetailOpen] = useState(false);
|
||||
const [selectedMatched, setSelectedMatched] = useState<MatchedItem | null>(null);
|
||||
|
||||
const fetchResultRef = useRef<() => Promise<void>>();
|
||||
const fetchResultRef = useRef<(() => Promise<void>) | undefined>(undefined);
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
@@ -144,12 +197,36 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchMatchedRecords = useCallback(async () => {
|
||||
setMatchedLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ items: MatchedItem[]; total: number }>(`/api/reconciliation/matched/${taskId}`, {
|
||||
params: { page: matchedPage, page_size: matchedPageSize },
|
||||
});
|
||||
setMatchedRecords(res.data.items || []);
|
||||
setMatchedTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取已匹配列表失败", error);
|
||||
} finally {
|
||||
setMatchedLoading(false);
|
||||
}
|
||||
}, [taskId, matchedPage, matchedPageSize]);
|
||||
|
||||
function onMatchedPageChange(newPage: number, newPageSize: number) {
|
||||
setMatchedPage(newPage);
|
||||
if (newPageSize !== matchedPageSize) {
|
||||
setMatchedPageSize(newPageSize);
|
||||
}
|
||||
fetchMatchedRecords();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
fetchTaskDetail();
|
||||
fetchExceptions();
|
||||
fetchMatchedRecords();
|
||||
}
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions]);
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions, fetchMatchedRecords]);
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
@@ -377,7 +454,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
{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}
|
||||
{typeLabels[type] || typeLabels[type.toLowerCase()] || typeLabels[type.toUpperCase()] || type}
|
||||
</span>
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
</div>
|
||||
@@ -484,7 +561,14 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exceptions.map((exc) => (
|
||||
<TableRow key={exc.id}>
|
||||
<TableRow
|
||||
key={exc.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
setSelectedException(exc);
|
||||
setExceptionDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exc.employee_name}</div>
|
||||
@@ -493,7 +577,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exc.exception_type] || exc.exception_type}
|
||||
{typeLabels[exc.exception_type] || typeLabels[exc.exception_type.toLowerCase()] || typeLabels[exc.exception_type.toUpperCase()] || exc.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -527,22 +611,217 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
<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>
|
||||
<Badge variant="secondary">{matchedTotal} 条记录</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>
|
||||
{matchedTotal > 0 && (
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
total={matchedTotal}
|
||||
page={matchedPage}
|
||||
pageSize={matchedPageSize}
|
||||
onChange={onMatchedPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{matchedLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : matchedRecords.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">暂无已匹配记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead className="text-right">应发工资</TableHead>
|
||||
<TableHead className="text-right">社保</TableHead>
|
||||
<TableHead className="text-right">个税</TableHead>
|
||||
<TableHead className="text-right">实发工资</TableHead>
|
||||
<TableHead className="w-[60px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matchedRecords.map((record, index) => (
|
||||
<TableRow
|
||||
key={`${record.employee_id}-${index}`}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
setSelectedMatched(record);
|
||||
setMatchedDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{record.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{record.employee_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.salary_amount != null ? record.salary_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.social_security_amount != null ? record.social_security_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.tax_amount != null ? record.tax_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{record.net_salary != null ? record.net_salary.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
|
||||
{/* 异常详情弹窗 */}
|
||||
<Dialog open={exceptionDetailOpen} onOpenChange={setExceptionDetailOpen}>
|
||||
<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] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||||
<Badge className={`${severityConfig[selectedException.severity].bg} ${severityConfig[selectedException.severity].text}`}>
|
||||
{severityConfig[selectedException.severity].label}
|
||||
</Badge>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 已匹配记录明细弹窗 */}
|
||||
<Dialog open={matchedDetailOpen} onOpenChange={setMatchedDetailOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>已匹配记录明细</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedMatched && (
|
||||
<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">{selectedMatched.employee_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工ID</p>
|
||||
<p className="font-medium">{selectedMatched.employee_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-3">金额详情</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-muted-foreground">应发工资</span>
|
||||
<span className="font-mono font-medium">
|
||||
{selectedMatched.salary_amount != null ? selectedMatched.salary_amount.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-muted-foreground">社保</span>
|
||||
<span className="font-mono font-medium">
|
||||
{selectedMatched.social_security_amount != null ? selectedMatched.social_security_amount.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-muted-foreground">个税</span>
|
||||
<span className="font-mono font-medium">
|
||||
{selectedMatched.tax_amount != null ? selectedMatched.tax_amount.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<span className="font-medium">实发工资</span>
|
||||
<span className="font-mono font-semibold text-lg">
|
||||
{selectedMatched.net_salary != null ? selectedMatched.net_salary.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
Filter,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
@@ -59,24 +58,40 @@ export default function TasksPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string>("");
|
||||
const [availablePeriods] = useState(getAvailablePeriods);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = selectedPeriod ? { period: selectedPeriod } : {};
|
||||
const res = await api.get<Task[]>(ENDPOINTS.TASK.LIST, { params });
|
||||
setTasks(res.data);
|
||||
const params = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(selectedPeriod ? { period: selectedPeriod } : {})
|
||||
};
|
||||
const res = await api.get<{ items: Task[]; total: number }>(ENDPOINTS.TASK.LIST, { params });
|
||||
setTasks(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("加载任务列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedPeriod]);
|
||||
}, [page, pageSize, selectedPeriod]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
function onPageChange(newPage: number, newPageSize: number) {
|
||||
setPage(newPage);
|
||||
if (newPageSize !== pageSize) {
|
||||
setPageSize(newPageSize);
|
||||
}
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
const getMatchRate = (task: Task) => {
|
||||
if (task.total_employees === 0) return "0%";
|
||||
const rate = Math.round((task.matched_count / task.total_employees) * 100);
|
||||
@@ -176,7 +191,16 @@ export default function TasksPage() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task, index) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
@@ -241,16 +265,8 @@ export default function TasksPage() {
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,10 +21,22 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
// 先检查 Content-Type,确保是 JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('GET proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +63,22 @@ export async function POST(
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
// 先检查 Content-Type,确保是 JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('POST proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +105,21 @@ export async function PUT(
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('PUT proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,10 +146,21 @@ export async function PATCH(
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('PATCH proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +185,20 @@ export async function DELETE(
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('DELETE proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
+185
-110
@@ -3,84 +3,86 @@
|
||||
@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 */
|
||||
/* Liner Style Design System - Minimal, Clean, Refined */
|
||||
:root {
|
||||
/* Colors - Professional Blue-Gray Palette */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 220 14% 18%;
|
||||
/* Colors - Light Mode - Soft & Clean */
|
||||
--background: 0 0% 99%;
|
||||
--foreground: 220 10% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 14% 18%;
|
||||
--card-foreground: 220 10% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 14% 18%;
|
||||
--popover-foreground: 220 10% 15%;
|
||||
|
||||
/* Primary - Professional Blue */
|
||||
--primary: 221 83% 53%;
|
||||
/* Primary - Subtle Blue */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary - Cool Gray */
|
||||
--secondary: 220 13% 95%;
|
||||
--secondary-foreground: 220 14% 18%;
|
||||
/* Secondary - Ultra Light Gray */
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 220 10% 25%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 220 13% 95%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
/* Muted - Very Light */
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 8% 46%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 220 13% 95%;
|
||||
--accent-foreground: 220 14% 18%;
|
||||
/* Accent - Soft Highlight */
|
||||
--accent: 217 91% 97%;
|
||||
--accent-foreground: 217 91% 40%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Inputs */
|
||||
/* Borders - Ultra Fine */
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
/* Radius - Larger, Softer */
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* 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);
|
||||
/* Shadows - Minimal */
|
||||
--shadow: 0 1px 2px 0 hsl(220 14% 96%);
|
||||
--shadow-sm: 0 0px 0px 1px hsl(220 13% 91%);
|
||||
--shadow-md: 0 2px 4px 0 hsl(220 14% 96%);
|
||||
--shadow-lg: 0 4px 8px 0 hsl(220 14% 96%);
|
||||
}
|
||||
|
||||
/* Dark Mode - Professional Dark */
|
||||
/* Dark Mode - Soft 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%;
|
||||
--background: 220 20% 7%;
|
||||
--foreground: 220 14% 96%;
|
||||
--card: 220 18% 9%;
|
||||
--card-foreground: 220 14% 96%;
|
||||
--popover: 220 18% 9%;
|
||||
--popover-foreground: 220 14% 96%;
|
||||
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 220 17% 10%;
|
||||
--primary: 217 91% 65%;
|
||||
--primary-foreground: 220 20% 7%;
|
||||
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 17% 95%;
|
||||
--secondary: 220 18% 14%;
|
||||
--secondary-foreground: 220 14% 96%;
|
||||
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--muted: 220 18% 14%;
|
||||
--muted-foreground: 220 8% 64%;
|
||||
|
||||
--accent: 217 33% 17%;
|
||||
--accent-foreground: 210 17% 95%;
|
||||
--accent: 217 33% 20%;
|
||||
--accent-foreground: 217 91% 75%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 17% 95%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 224 76% 48%;
|
||||
--border: 220 18% 16%;
|
||||
--input: 220 18% 16%;
|
||||
--ring: 217 91% 65%;
|
||||
|
||||
--shadow: 0 1px 2px 0 hsl(220 20% 4%);
|
||||
--shadow-sm: 0 0px 0px 1px hsl(220 18% 16%);
|
||||
--shadow-md: 0 2px 4px 0 hsl(220 20% 4%);
|
||||
--shadow-lg: 0 4px 8px 0 hsl(220 20% 4%);
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
@@ -89,12 +91,13 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Geist", "IBM Plex Sans", system-ui, sans-serif;
|
||||
font-family: "Geist", system-ui, -apple-system, sans-serif;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Smooth Scrolling */
|
||||
@@ -104,37 +107,37 @@ html {
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Focus Visible */
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline: 2px solid hsl(var(--ring) / 0.5);
|
||||
outline-offset: 2px;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
/* Scrollbar - Minimal */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* Animation Utilities */
|
||||
/* Animations - Subtle */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -143,7 +146,7 @@ html {
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -154,7 +157,7 @@ html {
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -165,7 +168,7 @@ html {
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -173,11 +176,6 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -185,78 +183,70 @@ html {
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
animation: slideUp 0.25s 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;
|
||||
animation: slideDown 0.25s 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;
|
||||
animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
animation: spin 0.8s 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; }
|
||||
/* Staggered Animation */
|
||||
.stagger-1 { animation-delay: 30ms; }
|
||||
.stagger-2 { animation-delay: 60ms; }
|
||||
.stagger-3 { animation-delay: 90ms; }
|
||||
.stagger-4 { animation-delay: 120ms; }
|
||||
.stagger-5 { animation-delay: 150ms; }
|
||||
|
||||
/* Transition Utilities */
|
||||
/* Transitions - Smooth */
|
||||
.transition-base {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: all 0.2s 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 Effects - Subtle Lift */
|
||||
.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);
|
||||
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.02);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
/* Button Active Effect */
|
||||
/* Button Press Effect */
|
||||
.btn-press:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Glass Effect */
|
||||
/* Glass Effect - Subtle */
|
||||
.glass {
|
||||
background: hsl(var(--background) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: hsl(var(--background) / 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@@ -275,38 +265,38 @@ html {
|
||||
.text-display {
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
letter-spacing: -0.03em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-heading-1 {
|
||||
font-size: 2.25rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-heading-2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-heading-3 {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-size: 1rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.text-body-small {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -314,4 +304,89 @@ html {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Liner-Style Card - Border only, no shadow */
|
||||
.liner-card {
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Liner-Style Button - Clean, minimal */
|
||||
.liner-btn {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.liner-btn:hover {
|
||||
background: hsl(var(--accent));
|
||||
border-color: hsl(var(--accent-foreground) / 0.2);
|
||||
}
|
||||
|
||||
.liner-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Liner-Style Input */
|
||||
.liner-input {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.liner-input:focus {
|
||||
border-color: hsl(var(--ring));
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.1);
|
||||
}
|
||||
|
||||
/* Liner-Style Badge */
|
||||
.liner-badge {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Liner-Style Table */
|
||||
.liner-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.liner-table th {
|
||||
background: hsl(var(--muted));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.liner-table td {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.liner-table tr:hover td {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.8));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Subtle Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, hsl(var(--border)), transparent);
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import {
|
||||
LogOut,
|
||||
User,
|
||||
Bell,
|
||||
Moon,
|
||||
Sun,
|
||||
Workflow
|
||||
} from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "管理员",
|
||||
accountant: "会计",
|
||||
viewer: "查看者",
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
@@ -13,68 +36,78 @@ export function Header() {
|
||||
|
||||
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">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/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>
|
||||
<Workflow className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground leading-tight">财务AI助手</span>
|
||||
<span className="text-[10px] text-muted-foreground">S2F 智能对账</span>
|
||||
</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>
|
||||
{/* 右侧 - 用户信息和操作 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 通知按钮 */}
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</Button>
|
||||
|
||||
{/* 用户下拉菜单 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 h-9 px-2">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium text-foreground leading-tight">
|
||||
{user.full_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{ROLE_LABELS[user.role] || user.role}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium">{user.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
个人设置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="gap-2 text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Sidebar() {
|
||||
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",
|
||||
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-card transition-all duration-300 flex flex-col",
|
||||
collapsed ? "w-16" : "w-56"
|
||||
)}
|
||||
>
|
||||
@@ -62,25 +62,40 @@ export function Sidebar() {
|
||||
"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",
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{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 }}
|
||||
/>
|
||||
<>
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-primary rounded-r-full"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||
/>
|
||||
<motion.div
|
||||
layoutId="activeBg"
|
||||
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>
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
isActive && "text-primary"
|
||||
)}>
|
||||
{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">
|
||||
<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 shadow-md">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
@@ -90,16 +105,24 @@ export function Sidebar() {
|
||||
</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>
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn(
|
||||
"flex items-center h-10 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
collapsed ? "justify-center w-full" : "px-4 gap-2"
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="text-xs">收起菜单</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<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",
|
||||
"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 dark:bg-slate-900 bg-white",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
d="M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.5 3.25C5.01575 3.25 3 5.26575 3 7.75C3 10.2342 5.01575 12.25 7.5 12.25C9.98425 12.25 12 10.2342 12 7.75C12 5.26575 9.98425 3.25 7.5 3.25ZM7.5 10.25C5.88575 10.25 4.5935 8.95775 4.5935 7.3435C4.5935 5.72925 5.88575 4.4375 7.5 4.4375C9.11425 4.4375 10.4065 5.72925 10.4065 7.3435C10.4065 8.95775 9.11425 10.25 7.5 10.25Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export interface PaginationProps {
|
||||
/** 当前页码(从 1 开始) */
|
||||
page: number;
|
||||
/** 每页条数 */
|
||||
pageSize: number;
|
||||
/** 总条数 */
|
||||
total: number;
|
||||
/** 页码或每页条数变化时的回调 */
|
||||
onChange: (page: number, pageSize: number) => void;
|
||||
/** 可选的每页条数选项,默认 [10, 20, 50] */
|
||||
pageSizeOptions?: number[];
|
||||
/** 是否显示每页条数选择器,默认 true */
|
||||
showPageSizeSelector?: boolean;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange,
|
||||
pageSizeOptions = [10, 20, 50],
|
||||
showPageSizeSelector = true,
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const start = total > 0 ? (page - 1) * pageSize + 1 : 0;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="flex items-center gap-4">
|
||||
{showPageSizeSelector && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">每页</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(val) => onChange(1, Number(val))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">条</span>
|
||||
</div>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
显示 {start}-{end},共 {total} 条
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onChange(1, pageSize)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onChange(page - 1, pageSize)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 mx-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={pageNum === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 w-9"
|
||||
onClick={() => onChange(pageNum, pageSize)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onChange(page + 1, pageSize)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onChange(totalPages, pageSize)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,10 @@ export interface CompanyRuleResponse {
|
||||
id: number;
|
||||
company_id: number;
|
||||
rule_type: string;
|
||||
match_condition: Record<string, unknown>;
|
||||
match_condition: {
|
||||
source_field?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
target_value: string;
|
||||
priority: number;
|
||||
status: string;
|
||||
|
||||
Generated
+293
@@ -12,6 +12,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
@@ -1563,6 +1564,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.20",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.20.tgz",
|
||||
"integrity": "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.5",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.2.0",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-menu": "2.1.20",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@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-dropdown-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.5.tgz",
|
||||
"integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.0.tgz",
|
||||
"integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
|
||||
"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-guards": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
|
||||
@@ -1621,6 +1672,233 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.20",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.20.tgz",
|
||||
"integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.5",
|
||||
"@radix-ui/react-collection": "1.1.12",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.2.0",
|
||||
"@radix-ui/react-direction": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.15",
|
||||
"@radix-ui/react-focus-guards": "1.1.4",
|
||||
"@radix-ui/react-focus-scope": "1.1.12",
|
||||
"@radix-ui/react-id": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.3.3",
|
||||
"@radix-ui/react-portal": "1.1.13",
|
||||
"@radix-ui/react-presence": "1.1.7",
|
||||
"@radix-ui/react-primitive": "2.1.7",
|
||||
"@radix-ui/react-roving-focus": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||
"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-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.5.tgz",
|
||||
"integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.12.tgz",
|
||||
"integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.2.0",
|
||||
"@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-menu/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.0.tgz",
|
||||
"integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
|
||||
"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-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz",
|
||||
"integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.5",
|
||||
"@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-menu/node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz",
|
||||
"integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==",
|
||||
"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-menu/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.3.tgz",
|
||||
"integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==",
|
||||
"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.2.0",
|
||||
"@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-menu/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.7.tgz",
|
||||
"integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==",
|
||||
"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-menu/node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz",
|
||||
"integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.5",
|
||||
"@radix-ui/react-collection": "1.1.12",
|
||||
"@radix-ui/react-compose-refs": "1.1.3",
|
||||
"@radix-ui/react-context": "1.2.0",
|
||||
"@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",
|
||||
"@radix-ui/react-use-is-hydrated": "0.1.1",
|
||||
"@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-popper": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
|
||||
@@ -1898,6 +2176,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz",
|
||||
"integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==",
|
||||
"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-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",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
|
||||
@@ -43,12 +43,13 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
task_id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
||||
status: "pending" | "processing" | "resolved" | "ignored";
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
@@ -73,10 +74,33 @@ interface Summary {
|
||||
}
|
||||
|
||||
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",
|
||||
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 severityLabels: Record<string, string> = {
|
||||
LOW: "低",
|
||||
MEDIUM: "中",
|
||||
HIGH: "高",
|
||||
CRITICAL: "严重",
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_RECORD: "记录缺失",
|
||||
DUPLICATE_RECORD: "重复记录",
|
||||
FORMAT_ERROR: "格式错误",
|
||||
LOGIC_ERROR: "逻辑错误",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
@@ -106,8 +130,8 @@ export default function ExceptionsPage() {
|
||||
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 [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
@@ -133,6 +157,15 @@ export default function ExceptionsPage() {
|
||||
}
|
||||
}, [taskId, status, severity, exceptionType, page, pageSize]);
|
||||
|
||||
function onPageChange(newPage: number, newPageSize: number) {
|
||||
setPage(newPage);
|
||||
if (newPageSize !== pageSize) {
|
||||
setPageSize(newPageSize);
|
||||
}
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<Summary>("/api/exceptions/summary", {
|
||||
@@ -259,10 +292,10 @@ export default function ExceptionsPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部程度</SelectItem>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="critical">严重</SelectItem>
|
||||
<SelectItem value="LOW">低</SelectItem>
|
||||
<SelectItem value="MEDIUM">中</SelectItem>
|
||||
<SelectItem value="HIGH">高</SelectItem>
|
||||
<SelectItem value="CRITICAL">严重</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={exceptionType} onValueChange={setExceptionType}>
|
||||
@@ -271,11 +304,14 @@ export default function ExceptionsPage() {
|
||||
</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>
|
||||
<SelectItem value="AMOUNT_MISMATCH">金额不一致</SelectItem>
|
||||
<SelectItem value="MISSING_EMPLOYEE">员工缺失</SelectItem>
|
||||
<SelectItem value="ZERO_AMOUNT">金额为零</SelectItem>
|
||||
<SelectItem value="NEGATIVE_AMOUNT">负数金额</SelectItem>
|
||||
<SelectItem value="UNUSUAL_AMOUNT">金额异常</SelectItem>
|
||||
<SelectItem value="DUPLICATE_EMPLOYEE">重复员工</SelectItem>
|
||||
<SelectItem value="BANK_MISMATCH">银行不匹配</SelectItem>
|
||||
<SelectItem value="BANK_MISSING">银行记录缺失</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch}>
|
||||
@@ -295,6 +331,14 @@ export default function ExceptionsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{total > 0 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -345,19 +389,12 @@ export default function ExceptionsPage() {
|
||||
</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" && "逻辑错误"}
|
||||
{typeLabels[exception.exception_type] || exception.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={severityColors[exception.severity]}>
|
||||
{exception.severity === "low" && "低"}
|
||||
{exception.severity === "medium" && "中"}
|
||||
{exception.severity === "high" && "高"}
|
||||
{exception.severity === "critical" && "严重"}
|
||||
{severityLabels[exception.severity] || exception.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
@@ -414,33 +451,6 @@ export default function ExceptionsPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -467,12 +477,12 @@ export default function ExceptionsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">异常类型</div>
|
||||
<Badge variant="outline">{selectedException.exception_type}</Badge>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">严重程度</div>
|
||||
<Badge className={severityColors[selectedException.severity]}>
|
||||
{selectedException.severity}
|
||||
{severityLabels[selectedException.severity] || selectedException.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -61,7 +63,7 @@ interface ReconciliationResult {
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
exception_type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
||||
status: string;
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
@@ -73,6 +75,31 @@ interface ExceptionItem {
|
||||
difference_amount: number | null;
|
||||
}
|
||||
|
||||
interface MatchedItem {
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
net_salary: number | null;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_RECORD: "记录缺失",
|
||||
DUPLICATE_RECORD: "重复记录",
|
||||
FORMAT_ERROR: "格式错误",
|
||||
LOGIC_ERROR: "逻辑错误",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
|
||||
export default function TaskResultPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -81,7 +108,12 @@ export default function TaskResultPage() {
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [result, setResult] = useState<ReconciliationResult | null>(null);
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [matchedRecords, setMatchedRecords] = useState<MatchedItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [matchedLoading, setMatchedLoading] = useState(false);
|
||||
const [matchedPage, setMatchedPage] = useState(1);
|
||||
const [matchedPageSize, setMatchedPageSize] = useState(10);
|
||||
const [matchedTotal, setMatchedTotal] = useState(0);
|
||||
|
||||
const fetchResultRef = useRef<() => Promise<void>>();
|
||||
|
||||
@@ -118,6 +150,29 @@ export default function TaskResultPage() {
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchMatchedRecords = useCallback(async () => {
|
||||
setMatchedLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ items: MatchedItem[]; total: number }>(`/api/reconciliation/matched/${taskId}`, {
|
||||
params: { page: matchedPage, page_size: matchedPageSize },
|
||||
});
|
||||
setMatchedRecords(res.data.items || []);
|
||||
setMatchedTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取已匹配列表失败", error);
|
||||
} finally {
|
||||
setMatchedLoading(false);
|
||||
}
|
||||
}, [taskId, matchedPage, matchedPageSize]);
|
||||
|
||||
function onMatchedPageChange(newPage: number, newPageSize: number) {
|
||||
setMatchedPage(newPage);
|
||||
if (newPageSize !== matchedPageSize) {
|
||||
setMatchedPageSize(newPageSize);
|
||||
}
|
||||
fetchMatchedRecords();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchResultRef.current = fetchResult;
|
||||
}, [fetchResult]);
|
||||
@@ -126,8 +181,9 @@ export default function TaskResultPage() {
|
||||
if (taskId) {
|
||||
fetchTaskDetail();
|
||||
fetchExceptions();
|
||||
fetchMatchedRecords();
|
||||
}
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions]);
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions, fetchMatchedRecords]);
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
@@ -292,20 +348,30 @@ export default function TaskResultPage() {
|
||||
<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" && "阈值超限"}
|
||||
{Object.entries(result.exceptions_by_type).map(([type, count]) => {
|
||||
const getTypeLabel = (t: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
return labels[t] || t;
|
||||
};
|
||||
return (
|
||||
<div key={type} className="border rounded-lg p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{getTypeLabel(type)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold mt-2">{count}</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold mt-2">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无数据</p>
|
||||
@@ -321,19 +387,19 @@ export default function TaskResultPage() {
|
||||
<CardContent>
|
||||
{result?.exceptions_by_severity && Object.keys(result.exceptions_by_severity).length > 0 ? (
|
||||
<div className="flex gap-4">
|
||||
{["critical", "high", "medium", "low"].map((severity) => {
|
||||
{["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",
|
||||
CRITICAL: "bg-red-500",
|
||||
HIGH: "bg-orange-500",
|
||||
MEDIUM: "bg-yellow-500",
|
||||
LOW: "bg-blue-500",
|
||||
};
|
||||
const labels = {
|
||||
critical: "严重",
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
CRITICAL: "严重",
|
||||
HIGH: "高",
|
||||
MEDIUM: "中",
|
||||
LOW: "低",
|
||||
};
|
||||
return (
|
||||
<div key={severity} className="flex items-center gap-2">
|
||||
@@ -429,29 +495,22 @@ export default function TaskResultPage() {
|
||||
</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" && "逻辑错误"}
|
||||
{typeLabels[exc.exception_type] || exc.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
exc.severity === "critical"
|
||||
exc.severity === "CRITICAL"
|
||||
? "bg-red-100 text-red-800"
|
||||
: exc.severity === "high"
|
||||
: exc.severity === "HIGH"
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: exc.severity === "medium"
|
||||
: 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" && "严重"}
|
||||
{exc.severity === "LOW" ? "低" : exc.severity === "MEDIUM" ? "中" : exc.severity === "HIGH" ? "高" : exc.severity === "CRITICAL" ? "严重" : exc.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
@@ -484,19 +543,72 @@ export default function TaskResultPage() {
|
||||
<TabsContent value="matched">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>已匹配列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {task.matched_count} 条匹配记录
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>已匹配列表</CardTitle>
|
||||
<CardDescription>共 {matchedTotal} 条匹配记录</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{matchedTotal > 0 && (
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
total={matchedTotal}
|
||||
page={matchedPage}
|
||||
pageSize={matchedPageSize}
|
||||
onChange={onMatchedPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{matchedLoading ? (
|
||||
<div className="text-center py-8">
|
||||
加载中...
|
||||
</div>
|
||||
) : matchedRecords.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>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead className="text-right">应发工资</TableHead>
|
||||
<TableHead className="text-right">社保</TableHead>
|
||||
<TableHead className="text-right">个税</TableHead>
|
||||
<TableHead className="text-right">实发工资</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matchedRecords.map((record, index) => (
|
||||
<TableRow key={`${record.employee_id}-${index}`}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{record.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{record.employee_id}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.salary_amount != null ? record.salary_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.social_security_amount != null ? record.social_security_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.tax_amount != null ? record.tax_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{record.net_salary != null ? record.net_salary.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user