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

1198 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { json, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
import { Link, useFetcher, useLoaderData, useRevalidator } from '@remix-run/react';
import type React from 'react';
import { useEffect, useMemo, useRef, 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 { getUserSession } from '~/api/login/auth.server';
import { API_BASE_URL } from '~/config/api-config';
import { loadRuleConfigPack, loadRuleConfigPacks, loadRuleConfigVersions, type RuleVersionItem } from '~/utils/rules-config-packs.server';
import { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, validateEditableRuleConfig, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor';
import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server';
import styles from '~/styles/pages/rules_test.css?url';
export const links = () => [
{ rel: 'stylesheet', href: styles }
];
export const meta: MetaFunction = () => [
{ title: '评查点详情 - 智慧法务' }
];
type LoaderData = {
pack: RuleYamlPack;
requestedRuleId: string;
versions: RuleVersionItem[];
};
type ActionData = {
success: boolean;
message: string;
intent: 'save' | 'publish' | 'rollback';
versionId?: number;
versionNo?: 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 loadRuleConfigPacks(request);
const pack = (packId ? await loadRuleConfigPack(request, packId) : undefined) || packs[0];
if (!pack) {
throw new Response('未找到 YAML 配置', { status: 404 });
}
const versions = await loadRuleConfigVersions(request, pack.metadata.typeId || '');
return Response.json({ pack, requestedRuleId, versions } satisfies LoaderData);
}
export async function action({ request }: ActionFunctionArgs) {
const { frontendJWT, userInfo } = await getUserSession(request);
if (!frontendJWT) {
return json<ActionData>({ success: false, intent: 'save', message: '登录已失效,请重新登录后再保存。' }, { status: 401 });
}
const formData = await request.formData();
const intent = String(formData.get('intent') || 'save').trim() as ActionData['intent'];
const ruleType = String(formData.get('ruleType') || '').trim();
const yamlText = String(formData.get('yamlText') || '');
const changeNote = String(formData.get('changeNote') || '').trim() || 'rulesTest.detail 保存评查点草稿';
const versionId = Number(formData.get('versionId') || 0);
if (!ruleType) {
return json<ActionData>({ success: false, intent, message: '当前规则类型缺失,无法保存。' }, { status: 400 });
}
if (intent === 'save' && !yamlText.trim()) {
return json<ActionData>({ success: false, intent, message: '当前 YAML 内容为空,无法保存。' }, { status: 400 });
}
if ((intent === 'publish' || intent === 'rollback') && (!Number.isFinite(versionId) || versionId <= 0)) {
return json<ActionData>({ success: false, intent, message: '目标版本缺失,无法继续操作。' }, { status: 400 });
}
try {
const apiPath = intent === 'save'
? `/api/rule-sets/${encodeURIComponent(ruleType)}/versions`
: `/api/rule-sets/${encodeURIComponent(ruleType)}/${intent}`;
const requestBody = intent === 'save'
? {
yamlText,
changeNote,
editorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined,
}
: {
versionId,
operatorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined,
};
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${frontendJWT}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(requestBody),
});
const payload = await response.json();
const data = payload?.data ?? null;
const message = String(payload?.message || payload?.msg || (response.ok ? '规则草稿保存成功' : '规则草稿保存失败'));
if (!response.ok || !data) {
return json<ActionData>({ success: false, intent, message }, { status: response.status || 500 });
}
return json<ActionData>({
success: true,
intent,
message,
versionId: Number(data.id),
versionNo: String(data.versionNo || ''),
});
} catch (error) {
return json<ActionData>({
success: false,
intent,
message: error instanceof Error ? error.message : '规则草稿保存失败',
}, { status: 500 });
}
}
export default function RulesTestDetail() {
const { pack, requestedRuleId, versions } = useLoaderData<typeof loader>() as LoaderData;
const saveFetcher = useFetcher<ActionData>();
const revalidator = useRevalidator();
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);
const [saveMessage, setSaveMessage] = useState('');
const [saveError, setSaveError] = useState('');
const promptEditorRef = useRef<HTMLTextAreaElement>(null);
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);
setSaveMessage('');
setSaveError('');
}, [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 configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]);
const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error');
const fullYamlText = useMemo(() => buildYamlPreview(editableConfig), [editableConfig]);
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 saveButtonBusy = saveFetcher.state !== 'idle';
const latestDraftVersion = useMemo(
() => versions.find((item) => !['published', 'rollback'].includes(item.status)),
[versions],
);
const rollbackTargetVersion = useMemo(
() => versions.find((item) => ['published', 'rollback'].includes(item.status) && item.id !== pack.currentVersionId),
[versions, pack.currentVersionId],
);
const currentResolvedVersion = useMemo(
() => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null,
[pack.currentVersionId, pack.fallbackVersionId, versions],
);
const versionStatusLabel = (status: string | undefined) => {
const normalized = String(status || '').trim().toLowerCase();
if (normalized === 'published') return '已发布';
if (normalized === 'rollback') return '回滚版本';
if (normalized === 'draft') return '草稿';
if (normalized === 'deprecated') return '已废弃';
return status || '-';
};
useEffect(() => {
if (!saveFetcher.data) return;
if (saveFetcher.data.success) {
setDraftSaved(saveFetcher.data.intent === 'save');
setSaveError('');
if (saveFetcher.data.intent === 'save') {
setSaveMessage(saveFetcher.data.versionNo
? `规则草稿已保存为版本 ${saveFetcher.data.versionNo}`
: saveFetcher.data.message || '规则草稿已保存');
} else {
setSaveMessage(saveFetcher.data.message || (saveFetcher.data.intent === 'publish' ? '规则版本已发布' : '规则版本已回滚'));
}
revalidator.revalidate();
return;
}
setDraftSaved(false);
setSaveMessage('');
setSaveError(saveFetcher.data.message || '规则草稿保存失败');
}, [revalidator, saveFetcher.data]);
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 insertDependencyVariable = (value: string) => {
const variable = `{{${value}}}`;
const textarea = promptEditorRef.current;
const prompt = ruleDraft.prompt || '';
const start = textarea?.selectionStart ?? prompt.length;
const end = textarea?.selectionEnd ?? prompt.length;
const prefix = prompt.slice(0, start);
const suffix = prompt.slice(end);
const nextPrompt = `${prefix}${variable}${suffix}`;
setRuleDraft({ ...ruleDraft, prompt: nextPrompt });
window.setTimeout(() => {
if (!promptEditorRef.current) return;
const nextCursor = start + variable.length;
promptEditorRef.current.focus();
promptEditorRef.current.setSelectionRange(nextCursor, nextCursor);
}, 0);
};
const removeDependency = (value: string) => {
setRuleDraft({
...ruleDraft,
dependencies: ruleDraft.dependencies.filter(dependency => dependency !== value)
});
};
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(false);
setSaveMessage('');
setSaveError('');
setEditor(null);
};
const saveDraftToServer = () => {
if (hasConfigErrors) {
setShowValidation(true);
setSaveError('当前规则配置仍有必改问题,请先处理后再保存。');
setSaveMessage('');
return;
}
const formData = new FormData();
formData.append('ruleType', pack.metadata.typeId || '');
formData.append('yamlText', fullYamlText);
formData.append('changeNote', `保存 ${pack.documentType}/${pack.mainType}/${pack.subtype} 规则配置`);
formData.append('intent', 'save');
saveFetcher.submit(formData, { method: 'post' });
};
const publishDraftVersion = () => {
if (!latestDraftVersion) {
setSaveError('当前没有可发布的新版本,请先保存规则配置。');
setSaveMessage('');
return;
}
const formData = new FormData();
formData.append('intent', 'publish');
formData.append('ruleType', pack.metadata.typeId || '');
formData.append('versionId', String(latestDraftVersion.id));
saveFetcher.submit(formData, { method: 'post' });
};
const rollbackRuleVersion = () => {
if (!rollbackTargetVersion) {
setSaveError('当前没有可回滚的历史可用版本。');
setSaveMessage('');
return;
}
const formData = new FormData();
formData.append('intent', 'rollback');
formData.append('ruleType', pack.metadata.typeId || '');
formData.append('versionId', String(rollbackTargetVersion.id));
saveFetcher.submit(formData, { method: 'post' });
};
const resetDraft = () => {
setRules(pack.rules);
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
setDependencyDialogOpen(false);
setDependencySearch('');
setDependencySelection([]);
setShowValidation(false);
setShowYamlPreview(false);
setDraftSaved(false);
setSaveMessage('');
setSaveError('');
};
const dependencyColumns = [
{
title: '依赖字段',
key: 'label',
width: '38%',
render: (_: unknown, record: DependencyOption) => (
<div className="rule-name">
<strong>{record.label}</strong>
</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 className="config-toolbar-desc">
{currentResolvedVersion?.versionNo || '-'} / {versionStatusLabel(currentResolvedVersion?.status)}
</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={saveButtonBusy} onClick={saveDraftToServer}>
<i className={`${saveButtonBusy ? 'ri-loader-4-line' : 'ri-save-line'} mr-1.5`}></i>
{saveButtonBusy ? '保存中...' : '保存规则配置'}
</button>
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !latestDraftVersion} onClick={publishDraftVersion}>
<i className="ri-upload-cloud-line mr-1.5"></i>
</button>
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !rollbackTargetVersion} onClick={rollbackRuleVersion}>
<i className="ri-history-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>
稿
</div>
)}
{saveMessage && (
<div className="draft-tip">
<i className="ri-checkbox-circle-line"></i>
{saveMessage}
</div>
)}
{saveError && (
<div className="draft-tip danger">
<i className="ri-error-warning-line"></i>
{saveError}
</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>
{(pack.fields.length > 0 || pack.subDocuments.length > 0 || pack.visualElements.length > 0) && (
<>
{pack.fields.length > 0 && <span id="fields" className="section-anchor" aria-hidden="true"></span>}
{pack.subDocuments.length > 0 && <span id="sub-documents" className="section-anchor" aria-hidden="true"></span>}
{pack.visualElements.length > 0 && <span id="visual-elements" className="section-anchor" aria-hidden="true"></span>}
</>
)}
<Card
className="ant-card"
title={`依赖字段 (${currentRule.dependencies.length}项)`}
>
<Table
className="rules-test-table"
columns={dependencyColumns}
dataSource={currentDependencyRows}
rowKey="value"
emptyText={<div className="empty-state"></div>}
/>
</Card>
<span id="rules" className="section-anchor" aria-hidden="true"></span>
<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>
</div>
</div>
<div className="subrule-list">
{currentRule.subRules.map(subRule => (
<div key={subRule.id} className="subrule-item">
<span className="subrule-index">{subRule.id}</span>
<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>
</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">
<span className="subrule-index">{ruleId}</span>
<div>
<strong>{referencedRule?.name || '引用规则'}</strong>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '未找到对应规则内容'}</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>
</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>
</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
ref={promptEditorRef}
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>
</div>
</div>
<div className="subrule-list">
{ruleDraft.subRules.length > 0 ? ruleDraft.subRules.map(subRule => (
<div key={subRule.id} className="subrule-item">
<span className="subrule-index">{subRule.id}</span>
<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">
<span className="subrule-index">{ruleId}</span>
<div>
<strong>{referencedRule?.name || '引用规则'}</strong>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '未找到对应规则内容'}</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 => (
<span
key={option.value}
className="dependency-variable-button"
>
<button
type="button"
className="dependency-variable-main"
onClick={() => insertDependencyVariable(option.value)}
title={`插入变量 {{${option.value}}}`}
>
{option.label}
</button>
<button
type="button"
className="dependency-variable-remove"
onClick={() => removeDependency(option.value)}
aria-label={`移除依赖字段 ${option.label}`}
>
<i className="ri-close-line"></i>
</button>
</span>
))}
<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>
</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>
);
}