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

451 lines
15 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 { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
import { Link, useLoaderData, useSearchParams } from '@remix-run/react';
import type React from 'react';
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterPanel';
import { Pagination } from '~/components/ui/Pagination';
import { Table } from '~/components/ui/Table';
import { Tag, type TagColor } from '~/components/ui/Tag';
import { loadRuleConfigPackSummaries, type RuleConfigPackSummary } from '~/utils/rules-config-packs.server';
import type { RuleSummary } from '~/utils/rules-yaml-mock.server';
import styles from '~/styles/pages/rules_test.css?url';
export const links = () => [
{ rel: 'stylesheet', href: styles }
];
export const meta: MetaFunction = () => [
{ title: '规则 YAML 列表 - 智慧法务' }
];
export const handle = {
breadcrumb: '评查点列表'
};
type RuleRow = RuleSummary & {
rowId: string;
packId: string;
documentType: string;
moduleType: string;
mainType: string;
subtype: string;
yamlName: string;
yamlStatus: RuleConfigPackSummary['sourceStatus'];
isPlaceholder?: boolean;
};
type LoaderData = {
rows: RuleRow[];
filters: {
documentType: string;
mainType: string;
subtype: string;
ruleGroup: string;
keyword: string;
page: number;
pageSize: number;
};
totalCount: number;
currentPage: number;
pageSize: number;
options: {
documentTypes: string[];
mainTypes: string[];
subtypes: string[];
ruleGroups: string[];
};
};
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
function resolveDocumentScope(pack: Pick<RuleConfigPackSummary, 'documentType' | 'mainType' | 'moduleType'>): string {
const values = [pack.documentType, pack.mainType, pack.moduleType].join(' ');
if (values.includes('合同')) return '合同';
if (values.includes('案卷') || values.includes('卷宗') || values.includes('行政处罚') || values.includes('行政许可')) {
return '案卷';
}
if (values.includes('公文')) return '内部公文';
return pack.documentType || pack.mainType || pack.moduleType || '未分类';
}
function resolveBusinessType(pack: Pick<RuleConfigPackSummary, 'businessType' | 'mainType'>): string {
return pack.businessType || pack.mainType || '';
}
function riskColor(risk: string): TagColor {
if (risk === 'high') return 'red';
if (risk === 'medium') return 'orange';
if (risk === 'low') return 'green';
return 'gray';
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const requestedMainType = url.searchParams.get('mainType') || url.searchParams.get('ruleTypeName') || '';
const requestedSubtype = url.searchParams.get('subtype') || url.searchParams.get('documentAttributeType') || '';
const requestedRuleGroup = url.searchParams.get('ruleGroup') || url.searchParams.getAll('ruleGroups')[0] || '';
const requestedPage = Number(url.searchParams.get('page') || '1');
const requestedPageSize = Number(url.searchParams.get('pageSize') || '50');
const requestedFilters = {
documentType: url.searchParams.get('documentType') || '',
mainType: requestedMainType,
subtype: requestedSubtype,
ruleGroup: requestedRuleGroup,
keyword: url.searchParams.get('keyword') || '',
page: Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1,
pageSize: [10, 20, 30, 50].includes(requestedPageSize) ? requestedPageSize : 50
};
const packs = await loadRuleConfigPackSummaries(request);
const packScopes = packs.map(pack => ({
pack,
scope: resolveDocumentScope(pack),
}));
const documentTypes = unique(packScopes.map(item => item.scope));
const requestedDocumentType = requestedFilters.documentType;
const inferredDocumentType = requestedMainType
? packScopes.find(item => resolveBusinessType(item.pack) === requestedMainType)?.scope || ''
: '';
const currentDocumentType = documentTypes.includes(requestedDocumentType)
? requestedDocumentType
: inferredDocumentType || documentTypes[0] || '';
const scopedDocumentPacks = packScopes
.filter(item => item.scope === currentDocumentType)
.map(item => item.pack);
const scopedFilters = {
...requestedFilters,
documentType: currentDocumentType,
mainType: scopedDocumentPacks.some(pack => resolveBusinessType(pack) === requestedFilters.mainType)
? requestedFilters.mainType
: '',
subtype: scopedDocumentPacks.some(pack =>
(!requestedFilters.mainType || resolveBusinessType(pack) === requestedFilters.mainType) &&
pack.subtype === requestedFilters.subtype
)
? requestedFilters.subtype
: ''
};
const scopedByMainTypePacks = scopedDocumentPacks.filter(pack =>
!scopedFilters.mainType || resolveBusinessType(pack) === scopedFilters.mainType
);
const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype));
const ruleGroupSourcePacks = scopedFilters.subtype
? scopedByMainTypePacks.filter(pack => pack.subtype === scopedFilters.subtype)
: subtypeOptions.length <= 1
? scopedByMainTypePacks
: [];
const ruleGroupOptions = unique(
ruleGroupSourcePacks.flatMap(pack => pack.rules.map(rule => rule.group))
).sort((a, b) => a.localeCompare(b, 'zh-CN'));
const filters = {
...scopedFilters,
ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : ''
};
const visiblePacks = scopedDocumentPacks.filter(pack =>
(!filters.mainType || resolveBusinessType(pack) === filters.mainType) &&
(!filters.subtype || pack.subtype === filters.subtype)
);
const filteredRows: RuleRow[] = visiblePacks.flatMap((pack): RuleRow[] => {
if (pack.rules.length === 0) {
return [{
rowId: `${pack.id}-empty`,
packId: pack.id,
documentType: pack.documentType,
moduleType: pack.moduleType,
mainType: resolveBusinessType(pack),
subtype: pack.subtype,
yamlName: pack.yamlName || '待配置 YAML',
yamlStatus: pack.sourceStatus,
id: `${pack.id}-empty`,
ruleId: '-',
name: `${pack.subtype}待配置`,
group: '待配置',
risk: '-',
score: '-',
type: '-',
checkTypes: [],
logic: '',
subRules: [],
subRuleIds: [],
scope: [],
dependencies: [],
stageCount: 0,
appliesIn: [],
prompt: '',
description: pack.sourceStatus === 'missing'
? '当前规则集已建立,但生效版本正文暂未成功加载,请进入配置页检查并重新保存。'
: '当前子类型还没有正式评查点,请进入配置页补充字段、子文档与评查规则。',
isPlaceholder: true,
}];
}
return pack.rules.map(rule => ({
...rule,
rowId: `${pack.id}-${rule.ruleId || rule.id}`,
packId: pack.id,
documentType: pack.documentType,
moduleType: pack.moduleType,
mainType: resolveBusinessType(pack),
subtype: pack.subtype,
yamlName: pack.yamlName,
yamlStatus: pack.sourceStatus
}));
}).filter(row => {
if (filters.ruleGroup && row.group !== filters.ruleGroup) {
return false;
}
if (!filters.keyword) return true;
return [row.ruleId, row.name, row.group, row.yamlName, row.subtype]
.some(value => value.toLowerCase().includes(filters.keyword.toLowerCase()));
});
const totalCount = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalCount / filters.pageSize));
const currentPage = Math.min(filters.page, totalPages);
const startIndex = (currentPage - 1) * filters.pageSize;
const rows = filteredRows.slice(startIndex, startIndex + filters.pageSize);
return Response.json({
rows,
filters: {
...filters,
page: currentPage
},
totalCount,
currentPage,
pageSize: filters.pageSize,
options: {
documentTypes,
mainTypes: unique(scopedDocumentPacks.map(pack => resolveBusinessType(pack))),
subtypes: subtypeOptions,
ruleGroups: ruleGroupOptions
}
} satisfies LoaderData);
}
export default function RulesTestList() {
const { rows, filters, options, totalCount, currentPage, pageSize } = useLoaderData<typeof loader>() as LoaderData;
const [searchParams, setSearchParams] = useSearchParams();
const updateParams = (updates: Record<string, string | number | undefined>, resetPage = true) => {
const nextParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === '') {
nextParams.delete(key);
return;
}
nextParams.set(key, String(value));
});
if (resetPage) {
nextParams.set('page', '1');
}
setSearchParams(nextParams);
};
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = event.target;
if (name === 'mainType') {
updateParams({
mainType: value,
ruleTypeName: undefined,
subtype: undefined,
documentAttributeType: undefined,
ruleGroup: undefined,
});
return;
}
if (name === 'subtype') {
updateParams({ subtype: value, documentAttributeType: undefined, ruleGroup: undefined });
return;
}
updateParams({ [name]: value });
};
const handleSearch = (keyword: string) => {
updateParams({ keyword });
};
const handleReset = () => {
const nextParams = new URLSearchParams(searchParams);
['mainType', 'ruleTypeName', 'ruleGroup', 'subtype', 'documentAttributeType', 'keyword', 'page'].forEach(key => nextParams.delete(key));
nextParams.set('page', '1');
setSearchParams(nextParams);
};
const handlePageChange = (page: number) => {
updateParams({ page }, false);
};
const handlePageSizeChange = (nextPageSize: number) => {
updateParams({ pageSize: nextPageSize, page: 1 }, false);
};
const columns = [
{
title: '规则',
key: 'rule',
width: '24%',
render: (_: unknown, record: RuleRow) => (
<div className="rule-name">
<strong>{record.name}</strong>
<span>{record.isPlaceholder ? record.description : record.ruleId}</span>
</div>
)
},
{
title: '子分类',
key: 'subtype',
width: '12%',
align: 'center' as const,
render: (_: unknown, record: RuleRow) => (
<div className="inline-tags">
<Tag color="blue" size="sm">{record.subtype}</Tag>
</div>
)
},
{
title: '规则组',
dataIndex: 'group' as keyof RuleRow,
key: 'group',
width: '14%',
align: 'center' as const
},
{
title: '风险',
key: 'risk',
width: '8%',
align: 'center' as const,
render: (_: unknown, record: RuleRow) => (
<Tag color={record.isPlaceholder ? (record.yamlStatus === 'missing' ? 'orange' : 'blue') : riskColor(record.risk)} size="sm">
{record.isPlaceholder ? (record.yamlStatus === 'missing' ? '待修复' : '待配置') : record.risk}
</Tag>
)
},
{
title: '分值',
key: 'score',
width: '8%',
align: 'center' as const,
render: (_: unknown, record: RuleRow) => (
<Tag color="gray" size="sm">{record.isPlaceholder ? '-' : record.score}</Tag>
)
},
{
title: '依赖字段',
key: 'dependencies',
width: '20%',
render: (_: unknown, record: RuleRow) => (
<span>{record.isPlaceholder ? '先进入配置页补规则与依赖' : (record.dependencies.length > 0 ? record.dependencies.slice(0, 3).join('、') : '-')}</span>
)
},
{
title: '操作',
key: 'operation',
width: '14%',
align: 'center' as const,
render: (_: unknown, record: RuleRow) => (
<Link className="operation-btn" to={`/rulesTest/detail?packId=${encodeURIComponent(record.packId)}&ruleId=${encodeURIComponent(record.ruleId || record.id)}`}>
<i className="ri-settings-3-line"></i> {record.isPlaceholder ? '去配置' : '配置'}
</Link>
)
}
];
return (
<div className="rules-test-page rules-page">
<div className="rules-test-list-shell">
<div className="rules-list-header">
<div className="rules-list-title">
<h2></h2>
<div className="rules-list-total">
<i className="ri-file-list-3-line"></i>
<span></span>
<strong>{totalCount}</strong>
</div>
</div>
</div>
<FilterPanel
className="rules-test-filter-panel 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="mainType"
value={filters.mainType}
options={options.mainTypes.map(mainType => ({ value: mainType, label: mainType }))}
onChange={handleFilterChange}
className="mr-3 w-[18%]"
placeholder="全部"
/>
<FilterSelect
label="子类型"
name="subtype"
value={filters.subtype}
options={options.subtypes.map(subtype => ({ value: subtype, label: subtype }))}
onChange={handleFilterChange}
className="mr-3 w-[18%]"
placeholder={filters.mainType || options.mainTypes.length <= 1 ? '全部' : '请先选择业务类型'}
/>
<FilterSelect
label="所属规则组"
name="ruleGroup"
value={filters.ruleGroup}
options={options.ruleGroups.map(group => ({ value: group, label: group }))}
onChange={handleFilterChange}
className="mr-3 w-[22%]"
disabled={options.ruleGroups.length === 0}
placeholder={filters.subtype || options.subtypes.length <= 1 ? '全部' : '请先选择子类型'}
/>
<SearchFilter
key={filters.keyword}
label="搜索"
placeholder="输入评查点名称或编码"
value={filters.keyword}
buttonText="搜索"
onSearch={handleSearch}
className="min-w-[200px] flex-1"
/>
</FilterPanel>
<Card className="ant-card rules-test-table-card">
<Table
className="rules-test-table rules-table"
columns={columns}
dataSource={rows}
rowKey="rowId"
scroll={{ y: 'calc(100vh - 340px)' }}
emptyText={<div className="empty-state"> YAML </div>}
/>
{totalCount > 0 && (
<Pagination
currentPage={currentPage}
total={totalCount}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showTotal={true}
showPageSizeChanger={true}
pageSizeOptions={[10, 20, 30, 50]}
/>
)}
</Card>
</div>
</div>
);
}