Files
s2f/frontend/app/(dashboard)/settings/rules/page.tsx
T
freedakgmail 33b4c734aa feat: 完善前后端核心功能模块
后端:
- 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API
- 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers
- 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等
- 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等
- 添加数据库迁移脚本

前端:
- 新增登录页面和仪表盘页面
- 新增任务列表、任务详情、字段映射页面
- 新增异常处理页面和规则设置页面
- 新增 API 代理路由 /api/[...path]
- 新增 UI 组件库 (button, card, dialog, input, table 等)
- 新增 auth 组件 (ProtectedRoute, PermissionGate)
- 新增 layout 组件 (Header, Sidebar)
- 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel)
- 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等)
- 新增状态管理 (auth-store, company-store, ui-store)
- 集成 Tailwind CSS 和 shadcn/ui 组件库

其他:
- 添加 Alembic 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
2026-07-07 09:04:47 +08:00

324 lines
13 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 { useRouter } from "next/navigation";
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
import { api } from "@/lib/api/client";
import { ENDPOINTS } from "@/lib/api/endpoints";
import { useToast } from "@/lib/hooks/useToast";
import type { CompanyRuleResponse } from "@/lib/api/types";
// 模拟数据
const mockRules: CompanyRuleResponse[] = [
{
id: 1,
company_id: 1,
rule_type: "FIELD_MAPPING",
match_condition: { source_field: "员工姓名" },
target_value: "员工姓名",
priority: 0,
status: "ACTIVE",
description: "工资表员工姓名字段映射",
match_count: 15,
last_used_at: "2024-01-15T10:30:00Z",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-15T10:30:00Z",
},
{
id: 2,
company_id: 1,
rule_type: "FIELD_MAPPING",
match_condition: { source_field: "基本薪资" },
target_value: "基本工资",
priority: 0,
status: "ACTIVE",
description: "工资表基本薪资字段映射",
match_count: 12,
last_used_at: "2024-01-15T10:30:00Z",
created_at: "2024-01-05T00:00:00Z",
updated_at: "2024-01-15T10:30:00Z",
},
{
id: 3,
company_id: 1,
rule_type: "FIELD_MAPPING",
match_condition: { source_field: "实发合计" },
target_value: "实发工资",
priority: 1,
status: "INACTIVE",
description: "工资表实发合计字段映射(已停用)",
match_count: 8,
last_used_at: "2024-01-10T00:00:00Z",
created_at: "2024-01-08T00:00:00Z",
updated_at: "2024-01-12T00:00:00Z",
},
];
const RULE_TYPE_LABELS: Record<string, string> = {
FIELD_MAPPING: "字段映射",
ACCOUNT_MAPPING: "科目映射",
DEPARTMENT_MAPPING: "部门映射",
};
export default function RulesPage() {
const router = useRouter();
const toast = useToast();
const [rules, setRules] = useState<CompanyRuleResponse[]>(mockRules);
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
// 过滤规则
const filteredRules = rules.filter((rule) => {
const matchesSearch =
!searchQuery ||
rule.match_condition.source_field?.toLowerCase().includes(searchQuery.toLowerCase()) ||
rule.target_value.toLowerCase().includes(searchQuery.toLowerCase()) ||
rule.description?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = !typeFilter || rule.rule_type === typeFilter;
const matchesStatus = !statusFilter || rule.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
// 切换规则状态
const handleToggleStatus = useCallback(
async (ruleId: number, currentStatus: string) => {
const newStatus = currentStatus === "ACTIVE" ? "INACTIVE" : "ACTIVE";
try {
await api.patch(ENDPOINTS.MAPPING.RULE_STATUS(ruleId), {
status: newStatus,
});
setRules((prev) =>
prev.map((rule) =>
rule.id === ruleId ? { ...rule, status: newStatus } : rule
)
);
toast.success(`规则已${newStatus === "ACTIVE" ? "启用" : "停用"}`);
} catch {
toast.error("状态更新失败");
}
},
[toast]
);
// 删除规则
const handleDeleteRule = useCallback(
async (ruleId: number) => {
if (!confirm("确定要删除这条规则吗?")) return;
try {
await api.delete(ENDPOINTS.MAPPING.RULE_DELETE(ruleId));
setRules((prev) => prev.filter((rule) => rule.id !== ruleId));
toast.success("规则已删除");
} catch {
toast.error("删除失败");
}
},
[toast]
);
const activeCount = rules.filter((r) => r.status === "ACTIVE").length;
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
return (
<div className="p-6 max-w-7xl mx-auto">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => router.push("/dashboard")}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
</p>
</div>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{rules.length}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-gray-400">{inactiveCount}</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
</div>
{/* 筛选栏 */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索规则..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
<option value="FIELD_MAPPING"></option>
<option value="ACCOUNT_MAPPING"></option>
<option value="DEPARTMENT_MAPPING"></option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
<option value="ACTIVE"></option>
<option value="INACTIVE"></option>
</select>
</div>
</div>
{/* 规则列表 */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
使
</th>
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{filteredRules.map((rule) => (
<tr
key={rule.id}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-4 py-3">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-xs">
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
</span>
</td>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{rule.target_value}
</td>
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
{rule.match_count}
</td>
<td className="px-4 py-3 text-center">
<span
className={`px-2 py-1 rounded text-xs ${
rule.status === "ACTIVE"
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
}`}
>
{rule.status === "ACTIVE" ? "启用" : "停用"}
</span>
</td>
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
{rule.last_used_at
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
: "-"}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggleStatus(rule.id, rule.status)}
className={`p-1 rounded transition-colors ${
rule.status === "ACTIVE"
? "text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30"
: "text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
title={rule.status === "ACTIVE" ? "停用" : "启用"}
>
{rule.status === "ACTIVE" ? (
<ToggleRight className="w-5 h-5" />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</button>
<button
onClick={() => handleDeleteRule(rule.id)}
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredRules.length === 0 && (
<div className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
</div>
)}
</div>
{/* 说明 */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h3 className="font-medium text-blue-800 dark:text-blue-300 mb-2">
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
);
}