451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
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 { loadRuleConfigPacks } from '~/utils/rules-config-packs.server';
|
||
import type { RuleSummary, RuleYamlPack } 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: RuleYamlPack['sourceStatus'];
|
||
};
|
||
|
||
type LoaderData = {
|
||
rows: RuleRow[];
|
||
packs: RuleYamlPack[];
|
||
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<RuleYamlPack, '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<RuleYamlPack, '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 { getUserSession } = await import("~/api/login/auth.server");
|
||
const { frontendJWT, userInfo } = await getUserSession(request);
|
||
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
|
||
await requireRoutePermission("/rulesTest/list", userInfo?.role || "", frontendJWT || undefined);
|
||
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') || '10');
|
||
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 : 10
|
||
};
|
||
|
||
const packs = await loadRuleConfigPacks(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.metadata.name || '待配置 YAML',
|
||
yamlStatus: pack.sourceStatus,
|
||
id: `${pack.id}-empty`,
|
||
ruleId: '-',
|
||
name: '暂无规则配置',
|
||
group: '待配置',
|
||
risk: '-',
|
||
score: '-',
|
||
type: '-',
|
||
checkTypes: [],
|
||
logic: '',
|
||
subRules: [],
|
||
subRuleIds: [],
|
||
scope: [],
|
||
dependencies: [],
|
||
stageCount: 0,
|
||
appliesIn: [],
|
||
prompt: '',
|
||
description: '当前文档类型已保留规则列表与 YAML 配置页流程,等待后续接入规则文件。'
|
||
}];
|
||
}
|
||
|
||
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.metadata.name,
|
||
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,
|
||
packs,
|
||
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.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={riskColor(record.risk)} size="sm">{record.risk}</Tag>
|
||
)
|
||
},
|
||
{
|
||
title: '分值',
|
||
key: 'score',
|
||
width: '8%',
|
||
align: 'center' as const,
|
||
render: (_: unknown, record: RuleRow) => (
|
||
<Tag color="gray" size="sm">{record.score}</Tag>
|
||
)
|
||
},
|
||
{
|
||
title: '依赖字段',
|
||
key: 'dependencies',
|
||
width: '20%',
|
||
render: (_: unknown, record: RuleRow) => (
|
||
<span>{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> 配置
|
||
</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">
|
||
<Table
|
||
className="rules-test-table rules-table"
|
||
columns={columns}
|
||
dataSource={rows}
|
||
rowKey="rowId"
|
||
scroll={{ y: 700 }}
|
||
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>
|
||
);
|
||
}
|