Files
leaudit-platform-frontend/app/routes/rules._index.tsx
T

599 lines
21 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.
import React, { useState } from 'react';
import { json, type MetaFunction, type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { useLoaderData, useSearchParams, useSubmit } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { SearchBox } from '~/components/ui/SearchBox';
import { Tag } from '~/components/ui/Tag';
import { StatusDot } from '~/components/ui/StatusDot';
import rulesStyles from "~/styles/pages/rules_index.css?url";
import type { Rule } from '~/models/rule';
import { RULE_TYPE_LABELS, RULE_TYPE_COLORS, RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule';
import type { TagColor } from '~/components/ui/Tag';
import { Link } from '@remix-run/react';
export const links = () => [
{ rel: "stylesheet", href: rulesStyles }
];
export const handle = {
breadcrumb: "评查点列表"
};
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点列表" },
{ name: "description", content: "管理评查点规则,支持根据类型、规则组和状态进行筛选" },
{ name: "keywords", content: "评查点,合同审核,规则管理,中国烟草" }
];
};
interface LoaderData {
rules: Rule[];
groups: {
id: string;
name: string;
}[];
totalCount: number;
currentPage: number;
pageSize: number;
totalPages: number;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const ruleType = url.searchParams.get("ruleType") || "";
const groupId = url.searchParams.get("groupId") || "";
const isActive = url.searchParams.get("isActive") || "";
const keyword = url.searchParams.get("keyword") || "";
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
try {
// 模拟数据,实际项目中应从API获取
const rules: Rule[] = [
{
id: "1",
code: "CP001",
name: "合同主体信息完整性检查",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "essential",
priority: "high",
description: "检查合同中是否完整包含签约方的基本信息,包括名称、地址、法定代表人等",
checkMethod: "automatic",
prompt: "检查合同主体双方信息是否完整,包括企业名称、注册地址、法定代表人或授权代表、联系方式等",
isActive: true,
createdAt: "2023-06-15 10:30",
updatedAt: "2023-06-15 10:30"
},
{
id: "2",
code: "CP002",
name: "合同金额一致性校验",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "content",
priority: "high",
description: "检查合同大小写金额是否一致",
checkMethod: "automatic",
prompt: "检查合同中的金额大写和小写表示是否一致,如¥10,000.00(壹万元整)",
isActive: true,
createdAt: "2023-06-20 14:15",
updatedAt: "2023-06-20 14:15"
},
{
id: "3",
code: "CP003",
name: "保密条款合规性审核",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "legal",
priority: "medium",
description: "检查合同是否包含保密条款并符合行业要求",
checkMethod: "mixed",
prompt: "检查合同中的保密条款是否完整、清晰,包含保密范围、期限、违约责任等",
isActive: true,
createdAt: "2023-07-05 09:45",
updatedAt: "2023-07-05 09:45"
},
{
id: "4",
code: "CP004",
name: "合同签约日期格式检查",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "format",
priority: "low",
description: "检查合同签约日期格式是否规范",
checkMethod: "automatic",
prompt: "检查合同签约日期格式是否符合YYYY年MM月DD日的规范格式",
isActive: false,
createdAt: "2023-07-10 16:20",
updatedAt: "2023-07-10 16:20"
},
{
id: "5",
code: "CP005",
name: "违约责任条款完整性检查",
ruleGroupId: "2",
groupName: "销售合同专项检查",
ruleType: "legal",
priority: "high",
description: "检查合同违约责任条款是否明确、完整",
checkMethod: "mixed",
prompt: "检查合同中的违约责任条款是否包含违约情形、违约金计算方式、责任承担方式等内容",
isActive: true,
createdAt: "2023-07-15 11:30",
updatedAt: "2023-07-15 11:30"
},
{
id: "6",
code: "CP006",
name: "交货期限有效性检查",
ruleGroupId: "2",
groupName: "销售合同专项检查",
ruleType: "business",
priority: "medium",
description: "检查合同中交货期限是否明确、合理",
checkMethod: "automatic",
prompt: "检查合同中是否明确约定了交货期限,并且期限设置是否合理",
isActive: true,
createdAt: "2023-08-01 14:40",
updatedAt: "2023-08-01 14:40"
},
{
id: "7",
code: "CP007",
name: "合同条款矛盾性检查",
ruleGroupId: "3",
groupName: "采购合同专项检查",
ruleType: "legal",
priority: "high",
description: "检查合同条款之间是否存在矛盾或冲突",
checkMethod: "mixed",
prompt: "分析合同各条款,检查是否存在相互矛盾或冲突的内容",
isActive: true,
createdAt: "2023-08-10 09:15",
updatedAt: "2023-08-10 09:15"
}
];
const groups = [
{ id: "1", name: "合同基本要素检查" },
{ id: "2", name: "销售合同专项检查" },
{ id: "3", name: "采购合同专项检查" },
{ id: "4", name: "专卖许可证审核规则" },
{ id: "5", name: "行政处罚规范性检查" }
];
// 过滤数据
let filteredRules = [...rules];
if (ruleType) {
filteredRules = filteredRules.filter(rule => rule.ruleType === ruleType);
}
if (groupId) {
filteredRules = filteredRules.filter(rule => rule.ruleGroupId === groupId);
}
if (isActive) {
const activeValue = isActive === 'true';
filteredRules = filteredRules.filter(rule => rule.isActive === activeValue);
}
if (keyword) {
const lowerKeyword = keyword.toLowerCase();
filteredRules = filteredRules.filter(rule =>
rule.name.toLowerCase().includes(lowerKeyword) ||
rule.code.toLowerCase().includes(lowerKeyword)
);
}
// 计算分页信息
const totalCount = filteredRules.length;
const totalPages = Math.ceil(totalCount / pageSize);
// 验证页码范围
if (currentPage < 1 || (totalCount > 0 && currentPage > totalPages)) {
// 如果页码超出范围,重定向到第一页
const newUrl = new URL(request.url);
newUrl.searchParams.set('page', '1');
return redirect(newUrl.pathname + newUrl.search);
}
// 分页截取
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedRules = filteredRules.slice(startIndex, endIndex);
return json<LoaderData>({
rules: paginatedRules,
groups,
totalCount,
currentPage,
pageSize,
totalPages
}, {
headers: {
// 添加缓存控制,在生产环境中可以调整
"Cache-Control": "max-age=60, s-maxage=180"
}
});
} catch (error) {
console.error('加载评查点列表失败:', error);
throw new Response('加载评查点列表失败', { status: 500 });
}
}
export async function action({ request }: LoaderFunctionArgs) {
const formData = await request.formData();
const _action = formData.get('_action');
const ruleId = formData.get('ruleId');
if (!ruleId) {
return json({ success: false, error: "缺少评查点ID" }, { status: 400 });
}
try {
if (_action === 'delete') {
// 实际项目中应调用API删除评查点
console.log(`删除评查点 ${ruleId}`);
// 模拟API调用
// const response = await fetch(`/api/rules/${ruleId}`, {
// method: 'DELETE',
// });
// if (!response.ok) {
// throw new Error(`删除失败: ${response.status}`);
// }
return json({ success: true });
}
if (_action === 'duplicate') {
// 实际项目中应调用API复制评查点
console.log(`复制评查点 ${ruleId}`);
// 模拟API调用
// const response = await fetch(`/api/rules/${ruleId}/duplicate`, {
// method: 'POST',
// });
// if (!response.ok) {
// throw new Error(`复制失败: ${response.status}`);
// }
return json({ success: true });
}
return json({ success: false, error: "未知操作" }, { status: 400 });
} catch (error) {
console.error('操作评查点失败:', error);
return json({ success: false, error: "操作失败" }, { status: 500 });
}
}
export function ErrorBoundary() {
return (
<div className="error-container p-6">
<h1 className="text-xl font-bold text-red-500 mb-4"></h1>
<p className="mb-4"></p>
<Button type="primary" to="/"></Button>
</div>
);
}
// 规则类型和优先级的描述标签映射
const typeLabels = {
'essential': '基本要素类',
'content': '内容合规类',
'legal': '法律风险类',
'format': '格式规范类',
'business': '业务专项类'
};
const priorityLabels = {
'high': '高',
'medium': '中',
'low': '低'
};
export default function RulesList() {
const { rules, groups, totalCount, currentPage, pageSize, totalPages } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const submit = useSubmit();
// 状态管理
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<Rule | null>(null);
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleSearch = (keyword: string) => {
const newParams = new URLSearchParams(searchParams);
if (keyword) {
newParams.set('keyword', keyword);
} else {
newParams.delete('keyword');
}
// 搜索时,重置到第一页
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleDeleteClick = (rule: Rule) => {
setRuleToDelete(rule);
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
if (!ruleToDelete) return;
const formData = new FormData();
formData.append('_action', 'delete');
formData.append('ruleId', ruleToDelete.id);
submit(formData, { method: 'post' });
setShowDeleteConfirm(false);
setRuleToDelete(null);
};
const handleCopy = (rule: Rule) => {
const formData = new FormData();
formData.append('_action', 'duplicate');
formData.append('ruleId', rule.id);
submit(formData, { method: 'post' });
};
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newPageSize = e.target.value;
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', newPageSize);
newParams.set('page', '1'); // 更改每页条数时,重置到第一页
setSearchParams(newParams);
};
return (
<div className="p-6 rules-page">
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium"></h2>
<Button type="primary" icon="ri-add-line" to="/rules/new">
</Button>
</div>
{/* 筛选区域 */}
<Card className="card-container">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label htmlFor="ruleType" className="form-label"></label>
<select
id="ruleType"
className="form-select"
name="ruleType"
value={searchParams.get('ruleType') || ''}
onChange={handleFilterChange}
>
<option value=""></option>
<option value="essential"></option>
<option value="content"></option>
<option value="format"></option>
<option value="legal"></option>
<option value="business"></option>
</select>
</div>
<div>
<label htmlFor="groupId" className="form-label"></label>
<select
id="groupId"
className="form-select"
name="groupId"
value={searchParams.get('groupId') || ''}
onChange={handleFilterChange}
>
<option value=""></option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="isActive" className="form-label"></label>
<select
id="isActive"
className="form-select"
name="isActive"
value={searchParams.get('isActive') || ''}
onChange={handleFilterChange}
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div>
<label htmlFor="keyword" className="form-label"></label>
<SearchBox
placeholder="输入评查点名称或编码"
defaultValue={searchParams.get('keyword') || ''}
onSearch={handleSearch}
/>
</div>
</div>
</Card>
{/* 评查点列表 */}
<Card className="ant-card">
<div className="overflow-x-auto">
<table className="ant-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rules.length > 0 ? (
rules.map((rule) => {
const typeColor = RULE_TYPE_COLORS[rule.ruleType] as TagColor;
const priorityColor = RULE_PRIORITY_COLORS[rule.priority] as TagColor;
return (
<tr key={rule.id}>
<td>{rule.code}</td>
<td>{rule.name}</td>
<td>
<Tag color={typeColor}>
{typeLabels[rule.ruleType as keyof typeof typeLabels] || RULE_TYPE_LABELS[rule.ruleType]}
</Tag>
</td>
<td>{rule.groupName}</td>
<td>
<Tag color={priorityColor}>
{priorityLabels[rule.priority as keyof typeof priorityLabels] || RULE_PRIORITY_LABELS[rule.priority]}
</Tag>
</td>
<td>
<StatusDot status={rule.isActive} />
</td>
<td>{rule.createdAt}</td>
<td className="operations-cell">
<Link to={`/rules/${rule.id}`} className="operation-btn">
<i className="ri-edit-line"></i>
</Link>
<button className="operation-btn" onClick={() => handleCopy(rule)}>
<i className="ri-file-copy-line"></i>
</button>
<button className="operation-btn operation-btn-danger" onClick={() => handleDeleteClick(rule)}>
<i className="ri-delete-bin-line"></i>
</button>
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={8} className="text-center py-8 text-gray-500">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 分页 */}
{totalCount > 0 && (
<div className="ant-pagination">
<div className="ant-pagination-options">
<span className="text-sm mr-2"> {totalCount} </span>
<select
className="form-select ant-pagination-options-size-changer"
style={{ width: "100px" }}
value={pageSize}
onChange={handlePageSizeChange}
>
<option value="10">10 /</option>
<option value="20">20 /</option>
<option value="50">50 /</option>
</select>
</div>
<div className="ant-pagination-right">
<button
className={`ant-pagination-item ant-pagination-prev ${currentPage <= 1 ? 'ant-pagination-disabled' : ''}`}
onClick={() => currentPage > 1 && handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<i className="ri-arrow-left-s-line"></i>
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
// 显示当前页附近的页码,最多显示5个
let pageNum;
if (totalPages <= 5) {
// 总页数少于5,直接显示所有页码
pageNum = i + 1;
} else if (currentPage <= 3) {
// 当前页靠近开始
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
// 当前页靠近结尾
pageNum = totalPages - 4 + i;
} else {
// 当前页在中间
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
className={`ant-pagination-item ${pageNum === currentPage ? 'ant-pagination-item-active' : ''}`}
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</button>
);
})}
<button
className={`ant-pagination-item ant-pagination-next ${currentPage >= totalPages ? 'ant-pagination-disabled' : ''}`}
onClick={() => currentPage < totalPages && handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<i className="ri-arrow-right-s-line"></i>
</button>
</div>
</div>
)}
</Card>
{/* 删除确认对话框 */}
{showDeleteConfirm && ruleToDelete && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h3 className="text-lg font-medium mb-4"></h3>
<p className="mb-6">&ldquo;{ruleToDelete.name}&rdquo;</p>
<div className="flex justify-end space-x-2">
<Button type="default" onClick={() => setShowDeleteConfirm(false)}></Button>
<Button type="danger" onClick={confirmDelete}></Button>
</div>
</div>
</div>
)}
</div>
);
}