feebbb10ac
- 使用公共Pagination组件统一各页面的分页逻辑 - 优化exceptions页面异常列表展示 - 简化settings/rules页面规则管理 - 后端tasks.py增加分页参数支持
422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
import { motion } from "motion/react";
|
||
import {
|
||
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";
|
||
import type { CompanyRuleResponse } from "@/lib/api/types";
|
||
|
||
// 模拟数据
|
||
const mockRules: CompanyRuleResponse[] = [
|
||
{
|
||
id: 1,
|
||
company_id: 1,
|
||
rule_type: "FIELD_MAPPING",
|
||
match_condition: { source_field: "员工姓名" },
|
||
target_value: "员工姓名",
|
||
priority: 0,
|
||
status: "ACTIVE",
|
||
description: "工资表员工姓名字段映射",
|
||
match_count: 15,
|
||
last_used_at: "2024-01-15T10:30:00Z",
|
||
created_at: "2024-01-01T00:00:00Z",
|
||
updated_at: "2024-01-15T10:30:00Z",
|
||
},
|
||
{
|
||
id: 2,
|
||
company_id: 1,
|
||
rule_type: "FIELD_MAPPING",
|
||
match_condition: { source_field: "基本薪资" },
|
||
target_value: "基本工资",
|
||
priority: 0,
|
||
status: "ACTIVE",
|
||
description: "工资表基本薪资字段映射",
|
||
match_count: 12,
|
||
last_used_at: "2024-01-15T10:30:00Z",
|
||
created_at: "2024-01-05T00:00:00Z",
|
||
updated_at: "2024-01-15T10:30:00Z",
|
||
},
|
||
{
|
||
id: 3,
|
||
company_id: 1,
|
||
rule_type: "FIELD_MAPPING",
|
||
match_condition: { source_field: "实发合计" },
|
||
target_value: "实发工资",
|
||
priority: 1,
|
||
status: "INACTIVE",
|
||
description: "工资表实发合计字段映射(已停用)",
|
||
match_count: 8,
|
||
last_used_at: "2024-01-10T00:00:00Z",
|
||
created_at: "2024-01-08T00:00:00Z",
|
||
updated_at: "2024-01-12T00:00:00Z",
|
||
},
|
||
];
|
||
|
||
const RULE_TYPE_LABELS: Record<string, string> = {
|
||
FIELD_MAPPING: "字段映射",
|
||
ACCOUNT_MAPPING: "科目映射",
|
||
DEPARTMENT_MAPPING: "部门映射",
|
||
};
|
||
|
||
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 toast = useToast();
|
||
|
||
const [rules, setRules] = useState<CompanyRuleResponse[]>(mockRules);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [typeFilter, setTypeFilter] = useState<string>("");
|
||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||
|
||
// 过滤规则
|
||
const filteredRules = rules.filter((rule) => {
|
||
const matchesSearch =
|
||
!searchQuery ||
|
||
rule.match_condition.source_field?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
rule.target_value.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
rule.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||
|
||
const matchesType = !typeFilter || rule.rule_type === typeFilter;
|
||
const matchesStatus = !statusFilter || rule.status === statusFilter;
|
||
|
||
return matchesSearch && matchesType && matchesStatus;
|
||
});
|
||
|
||
// 切换规则状态
|
||
const handleToggleStatus = useCallback(
|
||
async (ruleId: number, currentStatus: string) => {
|
||
const newStatus = currentStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||
|
||
try {
|
||
await api.patch(ENDPOINTS.MAPPING.RULE_STATUS(ruleId), {
|
||
status: newStatus,
|
||
});
|
||
|
||
setRules((prev) =>
|
||
prev.map((rule) =>
|
||
rule.id === ruleId ? { ...rule, status: newStatus } : rule
|
||
)
|
||
);
|
||
|
||
toast.success(`规则已${newStatus === "ACTIVE" ? "启用" : "停用"}`);
|
||
} catch {
|
||
toast.error("状态更新失败");
|
||
}
|
||
},
|
||
[toast]
|
||
);
|
||
|
||
// 删除规则
|
||
const handleDeleteRule = useCallback(
|
||
async (ruleId: number) => {
|
||
if (!confirm("确定要删除这条规则吗?")) return;
|
||
|
||
try {
|
||
await api.delete(ENDPOINTS.MAPPING.RULE_DELETE(ruleId));
|
||
|
||
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
|
||
toast.success("规则已删除");
|
||
} catch {
|
||
toast.error("删除失败");
|
||
}
|
||
},
|
||
[toast]
|
||
);
|
||
|
||
const activeCount = rules.filter((r) => r.status === "ACTIVE").length;
|
||
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
||
|
||
return (
|
||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||
{/* 页面头部 */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||
className="mb-8"
|
||
>
|
||
<div>
|
||
<h1 className="text-heading-1 text-foreground">规则管理</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
管理企业字段映射规则,实现自动识别
|
||
</p>
|
||
</div>
|
||
</motion.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>
|
||
|
||
{/* 筛选栏 */}
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{/* 规则列表 */}
|
||
<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>
|
||
|
||
{/* 说明 */}
|
||
<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>
|
||
);
|
||
} |