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 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
This commit is contained in:
freedakgmail
2026-07-07 09:04:47 +08:00
parent 8487f6eadf
commit 33b4c734aa
117 changed files with 22070 additions and 295 deletions
@@ -0,0 +1,324 @@
"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>
);
}