保存规则库 YAML 维护改造进展

This commit is contained in:
2026-04-28 22:00:00 +08:00
parent 7b86293263
commit dce5ac0c9a
96 changed files with 36801 additions and 615 deletions
@@ -4,6 +4,7 @@
*/
import { getUserRoutesByRole, type MenuItem } from './user-routes';
import { normalizeRoutePathForPermission } from '~/utils/route-alias';
/**
* 从 MenuItem 数组中提取所有路径(包括子路由)
@@ -50,15 +51,17 @@ function isDynamicIdSegment(segment: string): boolean {
* 检查路径是否在允许列表中
*/
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
const checkPath = normalizeRoutePathForPermission(pathname);
// 精确匹配
if (allowedPaths.includes(pathname)) {
if (allowedPaths.includes(checkPath)) {
return true;
}
// 动态路由匹配
for (const allowedPath of allowedPaths) {
if (pathname.startsWith(allowedPath + '/')) {
const subPath = pathname.substring(allowedPath.length + 1);
if (checkPath.startsWith(allowedPath + '/')) {
const subPath = checkPath.substring(allowedPath.length + 1);
const segments = subPath.split('/');
const firstSegment = segments[0];
@@ -69,7 +72,7 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
}
// 根路径
if (pathname === '/') {
if (checkPath === '/') {
return true;
}
+5 -5
View File
@@ -124,7 +124,7 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
{
id: 'rules-list',
title: '评查点列表',
path: '/rules',
path: '/rules/list',
icon: 'ri-list-check-3',
order: 2
},
@@ -266,7 +266,7 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
{
id: 'rules-list',
title: '评查点列表',
path: '/rules',
path: '/rules/list',
icon: 'ri-list-check-3',
order: 2
},
@@ -381,7 +381,7 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
{
id: 'rules-list',
title: '评查点列表',
path: '/rules',
path: '/rules/list',
icon: 'ri-list-check-3',
order: 2
},
@@ -489,7 +489,7 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
{
id: 'rules-list',
title: '评查点列表',
path: '/rules',
path: '/rules/list',
icon: 'ri-list-check-3',
order: 2
},
@@ -1009,4 +1009,4 @@ export function mapUserRoleToRoleKey(userRole: string): string {
// 如果找不到映射,返回 userRole 本身(假设后端已经返回了正确的 role_key)
return roleMapping[userRole] || userRole || 'common';
}
}
+10 -1
View File
@@ -35,6 +35,7 @@ export interface RulesQueryParams {
isActive?: boolean;
keyword?: string;
area?: string; // 地区过滤
documentAttributeType?: string; // 子类型(原文档属性类型)
orderBy?: string;
orderDirection?: 'asc' | 'desc';
userRole?: string; // 用户角色
@@ -116,6 +117,8 @@ export interface Rule {
priority: string;
description: string;
isActive: boolean;
area?: string;
documentAttributeType?: string;
createdAt: string;
updatedAt: string;
}
@@ -194,6 +197,7 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
isActive,
keyword,
area,
documentAttributeType,
userRole,
token
} = params;
@@ -238,6 +242,11 @@ export async function getRulesList(params: RulesQueryParams): Promise<{data: Rul
queryParams.append('is_enabled', isActive.toString());
}
// 添加子类型过滤(原 document_attribute_type
if (documentAttributeType) {
queryParams.append('document_attribute_type', documentAttributeType);
}
// 🔑 添加地区过滤
// if (user_role === 'provincial_admin') {
// queryParams.append('area', '省级');
@@ -1276,4 +1285,4 @@ export async function getAttributeTypes(
]
};
}
}
}
+156 -5
View File
@@ -26,6 +26,30 @@ interface Match {
data: unknown;
}
type RulesTestDetailData = {
pack?: {
documentType?: string;
mainType?: string;
fields?: unknown[];
subDocuments?: unknown[];
visualElements?: unknown[];
};
};
type RulesTestListData = {
filters?: {
documentType?: string;
mainType?: string;
subtype?: string;
ruleGroup?: string;
keyword?: string;
};
options?: {
subtypes?: string[];
ruleGroups?: string[];
};
};
export function Layout({ children, userRole = 'developer' as UserRole, frontendJWT = '', isMobile = false }: LayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [effectiveUserRole, setEffectiveUserRole] = useState<UserRole>(userRole);
@@ -121,7 +145,31 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
if (shouldHideSidebar) {
return <>{children}</>;
}
const isRulesTestList = location.pathname.startsWith('/rulesTest/list');
const isRulesTestDetail = location.pathname.startsWith('/rulesTest/detail');
const isRulesTestTopbarPage = isRulesTestList || isRulesTestDetail;
const rulesTestListData = matches.find(match => match.pathname.startsWith('/rulesTest/list'))?.data as RulesTestListData | undefined;
const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined;
const listFilters = rulesTestListData?.filters || {};
const listOptions = rulesTestListData?.options || {};
const detailPack = rulesTestDetailData?.pack;
const isContractDetail = !!detailPack?.documentType?.includes('合同');
const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷');
const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0;
const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0;
const showVisualNav = (detailPack?.visualElements?.length || 0) > 0;
const rulesListHref = detailPack?.documentType
? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPack.mainType ? `&mainType=${encodeURIComponent(detailPack.mainType)}` : ''}`
: '/rulesTest/list';
const listScopeText = [
listFilters.documentType,
listFilters.mainType && listFilters.mainType !== listFilters.documentType ? listFilters.mainType : ''
].filter(Boolean).join(' / ');
const submitTopbarFilter = (event: React.ChangeEvent<HTMLSelectElement>) => {
event.currentTarget.form?.requestSubmit();
};
return (
<div className="layout-container">
{/* 侧边栏始终保留,不再使用条件渲染 */}
@@ -131,8 +179,111 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
userRole={effectiveUserRole}
frontendJWT={effectiveFrontendJWT}
/>
<div className={`main-content ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
{/* 规则列表页顶部栏 */}
{isRulesTestList && (
<div className={`page-topbar rules-list-topbar ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<div className="topbar-content">
<div className="topbar-left">
<span className="topbar-icon" aria-hidden="true">
<i className="ri-list-settings-line"></i>
</span>
<div className="topbar-heading">
<h2 className="topbar-title"></h2>
<span className="topbar-breadcrumb">
<span></span>
{listScopeText && (
<>
<span className="separator">/</span>
<span>{listScopeText}</span>
</>
)}
</span>
</div>
</div>
<div className="topbar-right">
<a className="topbar-action secondary" href="/rules/list">
<i className="ri-history-line"></i>
<span></span>
</a>
</div>
</div>
<form method="get" action="/rulesTest/list" className="topbar-filter-strip">
<input type="hidden" name="documentType" defaultValue={listFilters.documentType || ''} />
{listFilters.mainType && <input type="hidden" name="mainType" defaultValue={listFilters.mainType} />}
<label className="topbar-filter-field">
<span></span>
<select name="subtype" value={listFilters.subtype || ''} onChange={submitTopbarFilter}>
<option value=""></option>
{(listOptions.subtypes || []).map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</label>
<label className="topbar-filter-field">
<span></span>
<select name="ruleGroup" value={listFilters.ruleGroup || ''} onChange={submitTopbarFilter}>
<option value=""></option>
{(listOptions.ruleGroups || []).map(group => (
<option key={group} value={group}>{group}</option>
))}
</select>
</label>
<label className="topbar-filter-field topbar-filter-field-search">
<span></span>
<input
key={listFilters.keyword || 'empty-keyword'}
name="keyword"
defaultValue={listFilters.keyword || ''}
placeholder="规则名称 / 编码 / 规则组"
/>
</label>
<button className="topbar-action" type="submit">
<i className="ri-search-line"></i>
<span></span>
</button>
</form>
</div>
)}
{/* 规则详情页顶部栏 */}
{isRulesTestDetail && (
<div className={`page-topbar ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<div className="topbar-content">
<div className="topbar-left">
<span className="topbar-icon" aria-hidden="true">
<i className="ri-file-settings-line"></i>
</span>
<div className="topbar-heading">
<h2 className="topbar-title"></h2>
<span className="topbar-breadcrumb">
<a href={rulesListHref}></a>
<span className="separator">/</span>
<span></span>
</span>
</div>
</div>
<div className="topbar-right">
<a className="topbar-action" href={rulesListHref}>
<i className="ri-arrow-left-line"></i>
<span></span>
</a>
<a className="topbar-action secondary" href="/rules/list">
<i className="ri-history-line"></i>
<span></span>
</a>
</div>
</div>
<div className="topbar-nav">
{showFieldNav && <a className="topbar-nav-link" href="#fields"><i className="ri-input-field"></i></a>}
{showSubDocumentNav && <a className="topbar-nav-link" href="#sub-documents"><i className="ri-file-list-3-line"></i></a>}
{showVisualNav && <a className="topbar-nav-link" href="#visual-elements"><i className="ri-stamp-line"></i></a>}
<a className="topbar-nav-link" href="#rules"><i className="ri-list-check-3"></i></a>
</div>
</div>
)}
<div className={`main-content ${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${isRulesTestDetail ? 'rules-detail-main' : ''} ${isRulesTestList ? 'rules-list-main' : ''}`}>
{/* 应用模块选择器 */}
{/* <div className="app-module-selector py-2 px-4 border-b border-gray-100 flex items-center">
{APP_MODULES.map(app => (
@@ -150,10 +301,10 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
</div> */}
<div className={`content-container${shouldNoPadding ? ' !p-0' : ''}`}>
{!shouldHideBreadcrumb && <Breadcrumb />}
{!shouldHideBreadcrumb && !isRulesTestTopbarPage && <Breadcrumb />}
{children}
</div>
</div>
</div>
);
}
}
+93 -20
View File
@@ -169,9 +169,18 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
}));
};
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
const isActive = (path: string) => {
const target = new URL(path, 'http://sidebar.local');
if (target.search) {
const currentParams = new URLSearchParams(location.search);
return location.pathname === target.pathname && Array.from(target.searchParams.entries()).every(
([key, value]) => currentParams.get(key) === value
);
}
return location.pathname === target.pathname || location.pathname.startsWith(`${target.pathname}/`);
};
// 处理侧边栏切换事件
const handleToggleSidebar = (e: React.MouseEvent) => {
@@ -181,17 +190,81 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
onToggle();
};
// 处理子菜单项点击事件
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
// 只需要阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
// const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// 处理菜单项:清理子菜单结构
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
// 处理子菜单项点击事件
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
// 只需要阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
const isRuleManagementMenu = (item: MenuItem) =>
item.id === 'rule-management' ||
item.path === '/rules' ||
item.title === '评查规则库' ||
!!item.children?.some(child => child.id === 'rules-list' || child.path === '/rules/list');
const isCaseFileModule = selectedModuleName.includes('案卷') || selectedModuleName.includes('卷宗');
const buildRulesTestListPath = (mainType?: string) => {
const params = new URLSearchParams();
if (isCaseFileModule) {
params.set('documentType', '案卷');
if (mainType) params.set('mainType', mainType);
} else if (selectedModuleName.includes('合同')) {
params.set('documentType', '合同');
params.set('mainType', '合同');
} else if (selectedModuleName.includes('公文')) {
params.set('documentType', '内部公文');
params.set('mainType', '内部公文');
} else if (selectedModuleName) {
params.set('documentType', selectedModuleName);
params.set('mainType', selectedModuleName);
}
const query = params.toString();
return query ? `/rulesTest/list?${query}` : '/rulesTest/list';
};
const normalizeRuleManagementMenu = (item: MenuItem): MenuItem => {
if (!isRuleManagementMenu(item)) {
return item;
}
if (isCaseFileModule) {
return {
...item,
children: [
{
id: 'rules-admin-penalty',
title: '行政处罚',
path: buildRulesTestListPath('行政处罚'),
icon: 'ri-list-check-3',
order: 1
},
{
id: 'rules-admin-license',
title: '行政许可',
path: buildRulesTestListPath('行政许可'),
icon: 'ri-list-check-3',
order: 2
}
]
};
}
return {
...item,
children: item.children?.map(child => (
child.id === 'rules-list' || child.path === '/rules' || child.path === '/rules/list'
? { ...child, path: buildRulesTestListPath() }
: child
))
};
};
// const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707'
// 处理菜单项:清理子菜单结构
const processedMenuItems: MenuItem[] = menuItems.filter(item =>{
// console.log('菜单项:', item.title, 'Icon:', item.icon)
// 🔑 优先检查:如果处于系统设置模式,只显示 /settings 及其子路由
@@ -255,11 +328,11 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) {
return false;
}
// 保留其他菜单
return true;
}).map((item): MenuItem => {
// 保留其他菜单
return true;
}).map(normalizeRuleManagementMenu).map((item): MenuItem => {
// 处理子菜单:过滤隐藏的子菜单
if (item.children && item.children.length > 0) {
// 过滤掉 hideBreadcrumb=true 的子菜单(这些通常是隐藏菜单)
@@ -449,4 +522,4 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
</div>
</>
);
}
}
+508 -492
View File
File diff suppressed because it is too large Load Diff
+14 -16
View File
@@ -48,11 +48,12 @@ import {
} from "~/config/api-config";
// 导入移动端检测工具
import {
isMobileDevice,
isMobileAllowedPath,
MOBILE_CHAT_PATH
} from "~/utils/mobile-detect.server";
import {
isMobileDevice,
isMobileAllowedPath,
MOBILE_CHAT_PATH
} from "~/utils/mobile-detect.server";
import { normalizeRoutePathForPermission } from "~/utils/route-alias";
// 定义需要高级权限的路径
// export const developerOnlyPaths = [
@@ -138,16 +139,13 @@ function isDynamicIdSegment(segment: string): boolean {
* @param allowedPaths 允许访问的路径列表(从菜单配置中提取)
* @returns true 表示允许访问,false 表示拒绝访问
*/
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
// --- 开发测试 STARTreviewsTest 复用 reviews 的权限(测试完后 git checkout app/root.tsx 还原)---
const testPath = pathname.replace(/^\/reviewsTest/, '/reviews');
const checkPath = testPath !== pathname ? testPath : pathname;
// --- 开发测试 END ---
// 1. 精确匹配(原版用 pathname,测试期间用 checkPath
if (allowedPaths.includes(checkPath)) {
return true;
}
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
const checkPath = normalizeRoutePathForPermission(pathname);
// 1. 精确匹配
if (allowedPaths.includes(checkPath)) {
return true;
}
// 2. 动态路由匹配(只允许看起来像ID的子路径)
for (const allowedPath of allowedPaths) {
@@ -495,4 +493,4 @@ export function ErrorBoundary() {
</body>
</html>
);
}
}
+4 -4
View File
@@ -810,7 +810,7 @@ export default function DocumentsIndex() {
if (typeof window !== 'undefined') {
sessionStorage.setItem(SEARCH_PARAMS_STORAGE_KEY, searchParams.toString());
}
navigate(`/reviews?id=${fileId}&previousRoute=documents`);
navigate(`/reviewsTest?id=${fileId}&previousRoute=documents`);
};
// 处理附件追加文件选择
@@ -1163,8 +1163,8 @@ export default function DocumentsIndex() {
{/* 查看按钮 - 需要 document:document:view 权限 */}
{canView && (
<Link
to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
// to={`/reviews?id=${historyDoc.id}&previousRoute=documents`}
to={`/reviewsTest?id=${historyDoc.id}&previousRoute=documents`}
// to={`/reviewsTest?id=${historyDoc.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
@@ -1422,7 +1422,7 @@ export default function DocumentsIndex() {
</Link>
) : (
<Link
to={`/reviews?id=${record.id}&previousRoute=documents`}
to={`/reviewsTest?id=${record.id}&previousRoute=documents`}
className="text-xs px-2 py-1 h-7 mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
+3 -3
View File
@@ -2087,8 +2087,8 @@ export default function FilesUpload() {
setTimeout(() => {
try {
if (isMountedRef.current) {
// console.log(`【调试-handleViewFile】执行导航,URL: /reviews?id=${record.id}&previousRoute=filesUpload`);
navigate(`/reviews?id=${record.id}&previousRoute=filesUpload`);
// console.log(`【调试-handleViewFile】执行导航,URL: /reviewsTest?id=${record.id}&previousRoute=filesUpload`);
navigate(`/reviewsTest?id=${record.id}&previousRoute=filesUpload`);
} else {
console.error('【调试-handleViewFile】组件已卸载,取消延迟导航');
}
@@ -3034,4 +3034,4 @@ export function ErrorBoundary({ error }: { error?: Error }) {
<Button type="primary" to="/"></Button>
</div>
);
}
}
+1 -1
View File
@@ -390,7 +390,7 @@ export default function Home() {
<div className="shortcut-grid">
<ShortcutItem icon="ri-upload-cloud-line" label="上传文件" to="/files/upload" />
<ShortcutItem icon="ri-file-list-3-line" label="文档列表" to="/documents/list" />
<ShortcutItem icon="ri-list-check-3" label="评查点列表" to="/rules" />
<ShortcutItem icon="ri-list-check-3" label="评查点列表" to="/rules/list" />
<ShortcutItem icon="ri-folder-open-line" label="评查点分组" to="/rule-groups" />
</div>
</Card> */}
+300 -55
View File
@@ -9,7 +9,7 @@ import { Switch } from '~/components/ui/Switch';
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 { 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';
@@ -22,12 +22,15 @@ import {
deleteRule,
getRuleTypes,
getRuleGroupsByType,
getAttributeTypes,
batchUpdateRuleStatus,
batchDeleteRules,
updateEvaluationPoint,
type RuleType as ApiRuleType,
type RuleGroup
type RuleGroup,
type AttributeTypeOption
} from '~/api/evaluation_points/rules';
import { CONTRACT_TYPES } from '~/constants/contractTypes';
export const links = () => [
{ rel: "stylesheet", href: rulesStyles }
@@ -79,6 +82,131 @@ interface ActionResponse {
message: string;
}
type SubtypeOption = {
code: string;
label: string;
};
const ADMIN_LICENSE_SUBTYPE_OPTIONS: SubtypeOption[] = [
{ code: '新办', label: '新办' },
{ code: '变更', label: '变更' },
{ code: '延续', label: '延续' },
{ code: '停业', label: '停业' },
{ code: '歇业', label: '歇业' },
{ code: '补办', label: '补办' },
{ code: '恢复营业', label: '恢复营业' },
{ code: '收回', label: '收回' },
{ code: '注销', label: '注销' }
];
const CONTRACT_SUBTYPE_OPTIONS: SubtypeOption[] = CONTRACT_TYPES.map(type => ({
code: type.value === '通用' ? '通用' : type.label,
label: type.label
}));
const SUBTYPE_DISCOVERY_PAGE_SIZE = 100;
function uniqueOptions(options: SubtypeOption[]): SubtypeOption[] {
const seen = new Set<string>();
return options.filter(option => {
if (!option.code || seen.has(option.code)) {
return false;
}
seen.add(option.code);
return true;
});
}
function matchesOption(option: AttributeTypeOption, target: SubtypeOption): boolean {
return [option.code, option.label].some(value =>
value === target.code ||
value === target.label ||
value.replace(/合同$/, '') === target.code ||
target.label.replace(/合同$/, '') === value
);
}
function normalizeSubtypeCode(value: string | undefined, options: SubtypeOption[]): string | undefined {
if (!value) {
return undefined;
}
const matchedOption = options.find(option =>
option.code === value ||
option.label === value ||
option.label.replace(/合同$/, '') === value
);
return matchedOption?.code || value;
}
function matchRuleTypeByName(ruleTypes: ApiRuleType[], ruleTypeName?: string | null): ApiRuleType | undefined {
if (!ruleTypeName) {
return undefined;
}
return ruleTypes.find(type =>
type.name === ruleTypeName ||
type.name.includes(ruleTypeName) ||
ruleTypeName.includes(type.name)
);
}
function resolveCurrentRuleTypeId(
ruleTypes: ApiRuleType[],
ruleTypeId?: string | null,
ruleTypeName?: string | null
): string | undefined {
if (ruleTypeId && ruleTypeId !== 'all') {
return ruleTypeId;
}
return matchRuleTypeByName(ruleTypes, ruleTypeName)?.id || ruleTypes[0]?.id;
}
function resolveScopedSubtypeOptions(
params: {
documentTypeIds: number[];
selectedModuleName: string;
ruleTypeName?: string | null;
apiOptions: AttributeTypeOption[];
ruleOptions: SubtypeOption[];
}
): SubtypeOption[] {
const { documentTypeIds, selectedModuleName, ruleTypeName, apiOptions, ruleOptions } = params;
const isContractModule = selectedModuleName.includes('合同') || documentTypeIds.includes(1);
const isCaseFileModule = selectedModuleName.includes('案卷') || selectedModuleName.includes('卷宗') || documentTypeIds.includes(2) || documentTypeIds.includes(3);
if (isContractModule) {
const apiContractOptions = CONTRACT_SUBTYPE_OPTIONS.flatMap(target =>
apiOptions
.filter(option => matchesOption(option, target))
.map(option => ({ code: option.code, label: option.label }))
);
return uniqueOptions(apiContractOptions);
}
if (isCaseFileModule && ruleTypeName?.includes('行政许可')) {
const apiLicenseOptions = ADMIN_LICENSE_SUBTYPE_OPTIONS.flatMap(target =>
apiOptions
.filter(option => matchesOption(option, target))
.map(option => ({ code: option.code, label: option.label }))
);
return uniqueOptions(apiLicenseOptions);
}
if (isCaseFileModule && ruleTypeName?.includes('行政处罚')) {
const punishmentOptions = apiOptions
.filter(option => option.code === '通用' || option.label === '通用')
.map(option => ({ code: option.code, label: option.label }));
return uniqueOptions(punishmentOptions.length > 0 ? punishmentOptions : [{ code: '通用', label: '通用' }]);
}
return uniqueOptions(ruleOptions);
}
function mapApiRuleToModel(apiRule: ApiRule): Rule {
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
@@ -220,6 +348,7 @@ export default function RulesIndex() {
const [filteredRules, setFilteredRules] = useState<Rule[]>(initialRules);
const [filteredTotalCount, setFilteredTotalCount] = useState<number>(initialTotalCount);
const [ruleTypes, setRuleTypes] = useState<ApiRuleType[]>(initialRuleTypes);
const [attributeTypes, setAttributeTypes] = useState<Array<{ code: string; label: string }>>([]);
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
@@ -235,9 +364,11 @@ export default function RulesIndex() {
// 获取当前的ruleType值
const ruleTypeParam = searchParams.get('ruleType');
const ruleTypeNameParam = searchParams.get('ruleTypeName');
const selectedRuleTypeId = resolveCurrentRuleTypeId(ruleTypes, ruleTypeParam, ruleTypeNameParam) || '';
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
const isRuleGroupSelectDisabled = loadingGroups || !selectedRuleTypeId || ruleGroups.length === 0;
// 在组件渲染时初始化状态
// useEffect(() => {
@@ -267,6 +398,9 @@ export default function RulesIndex() {
// 🔑 从 sessionStorage 获取 documentTypeIds
const typeIdsStr = typeof window !== 'undefined' ? sessionStorage.getItem('documentTypeIds') : null;
const documentTypeIds = typeIdsStr ? JSON.parse(typeIdsStr) : null;
const selectedModuleName = typeof window !== 'undefined'
? sessionStorage.getItem('selectedModuleName') || ''
: '';
if (!documentTypeIds || documentTypeIds.length === 0) {
console.warn('无法加载评查点数据:未找到 documentTypeIds');
@@ -306,31 +440,139 @@ export default function RulesIndex() {
console.error('加载评查点类型失败:', error);
}
// 构建查询参数
// 🔑 当选择"全部"或未选择评查点类型时,使用下拉框中所有评查点类型的 id 组合
let finalRuleType: string | undefined = undefined;
if (ruleTypeParam && ruleTypeParam !== 'all') {
// 选择了具体的评查点类型
finalRuleType = ruleTypeParam;
} else if (loadedRuleTypes && loadedRuleTypes.length > 0) {
// 选择"全部"或未选择,使用刚加载的评查点类型的 id
finalRuleType = loadedRuleTypes.map(type => type.id).join(',');
// console.log("📋 [fetchData] 选择全部类型,使用 loadedRuleTypes 的 id 组合:", finalRuleType);
// 主类型筛选已从界面隐藏,未显式传参时默认使用当前模块的第一个主类型。
const finalRuleType = resolveCurrentRuleTypeId(loadedRuleTypes, ruleTypeParam, ruleTypeNameParam);
let apiAttributeTypes: AttributeTypeOption[] = [];
try {
const attributeTypesResponse = await getAttributeTypes(loaderData.frontendJWT);
if (attributeTypesResponse.data) {
apiAttributeTypes = attributeTypesResponse.data;
}
} catch (error) {
console.error('加载子类型枚举失败:', error);
}
const queryParams = {
let effectiveAttributeType = searchParams.get('documentAttributeType') || undefined;
const presetScopedAttributeTypes = resolveScopedSubtypeOptions({
documentTypeIds,
selectedModuleName,
ruleTypeName: ruleTypeNameParam,
apiOptions: apiAttributeTypes,
ruleOptions: []
});
if (presetScopedAttributeTypes.length > 0) {
setAttributeTypes(presetScopedAttributeTypes);
const originalAttributeType = effectiveAttributeType;
effectiveAttributeType = normalizeSubtypeCode(effectiveAttributeType, presetScopedAttributeTypes);
if (effectiveAttributeType && originalAttributeType !== effectiveAttributeType) {
const nextParams = new URLSearchParams(searchParams);
nextParams.set('documentAttributeType', effectiveAttributeType);
nextParams.set('page', '1');
setSearchParams(nextParams);
} else if (effectiveAttributeType && !presetScopedAttributeTypes.some(type => type.code === effectiveAttributeType)) {
effectiveAttributeType = undefined;
const nextParams = new URLSearchParams(searchParams);
nextParams.delete('documentAttributeType');
nextParams.set('page', '1');
setSearchParams(nextParams);
}
} else if (finalRuleType) {
const attributeResponse = await getRulesList({
ruleType: finalRuleType,
page: 1,
pageSize: SUBTYPE_DISCOVERY_PAGE_SIZE,
token: loaderData.frontendJWT
});
if (attributeResponse.data) {
const ruleAttributeTypes = Array.from(
new Set(
attributeResponse.data.rules
.map(rule => rule.documentAttributeType)
.filter((value): value is string => Boolean(value))
)
).map(value => ({ code: value, label: value }));
const scopedAttributeTypes = resolveScopedSubtypeOptions({
documentTypeIds,
selectedModuleName,
ruleTypeName: ruleTypeNameParam,
apiOptions: apiAttributeTypes,
ruleOptions: ruleAttributeTypes
});
setAttributeTypes(scopedAttributeTypes);
const originalAttributeType = effectiveAttributeType;
effectiveAttributeType = normalizeSubtypeCode(effectiveAttributeType, scopedAttributeTypes);
if (effectiveAttributeType && originalAttributeType !== effectiveAttributeType) {
const nextParams = new URLSearchParams(searchParams);
nextParams.set('documentAttributeType', effectiveAttributeType);
nextParams.set('page', '1');
setSearchParams(nextParams);
} else if (effectiveAttributeType && !scopedAttributeTypes.some(type => type.code === effectiveAttributeType)) {
effectiveAttributeType = undefined;
const nextParams = new URLSearchParams(searchParams);
nextParams.delete('documentAttributeType');
nextParams.set('page', '1');
setSearchParams(nextParams);
}
} else {
setAttributeTypes([]);
effectiveAttributeType = undefined;
}
} else {
setAttributeTypes([]);
effectiveAttributeType = undefined;
}
const baseQueryParams = {
ruleType: finalRuleType,
groupId: searchParams.get('groupId') || undefined,
isActive: searchParams.get('isActive') ? searchParams.get('isActive') === 'true' : undefined,
keyword: searchParams.get('keyword') || undefined,
area: userArea, // 添加地区过滤
page: currentPage,
pageSize,
token: loaderData.frontendJWT
};
if (effectiveAttributeType) {
const allRules: ApiRule[] = [];
let totalCount = 0;
let pageToFetch = 1;
do {
const response = await getRulesList({
...baseQueryParams,
page: pageToFetch,
pageSize: SUBTYPE_DISCOVERY_PAGE_SIZE
});
if (!response.data) {
break;
}
allRules.push(...(response.data.rules as unknown as ApiRule[]));
totalCount = response.data.totalCount || 0;
pageToFetch += 1;
} while (allRules.length < totalCount);
const subtypeRules = allRules.filter(rule => rule.documentAttributeType === effectiveAttributeType);
const startIndex = (currentPage - 1) * pageSize;
const pageRules = subtypeRules.slice(startIndex, startIndex + pageSize);
setFilteredRules(pageRules.map((apiRule: ApiRule) => mapApiRuleToModel(apiRule)));
setFilteredTotalCount(subtypeRules.length);
return;
}
// 调用 API 获取数据
const response = await getRulesList(queryParams);
const response = await getRulesList({
...baseQueryParams,
page: currentPage,
pageSize
});
if (response.data) {
const apiRules = response.data.rules || [];
@@ -347,12 +589,12 @@ export default function RulesIndex() {
setLoading(false);
isLoadingRef.current = false;
}
}, [ruleTypeParam, searchParams, currentPage, pageSize, loaderData.frontendJWT]);
}, [ruleTypeParam, ruleTypeNameParam, searchParams, currentPage, pageSize, loaderData.frontendJWT, setSearchParams]);
// 当评查点类型变化时,加载对应的规则组
useEffect(() => {
// 如果选择了"全部"或未选择,则清空规则组
if (!ruleTypeParam || ruleTypeParam === 'all') {
if (!selectedRuleTypeId) {
setRuleGroups([]);
return;
}
@@ -361,7 +603,7 @@ export default function RulesIndex() {
const loadRuleGroups = async () => {
setLoadingGroups(true);
try {
const response = await getRuleGroupsByType(ruleTypeParam, loaderData.frontendJWT);
const response = await getRuleGroupsByType(selectedRuleTypeId, loaderData.frontendJWT);
if (response.data) {
setRuleGroups(response.data);
} else if (response.error) {
@@ -377,7 +619,7 @@ export default function RulesIndex() {
};
loadRuleGroups();
}, [ruleTypeParam]);
}, [selectedRuleTypeId, loaderData.frontendJWT]);
// 使用useEffect监听fetcher状态变化并显示Toast fetcher.state有以下几种状态: 通过fetcher提交数据后,action返回结果,fetcher.state会发生变化
// idle: 空闲状态
@@ -476,6 +718,7 @@ export default function RulesIndex() {
if (value === '' || value === 'all') {
setRuleGroups([]);
}
newParams.delete('ruleTypeName');
}
} else {
newParams.delete(name);
@@ -485,6 +728,9 @@ export default function RulesIndex() {
newParams.delete('groupId');
setRuleGroups([]);
}
if (name === 'ruleType') {
newParams.delete('ruleTypeName');
}
}
// 切换筛选条件时,重置到第一页
@@ -757,26 +1003,27 @@ export default function RulesIndex() {
align: "left" as const,
width: "12%"
},
{
title: "评查点类型",
key: "ruleType",
align: "left" as const,
width: "8%",
render: (_: unknown, record: Rule) => {
const typeColor = RULE_TYPE_COLORS[record.ruleType] as TagColor;
return (
record.ruleType ? <Tag color={typeColor}>
{record.ruleType}
</Tag> : null
);
}
},
// 主类型已拆分到左侧菜单,列表不再重复展示评查点类型列。
// {
// title: "评查点类型",
// key: "ruleType",
// align: "left" as const,
// width: "8%",
// 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: "8%"
width: "12%"
},
{
title: "地区",
@@ -787,11 +1034,11 @@ export default function RulesIndex() {
render: (value: string) => value || '-'
},
{
title: "属性类型",
title: "类型",
dataIndex: "documentAttributeType" as keyof Rule,
key: "documentAttributeType",
align: "left" as const,
width: "6%",
width: "10%",
render: (value: string) => value || '-'
},
{
@@ -935,33 +1182,31 @@ export default function RulesIndex() {
</>
}
>
<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: "请先选择评查点类型" }] : []),
...(isRuleGroupSelectDisabled ? [{ value: "", label: "请先选择类型" }] : []),
...ruleGroups.map(group => ({
value: group.id,
label: group.name
}))
]}
onChange={handleFilterChange}
className={`mr-3 w-[20%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`}
className={`mr-3 w-[22%] ${isRuleGroupSelectDisabled ? 'opacity-50' : ''}`}
/>
<FilterSelect
label="子类型"
name="documentAttributeType"
value={searchParams.get('documentAttributeType') || ''}
options={attributeTypes.map(type => ({
value: type.code,
label: type.label
}))}
onChange={handleFilterChange}
className="mr-3 w-[18%]"
/>
<FilterSelect
@@ -1034,4 +1279,4 @@ export function ErrorBoundary() {
<Button type="primary" to="/"></Button>
</div>
);
}
}
+4 -4
View File
@@ -319,7 +319,7 @@ export default function RuleNew() {
// API返回错误
toastService.error(`获取评查点数据失败: ${response.error}`);
resetFormData();
navigate('/rules');
navigate('/rules/list');
return;
}
@@ -366,7 +366,7 @@ export default function RuleNew() {
console.error('JSON处理错误:', jsonError);
toastService.error(`数据处理错误: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`);
resetFormData();
navigate('/rules');
navigate('/rules/list');
}
}
} catch (error) {
@@ -374,7 +374,7 @@ export default function RuleNew() {
toastService.error(`获取评查点数据失败: ${error instanceof Error ? error.message : '未知错误'}`);
// 获取数据失败时返回上一页
resetFormData();
navigate('/rules');
navigate('/rules/list');
} finally {
setIsLoading(false);
}
@@ -909,7 +909,7 @@ export default function RuleNew() {
} else {
// 无法获取ID的情况
toastService.warning(`评查点${isEditMode ? '更新' : '创建'}成功,但无法获取ID。正在返回列表页面。`);
navigate('/rules');
navigate('/rules/list');
}
} else {
toastService.error('系统繁忙');
+2 -2
View File
@@ -14,7 +14,7 @@ export const meta: MetaFunction = () => {
export const handle = {
breadcrumb: "评查点列表",
to: "/rules/list" // 指定面包屑点击后跳转的路径
to: "/rulesTest/list" // 新版规则维护入口;旧版可从新版页面内返回
};
/**
@@ -22,4 +22,4 @@ export const handle = {
*/
export default function RulesLayout() {
return <Outlet />;
}
}
+948
View File
@@ -0,0 +1,948 @@
import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
import { Link, useLoaderData } from '@remix-run/react';
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Table } from '~/components/ui/Table';
import { Tag, type TagColor } from '~/components/ui/Tag';
import { loadRuleYamlPack, loadRuleYamlPacks, type RuleSummary, type RuleYamlPack } from '~/utils/rules-yaml-mock.server';
import { buildRuleYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor';
import styles from '~/styles/pages/rules_test.css?url';
export const links = () => [
{ rel: 'stylesheet', href: styles }
];
export const meta: MetaFunction = () => [
{ title: '评查点详情 - 智慧法务' }
];
type LoaderData = {
pack: RuleYamlPack;
requestedRuleId: string;
};
type EditorState = { kind: 'rule'; mode: 'create' | 'edit'; id?: string } | null;
type RuleDraft = Pick<RuleSummary, 'id' | 'ruleId' | 'name' | 'group' | 'risk' | 'score' | 'type' | 'logic' | 'subRules' | 'subRuleIds' | 'prompt' | 'description'> & {
checkTypes: string[];
dependencies: string[];
};
function riskColor(risk: string): TagColor {
if (risk === 'high') return 'red';
if (risk === 'medium') return 'orange';
if (risk === 'low') return 'green';
return 'gray';
}
function riskLabel(risk: string): string {
if (risk === 'high') return '高';
if (risk === 'medium') return '中';
if (risk === 'low') return '低';
return risk || '-';
}
function uniqueOptions(values: Array<string | undefined>): string[] {
return Array.from(new Set(values.map(value => value?.trim()).filter(Boolean) as string[]));
}
function uniqueDependencyOptions(options: DependencyOption[]): DependencyOption[] {
const seen = new Set<string>();
return options.filter(option => {
if (!option.value || seen.has(option.value)) {
return false;
}
seen.add(option.value);
return true;
});
}
function ruleKey(rule: Pick<RuleSummary, 'id' | 'ruleId'>): string {
return rule.ruleId || rule.id;
}
function ruleTypeLabel(type: string): string {
const labels: Record<string, string> = {
deterministic: '确定性检查',
ai_rule: '智能语义检查',
rule_group: '规则组合',
llm: '智能语义检查',
manual: '人工复核'
};
return labels[type] ? `${labels[type]} (${type})` : type || '-';
}
function checkTypeLabel(type: string): string {
const labels: Record<string, string> = {
required: '必填',
ai: '智能判断',
contains: '包含',
match: '匹配',
format: '格式',
compare: '比较',
amount_match: '金额一致',
visual: '视觉要素',
assert: '断言'
};
return labels[type] ? `${labels[type]} (${type})` : type;
}
function phaseLabel(phase: string): string {
const labels: Record<string, string> = {
draft: '草稿',
executed: '已执行'
};
return labels[phase] ? `${labels[phase]} (${phase})` : phase;
}
function isStepReferenced(logic: string, stepId: string): boolean {
if (!logic.trim()) return false;
return new RegExp(`(^|[^\\w-])${stepId}([^\\w-]|$)`).test(logic);
}
function fallbackDependencyOption(value: string, optionMap?: Map<string, DependencyOption>): DependencyOption {
if (/^-?\d+(\.\d+)?$/.test(value)) {
return { value, label: value, source: '常量', group: '常量' };
}
if (value.startsWith('derived.')) {
return { value, label: value.replace(/^derived\./, ''), source: '派生字段', group: '派生字段' };
}
if (value.startsWith('visual.')) {
return { value, label: value.replace(/^visual\./, ''), source: '视觉要素引用', group: '视觉要素' };
}
if (value.includes('[*].')) {
return { value, label: value, source: '多实体字段', group: value.split('[*].')[0] };
}
const prefix = value.split('.')[0];
const parent = value.includes('.') ? optionMap?.get(prefix) : undefined;
if (parent) {
return { value, label: value, source: `${parent.source} / 子项未显式定义`, group: parent.group };
}
return {
value,
label: value,
source: '未匹配',
group: '未匹配'
};
}
function makeId(prefix: string): string {
return `${prefix}-${Date.now()}`;
}
function emptyRuleDraft(group = '未分组'): RuleDraft {
return {
id: makeId('rule'),
ruleId: '',
name: '',
group,
risk: 'medium',
score: '1',
type: 'deterministic',
checkTypes: [],
logic: '',
subRules: [],
subRuleIds: [],
prompt: '',
description: '',
dependencies: []
};
}
function issueColor(severity: ValidationIssue['severity']): TagColor {
return severity === 'error' ? 'red' : 'orange';
}
function renderYamlLine(line: string, index: number) {
const indent = line.match(/^\s*/)?.[0] || '';
const content = line.slice(indent.length);
const listMatch = content.match(/^(-\s+)([^:]+:)(.*)$/);
const keyMatch = content.match(/^([^:]+:)(.*)$/);
if (!content) {
return <span key={index} className="yaml-line">&nbsp;</span>;
}
if (content.startsWith('#')) {
return (
<span key={index} className="yaml-line">
<span className="yaml-value">{content}</span>
</span>
);
}
if (listMatch) {
return (
<span key={index} className="yaml-line">
<span className="yaml-indent">{indent}</span>
<span className="yaml-marker">{listMatch[1]}</span>
<span className="yaml-key">{listMatch[2]}</span>
<YamlValue value={listMatch[3]} />
</span>
);
}
if (keyMatch) {
return (
<span key={index} className="yaml-line">
<span className="yaml-indent">{indent}</span>
<span className="yaml-key">{keyMatch[1]}</span>
<YamlValue value={keyMatch[2]} />
</span>
);
}
return (
<span key={index} className="yaml-line">
<span className="yaml-indent">{indent}</span>
<span>{content}</span>
</span>
);
}
function YamlValue({ value }: { value: string }) {
const trimmed = value.trim();
const className = /^'.*'$|^".*"$/.test(trimmed)
? 'yaml-string'
: /^(true|false|null)$/i.test(trimmed)
? 'yaml-boolean'
: /^-?\d+(\.\d+)?$/.test(trimmed)
? 'yaml-number'
: 'yaml-value';
return <span className={className}>{value}</span>;
}
function validateRule(rule: RuleSummary | undefined, dependencyOptions: DependencyOption[]): ValidationIssue[] {
if (!rule) {
return [{
id: 'rule-missing',
severity: 'error',
area: '评查规则',
target: '未找到评查点',
message: '当前链接没有匹配到评查点,请从规则列表重新进入。'
}];
}
const issues: ValidationIssue[] = [];
const dependencyValues = new Set(dependencyOptions.map(option => option.value));
const hasKnownDependency = (dependency: string) => {
if (/^-?\d+(\.\d+)?$/.test(dependency)) return true;
if (dependencyValues.has(dependency)) return true;
const prefix = dependency.split('.')[0];
return dependency.includes('.') && dependencyValues.has(prefix);
};
if (!rule.name.trim()) {
issues.push({
id: `rule-name-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.ruleId || rule.id,
message: '评查点名称不能为空。'
});
}
if (!rule.group.trim()) {
issues.push({
id: `rule-group-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '评查点必须选择规则组。'
});
}
if (!rule.score.trim() || rule.score === '-') {
issues.push({
id: `rule-score-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '评查点必须设置分值。'
});
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && !rule.prompt.trim()) {
issues.push({
id: `rule-prompt-${rule.id}`,
severity: 'warning',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '智能语义检查建议维护提示词。'
});
}
if (rule.type === 'rule_group' && !rule.logic.trim()) {
issues.push({
id: `rule-group-logic-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '规则组合必须维护逻辑运算式。'
});
}
rule.dependencies.forEach(dependency => {
if (!hasKnownDependency(dependency)) {
issues.push({
id: `rule-dependency-${rule.id}-${dependency}`,
severity: 'warning',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: `依赖字段【${dependency}】未在当前 YAML 的字段配置或视觉要素中找到。`
});
}
});
return issues;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const packId = url.searchParams.get('packId') || url.searchParams.get('id') || '';
const requestedRuleId = url.searchParams.get('ruleId') || '';
const packs = await loadRuleYamlPacks();
const pack = (packId ? await loadRuleYamlPack(packId) : undefined) || packs[0];
if (!pack) {
throw new Response('未找到 YAML 配置', { status: 404 });
}
return Response.json({ pack, requestedRuleId } satisfies LoaderData);
}
export default function RulesTestDetail() {
const { pack, requestedRuleId } = useLoaderData<typeof loader>() as LoaderData;
const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' });
const [rules, setRules] = useState<RuleSummary[]>(pack.rules);
const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey);
const [editor, setEditor] = useState<EditorState>(null);
const [ruleDraft, setRuleDraft] = useState<RuleDraft>(emptyRuleDraft(pack.rules[0]?.group));
const [dependencyDialogOpen, setDependencyDialogOpen] = useState(false);
const [dependencySearch, setDependencySearch] = useState('');
const [dependencySelection, setDependencySelection] = useState<string[]>([]);
const [expandedDependencyGroups, setExpandedDependencyGroups] = useState<string[]>([]);
const [showValidation, setShowValidation] = useState(false);
const [showYamlPreview, setShowYamlPreview] = useState(false);
const [draftSaved, setDraftSaved] = useState(false);
useEffect(() => {
setRules(pack.rules);
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
setEditor(null);
setDependencyDialogOpen(false);
setDependencySearch('');
setDependencySelection([]);
setExpandedDependencyGroups([]);
setShowValidation(false);
setShowYamlPreview(false);
setDraftSaved(false);
}, [pack.id, requestedRuleId]);
const currentRule = useMemo(() => {
return rules.find(rule => rule.id === selectedRuleKey || rule.ruleId === selectedRuleKey) || rules[0];
}, [rules, selectedRuleKey]);
const editableConfig: EditableRuleConfig = useMemo(() => ({
metadata: pack.metadata,
documentType: pack.documentType,
mainType: pack.mainType,
subtype: pack.subtype,
fields: pack.fields,
subDocuments: pack.subDocuments,
visualElements: pack.visualElements,
rules
}), [pack, rules]);
const dependencyOptions = useMemo(() => collectDependencyOptions(editableConfig), [editableConfig]);
const dependencyOptionMap = useMemo(() => new Map(dependencyOptions.map(option => [option.value, option])), [dependencyOptions]);
const validationIssues = useMemo(() => validateRule(currentRule, dependencyOptions), [currentRule, dependencyOptions]);
const yamlPreview = useMemo(() => currentRule ? buildRuleYamlPreview(editableConfig, currentRule) : '', [currentRule, editableConfig]);
const ruleGroups = useMemo(() => Array.from(new Set(rules.map(rule => rule.group || '未分组'))), [rules]);
const ruleTypeOptions = useMemo(() => uniqueOptions([
...rules.map(rule => rule.type),
'deterministic',
'ai_rule',
'rule_group'
]), [rules]);
const selectedDependencyOptions = useMemo(() => {
return ruleDraft.dependencies.map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap));
}, [dependencyOptionMap, ruleDraft.dependencies]);
const currentDependencyRows = useMemo(() => {
return (currentRule?.dependencies || []).map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap));
}, [currentRule, dependencyOptionMap]);
const dialogDependencyOptions = useMemo(() => {
const selectedValues = new Set(ruleDraft.dependencies);
return uniqueDependencyOptions([
...selectedDependencyOptions,
...dependencyOptions
]).sort((left, right) => {
const selectedDelta = Number(selectedValues.has(right.value)) - Number(selectedValues.has(left.value));
if (selectedDelta !== 0) return selectedDelta;
return left.label.localeCompare(right.label, 'zh-CN');
});
}, [dependencyOptions, ruleDraft.dependencies, selectedDependencyOptions]);
const filteredDependencyOptions = useMemo(() => {
const keyword = dependencySearch.trim().toLowerCase();
return dialogDependencyOptions.filter(option => {
if (!keyword) return true;
return [option.value, option.label, option.source, option.group]
.some(text => text.toLowerCase().includes(keyword));
});
}, [dialogDependencyOptions, dependencySearch]);
const dependencyGroups = useMemo(() => {
const groups = new Map<string, typeof filteredDependencyOptions>();
filteredDependencyOptions.forEach(option => {
const current = groups.get(option.group) || [];
current.push(option);
groups.set(option.group, current);
});
return Array.from(groups.entries());
}, [filteredDependencyOptions]);
const isDependencySearching = Boolean(dependencySearch.trim());
const defaultExpandedDependencyGroups = useMemo(() => {
return getDefaultExpandedDependencyGroups(dialogDependencyOptions, dependencySelection);
}, [dialogDependencyOptions, dependencySelection]);
const dependencyDialogEmptyText = dependencySearch.trim() ? '没有匹配的字段。' : '当前文档类型暂无可追加字段。';
const hasErrors = validationIssues.some(issue => issue.severity === 'error');
const isSmartRuleDraft = ruleDraft.type === 'ai_rule' || ruleDraft.checkTypes.includes('ai');
const isRuleGroupDraft = ruleDraft.type === 'rule_group';
const rulesById = useMemo(() => new Map(rules.map(rule => [rule.ruleId || rule.id, rule])), [rules]);
const openRuleEditor = (rule?: RuleSummary) => {
setRuleDraft(rule ? { ...rule } : emptyRuleDraft(ruleGroups[0]));
setDependencyDialogOpen(false);
setDependencySearch('');
setDependencySelection(rule?.dependencies || []);
setExpandedDependencyGroups([]);
setEditor({ kind: 'rule', mode: rule ? 'edit' : 'create', id: rule?.id });
};
const openDependencyDialog = () => {
setDependencySelection(ruleDraft.dependencies);
setDependencySearch('');
setExpandedDependencyGroups(getDefaultExpandedDependencyGroups(dialogDependencyOptions, ruleDraft.dependencies));
setDependencyDialogOpen(true);
};
const updateDependencySearch = (value: string) => {
setDependencySearch(value);
if (!value.trim()) {
setExpandedDependencyGroups(defaultExpandedDependencyGroups);
}
};
const toggleDependencyGroup = (group: string) => {
setExpandedDependencyGroups(current => (
current.includes(group)
? current.filter(item => item !== group)
: [...current, group]
));
};
const applyDependencySelection = () => {
setRuleDraft({ ...ruleDraft, dependencies: dependencySelection });
setDependencyDialogOpen(false);
};
const saveRule = () => {
if (!editor || editor.kind !== 'rule') return;
const existingRule = editor.id ? rules.find(rule => rule.id === editor.id) : undefined;
const normalizedRule: RuleSummary = {
...ruleDraft,
id: ruleDraft.id || makeId('rule'),
ruleId: ruleDraft.ruleId || ruleDraft.id,
group: ruleDraft.group || '未分组',
checkTypes: ruleDraft.type === 'ai_rule' ? uniqueOptions([...ruleDraft.checkTypes, 'ai']) : ruleDraft.checkTypes,
appliesIn: existingRule?.appliesIn || [],
scope: existingRule?.scope || [],
stageCount: existingRule?.stageCount || ruleDraft.subRules.length
};
setRules(current => editor.mode === 'edit'
? current.map(rule => rule.id === editor.id ? normalizedRule : rule)
: [...current, normalizedRule]);
setSelectedRuleKey(ruleKey(normalizedRule));
setDraftSaved(true);
setEditor(null);
};
const resetDraft = () => {
setRules(pack.rules);
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
setDependencyDialogOpen(false);
setDependencySearch('');
setDependencySelection([]);
setShowValidation(false);
setShowYamlPreview(false);
setDraftSaved(false);
};
const dependencyColumns = [
{
title: '依赖字段',
key: 'label',
width: '38%',
render: (_: unknown, record: DependencyOption) => (
<div className="rule-name">
<strong>{record.label}</strong>
<span>YAML引用{record.value}</span>
</div>
)
},
{
title: '来源',
dataIndex: 'source' as keyof DependencyOption,
key: 'source',
width: '26%'
},
{
title: '分组',
dataIndex: 'group' as keyof DependencyOption,
key: 'group',
width: '36%'
}
];
const backLink = `/rulesTest/list?documentType=${encodeURIComponent(pack.documentType)}&mainType=${encodeURIComponent(pack.mainType)}&subtype=${encodeURIComponent(pack.subtype)}`;
return (
<div className="rules-test-page rules-page">
<div className="yaml-layout-single">
<Card className="ant-card config-toolbar-card">
<div className="config-toolbar">
<div>
<div className="config-toolbar-title">{currentRule?.name || '未找到评查点'}</div>
<div className="config-toolbar-desc">
{pack.documentType} / {pack.mainType} / {pack.subtype} / {currentRule?.ruleId || '-'}
</div>
</div>
<div className="config-toolbar-actions">
<Link to={backLink} className="ant-btn ant-btn-default">
<i className="ri-arrow-left-line mr-1.5"></i>
</Link>
<button type="button" className="ant-btn ant-btn-default" onClick={() => setShowValidation(current => !current)}>
<i className="ri-shield-check-line mr-1.5"></i>
</button>
<button type="button" className="ant-btn ant-btn-default" onClick={() => setShowYamlPreview(current => !current)}>
<i className="ri-file-code-line mr-1.5"></i>{showYamlPreview ? '收起片段' : 'YAML 片段'}
</button>
<button type="button" className="ant-btn ant-btn-default" onClick={resetDraft}>
<i className="ri-refresh-line mr-1.5"></i>
</button>
<button type="button" className="ant-btn ant-btn-primary" disabled={!currentRule} onClick={() => currentRule && openRuleEditor(currentRule)}>
<i className="ri-edit-line mr-1.5"></i>
</button>
</div>
</div>
{draftSaved && (
<div className="draft-tip">
<i className="ri-checkbox-circle-line"></i>
稿 OSS
</div>
)}
</Card>
{showValidation && (
<Card className="ant-card validation-card" title="评查点校验">
<div className="validation-summary">
<Tag color={hasErrors ? 'red' : 'green'}>{hasErrors ? '存在必改问题' : '可提交验证'}</Tag>
<span> {validationIssues.length} {validationIssues.filter(issue => issue.severity === 'error').length} </span>
</div>
<div className="validation-list">
{validationIssues.length === 0 ? (
<div className="empty-state"></div>
) : validationIssues.map(issue => (
<div key={issue.id} className={`validation-item ${issue.severity}`}>
<Tag color={issueColor(issue.severity)}>{issue.severity === 'error' ? '必改' : '提醒'}</Tag>
<strong>{issue.area}</strong>
<span>{issue.target}</span>
<p>{issue.message}</p>
</div>
))}
</div>
</Card>
)}
{showYamlPreview && currentRule && (
<Card className="ant-card" title="当前评查点 YAML 片段">
<pre className="yaml-source yaml-source-highlighted">
<code>{yamlPreview.split('\n').map(renderYamlLine)}</code>
</pre>
</Card>
)}
{currentRule ? (
<>
<Card className="ant-card" title="评查点定义">
<div className="rule-detail-grid">
<div className="info-box">
<label></label>
<div>{currentRule.ruleId || '-'}</div>
</div>
<div className="info-box">
<label></label>
<div>{currentRule.group || '-'}</div>
</div>
<div className="info-box">
<label></label>
<div>{ruleTypeLabel(currentRule.type)}</div>
</div>
<div className="info-box">
<label></label>
<div>{currentRule.appliesIn.length > 0 ? currentRule.appliesIn.map(phaseLabel).join('、') : '全部阶段'}</div>
</div>
<div className="info-box">
<label></label>
<div><Tag color={riskColor(currentRule.risk)} size="sm">{riskLabel(currentRule.risk)}</Tag></div>
</div>
<div className="info-box">
<label></label>
<div>{currentRule.score || '-'}</div>
</div>
<div className="info-box">
<label></label>
<div>{currentRule.checkTypes.length > 0 ? currentRule.checkTypes.map(checkTypeLabel).join('、') : '-'}</div>
</div>
</div>
{currentRule.description && (
<div className="rule-description-block">
<label></label>
<p>{currentRule.description}</p>
</div>
)}
</Card>
<Card
className="ant-card"
title={`依赖字段 (${currentRule.dependencies.length}项)`}
extra={<Button size="small" type="primary" icon="ri-add-line" onClick={() => openRuleEditor(currentRule)}></Button>}
>
<Table
className="rules-test-table"
columns={dependencyColumns}
dataSource={currentDependencyRows}
rowKey="value"
emptyText={<div className="empty-state"></div>}
/>
</Card>
<Card className="ant-card" title="评查规则">
<div className="rule-content-stack">
{currentRule.subRules.length > 0 && (
<div className="drawer-subsection">
<div className="drawer-subsection-header">
<div>
<strong>{currentRule.type === 'rule_group' ? `子规则与逻辑(${currentRule.subRules.length}步)` : `规则步骤(${currentRule.subRules.length}步)`}</strong>
<span>{currentRule.type === 'rule_group' ? '规则组合只维护子规则编号、内容和逻辑表达式,不在此处维护字段库。' : '展示当前评查点 YAML stages 中的每一个检查步骤。'}</span>
</div>
</div>
<div className="subrule-list">
{currentRule.subRules.map(subRule => (
<div key={subRule.id} className="subrule-item">
<Tag color="gray" size="sm">{subRule.id}</Tag>
<div>
<strong>
{checkTypeLabel(subRule.check)}
{currentRule.logic && (
<Tag color={isStepReferenced(currentRule.logic, subRule.id) ? 'green' : 'orange'} size="sm">
{isStepReferenced(currentRule.logic, subRule.id) ? '参与逻辑' : '未参与逻辑'}
</Tag>
)}
</strong>
<span>{subRule.content}</span>
</div>
</div>
))}
</div>
{currentRule.logic && (
<div className="logic-expression">
<label></label>
<div>{currentRule.logic}</div>
</div>
)}
</div>
)}
{currentRule.type === 'rule_group' && currentRule.subRules.length === 0 && (
<div className="drawer-subsection">
<div className="drawer-subsection-header">
<div>
<strong></strong>
<span></span>
</div>
</div>
<div className="subrule-list">
{currentRule.subRuleIds.length > 0 ? currentRule.subRuleIds.map(ruleId => {
const referencedRule = rulesById.get(ruleId);
return (
<div key={ruleId} className="subrule-item">
<Tag color="gray" size="sm">{ruleId}</Tag>
<div>
<strong>{referencedRule?.name || '引用规则'}</strong>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '当前 YAML 未找到对应规则内容'}</span>
</div>
</div>
);
}) : (
<div className="drawer-empty"></div>
)}
</div>
<div className="logic-expression">
<label></label>
<div>{currentRule.logic || '-'}</div>
</div>
</div>
)}
{(currentRule.type === 'ai_rule' || currentRule.checkTypes.includes('ai')) && (
<div className="drawer-subsection">
<div className="drawer-subsection-header">
<div>
<strong></strong>
<span></span>
</div>
</div>
<pre className="rule-prompt-preview">{currentRule.prompt || '当前评查点尚未维护提示词。'}</pre>
</div>
)}
{currentRule.subRules.length === 0 && currentRule.type !== 'rule_group' && currentRule.type !== 'ai_rule' && !currentRule.checkTypes.includes('ai') && (
<div className="rule-description-block compact">
<label></label>
<p>{currentRule.description || '当前评查点没有额外规则内容。'}</p>
</div>
)}
</div>
</Card>
</>
) : (
<Card className="ant-card">
<div className="empty-state"></div>
</Card>
)}
</div>
{editor && (
<div className="rules-drawer-shell">
<button className="rules-drawer-mask" type="button" aria-label="关闭编辑抽屉" onClick={() => setEditor(null)}></button>
<aside className="rules-drawer" aria-label="评查点编辑">
<div className="rules-drawer-header">
<div>
<h3>{editor.mode === 'edit' ? '编辑评查点' : '新增评查点'}</h3>
<p></p>
</div>
<button type="button" className="drawer-close" onClick={() => setEditor(null)}><i className="ri-close-line"></i></button>
</div>
<div className="drawer-form">
<label>
<span></span>
<input value={ruleDraft.name} onChange={event => setRuleDraft({ ...ruleDraft, name: event.target.value })} placeholder="如:合同金额不得为空" />
</label>
<label>
<span></span>
<input value={ruleDraft.ruleId} onChange={event => setRuleDraft({ ...ruleDraft, ruleId: event.target.value })} placeholder="如:CONTRACT-001" />
</label>
<label>
<span></span>
<select value={ruleDraft.group} onChange={event => setRuleDraft({ ...ruleDraft, group: event.target.value })}>
{uniqueOptions([ruleDraft.group, ...ruleGroups, '未分组']).map(group => (
<option key={group} value={group}>{group}</option>
))}
</select>
</label>
<div className="drawer-grid">
<label>
<span></span>
<select value={ruleDraft.risk} onChange={event => setRuleDraft({ ...ruleDraft, risk: event.target.value })}>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</label>
<label>
<span></span>
<input value={ruleDraft.score} onChange={event => setRuleDraft({ ...ruleDraft, score: event.target.value })} />
</label>
</div>
<label>
<span></span>
<select
value={ruleDraft.type}
onChange={event => {
const nextType = event.target.value;
setRuleDraft({
...ruleDraft,
type: nextType,
checkTypes: nextType === 'ai_rule'
? uniqueOptions([...ruleDraft.checkTypes, 'ai'])
: ruleDraft.checkTypes.filter(type => type !== 'ai')
});
}}
>
{uniqueOptions([ruleDraft.type, ...ruleTypeOptions]).map(type => (
<option key={type} value={type}>{ruleTypeLabel(type)}</option>
))}
</select>
</label>
{isSmartRuleDraft && (
<label>
<span></span>
<textarea
className="prompt-editor"
value={ruleDraft.prompt}
onChange={event => setRuleDraft({ ...ruleDraft, prompt: event.target.value })}
placeholder="用自然语言描述智能语义检查的判断标准,可引用 {{字段名}} 或 {{文书名称.字段名}}。"
/>
</label>
)}
{isRuleGroupDraft && (
<div className="drawer-subsection">
<div className="drawer-subsection-header">
<div>
<strong></strong>
<span></span>
</div>
</div>
<div className="subrule-list">
{ruleDraft.subRules.length > 0 ? ruleDraft.subRules.map(subRule => (
<div key={subRule.id} className="subrule-item">
<Tag color="gray" size="sm">{subRule.id}</Tag>
<div>
<strong>{checkTypeLabel(subRule.check)}</strong>
<span>{subRule.content}</span>
</div>
</div>
)) : ruleDraft.subRuleIds.length > 0 ? ruleDraft.subRuleIds.map(ruleId => {
const referencedRule = rulesById.get(ruleId);
return (
<div key={ruleId} className="subrule-item">
<Tag color="gray" size="sm">{ruleId}</Tag>
<div>
<strong>{referencedRule?.name || '引用规则'}</strong>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '当前 YAML 未找到对应规则内容'}</span>
</div>
</div>
);
}) : (
<div className="drawer-empty"></div>
)}
</div>
<label>
<span></span>
<input
value={ruleDraft.logic}
onChange={event => setRuleDraft({ ...ruleDraft, logic: event.target.value })}
placeholder="如:1 AND 2,或 JK-002 AND JK-005"
/>
</label>
</div>
)}
<label>
<span></span>
<div className="selected-dependencies">
{selectedDependencyOptions.length === 0 ? (
<div className="drawer-empty"></div>
) : selectedDependencyOptions.map(option => (
<Tag
key={option.value}
color="green"
size="sm"
closable
onClose={() => setRuleDraft({
...ruleDraft,
dependencies: ruleDraft.dependencies.filter(dependency => dependency !== option.value)
})}
>
{option.label}
</Tag>
))}
<Button size="small" type="default" icon="ri-add-line" onClick={openDependencyDialog}></Button>
</div>
</label>
<label>
<span></span>
<textarea value={ruleDraft.description} onChange={event => setRuleDraft({ ...ruleDraft, description: event.target.value })} placeholder="用业务语言描述该评查点如何判断" />
</label>
<div className="drawer-actions">
<Button type="default" onClick={() => setEditor(null)}></Button>
<Button type="primary" onClick={saveRule}></Button>
</div>
</div>
</aside>
{dependencyDialogOpen && (
<div className="dependency-dialog-shell" role="dialog" aria-modal="true" aria-label="追加依赖字段">
<button className="dependency-dialog-mask" type="button" aria-label="关闭依赖字段选择" onClick={() => setDependencyDialogOpen(false)}></button>
<div className="dependency-dialog">
<div className="dependency-dialog-header">
<div>
<h3></h3>
<p></p>
</div>
<button type="button" className="drawer-close" onClick={() => setDependencyDialogOpen(false)}><i className="ri-close-line"></i></button>
</div>
<div className="dependency-dialog-search">
<i className="ri-search-line"></i>
<input value={dependencySearch} onChange={event => updateDependencySearch(event.target.value)} placeholder="搜索字段、文书、字段组" />
</div>
<div className="dependency-dialog-body">
{dependencyGroups.length === 0 ? (
<div className="drawer-empty">{dependencyDialogEmptyText}</div>
) : dependencyGroups.map(([group, options]) => {
const isExpanded = isDependencySearching || expandedDependencyGroups.includes(group);
return (
<div key={group} className={`dependency-option-group${isExpanded ? ' expanded' : ''}`}>
<button
type="button"
className="dependency-option-group-title"
aria-expanded={isExpanded}
onClick={() => toggleDependencyGroup(group)}
>
<i className="ri-arrow-right-s-line"></i>
<span>{group}</span>
<em>{options.length} </em>
</button>
{isExpanded && (
<div className="dependency-option-list">
{options.map(option => (
<label key={`${option.group}-${option.value}`} className="dependency-option">
<input
type="checkbox"
checked={dependencySelection.includes(option.value)}
onChange={event => {
const nextSelection = event.target.checked
? uniqueOptions([...dependencySelection, option.value])
: dependencySelection.filter(value => value !== option.value);
setDependencySelection(nextSelection);
}}
/>
<span className="dependency-option-main">
<span className="dependency-option-name">{option.label}</span>
<em>{option.source}</em>
</span>
</label>
))}
</div>
)}
</div>
);
})}
</div>
<div className="dependency-dialog-actions">
<span> {dependencySelection.length} </span>
<div>
<Button type="default" onClick={() => setDependencyDialogOpen(false)}></Button>
<Button type="primary" onClick={applyDependencySelection}></Button>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
import { Link, useLoaderData } from '@remix-run/react';
import { Card } from '~/components/ui/Card';
import { Table } from '~/components/ui/Table';
import { Tag, type TagColor } from '~/components/ui/Tag';
import { loadRuleYamlPacks, type RuleSummary, type 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 列表 - 智慧法务' }
];
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;
};
options: {
documentTypes: string[];
mainTypes: string[];
subtypes: string[];
ruleGroups: string[];
};
};
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
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 requestedFilters = {
documentType: url.searchParams.get('documentType') || '',
mainType: requestedMainType,
subtype: requestedSubtype,
ruleGroup: requestedRuleGroup,
keyword: url.searchParams.get('keyword') || ''
};
const packs = await loadRuleYamlPacks();
const documentTypes = unique(packs.map(pack => pack.documentType));
const inferredDocumentType = packs.find(pack => pack.mainType === requestedFilters.mainType)?.documentType || '';
const currentDocumentType = documentTypes.includes(requestedFilters.documentType)
? requestedFilters.documentType
: inferredDocumentType || documentTypes[0] || '';
const scopedFilters = {
...requestedFilters,
documentType: currentDocumentType,
mainType: packs.some(pack => pack.documentType === currentDocumentType && pack.mainType === requestedFilters.mainType)
? requestedFilters.mainType
: '',
subtype: packs.some(pack =>
pack.documentType === currentDocumentType &&
(!requestedFilters.mainType || pack.mainType === requestedFilters.mainType) &&
pack.subtype === requestedFilters.subtype
)
? requestedFilters.subtype
: ''
};
const scopedPacks = packs.filter(pack =>
pack.documentType === scopedFilters.documentType &&
(!scopedFilters.mainType || pack.mainType === scopedFilters.mainType) &&
(!scopedFilters.subtype || pack.subtype === scopedFilters.subtype)
);
const ruleGroupOptions = unique(scopedPacks.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 = packs.filter(pack =>
pack.documentType === filters.documentType &&
(!filters.mainType || pack.mainType === filters.mainType) &&
(!filters.subtype || pack.subtype === filters.subtype)
);
const rows: 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: pack.mainType,
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: pack.mainType,
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()));
});
return Response.json({
rows,
packs,
filters,
options: {
documentTypes,
mainTypes: unique(packs.filter(pack => pack.documentType === filters.documentType).map(pack => pack.mainType)),
subtypes: unique(packs.filter(pack =>
pack.documentType === filters.documentType &&
(!filters.mainType || pack.mainType === filters.mainType)
).map(pack => pack.subtype)),
ruleGroups: ruleGroupOptions
}
} satisfies LoaderData);
}
export default function RulesTestList() {
const { rows } = useLoaderData<typeof loader>() as LoaderData;
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="page-shell">
<Card className="ant-card">
<Table
className="rules-test-table rules-table"
columns={columns}
dataSource={rows}
rowKey="rowId"
emptyText={<div className="empty-state"> YAML </div>}
/>
</Card>
</div>
</div>
);
}
+164 -3
View File
@@ -292,9 +292,170 @@
/* 内容容器 */
.content-container {
@apply p-6 bg-gray-50 flex-1 overflow-auto;
@apply py-6 bg-gray-50 flex-1 overflow-auto;
}
/* === 页面顶部栏 === */
.page-topbar {
@apply fixed top-0 right-0 z-[90] border-b border-gray-100 transition-all duration-300;
left: 240px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 6px 18px rgba(19, 51, 38, 0.08);
backdrop-filter: blur(10px);
}
.page-topbar.sidebar-collapsed {
left: 80px;
}
.page-topbar .topbar-content {
@apply flex items-center justify-between px-6 py-3 gap-4;
}
.page-topbar .topbar-left {
@apply flex items-center gap-3 flex-1 min-w-0;
}
.page-topbar .topbar-icon {
@apply inline-flex items-center justify-center w-10 h-10 rounded-md flex-shrink-0;
color: #00684a;
background: rgba(0, 104, 74, 0.08);
border: 1px solid rgba(0, 104, 74, 0.16);
}
.page-topbar .topbar-icon i {
@apply text-xl;
}
.page-topbar .topbar-heading {
@apply flex flex-col gap-1 min-w-0;
}
.page-topbar .topbar-title {
@apply m-0 text-lg font-semibold text-gray-900 whitespace-nowrap;
line-height: 1.2;
}
.page-topbar .topbar-breadcrumb {
@apply flex items-center gap-2 text-gray-500 text-xs;
}
.page-topbar .topbar-breadcrumb a {
@apply text-green-700 no-underline transition-colors duration-200;
}
.page-topbar .topbar-breadcrumb a:hover {
@apply text-green-800 underline;
}
.page-topbar .separator {
@apply text-gray-400;
}
.page-topbar .topbar-right {
@apply flex items-center gap-2 flex-shrink-0;
}
.page-topbar .topbar-action {
@apply inline-flex items-center justify-center gap-1.5 h-9 px-3.5 rounded-md
text-sm font-medium no-underline transition-all duration-200;
color: #ffffff;
background: #00684a;
border: 1px solid #00684a;
}
.page-topbar .topbar-action:hover {
color: #ffffff;
background: #005a3f;
border-color: #005a3f;
box-shadow: 0 4px 10px rgba(0, 104, 74, 0.18);
}
.page-topbar .topbar-action.secondary {
color: #243d32;
background: #ffffff;
border-color: #d7e2dc;
}
.page-topbar .topbar-action.secondary:hover {
color: #00684a;
background: #f3faf6;
border-color: rgba(0, 104, 74, 0.32);
box-shadow: none;
}
.page-topbar .topbar-nav {
@apply flex items-center gap-2 px-6 py-2.5 border-t border-gray-100 flex-wrap;
background: #f7faf8;
}
.page-topbar .topbar-nav-link {
@apply inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-white text-gray-700
no-underline text-sm transition-all duration-200 border border-gray-200
cursor-pointer hover:border-green-700 hover:text-green-700 hover:bg-green-50;
}
.page-topbar .topbar-nav-link i {
@apply text-base;
color: #6b7d74;
}
.page-topbar .topbar-nav-link:hover i {
color: #00684a;
}
.page-topbar .topbar-filter-strip {
@apply flex items-end gap-3 px-6 py-3 border-t border-gray-100 flex-wrap;
background: #f7faf8;
}
.page-topbar .topbar-filter-field {
@apply flex flex-col gap-1.5 min-w-[180px];
}
.page-topbar .topbar-filter-field-search {
@apply flex-1 min-w-[260px];
}
.page-topbar .topbar-filter-field span {
@apply text-xs font-medium text-gray-600;
}
.page-topbar .topbar-filter-field select,
.page-topbar .topbar-filter-field input {
@apply h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-800
outline-none transition-all duration-200;
}
.page-topbar .topbar-filter-field select:focus,
.page-topbar .topbar-filter-field input:focus {
border-color: #00684a;
box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.1);
}
.main-content.rules-detail-main,
.main-content.rules-list-main {
@apply h-screen overflow-hidden;
}
.main-content.rules-detail-main .content-container,
.main-content.rules-list-main .content-container {
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
padding-left: 24px;
padding-right: 24px;
padding-bottom: 24px;
}
.main-content.rules-detail-main .content-container {
padding-top: 132px;
}
.main-content.rules-list-main .content-container {
padding-top: 156px;
}
/* === 面包屑导航 === */
.breadcrumb {
@apply flex items-center text-sm text-gray-500 mb-4;
@@ -388,4 +549,4 @@ i[class^="ri-"],
i[class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal !important;
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
const permissionRouteAliases: Array<[RegExp, string]> = [
[/^\/reviewsTest(?=\/|$)/, '/reviews'],
[/^\/rulesTest\/list(?=\/|$)/, '/rules/list'],
[/^\/rulesTest\/detail(?=\/|$)/, '/rules/new'],
];
export function normalizeRoutePathForPermission(pathname: string): string {
for (const [pattern, replacement] of permissionRouteAliases) {
if (pattern.test(pathname)) {
return pathname.replace(pattern, replacement);
}
}
return pathname;
}
+419
View File
@@ -0,0 +1,419 @@
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
export type DependencyOption = {
value: string;
label: string;
source: string;
group: string;
};
export type ValidationIssue = {
id: string;
severity: 'error' | 'warning';
area: '抽取配置' | '案卷文书' | '评查规则';
target: string;
message: string;
};
export type EditableRuleConfig = {
metadata: RuleYamlPack['metadata'];
documentType: string;
mainType: string;
subtype: string;
fields: ExtractFieldSummary[];
subDocuments: SubDocumentSummary[];
visualElements: RuleYamlPack['visualElements'];
rules: RuleSummary[];
};
function uniqueByValue(options: DependencyOption[]): DependencyOption[] {
const seen = new Set<string>();
return options.filter(option => {
if (!option.value || seen.has(option.value)) {
return false;
}
seen.add(option.value);
return true;
});
}
export function getDefaultExpandedDependencyGroups(options: DependencyOption[], selectedValues: string[]): string[] {
const selected = new Set(selectedValues);
const seen = new Set<string>();
return options
.filter(option => selected.has(option.value))
.map(option => option.group)
.filter(group => {
if (!group || seen.has(group)) {
return false;
}
seen.add(group);
return true;
});
}
export function collectDependencyOptions(config: Pick<EditableRuleConfig, 'fields' | 'subDocuments' | 'visualElements'>): DependencyOption[] {
const topLevelFields = config.fields.flatMap(field => {
const source = field.group ? `字段抽取 / ${field.group}` : '字段抽取';
const options = [{
value: field.name,
label: field.name,
source,
group: source
}];
if (field.group === '派生字段') {
options.push({
value: `derived.${field.name}`,
label: field.name,
source: '派生字段',
group: '派生字段'
});
}
if (field.name.includes('[*].')) {
options.push({
value: field.name.replace('[*].', '.'),
label: field.name.replace('[*].', ' / '),
source,
group: source
});
}
return options;
});
const documentFields = config.subDocuments.flatMap(document => [
{
value: document.name,
label: document.name,
source: '案卷文书',
group: '案卷文书'
},
...(document.fields || []).flatMap(field => [
{
value: `${document.name}.${field.name}`,
label: `${document.name} / ${field.name}`,
source: field.group ? `案卷文书 / ${field.group}` : '案卷文书',
group: field.group ? `${document.name} / ${field.group}` : document.name
},
{
value: field.name,
label: `${field.name}${document.name}`,
source: field.group ? `案卷文书 / ${field.group}` : '案卷文书',
group: field.group ? `${document.name} / ${field.group}` : document.name
}
])
]);
const visualElements = config.visualElements.flatMap(item => {
const label = item.name || item.id;
const source = `视觉要素 / ${item.type}`;
return [
{
value: item.id,
label,
source,
group: source
},
{
value: item.name || item.id,
label,
source,
group: source
},
{
value: `visual.${item.id}`,
label,
source,
group: source
},
{
value: `visual.${item.name || item.id}`,
label,
source,
group: source
},
{
value: item.type,
label: item.type,
source: '视觉要素',
group: '视觉要素'
}
];
});
return uniqueByValue([...topLevelFields, ...documentFields, ...visualElements]);
}
export function validateEditableRuleConfig(config: EditableRuleConfig): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const dependencyOptions = collectDependencyOptions(config);
const dependencyValues = new Set(dependencyOptions.map(option => option.value));
const hasKnownDependency = (dependency: string) => {
if (/^-?\d+(\.\d+)?$/.test(dependency)) return true;
if (dependencyValues.has(dependency)) return true;
const prefix = dependency.split('.')[0];
return dependency.includes('.') && dependencyValues.has(prefix);
};
config.fields.forEach(field => {
if (!field.name.trim()) {
issues.push({
id: `field-name-${field.id}`,
severity: 'error',
area: '抽取配置',
target: field.group || '未分组字段',
message: '字段名称不能为空。'
});
}
if (!field.type.trim() || field.type === '-') {
issues.push({
id: `field-type-${field.id}`,
severity: 'error',
area: '抽取配置',
target: field.name || '未命名字段',
message: '字段类型不能为空。'
});
}
});
config.subDocuments.forEach(document => {
if (!document.name.trim()) {
issues.push({
id: `document-name-${document.id}`,
severity: 'error',
area: '案卷文书',
target: document.id,
message: '文书名称不能为空。'
});
}
if ((document.fields || []).length === 0) {
issues.push({
id: `document-fields-${document.id}`,
severity: 'warning',
area: '案卷文书',
target: document.name || document.id,
message: '当前文书还没有配置文书字段。'
});
}
(document.fields || []).forEach(field => {
if (!field.name.trim()) {
issues.push({
id: `document-field-name-${document.id}-${field.id}`,
severity: 'error',
area: '案卷文书',
target: document.name || document.id,
message: '文书字段名称不能为空。'
});
}
if (!field.type.trim() || field.type === '-') {
issues.push({
id: `document-field-type-${document.id}-${field.id}`,
severity: 'error',
area: '案卷文书',
target: field.name || '未命名字段',
message: '文书字段类型不能为空。'
});
}
});
});
config.rules.forEach(rule => {
if (!rule.name.trim()) {
issues.push({
id: `rule-name-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.ruleId || rule.id,
message: '评查点名称不能为空。'
});
}
if (!rule.group.trim()) {
issues.push({
id: `rule-group-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '评查点必须选择规则组。'
});
}
if (!rule.score.trim() || rule.score === '-') {
issues.push({
id: `rule-score-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '评查点必须设置分值。'
});
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && !rule.prompt.trim()) {
issues.push({
id: `rule-prompt-${rule.id}`,
severity: 'warning',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '智能语义检查建议维护提示词,便于后续重组 YAML。'
});
}
if (rule.type === 'rule_group' && !rule.logic.trim()) {
issues.push({
id: `rule-group-logic-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '规则组合必须维护逻辑运算式。'
});
}
rule.dependencies.forEach(dependency => {
if (!hasKnownDependency(dependency)) {
issues.push({
id: `rule-dependency-${rule.id}-${dependency}`,
severity: 'warning',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: `依赖字段【${dependency}】未在当前 YAML 的字段配置或视觉要素中找到。`
});
}
});
});
return issues;
}
function yamlValue(value: string | number | boolean | undefined): string {
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return String(value);
const text = String(value || '').replace(/'/g, "''");
return text ? `'${text}'` : "''";
}
export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSummary): string {
const lines: string[] = [
`# ${config.documentType} / ${config.mainType} / ${config.subtype}`,
`- group: ${yamlValue(rule.group || '未分组')}`,
' rules:',
` - rule_id: ${yamlValue(rule.ruleId)}`,
` name: ${yamlValue(rule.name)}`,
` risk: ${yamlValue(rule.risk)}`,
` score: ${yamlValue(rule.score)}`,
` type: ${yamlValue(rule.type)}`,
` desc: ${yamlValue(rule.description)}`
];
if (rule.appliesIn.length > 0) {
lines.push(' applies_in:');
rule.appliesIn.forEach(phase => lines.push(` - ${yamlValue(phase)}`));
}
if (rule.type === 'rule_group' && rule.logic.trim()) {
lines.push(` logic: ${yamlValue(rule.logic)}`);
if (rule.subRuleIds.length > 0) {
lines.push(' rules:');
rule.subRuleIds.forEach(ruleId => lines.push(` - ${yamlValue(ruleId)}`));
}
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && rule.prompt.trim()) {
lines.push(
' stages:',
` - id: '1'`,
` check: ai`,
` prompt: ${yamlValue(rule.prompt)}`
);
}
if (rule.dependencies.length > 0) {
lines.push(' dependencies:');
rule.dependencies.forEach(dependency => lines.push(` - ${yamlValue(dependency)}`));
}
return `${lines.join('\n')}\n`;
}
export function buildYamlPreview(config: EditableRuleConfig): string {
const lines: string[] = [
'metadata:',
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
` version: ${yamlValue(config.metadata.version || 'mock')}`,
` description: ${yamlValue(config.metadata.description || '前端交互验证草稿')}`
];
if (config.fields.length > 0) {
lines.push('extract:');
const groups = Array.from(new Set(config.fields.map(field => field.group || '未分组')));
groups.forEach(group => {
lines.push(`- group: ${yamlValue(group)}`, ' fields:');
config.fields.filter(field => (field.group || '未分组') === group).forEach(field => {
lines.push(
` - name: ${yamlValue(field.name)}`,
` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`,
` desc: ${yamlValue(field.description)}`
);
});
});
}
if (config.subDocuments.length > 0) {
lines.push('sub_documents:');
config.subDocuments.forEach(document => {
lines.push(
`- id: ${yamlValue(document.id)}`,
` name: ${yamlValue(document.name)}`,
` required: ${yamlValue(document.required)}`
);
if ((document.fields || []).length > 0) {
lines.push(' extract:');
const groups = Array.from(new Set(document.fields.map(field => field.group || '未分组')));
groups.forEach(group => {
lines.push(` - group: ${yamlValue(group)}`, ' fields:');
document.fields.filter(field => (field.group || '未分组') === group).forEach(field => {
lines.push(
` - name: ${yamlValue(field.name)}`,
` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`,
` desc: ${yamlValue(field.description)}`
);
});
});
}
});
}
lines.push('rules:');
const ruleGroups = Array.from(new Set(config.rules.map(rule => rule.group || '未分组')));
ruleGroups.forEach(group => {
lines.push(`- group: ${yamlValue(group)}`, ' rules:');
config.rules.filter(rule => (rule.group || '未分组') === group).forEach(rule => {
lines.push(
` - rule_id: ${yamlValue(rule.ruleId)}`,
` name: ${yamlValue(rule.name)}`,
` risk: ${yamlValue(rule.risk)}`,
` score: ${yamlValue(rule.score)}`,
` type: ${yamlValue(rule.type)}`,
` desc: ${yamlValue(rule.description)}`
);
if (rule.appliesIn.length > 0) {
lines.push(' applies_in:');
rule.appliesIn.forEach(phase => lines.push(` - ${yamlValue(phase)}`));
}
if (rule.type === 'rule_group' && rule.logic.trim()) {
lines.push(` logic: ${yamlValue(rule.logic)}`);
if (rule.subRuleIds.length > 0) {
lines.push(' rules:');
rule.subRuleIds.forEach(ruleId => lines.push(` - ${yamlValue(ruleId)}`));
}
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && rule.prompt.trim()) {
lines.push(
' stages:',
` - id: '1'`,
` check: ai`,
` prompt: ${yamlValue(rule.prompt)}`
);
}
if (rule.dependencies.length > 0) {
lines.push(' dependencies:');
rule.dependencies.forEach(dependency => lines.push(` - ${yamlValue(dependency)}`));
}
});
});
return `${lines.join('\n')}\n`;
}
+565
View File
@@ -0,0 +1,565 @@
import { readFile } from 'node:fs/promises';
const LEAUDIT_RULES_ROOT = `${process.cwd()}/mock-data/leaudit-rules/packs/yc`;
export type RulePackScope = {
documentType: string;
moduleType: string;
mainType: string;
subtype: string;
};
export type RuleSummary = {
id: string;
ruleId: string;
name: string;
group: string;
risk: string;
score: string;
type: string;
checkTypes: string[];
logic: string;
subRules: Array<{
id: string;
check: string;
content: string;
}>;
subRuleIds: string[];
scope: string[];
dependencies: string[];
stageCount: number;
appliesIn: string[];
prompt: string;
description: string;
};
export type ExtractFieldSummary = {
id: string;
group: string;
name: string;
type: string;
multipleEntities: boolean;
requiredFrom: string;
description: string;
};
export type SubDocumentSummary = {
id: string;
name: string;
required: string;
fieldCount: number;
groups: string[];
description: string;
fields: ExtractFieldSummary[];
};
export type RuleYamlPack = RulePackScope & {
id: string;
yamlPath: string | null;
yamlSource: string;
sourceStatus: 'ready' | 'empty' | 'missing';
metadata: {
typeId: string;
name: string;
version: string;
lastUpdated: string;
parent: string;
description: string;
tags: string[];
keywords: string[];
inheritsFrom: string[];
};
stats: {
ruleCount: number;
fieldCount: number;
subDocumentCount: number;
visualElementCount: number;
};
rules: RuleSummary[];
fields: ExtractFieldSummary[];
subDocuments: SubDocumentSummary[];
visualElements: Array<{
id: string;
name: string;
type: string;
required: string;
signerRoles?: string[];
signatureTypes?: string[];
privateSealRestricted?: boolean;
}>;
};
const EMPTY_YAML = `metadata:
type_id: pending.internal.document
name: 内部公文规则配置
version: '0.1'
last_updated: '待配置'
description: '当前暂无内部公文规则 YAML。此测试页面保留规则列表与配置页流程。'
extract: []
rules: []
`;
const MOCK_RULE_PACKS: Array<RulePackScope & { id: string; yamlPath: string | null }> = [
{ id: 'contract-purchase', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '买卖合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_purchase/rules.yaml` },
{ id: 'contract-sale', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '通用买卖合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_sale/rules.yaml` },
{ id: 'contract-tech', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '技术合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_tech/rules.yaml` },
{ id: 'contract-lease', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '租赁合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_lease/rules.yaml` },
{ id: 'contract-entrust', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '委托合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_entrust/rules.yaml` },
{ id: 'contract-construction', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '建设工程合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_construction/rules.yaml` },
{ id: 'contract-evaluation', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '委托评估合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_evaluation/rules.yaml` },
{ id: 'contract-gift-general', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '赠与合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_gift_general/rules.yaml` },
{ id: 'contract-gift-charity', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '公益捐赠合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_gift_charity/rules.yaml` },
{ id: 'contract-loan', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '借款合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_loan/rules.yaml` },
{ id: 'case-penalty', documentType: '案卷', moduleType: '案卷评查', mainType: '行政处罚', subtype: '通用', yamlPath: `${LEAUDIT_RULES_ROOT}/行政处罚/rules.yaml` },
{ id: 'case-license-new', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '新办', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_新办/rules.yaml` },
{ id: 'case-license-extend', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '延续', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_延续/rules.yaml` },
{ id: 'case-license-change', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '变更', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_变更/rules.yaml` },
{ id: 'case-license-cancel', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '注销', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_注销/rules.yaml` },
{ id: 'case-license-suspend', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '停业', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_停业/rules.yaml` },
{ id: 'case-license-close', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '歇业', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_歇业/rules.yaml` },
{ id: 'case-license-reissue', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '补办', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_补办/rules.yaml` },
{ id: 'case-license-retrieve', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '收回', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_收回/rules.yaml` },
{ id: 'case-license-restore', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '恢复营业', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_恢复营业/rules.yaml` },
{ id: 'internal-document', documentType: '内部公文', moduleType: '内部公文评查', mainType: '内部公文', subtype: '通用', yamlPath: null }
];
function getTopLevelSection(source: string, key: string): string {
const lines = source.split('\n');
const start = lines.findIndex(line => line === `${key}:`);
if (start === -1) {
return '';
}
const end = lines.findIndex((line, index) => index > start && /^[a-zA-Z_][\w-]*:/.test(line));
return lines.slice(start + 1, end === -1 ? undefined : end).join('\n');
}
function stripYamlValue(value = ''): string {
return value.trim().replace(/^['"]|['"]$/g, '').replace(/\u0000/g, '');
}
function parseScalar(section: string, key: string): string {
const match = section.match(new RegExp(`^\\s{2}${key}:\\s*(.*)$`, 'm'));
return stripYamlValue(match?.[1] || '');
}
function parseListAfterKey(section: string, key: string): string[] {
const lines = section.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{2}${key}:\\s*$`).test(line));
if (start === -1) {
return [];
}
const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\s{2}\w/.test(line)) {
break;
}
const match = line.match(/^\s{2,}-\s*(.+)$/);
if (match) {
values.push(stripYamlValue(match[1]));
}
}
return values;
}
function parseMetadata(source: string): RuleYamlPack['metadata'] {
const section = getTopLevelSection(source, 'metadata');
return {
typeId: parseScalar(section, 'type_id'),
name: parseScalar(section, 'name'),
version: parseScalar(section, 'version'),
lastUpdated: parseScalar(section, 'last_updated'),
parent: parseScalar(section, 'parent'),
description: stripYamlValue((section.match(/^\s{2}description:\s*'?([\s\S]*?)(?:\n\s{2}\w|\n[a-zA-Z_]|\n?$)/m)?.[1] || '').replace(/\n\s+/g, ' ').trim()),
tags: parseListAfterKey(section, 'tags'),
keywords: parseListAfterKey(section, 'classification_keywords'),
inheritsFrom: parseListAfterKey(section, 'inherits_from')
};
}
function splitBlocks(section: string, marker: RegExp): string[] {
const lines = section.split('\n');
const starts = lines.reduce<number[]>((indexes, line, index) => {
if (marker.test(line)) {
indexes.push(index);
}
return indexes;
}, []);
return starts.map((start, index) => lines.slice(start, starts[index + 1]).join('\n'));
}
function parseRules(source: string): RuleSummary[] {
const section = getTopLevelSection(source, 'rules');
const groups = splitBlocks(section, /^-\s+group:\s*/);
const readExplicitDependencies = (block: string): string[] => {
const lines = block.split('\n');
const start = lines.findIndex(line => /^\s{4}dependencies:\s*$/.test(line));
if (start === -1) {
return [];
}
const dependencies: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\s{4}[a-zA-Z_][^:]*:\s*/.test(line)) {
break;
}
const match = line.match(/^\s{4}-\s+(.+)$/);
if (match) {
dependencies.push(stripYamlValue(match[1]));
}
}
return dependencies;
};
const normalizeDependency = (value: string) => {
const normalized = stripYamlValue(value);
if (normalized === 'cross_page_seal') return '骑缝章';
if (normalized === 'seal') return '印章';
if (normalized === 'signature') return '签名';
return normalized;
};
const readPrompts = (block: string): string[] => {
const lines = block.split('\n');
const prompts: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(/^(\s*)prompt:\s*(.*)$/);
if (!match) continue;
const indent = match[1].length;
const parts = [match[2]];
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
const line = lines[nextIndex];
const nextIndent = line.match(/^\s*/)?.[0].length || 0;
const trimmed = line.trim();
if (trimmed && nextIndent <= indent) break;
if (trimmed && nextIndent === indent + 2 && /^[a-zA-Z_][\w-]*:\s*/.test(trimmed)) break;
parts.push(line);
}
prompts.push(parts.join('\n')
.replace(/^['"]/, '')
.replace(/['"]\s*$/, '')
.split('\n')
.map(line => line.replace(/^\s{8}/, ''))
.join('\n')
.trim());
}
return prompts.filter(Boolean);
};
const readList = (block: string, key: string, indent = 4): string[] => {
const lines = block.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{${indent}}${key}:\\s*$`).test(line));
if (start === -1) {
return [];
}
const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (new RegExp(`^\\s{${indent}}[a-zA-Z_][^:]*:\\s*`).test(line)) {
break;
}
const match = line.match(new RegExp(`^\\s{${indent}}-\\s+(.+)$`));
if (match) {
values.push(stripYamlValue(match[1]));
}
}
return values;
};
const readFlexibleList = (block: string, key: string): string[] => {
const lines = block.split('\n');
const start = lines.findIndex(line => new RegExp(`^(\\s*)${key}:\\s*$`).test(line));
if (start === -1) return [];
const indent = lines[start].match(/^\s*/)?.[0].length || 0;
const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
const lineIndent = line.match(/^\s*/)?.[0].length || 0;
const match = line.match(/^\s*-\s+(.+)$/);
if (match) {
values.push(stripYamlValue(match[1]));
continue;
}
if (line.trim() && lineIndent <= indent) break;
}
return values;
};
const readStageList = (block: string, key: string): string[] => {
const lines = block.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{6}${key}:\\s*$`).test(line));
if (start === -1) {
return [];
}
const values: string[] = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\s{6}[a-zA-Z_][^:]*:\s*/.test(line)) {
break;
}
const match = line.match(/^\s{6}-\s+(.+)$/);
if (match) {
values.push(stripYamlValue(match[1]));
}
}
return values;
};
const readStageScalar = (block: string, key: string): string => stripYamlValue(block.match(new RegExp(`^\\s{6}${key}:\\s*(.+)$`, 'm'))?.[1] || '');
const summarizeStage = (stageBlock: string): string => {
const fields = readStageList(stageBlock, 'fields');
const field = readStageScalar(stageBlock, 'field');
const left = readStageScalar(stageBlock, 'left') || readStageScalar(stageBlock, 'left_field');
const op = readStageScalar(stageBlock, 'op');
const right = readStageScalar(stageBlock, 'right') || readStageScalar(stageBlock, 'right_field');
const value = readStageScalar(stageBlock, 'value');
const prompt = readStageScalar(stageBlock, 'prompt');
const element = readStageScalar(stageBlock, 'element') || readStageScalar(stageBlock, 'seal_id') || readStageScalar(stageBlock, 'signature_id');
if (fields.length > 0) return fields.join('、');
if (left || right) return [left, op, right].filter(Boolean).join(' ');
if (field && value) return `${field} = ${value}`;
if (field) return field;
if (element) return element;
if (prompt) return prompt.slice(0, 80);
return stageBlock.split('\n').map(line => line.trim()).filter(Boolean).slice(1, 4).join('') || '未配置内容';
};
const readSubRules = (block: string) => splitBlocks(block, /^\s{4}-\s+id:\s*/).map(stageBlock => {
const id = stripYamlValue(stageBlock.match(/^\s{4}-\s+id:\s*(.+)$/m)?.[1] || '');
const check = readStageScalar(stageBlock, 'check') || readStageScalar(stageBlock, 'type') || '-';
return {
id,
check,
content: summarizeStage(stageBlock)
};
}).filter(stage => stage.id);
return groups.flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{2}-\s+rule_id:\s*/).map(ruleBlock => {
const ruleId = stripYamlValue(ruleBlock.match(/^\s{2}-\s+rule_id:\s*(.+)$/m)?.[1] || '');
const name = stripYamlValue(ruleBlock.match(/^\s{4}name:\s*(.+)$/m)?.[1] || '未命名规则');
const checkTypes = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s{6,}(?:check|type):\s*(.+)$/gm)).map(match => stripYamlValue(match[1]))));
const stageDependencies = Array.from(ruleBlock.matchAll(/^\s{6,}(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id):\s*(.+)$/gm))
.map(match => normalizeDependency(match[1]));
const dependencies = Array.from(new Set([...readExplicitDependencies(ruleBlock), ...stageDependencies]));
const scope = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s{4,}-\s*([^:\n]+)$/gm)).map(match => stripYamlValue(match[1])).filter(value => !/^\d+$/.test(value))));
const prompts = readPrompts(ruleBlock);
const subRules = readSubRules(ruleBlock);
return {
id: ruleId || `${group}-${name}`,
ruleId,
name,
group,
risk: stripYamlValue(ruleBlock.match(/^\s{4}risk:\s*(.+)$/m)?.[1] || 'medium'),
score: stripYamlValue(ruleBlock.match(/^\s{4}score:\s*(.+)$/m)?.[1] || '-'),
type: stripYamlValue(ruleBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || 'deterministic'),
checkTypes,
logic: stripYamlValue(ruleBlock.match(/^\s{4}logic:\s*(.+)$/m)?.[1] || ''),
subRules,
subRuleIds: readList(ruleBlock, 'rules'),
scope: scope.slice(0, 8),
dependencies: dependencies.slice(0, 8),
stageCount: subRules.length,
appliesIn: readFlexibleList(ruleBlock, 'applies_in'),
prompt: prompts.join('\n\n'),
description: stripYamlValue(ruleBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '')
};
});
});
}
function parseTopLevelFields(source: string): ExtractFieldSummary[] {
const section = getTopLevelSection(source, 'extract');
const extractedFields = splitBlocks(section, /^-\s+group:\s*/).flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{2}-\s+name:\s*/).flatMap(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^\s{2}-\s+name:\s*(.+)$/m)?.[1] || '');
const rawType = stripYamlValue(fieldBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || '-');
const parentField = {
id: `${group}-${name}`,
group,
name,
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
multipleEntities: rawType === 'multi_entity',
requiredFrom: stripYamlValue(fieldBlock.match(/^\s{4}required_from:\s*(.+)$/m)?.[1] || '-'),
description: stripYamlValue(fieldBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '')
};
const childFields = Array.from(fieldBlock.matchAll(/^\s{4}-\s+name:\s*(.+)$/gm)).map(match => {
const childName = stripYamlValue(match[1]);
const start = fieldBlock.indexOf(match[0]);
const next = fieldBlock.slice(start + match[0].length).search(/^\s{4}-\s+name:\s*/m);
const childBlock = next === -1 ? fieldBlock.slice(start) : fieldBlock.slice(start, start + match[0].length + next);
const childType = stripYamlValue(childBlock.match(/^\s{6}type:\s*(.+)$/m)?.[1] || 'verbatim');
return {
id: `${group}-${name}-${childName}`,
group,
name: `${name}[*].${childName}`,
type: childType,
multipleEntities: false,
requiredFrom: stripYamlValue(childBlock.match(/^\s{6}required_from:\s*(.+)$/m)?.[1] || parentField.requiredFrom),
description: stripYamlValue(childBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || `${name}的子字段`)
};
});
return [parentField, ...childFields];
});
}).filter(field => field.name);
const derivedSection = getTopLevelSection(source, 'derived_fields');
const derivedFields = splitBlocks(derivedSection, /^-\s+name:\s*/).map(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^-\s+name:\s*(.+)$/m)?.[1] || '');
return {
id: `derived-${name}`,
group: '派生字段',
name,
type: stripYamlValue(fieldBlock.match(/^\s{2}type:\s*(.+)$/m)?.[1] || 'computed'),
multipleEntities: false,
requiredFrom: '-',
description: stripYamlValue(fieldBlock.match(/^\s{2}compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
};
}).filter(field => field.name);
return [...extractedFields, ...derivedFields];
}
function parseDocumentFields(docBlock: string, documentId: string): ExtractFieldSummary[] {
return splitBlocks(docBlock, /^\s{2}-\s+group:\s*/).flatMap(groupBlock => {
const group = stripYamlValue(groupBlock.match(/^\s{2}-\s+group:\s*(.+)$/m)?.[1] || '未分组');
return splitBlocks(groupBlock, /^\s{4}-\s+name:\s*/).map(fieldBlock => {
const name = stripYamlValue(fieldBlock.match(/^\s{4}-\s+name:\s*(.+)$/m)?.[1] || '');
const rawType = stripYamlValue(fieldBlock.match(/^\s{6}type:\s*(.+)$/m)?.[1] || '-');
return {
id: `${documentId}-${group}-${name}`,
group,
name,
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
multipleEntities: rawType === 'multi_entity',
requiredFrom: '-',
description: stripYamlValue(fieldBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || '')
};
});
}).filter(field => field.name);
}
function parseSubDocuments(source: string): SubDocumentSummary[] {
const section = getTopLevelSection(source, 'sub_documents');
return splitBlocks(section, /^-\s+id:\s*/).map(docBlock => {
const id = stripYamlValue(docBlock.match(/^-\s+id:\s*(.+)$/m)?.[1] || '');
const groups = Array.from(new Set(Array.from(docBlock.matchAll(/^\s{2,}-\s+group:\s*(.+)$/gm)).map(match => stripYamlValue(match[1]))));
const fields = parseDocumentFields(docBlock, id);
const classifier = docBlock.match(/^\s{2}classifier:\s*$/m);
let description = '';
if (classifier) {
const keywordsMatch = docBlock.match(/keywords:\s*\n((?:\s{4}-\s+.+\n)+)/m);
if (keywordsMatch) {
const keywords = Array.from(keywordsMatch[1].matchAll(/^\s{4}-\s+(.+)$/gm)).map(match => stripYamlValue(match[1])).slice(0, 3);
description = keywords.join('、');
}
}
return {
id,
name: stripYamlValue(docBlock.match(/^\s{2}name:\s*(.+)$/m)?.[1] || id),
required: stripYamlValue(docBlock.match(/^\s{2}required:\s*(.+)$/m)?.[1] || '-'),
fieldCount: fields.length,
groups,
description,
fields
};
}).filter(doc => doc.id);
}
function parseVisualElements(source: string): RuleYamlPack['visualElements'] {
const section = getTopLevelSection(source, 'visual_elements');
const typedSections = [
{ key: 'seals', label: '签章' },
{ key: 'signatures', label: '签名' },
{ key: 'cross_page_seals', label: '骑缝章' }
];
return typedSections.flatMap(({ key, label }) => {
const lines = section.split('\n');
const start = lines.findIndex(line => new RegExp(`^\\s{2}${key}:`).test(line));
if (start === -1) {
return [];
}
// 找到下一个同级分类的起始位置(2空格+字母+冒号)
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^\s{2}[a-zA-Z_][\w-]*:/.test(lines[i])) {
end = i;
break;
}
}
const subSection = lines.slice(start + 1, end).join('\n');
return splitBlocks(subSection, /^\s{2}-\s+id:\s*/).map(block => ({
id: stripYamlValue(block.match(/^\s{2}-\s+id:\s*(.+)$/m)?.[1] || ''),
name: stripYamlValue(block.match(/^\s{4}name:\s*(.+)$/m)?.[1] || ''),
type: label,
required: stripYamlValue(block.match(/^\s{4}required:\s*(.+)$/m)?.[1] || '-'),
signerRoles: block.match(/^\s{4}signer_roles:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [],
signatureTypes: block.match(/^\s{4}signature_types:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [],
privateSealRestricted: block.match(/^\s{4}private_seal_restricted:\s*(.+)$/m)?.[1] === 'true'
}));
}).filter(item => item.id);
}
function buildPack(config: RulePackScope & { id: string; yamlPath: string | null }, yamlSource: string, sourceStatus: RuleYamlPack['sourceStatus']): RuleYamlPack {
const metadata = parseMetadata(yamlSource);
const fields = parseTopLevelFields(yamlSource);
const subDocuments = parseSubDocuments(yamlSource);
const rules = parseRules(yamlSource);
const visualElements = parseVisualElements(yamlSource);
return {
...config,
yamlSource,
sourceStatus,
metadata,
rules,
fields,
subDocuments,
visualElements,
stats: {
ruleCount: rules.length,
fieldCount: fields.length + subDocuments.reduce((sum, doc) => sum + doc.fieldCount, 0),
subDocumentCount: subDocuments.length,
visualElementCount: visualElements.length
}
};
}
export async function loadRuleYamlPacks(): Promise<RuleYamlPack[]> {
return Promise.all(MOCK_RULE_PACKS.map(async config => {
// TODO(production-data-source):
// 当前测试页直接读取 leaudit 本地 YAML 作为 mock。
// 生产切换时,这里应改为调用后端接口:
// 1. 后端根据文档类型/主类型/子类型查询数据库中的 OSS YAML 路径;
// 2. 后端读取 OSS YAML 正文并返回元数据和内容;
// 3. 前端仍消费 buildPack 之后的结构化数据,页面不直接关心 OSS 实现。
if (!config.yamlPath) {
return buildPack(config, EMPTY_YAML, 'empty');
}
try {
const yamlSource = await readFile(config.yamlPath, 'utf8');
return buildPack(config, yamlSource, 'ready');
} catch {
return buildPack(config, EMPTY_YAML, 'missing');
}
}));
}
export async function loadRuleYamlPack(id: string): Promise<RuleYamlPack | undefined> {
const packs = await loadRuleYamlPacks();
return packs.find(pack => pack.id === id);
}