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

775 lines
25 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, useEffect, useCallback } from 'react';
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useRouteLoaderData, useLocation } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag } from '~/components/ui/Tag';
import { StatusDot } from '~/components/ui/StatusDot';
import { TableRowSkeleton, LoadingIndicator, NumberSkeleton } from '~/components/ui/SkeletonScreen';
import rulesStyles from "~/styles/pages/rules_index.css?url";
import type { Rule, RuleType, RulePriority } from '~/models/rule';
import { RULE_TYPE_COLORS, RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule';
import type { TagColor } from '~/components/ui/Tag';
import { Table } from '~/components/ui/Table';
import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterPanel';
import { Pagination } from '~/components/ui/Pagination';
import { messageService } from '~/components/ui/MessageModal';
import { toastService } from '~/components/ui/Toast';
import {
getRulesList,
deleteRule,
getRuleTypes,
getRuleGroupsByType,
type RuleType as ApiRuleType,
type RuleGroup
} from '~/api/evaluation_points/rules';
import type { UserRole } from '~/root';
export const links = () => [
{ rel: "stylesheet", href: rulesStyles }
];
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点列表" },
{ name: "rules", content: "管理评查点规则,支持根据类型、规则组和状态进行筛选" },
{ name: "keywords", content: "评查点,合同审核,规则管理,中国烟草" }
];
};
// 声明loader返回的数据类型
export type LoaderData = {
rules: Rule[];
totalCount: number;
currentPage: number;
pageSize: number;
totalPages: number;
ruleTypes: ApiRuleType[]; // 添加评查点类型
initialLoad?: boolean; // 添加初始加载标志
};
// API返回的数据映射到前端模型
interface ApiRule {
id: string;
code: string;
name: string;
ruleType: string;
groupId: string;
groupName: string;
priority: string;
description: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
interface ActionResponse {
result: boolean;
message: string;
}
function mapApiRuleToModel(apiRule: ApiRule): Rule {
return {
id: apiRule.id,
code: apiRule.code,
name: apiRule.name,
ruleType: apiRule.ruleType as RuleType, // 类型转换
ruleGroupId: apiRule.groupId,
groupName: apiRule.groupName,
priority: apiRule.priority as RulePriority, // 类型转换
description: apiRule.description,
checkMethod: 'automatic', // 默认值
prompt: apiRule.description, // 使用描述作为默认prompt
isActive: apiRule.isActive,
createdAt: apiRule.createdAt,
updatedAt: apiRule.updatedAt
};
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
// 从 URL 参数中提取查询条件
const params = {
page: parseInt(url.searchParams.get("page") || "1", 10),
pageSize: parseInt(url.searchParams.get("pageSize") || "10", 10)
};
try {
// 🔑 使用 handleServerAuth 包装,自动处理 token 过期
const { handleServerAuth } = await import("~/utils/server-auth-handler");
return handleServerAuth(async () => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 获取评查点类型列表,供前端筛选使用
const typeResponse = await getRuleTypes(undefined, frontendJWT);
if (typeResponse.error) {
console.error('获取评查点类型失败:', typeResponse.error);
}
const ruleTypes = typeResponse.error ? [] : typeResponse.data;
// 返回初始空数据,客户端将根据 sessionStorage 中的 reviewType 加载实际数据
return Response.json({
rules: [],
totalCount: 0,
currentPage: params.page,
pageSize: params.pageSize,
ruleTypes,
initialLoad: true,
frontendJWT
}, {
headers: {
"Cache-Control": "max-age=60, s-maxage=180"
}
});
}, url.pathname);
} catch (error) {
console.error('加载评查点列表失败:', error);
return Response.json({
error: error || '加载评查点列表失败',
status: 500
}, { status: 500 });
}
}
export async function action({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const formData = await request.formData();
const _action = formData.get('_action');
const ruleId = formData.get('ruleId');
if (!ruleId) {
return Response.json({ result: false, message: "缺少评查点ID" }, { status: 400 });
}
try {
// 🔑 使用 handleServerAuth 包装,自动处理 token 过期
const { handleServerAuth } = await import("~/utils/server-auth-handler");
return handleServerAuth(async () => {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (_action === 'delete') {
// 调用API删除评查点
const deleteResponse = await deleteRule(ruleId as string, frontendJWT);
if (deleteResponse.error) {
return Response.json({ result: false, message: deleteResponse.error }, { status: deleteResponse.status || 500 });
}
return Response.json({ result: true, message: "评查点删除成功" }, { status: 200 });
}
return Response.json({ result: false, message: "未知操作" }, { status: 400 });
}, url.pathname);
} catch (error) {
console.error('操作评查点失败:', error);
return Response.json({ result: false, message: error instanceof Error ? error.message : "操作失败" }, { status: 500 });
}
}
// 规则优先级的描述标签映射
const priorityLabels = {
'high': '高',
'medium': '中',
'low': '低'
};
export default function RulesIndex() {
const loaderData = useLoaderData<typeof loader>();
const rootData = useRouteLoaderData("root") as { userRole: UserRole };
const { rules: initialRules, totalCount: initialTotalCount, currentPage, pageSize, ruleTypes: initialRuleTypes, initialLoad } = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const fetcher = useFetcher<ActionResponse>();
const location = useLocation();
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
const [reviewType, setReviewType] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [filteredRules, setFilteredRules] = useState<Rule[]>(initialRules);
const [filteredTotalCount, setFilteredTotalCount] = useState<number>(initialTotalCount);
const [ruleTypes, setRuleTypes] = useState<ApiRuleType[]>(initialRuleTypes);
// 添加一个路由变化计数器
const [routeChangeCount, setRouteChangeCount] = useState(0);
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
// 查询参数记忆 key 与保存/恢复
const SEARCH_PARAMS_STORAGE_KEY = 'rules.searchParams';
const persistSearchParams = (params: URLSearchParams) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, params.toString());
}
};
// 首次进入页且 URL 无参数时尝试恢复
useEffect(() => {
if (typeof window === 'undefined') return;
const hasAnyParam = Array.from(searchParams.keys()).length > 0;
const stored = sessionStorage.getItem(SEARCH_PARAMS_STORAGE_KEY);
if (!hasAnyParam && stored) {
setSearchParams(new URLSearchParams(stored));
}
// 仅初始化检查一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 获取当前的ruleType值
const ruleTypeParam = searchParams.get('ruleType');
// 追踪路由变化
useEffect(() => {
// console.log("路由变化:", location.key);
setRouteChangeCount(prev => prev + 1);
}, [location]);
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 检查用户是否为开发者角色
const userRole = rootData?.userRole || 'common';
const isDeveloper = userRole === 'admin';
// 调试日志
// console.log("🔑 [Rules List] rootData:", rootData);
// console.log("🔑 [Rules List] 用户角色:", userRole);
// console.log("🔑 [Rules List] 是否为管理员:", isDeveloper);
// 在组件渲染时初始化状态
// useEffect(() => {
// setFilteredRules(initialRules);
// setFilteredTotalCount(initialTotalCount);
// setRuleTypes(initialRuleTypes);
// }, [initialRules, initialTotalCount, initialRuleTypes]);
// 使用useEffect监听loaderData.error变化并显示Toast
useEffect(() => {
if(loaderData.error) {
toastService.error(loaderData.error);
}else if(loaderData.ruleTypes.length === 0){
toastService.error("评查点类型数据为空");
}
}, [loaderData.error,loaderData.ruleTypes]);
// 客户端数据加载函数
const fetchData = useCallback(async () => {
try {
// 从sessionStorage获取reviewType
const storedReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
const typeToUse = reviewType || storedReviewType;
if (!typeToUse) {
console.warn('无法加载评查点数据:未找到reviewType');
return;
}
// console.log("fetchData被调用,加载评查点数据", typeToUse);
setLoading(true);
// 获取评查点类型
try {
const typeResponse = await getRuleTypes(typeToUse, loaderData.frontendJWT);
if (typeResponse.data) {
setRuleTypes(typeResponse.data);
}
} catch (error) {
console.error('加载评查点类型失败:', error);
}
// 构建查询参数
const queryParams = {
ruleType: ruleTypeParam || undefined,
groupId: searchParams.get('groupId') || undefined,
isActive: searchParams.get('isActive') ? searchParams.get('isActive') === 'true' : undefined,
keyword: searchParams.get('keyword') || undefined,
page: currentPage,
pageSize,
reviewType: typeToUse,
token: loaderData.frontendJWT
};
// 调用 API 获取数据
const response = await getRulesList(queryParams);
if (response.data) {
const apiRules = response.data.rules || [];
const total = response.data.totalCount || 0;
const mappedRules = apiRules.map((apiRule: ApiRule) => mapApiRuleToModel(apiRule));
setFilteredRules(mappedRules);
setFilteredTotalCount(total);
}
} catch (error) {
console.error('客户端加载评查点列表失败:', error);
toastService.error('加载评查点列表失败');
} finally {
setLoading(false);
}
}, [reviewType, ruleTypeParam, searchParams, currentPage, pageSize]);
// 当评查点类型变化时,加载对应的规则组
useEffect(() => {
// 如果选择了"全部"或未选择,则清空规则组
if (!ruleTypeParam || ruleTypeParam === 'all') {
setRuleGroups([]);
return;
}
// 加载当前类型的规则组
const loadRuleGroups = async () => {
setLoadingGroups(true);
try {
const response = await getRuleGroupsByType(ruleTypeParam, loaderData.frontendJWT);
if (response.data) {
setRuleGroups(response.data);
} else if (response.error) {
console.error('加载规则组失败:', response.error);
setRuleGroups([]);
}
} catch (error) {
console.error('加载规则组出错:', error);
setRuleGroups([]);
} finally {
setLoadingGroups(false);
}
};
loadRuleGroups();
}, [ruleTypeParam]);
// 使用useEffect监听fetcher状态变化并显示Toast fetcher.state有以下几种状态: 通过fetcher提交数据后,action返回结果,fetcher.state会发生变化
// idle: 空闲状态
// loading: 加载中状态
// submittting: 提交中状态
// loading: 加载中状态
// idle: 空闲状态
useEffect(() => {
// 仅在fetcher有数据且状态为idle时处理
if (fetcher.data && fetcher.state === 'idle' && isDeleting) {
// 重置删除状态
setIsDeleting(false);
if (fetcher.data.result) {
toastService.success(fetcher.data.message);
// 删除成功后重新加载数据
fetchData();
} else if (!fetcher.data.result) {
// 删除失败只显示错误信息,不刷新数据
if(fetcher.data.message.includes("evaluation_results_evaluation_point_id_fkey")) {
toastService.error('对表evaluation_points进行更新或删除违反了表evaluation results上的外键约束evaluations results_evaluation _point_id_fkey');
} else {
toastService.error(fetcher.data.message);
}
// 删除失败不刷新数据
}
}
}, [fetcher.data, fetcher.state, fetchData, isDeleting]);
// 在组件挂载时从 sessionStorage 获取 reviewType 并加载数据
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const storedReviewType = sessionStorage.getItem('reviewType');
// console.log("组件挂载,从sessionStorage获取reviewType:", storedReviewType);
if (storedReviewType !== reviewType) {
setReviewType(storedReviewType);
}
// 无论如何,都加载数据,不依赖于reviewType的变化
if (storedReviewType) {
// 使用setTimeout确保该操作在其他状态更新之后执行
setTimeout(() => {
fetchData();
}, 0);
}
}
} catch (error) {
console.error('获取 sessionStorage 中的 reviewType 失败:', error);
}
}, [initialLoad, fetchData]);
// 监听路由变化,每次路由到此页面时刷新数据
useEffect(() => {
if (routeChangeCount > 0) {
// console.log("路由变化触发数据刷新,计数:", routeChangeCount);
const storedReviewType = typeof window !== 'undefined' ? sessionStorage.getItem('reviewType') : null;
// console.log("storedReviewType:", storedReviewType);
if (storedReviewType) {
if (storedReviewType !== reviewType) {
setReviewType(storedReviewType);
}
// 使用setTimeout确保该操作在其他状态更新之后执行
setTimeout(() => {
fetchData();
}, 0);
}
}
}, [routeChangeCount, fetchData]);
// 监听 URL 参数变化,重新获取数据
useEffect(() => {
if (reviewType) {
fetchData();
}
}, [searchParams, fetchData, reviewType]);
// 筛选评查点
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
// 如果是规则组选择,但是当前应该被禁用,则不处理
if (name === 'groupId' && isRuleGroupSelectDisabled) {
return;
}
if (value) {
newParams.set(name, value);
// 如果是评查点类型变更,清空规则组选择
if (name === 'ruleType') {
newParams.delete('groupId');
// 如果选择了"全部"或空值,也清空规则组选择
if (value === '' || value === 'all') {
setRuleGroups([]);
}
}
} else {
newParams.delete(name);
// 如果清除评查点类型,也清除规则组
if (name === 'ruleType') {
newParams.delete('groupId');
setRuleGroups([]);
}
}
// 切换筛选条件时,重置到第一页
newParams.set('page', '1');
persistSearchParams(newParams);
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');
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 删除评查点
const handleDeleteClick = (rule: Rule) => {
messageService.show({
title: "确认删除",
message: `确认删除评查点【${rule.name}】吗?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
onConfirm: () => {
// 设置删除状态为true
setIsDeleting(true);
const form = new FormData();
form.append("_action", "delete");
form.append("ruleId", rule.id);
fetcher.submit(form, { method: "post" });
}
});
};
// 复制评查点
const handleCopy = (rule: Rule) => {
navigate(`/rules/new?id=${rule.id}&mode=copy`);
};
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理每页条数变化
const handlePageSizeChange = (size: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('pageSize', size.toString());
newParams.set('page', '1'); // 更改每页条数时,重置到第一页
persistSearchParams(newParams);
setSearchParams(newParams);
};
// 处理重置筛选
const handleReset = () => {
const input = document.querySelector('input[placeholder="输入评查点名称或编码"]');
if (input) {
(input as HTMLInputElement).value = '';
}
// 保留reviewType的过滤条件,只重置其他条件
const newParams = new URLSearchParams();
if (typeof window !== 'undefined') {
sessionStorage.removeItem(SEARCH_PARAMS_STORAGE_KEY);
}
setSearchParams(newParams);
};
// 定义表格列配置
const columns = [
{
title: "评查点编码",
dataIndex: "code" as keyof Rule,
key: "code",
align: "left" as const,
width: "20%",
className: "whitespace-normal break-all",
render: (value: string) => (
<div className="whitespace-normal break-all overflow-visible">{value}</div>
)
},
{
title: "评查点名称",
dataIndex: "name" as keyof Rule,
key: "name",
align: "left" as const,
width: "20%"
},
{
title: "评查点类型",
key: "ruleType",
align: "left" as const,
width: "12%",
render: (_: unknown, record: Rule) => {
const typeColor = RULE_TYPE_COLORS[record.ruleType] as TagColor;
return (
record.ruleType ? <Tag color={typeColor}>
{record.ruleType}
</Tag> : null
);
}
},
{
title: "所属规则组",
dataIndex: "groupName" as keyof Rule,
key: "groupName",
align: "left" as const,
width: "10%"
},
{
title: "优先级",
key: "priority",
align: "left" as const,
width: "8%",
render: (_: unknown, record: Rule) => {
const priorityColor = RULE_PRIORITY_COLORS[record.priority] as TagColor;
return (
<Tag color={priorityColor}>
{priorityLabels[record.priority as keyof typeof priorityLabels] || RULE_PRIORITY_LABELS[record.priority]}
</Tag>
);
}
},
{
title: "状态",
key: "isActive",
align: "left" as const,
width: "8%",
render: (_: unknown, record: Rule) => (
<StatusDot status={record.isActive} text={record.isActive ? "启用" : "禁用"} />
)
},
{
title: "创建时间",
dataIndex: "createdAt" as keyof Rule,
key: "createdAt",
align: "left" as const,
width: "10%"
},
{
title: "操作",
key: "operation",
align: "left" as const,
width: "10%",
render: (_: unknown, record: Rule) => (
<div className="operations-cell">
{isDeveloper ? (
// 开发者可以看到编辑、复制、删除
<>
<Link to={`/rules/new?id=${record.id}`} className="operation-btn">
<i className="ri-edit-line"></i>
</Link>
<button className="operation-btn" onClick={() => handleCopy(record)}>
<i className="ri-file-copy-line"></i>
</button>
<button className="operation-btn operation-btn-danger" onClick={() => handleDeleteClick(record)}>
<i className="ri-delete-bin-line"></i>
</button>
</>
) : (
// 普通用户只能查看
<Link to={`/rules/new?id=${record.id}&mode=view`} className="operation-btn">
<i className="ri-eye-line"></i>
</Link>
)}
</div>
)
}
];
return (
<div className="rules-page">
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<h2 className="text-xl font-medium"></h2>
{loading ? (
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
<NumberSkeleton className="ml-1" />
</div>
) : (
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
<i className="ri-file-list-3-line text-primary text-lg mr-1"></i>
<span className="text-sm text-secondary"></span>
<span className="text-base font-normal text-primary ml-1">{filteredTotalCount}</span>
</div>
)}
</div>
{isDeveloper && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
)}
</div>
{/* 筛选区域 */}
<FilterPanel className="px-3 py-3" noActionDivider={true}
actions={
<>
<Button type="default" icon="ri-refresh-line" onClick={handleReset} className="mr-2 hover:!border-gray-300">
</Button>
</>
}
>
<FilterSelect
label="评查点类型"
name="ruleType"
value={searchParams.get('ruleType') || ''}
options={[
...ruleTypes.map((type: ApiRuleType) => ({
value: type.id,
label: type.name
}))
]}
onChange={handleFilterChange}
className="mr-3 w-[15%]"
/>
<FilterSelect
label="所属规则组"
name="groupId"
value={searchParams.get('groupId') || ''}
options={[
...(isRuleGroupSelectDisabled ? [{ value: "", label: "请先选择评查点类型" }] : []),
...ruleGroups.map(group => ({
value: group.id,
label: group.name
}))
]}
onChange={handleFilterChange}
className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`}
/>
<FilterSelect
label="状态"
name="isActive"
value={searchParams.get('isActive') || ''}
options={[
{ value: "true", label: "启用" },
{ value: "false", label: "禁用" }
]}
onChange={handleFilterChange}
className="mr-3 w-[15%]"
/>
<SearchFilter
label="搜索"
placeholder="输入评查点名称或编码"
value={searchParams.get('keyword') || ''}
buttonText="搜索"
onSearch={handleSearch}
className="min-w-[200px] flex-1"
/>
</FilterPanel>
{/* 评查点列表 - 使用Table组件 */}
<Card className="ant-card">
{loading && <LoadingIndicator />}
<div className={loading ? "opacity-70 pointer-events-none transition-opacity" : ""}>
{loading && filteredRules.length === 0 ? (
<TableRowSkeleton count={5} />
) : (
<Table
columns={columns}
dataSource={filteredRules}
rowKey="id"
// emptyText={loading ? "正在加载数据..." : "暂无评查点数据"}
className="rules-table"
/>
)}
{/* 分页 */}
{filteredTotalCount > 0 && (
<Pagination
currentPage={currentPage}
total={filteredTotalCount}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showTotal={true}
showPageSizeChanger={true}
pageSizeOptions={[10, 20, 30, 50]}
/>
)}
</div>
</Card>
</div>
);
}
// 错误边界
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>
);
}