feat: UI优化和功能完善

- 添加公共分页组件,支持上边显示、分页大小10
- 侧边栏/Header按Liner风格优化,添加动画效果
- 异常处理列表支持分页和明细弹窗
- 任务结果页面支持已匹配列表分页
- 修复Dialog弹窗背景透明问题
- 新增dropdown-menu组件
- 添加parsed_file_record模型和回填脚本
- 前端枚举中文化
This commit is contained in:
freedakgmail
2026-07-07 12:57:15 +08:00
parent 78bd27a59a
commit b93929eb1a
24 changed files with 3155 additions and 608 deletions
+13 -1
View File
@@ -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
+57 -36
View File
@@ -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>
+282 -172
View File
@@ -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>
);
}
+33 -17
View File
@@ -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>
);
+72 -15
View File
@@ -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
View File
@@ -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);
}
+76 -43
View File
@@ -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>
+42 -19
View File
@@ -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>
);
}
+1 -1
View File
@@ -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}
+220
View File
@@ -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,
};
+138
View File
@@ -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>
);
}
+4 -1
View File
@@ -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;
+293
View File
@@ -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",
+1
View File
@@ -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>