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

422 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}