fix: stabilize rules detail editor flow
This commit is contained in:
@@ -30,6 +30,7 @@ type RulesTestDetailData = {
|
|||||||
pack?: {
|
pack?: {
|
||||||
documentType?: string;
|
documentType?: string;
|
||||||
mainType?: string;
|
mainType?: string;
|
||||||
|
businessType?: string;
|
||||||
fields?: unknown[];
|
fields?: unknown[];
|
||||||
subDocuments?: unknown[];
|
subDocuments?: unknown[];
|
||||||
visualElements?: unknown[];
|
visualElements?: unknown[];
|
||||||
@@ -137,10 +138,8 @@ export function Layout({ children, userRole = 'developer' as UserRole, frontendJ
|
|||||||
const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined;
|
const rulesTestDetailData = matches.find(match => match.pathname.startsWith('/rulesTest/detail'))?.data as RulesTestDetailData | undefined;
|
||||||
const detailPack = rulesTestDetailData?.pack;
|
const detailPack = rulesTestDetailData?.pack;
|
||||||
const detailPackFilterMainType = detailPack?.businessType || detailPack?.mainType || '';
|
const detailPackFilterMainType = detailPack?.businessType || detailPack?.mainType || '';
|
||||||
const isContractDetail = !!detailPack?.documentType?.includes('合同');
|
const showFieldNav = (detailPack?.fields?.length || 0) > 0;
|
||||||
const isCaseFileDetail = !!detailPack?.documentType?.includes('案卷');
|
const showSubDocumentNav = (detailPack?.subDocuments?.length || 0) > 0;
|
||||||
const showFieldNav = isContractDetail && (detailPack?.fields?.length || 0) > 0;
|
|
||||||
const showSubDocumentNav = isCaseFileDetail && (detailPack?.subDocuments?.length || 0) > 0;
|
|
||||||
const showVisualNav = (detailPack?.visualElements?.length || 0) > 0;
|
const showVisualNav = (detailPack?.visualElements?.length || 0) > 0;
|
||||||
const rulesListHref = detailPack?.documentType
|
const rulesListHref = detailPack?.documentType
|
||||||
? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPackFilterMainType ? `&mainType=${encodeURIComponent(detailPackFilterMainType)}` : ''}`
|
? `/rulesTest/list?documentType=${encodeURIComponent(detailPack.documentType)}${detailPackFilterMainType ? `&mainType=${encodeURIComponent(detailPackFilterMainType)}` : ''}`
|
||||||
|
|||||||
+657
-157
@@ -9,8 +9,8 @@ import { Tag, type TagColor } from '~/components/ui/Tag';
|
|||||||
import { getUserSession } from '~/api/login/auth.server';
|
import { getUserSession } from '~/api/login/auth.server';
|
||||||
import { API_BASE_URL } from '~/config/api-config';
|
import { API_BASE_URL } from '~/config/api-config';
|
||||||
import { loadRuleConfigPack, loadRuleConfigPacks, loadRuleConfigVersions, type RuleVersionItem } from '~/utils/rules-config-packs.server';
|
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 { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, prepareDraftYamlForSave, serializeEditableRuleConfig, validateEditableRuleConfig, type DependencyOption, type EditableRuleConfig, type ValidationIssue, type VisualElementSummary } from '~/utils/rules-config-editor';
|
||||||
import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server';
|
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from '~/utils/rules-yaml-mock.server';
|
||||||
import styles from '~/styles/pages/rules_test.css?url';
|
import styles from '~/styles/pages/rules_test.css?url';
|
||||||
|
|
||||||
export const links = () => [
|
export const links = () => [
|
||||||
@@ -35,13 +35,22 @@ type ActionData = {
|
|||||||
versionNo?: string;
|
versionNo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorState = { kind: 'rule'; mode: 'create' | 'edit'; id?: string } | null;
|
type EditorState =
|
||||||
|
| { kind: 'rule'; mode: 'create' | 'edit'; id?: string }
|
||||||
|
| { kind: 'field'; mode: 'create' | 'edit'; id?: string }
|
||||||
|
| { kind: 'document'; mode: 'create' | 'edit'; id?: string }
|
||||||
|
| { kind: 'visual'; mode: 'create' | 'edit'; id?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
type RuleDraft = Pick<RuleSummary, 'id' | 'ruleId' | 'name' | 'group' | 'risk' | 'score' | 'type' | 'logic' | 'subRules' | 'subRuleIds' | 'prompt' | 'description'> & {
|
type RuleDraft = Pick<RuleSummary, 'id' | 'ruleId' | 'name' | 'group' | 'risk' | 'score' | 'type' | 'logic' | 'subRules' | 'subRuleIds' | 'prompt' | 'description'> & {
|
||||||
checkTypes: string[];
|
checkTypes: string[];
|
||||||
dependencies: string[];
|
dependencies: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FieldDraft = ExtractFieldSummary;
|
||||||
|
type DocumentDraft = SubDocumentSummary;
|
||||||
|
type VisualDraft = VisualElementSummary;
|
||||||
|
|
||||||
function riskColor(risk: string): TagColor {
|
function riskColor(risk: string): TagColor {
|
||||||
if (risk === 'high') return 'red';
|
if (risk === 'high') return 'red';
|
||||||
if (risk === 'medium') return 'orange';
|
if (risk === 'medium') return 'orange';
|
||||||
@@ -109,6 +118,40 @@ function phaseLabel(phase: string): string {
|
|||||||
return labels[phase] ? `${labels[phase]} (${phase})` : phase;
|
return labels[phase] ? `${labels[phase]} (${phase})` : phase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
verbatim: '原文',
|
||||||
|
string: '文本',
|
||||||
|
money: '金额',
|
||||||
|
date: '日期',
|
||||||
|
enum: '枚举',
|
||||||
|
number: '数字',
|
||||||
|
multi_entity: '多实体',
|
||||||
|
};
|
||||||
|
return labels[type] || type || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredFromLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
draft: '草稿阶段',
|
||||||
|
executed: '签署后阶段',
|
||||||
|
'-': '未限定',
|
||||||
|
true: '必需',
|
||||||
|
false: '可选',
|
||||||
|
conditional: '条件必需',
|
||||||
|
};
|
||||||
|
return labels[value] || value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCurrentRuleDependency(currentRule: RuleSummary | undefined, candidates: Array<string | undefined>): boolean {
|
||||||
|
const deps = new Set(currentRule?.dependencies || []);
|
||||||
|
if (deps.size === 0) return false;
|
||||||
|
return candidates.some((candidate) => {
|
||||||
|
const value = String(candidate || '').trim();
|
||||||
|
return value ? deps.has(value) : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isStepReferenced(logic: string, stepId: string): boolean {
|
function isStepReferenced(logic: string, stepId: string): boolean {
|
||||||
if (!logic.trim()) return false;
|
if (!logic.trim()) return false;
|
||||||
return new RegExp(`(^|[^\\w-])${stepId}([^\\w-]|$)`).test(logic);
|
return new RegExp(`(^|[^\\w-])${stepId}([^\\w-]|$)`).test(logic);
|
||||||
@@ -163,6 +206,42 @@ function emptyRuleDraft(group = '未分组'): RuleDraft {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyFieldDraft(group = '基础信息'): FieldDraft {
|
||||||
|
return {
|
||||||
|
id: makeId('field'),
|
||||||
|
group,
|
||||||
|
name: '',
|
||||||
|
type: 'verbatim',
|
||||||
|
multipleEntities: false,
|
||||||
|
requiredFrom: 'draft',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDocumentDraft(): DocumentDraft {
|
||||||
|
return {
|
||||||
|
id: makeId('document'),
|
||||||
|
name: '',
|
||||||
|
required: 'false',
|
||||||
|
fieldCount: 0,
|
||||||
|
groups: [],
|
||||||
|
description: '',
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyVisualDraft(type = '签章'): VisualDraft {
|
||||||
|
return {
|
||||||
|
id: makeId('visual'),
|
||||||
|
name: '',
|
||||||
|
type,
|
||||||
|
required: 'true',
|
||||||
|
signerRoles: [],
|
||||||
|
signatureTypes: [],
|
||||||
|
privateSealRestricted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function issueColor(severity: ValidationIssue['severity']): TagColor {
|
function issueColor(severity: ValidationIssue['severity']): TagColor {
|
||||||
return severity === 'error' ? 'red' : 'orange';
|
return severity === 'error' ? 'red' : 'orange';
|
||||||
}
|
}
|
||||||
@@ -314,21 +393,18 @@ function validateRule(rule: RuleSummary | undefined, dependencyOptions: Dependen
|
|||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const { frontendJWT, userInfo } = await getUserSession(request);
|
|
||||||
const { requireRoutePermission } = await import('~/api/auth/check-route-permission.server');
|
|
||||||
await requireRoutePermission('/rulesTest/detail', userInfo?.role || '', frontendJWT || undefined);
|
|
||||||
const packId = url.searchParams.get('packId') || url.searchParams.get('id') || '';
|
const packId = url.searchParams.get('packId') || url.searchParams.get('id') || '';
|
||||||
const requestedRuleId = url.searchParams.get('ruleId') || '';
|
const requestedRuleId = url.searchParams.get('ruleId') || '';
|
||||||
const packs = await loadRuleConfigPacks(request);
|
const pack = packId ? await loadRuleConfigPack(request, packId) : undefined;
|
||||||
const pack = (packId ? await loadRuleConfigPack(request, packId) : undefined) || packs[0];
|
const resolvedPack = pack || (await loadRuleConfigPacks(request))[0];
|
||||||
|
|
||||||
if (!pack) {
|
if (!resolvedPack) {
|
||||||
throw new Response('未找到 YAML 配置', { status: 404 });
|
throw new Response('未找到 YAML 配置', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const versions = await loadRuleConfigVersions(request, pack.metadata.typeId || '');
|
const versions = await loadRuleConfigVersions(request, resolvedPack.metadata.typeId || '');
|
||||||
|
|
||||||
return Response.json({ pack, requestedRuleId, versions } satisfies LoaderData);
|
return Response.json({ pack: resolvedPack, requestedRuleId, versions } satisfies LoaderData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
@@ -413,9 +489,16 @@ export default function RulesTestDetail() {
|
|||||||
const revalidator = useRevalidator();
|
const revalidator = useRevalidator();
|
||||||
const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' });
|
const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' });
|
||||||
const [rules, setRules] = useState<RuleSummary[]>(pack.rules);
|
const [rules, setRules] = useState<RuleSummary[]>(pack.rules);
|
||||||
|
const [fields, setFields] = useState<ExtractFieldSummary[]>(pack.fields);
|
||||||
|
const [subDocuments, setSubDocuments] = useState<SubDocumentSummary[]>(pack.subDocuments);
|
||||||
|
const [visualElements, setVisualElements] = useState<VisualElementSummary[]>(pack.visualElements);
|
||||||
const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey);
|
const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey);
|
||||||
const [editor, setEditor] = useState<EditorState>(null);
|
const [editor, setEditor] = useState<EditorState>(null);
|
||||||
const [ruleDraft, setRuleDraft] = useState<RuleDraft>(emptyRuleDraft(pack.rules[0]?.group));
|
const [ruleDraft, setRuleDraft] = useState<RuleDraft>(emptyRuleDraft(pack.rules[0]?.group));
|
||||||
|
const [fieldDraft, setFieldDraft] = useState<FieldDraft>(emptyFieldDraft(pack.fields[0]?.group || '基础信息'));
|
||||||
|
const [documentDraft, setDocumentDraft] = useState<DocumentDraft>(emptyDocumentDraft());
|
||||||
|
const [visualDraft, setVisualDraft] = useState<VisualDraft>(emptyVisualDraft(pack.visualElements[0]?.type || '签章'));
|
||||||
|
const [selectedRollbackVersionId, setSelectedRollbackVersionId] = useState('');
|
||||||
const [dependencyDialogOpen, setDependencyDialogOpen] = useState(false);
|
const [dependencyDialogOpen, setDependencyDialogOpen] = useState(false);
|
||||||
const [dependencySearch, setDependencySearch] = useState('');
|
const [dependencySearch, setDependencySearch] = useState('');
|
||||||
const [dependencySelection, setDependencySelection] = useState<string[]>([]);
|
const [dependencySelection, setDependencySelection] = useState<string[]>([]);
|
||||||
@@ -429,6 +512,9 @@ export default function RulesTestDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRules(pack.rules);
|
setRules(pack.rules);
|
||||||
|
setFields(pack.fields);
|
||||||
|
setSubDocuments(pack.subDocuments);
|
||||||
|
setVisualElements(pack.visualElements);
|
||||||
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
||||||
setEditor(null);
|
setEditor(null);
|
||||||
setDependencyDialogOpen(false);
|
setDependencyDialogOpen(false);
|
||||||
@@ -448,14 +534,15 @@ export default function RulesTestDetail() {
|
|||||||
|
|
||||||
const editableConfig: EditableRuleConfig = useMemo(() => ({
|
const editableConfig: EditableRuleConfig = useMemo(() => ({
|
||||||
metadata: pack.metadata,
|
metadata: pack.metadata,
|
||||||
|
yamlSource: pack.yamlSource,
|
||||||
documentType: pack.documentType,
|
documentType: pack.documentType,
|
||||||
mainType: pack.mainType,
|
mainType: pack.mainType,
|
||||||
subtype: pack.subtype,
|
subtype: pack.subtype,
|
||||||
fields: pack.fields,
|
fields,
|
||||||
subDocuments: pack.subDocuments,
|
subDocuments,
|
||||||
visualElements: pack.visualElements,
|
visualElements,
|
||||||
rules
|
rules
|
||||||
}), [pack, rules]);
|
}), [fields, pack, rules, subDocuments, visualElements]);
|
||||||
|
|
||||||
const dependencyOptions = useMemo(() => collectDependencyOptions(editableConfig), [editableConfig]);
|
const dependencyOptions = useMemo(() => collectDependencyOptions(editableConfig), [editableConfig]);
|
||||||
const dependencyOptionMap = useMemo(() => new Map(dependencyOptions.map(option => [option.value, option])), [dependencyOptions]);
|
const dependencyOptionMap = useMemo(() => new Map(dependencyOptions.map(option => [option.value, option])), [dependencyOptions]);
|
||||||
@@ -474,6 +561,24 @@ export default function RulesTestDetail() {
|
|||||||
const currentDependencyRows = useMemo(() => {
|
const currentDependencyRows = useMemo(() => {
|
||||||
return (currentRule?.dependencies || []).map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap));
|
return (currentRule?.dependencies || []).map(value => dependencyOptionMap.get(value) || fallbackDependencyOption(value, dependencyOptionMap));
|
||||||
}, [currentRule, dependencyOptionMap]);
|
}, [currentRule, dependencyOptionMap]);
|
||||||
|
const currentRuleFields = useMemo(
|
||||||
|
() => fields.filter((field) => matchesCurrentRuleDependency(currentRule, [field.name])),
|
||||||
|
[currentRule, fields],
|
||||||
|
);
|
||||||
|
const currentRuleSubDocuments = useMemo(
|
||||||
|
() => subDocuments.filter((document) => matchesCurrentRuleDependency(currentRule, [document.name, document.id])),
|
||||||
|
[currentRule, subDocuments],
|
||||||
|
);
|
||||||
|
const currentRuleVisualElements = useMemo(
|
||||||
|
() => visualElements.filter((item) => matchesCurrentRuleDependency(currentRule, [
|
||||||
|
item.id,
|
||||||
|
item.name,
|
||||||
|
`visual.${item.id}`,
|
||||||
|
`visual.${item.name || item.id}`,
|
||||||
|
item.type,
|
||||||
|
])),
|
||||||
|
[currentRule, visualElements],
|
||||||
|
);
|
||||||
const dialogDependencyOptions = useMemo(() => {
|
const dialogDependencyOptions = useMemo(() => {
|
||||||
const selectedValues = new Set(ruleDraft.dependencies);
|
const selectedValues = new Set(ruleDraft.dependencies);
|
||||||
return uniqueDependencyOptions([
|
return uniqueDependencyOptions([
|
||||||
@@ -510,7 +615,17 @@ export default function RulesTestDetail() {
|
|||||||
const hasErrors = validationIssues.some(issue => issue.severity === 'error');
|
const hasErrors = validationIssues.some(issue => issue.severity === 'error');
|
||||||
const configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]);
|
const configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]);
|
||||||
const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error');
|
const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error');
|
||||||
const fullYamlText = useMemo(() => buildYamlPreview(editableConfig), [editableConfig]);
|
const serializedYamlResult = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return { yamlText: serializeEditableRuleConfig(editableConfig), error: '' };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
yamlText: buildYamlPreview(editableConfig),
|
||||||
|
error: error instanceof Error ? error.message : '规则 YAML 生成失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [editableConfig]);
|
||||||
|
const fullYamlText = serializedYamlResult.yamlText;
|
||||||
const isSmartRuleDraft = ruleDraft.type === 'ai_rule' || ruleDraft.checkTypes.includes('ai');
|
const isSmartRuleDraft = ruleDraft.type === 'ai_rule' || ruleDraft.checkTypes.includes('ai');
|
||||||
const isRuleGroupDraft = ruleDraft.type === 'rule_group';
|
const isRuleGroupDraft = ruleDraft.type === 'rule_group';
|
||||||
const rulesById = useMemo(() => new Map(rules.map(rule => [rule.ruleId || rule.id, rule])), [rules]);
|
const rulesById = useMemo(() => new Map(rules.map(rule => [rule.ruleId || rule.id, rule])), [rules]);
|
||||||
@@ -519,10 +634,14 @@ export default function RulesTestDetail() {
|
|||||||
() => versions.find((item) => !['published', 'rollback'].includes(item.status)),
|
() => versions.find((item) => !['published', 'rollback'].includes(item.status)),
|
||||||
[versions],
|
[versions],
|
||||||
);
|
);
|
||||||
const rollbackTargetVersion = useMemo(
|
const rollbackOptions = useMemo(
|
||||||
() => versions.find((item) => ['published', 'rollback'].includes(item.status) && item.id !== pack.currentVersionId),
|
() => versions.filter((item) => item.id !== pack.currentVersionId),
|
||||||
[versions, pack.currentVersionId],
|
[versions, pack.currentVersionId],
|
||||||
);
|
);
|
||||||
|
const rollbackTargetVersion = useMemo(
|
||||||
|
() => rollbackOptions.find((item) => String(item.id) === selectedRollbackVersionId) || rollbackOptions[0] || null,
|
||||||
|
[rollbackOptions, selectedRollbackVersionId],
|
||||||
|
);
|
||||||
const packFilterMainType = pack.businessType || pack.mainType;
|
const packFilterMainType = pack.businessType || pack.mainType;
|
||||||
const currentResolvedVersion = useMemo(
|
const currentResolvedVersion = useMemo(
|
||||||
() => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null,
|
() => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null,
|
||||||
@@ -537,6 +656,10 @@ export default function RulesTestDetail() {
|
|||||||
return status || '-';
|
return status || '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRollbackVersionId('');
|
||||||
|
}, [pack.id, pack.currentVersionId, versions.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!saveFetcher.data) return;
|
if (!saveFetcher.data) return;
|
||||||
if (saveFetcher.data.success) {
|
if (saveFetcher.data.success) {
|
||||||
@@ -643,6 +766,137 @@ export default function RulesTestDetail() {
|
|||||||
setEditor(null);
|
setEditor(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFieldEditor = (field?: ExtractFieldSummary) => {
|
||||||
|
setFieldDraft(field ? { ...field } : emptyFieldDraft(fields[0]?.group || '基础信息'));
|
||||||
|
setEditor({ kind: 'field', mode: field ? 'edit' : 'create', id: field?.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveField = () => {
|
||||||
|
if (!editor || editor.kind !== 'field') return;
|
||||||
|
const normalizedField: ExtractFieldSummary = {
|
||||||
|
...fieldDraft,
|
||||||
|
id: fieldDraft.id || makeId('field'),
|
||||||
|
group: fieldDraft.group || '未分组',
|
||||||
|
requiredFrom: fieldDraft.requiredFrom || 'draft',
|
||||||
|
type: fieldDraft.type || 'verbatim',
|
||||||
|
description: fieldDraft.description || '',
|
||||||
|
};
|
||||||
|
setFields((current) => editor.mode === 'edit'
|
||||||
|
? current.map((field) => (field.id === editor.id ? normalizedField : field))
|
||||||
|
: [...current, normalizedField]);
|
||||||
|
setDraftSaved(false);
|
||||||
|
setSaveMessage('');
|
||||||
|
setSaveError('');
|
||||||
|
setEditor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (fieldId: string) => {
|
||||||
|
setFields((current) => current.filter((field) => field.id !== fieldId));
|
||||||
|
setDraftSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDocumentEditor = (document?: SubDocumentSummary) => {
|
||||||
|
setDocumentDraft(document ? { ...document, fields: [...document.fields] } : emptyDocumentDraft());
|
||||||
|
setEditor({ kind: 'document', mode: document ? 'edit' : 'create', id: document?.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDocument = () => {
|
||||||
|
if (!editor || editor.kind !== 'document') return;
|
||||||
|
const normalizedDocument: SubDocumentSummary = {
|
||||||
|
...documentDraft,
|
||||||
|
id: documentDraft.id || makeId('document'),
|
||||||
|
name: documentDraft.name || documentDraft.id,
|
||||||
|
required: documentDraft.required || 'false',
|
||||||
|
fieldCount: (documentDraft.fields || []).length,
|
||||||
|
groups: Array.from(new Set((documentDraft.fields || []).map((field) => field.group || '未分组'))),
|
||||||
|
description: documentDraft.description || '',
|
||||||
|
fields: (documentDraft.fields || []).map((field) => ({
|
||||||
|
...field,
|
||||||
|
id: field.id || makeId('document-field'),
|
||||||
|
group: field.group || '未分组',
|
||||||
|
requiredFrom: field.requiredFrom || '-',
|
||||||
|
type: field.type || 'verbatim',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
setSubDocuments((current) => editor.mode === 'edit'
|
||||||
|
? current.map((document) => (document.id === editor.id ? normalizedDocument : document))
|
||||||
|
: [...current, normalizedDocument]);
|
||||||
|
setDraftSaved(false);
|
||||||
|
setSaveMessage('');
|
||||||
|
setSaveError('');
|
||||||
|
setEditor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDocument = (documentId: string) => {
|
||||||
|
setSubDocuments((current) => current.filter((document) => document.id !== documentId));
|
||||||
|
setDraftSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDocumentField = (fieldId: string, patch: Partial<ExtractFieldSummary>) => {
|
||||||
|
setDocumentDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
fields: (current.fields || []).map((field) => (
|
||||||
|
field.id === fieldId ? { ...field, ...patch } : field
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDocumentField = () => {
|
||||||
|
setDocumentDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
fields: [
|
||||||
|
...(current.fields || []),
|
||||||
|
{
|
||||||
|
id: `${current.id || 'document'}-field-${Date.now()}`,
|
||||||
|
group: current.groups[0] || '未分组',
|
||||||
|
name: '',
|
||||||
|
type: 'verbatim',
|
||||||
|
multipleEntities: false,
|
||||||
|
requiredFrom: '-',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDocumentField = (fieldId: string) => {
|
||||||
|
setDocumentDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
fields: (current.fields || []).filter((field) => field.id !== fieldId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVisualEditor = (item?: VisualElementSummary) => {
|
||||||
|
setVisualDraft(item ? { ...item, signerRoles: [...(item.signerRoles || [])], signatureTypes: [...(item.signatureTypes || [])] } : emptyVisualDraft(visualElements[0]?.type || '签章'));
|
||||||
|
setEditor({ kind: 'visual', mode: item ? 'edit' : 'create', id: item?.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveVisual = () => {
|
||||||
|
if (!editor || editor.kind !== 'visual') return;
|
||||||
|
const normalizedVisual: VisualElementSummary = {
|
||||||
|
...visualDraft,
|
||||||
|
id: visualDraft.id || makeId('visual'),
|
||||||
|
name: visualDraft.name || visualDraft.id,
|
||||||
|
type: visualDraft.type || '签章',
|
||||||
|
required: visualDraft.required || 'true',
|
||||||
|
signerRoles: (visualDraft.signerRoles || []).filter(Boolean),
|
||||||
|
signatureTypes: (visualDraft.signatureTypes || []).filter(Boolean),
|
||||||
|
privateSealRestricted: Boolean(visualDraft.privateSealRestricted),
|
||||||
|
};
|
||||||
|
setVisualElements((current) => editor.mode === 'edit'
|
||||||
|
? current.map((item) => (item.id === editor.id ? normalizedVisual : item))
|
||||||
|
: [...current, normalizedVisual]);
|
||||||
|
setDraftSaved(false);
|
||||||
|
setSaveMessage('');
|
||||||
|
setSaveError('');
|
||||||
|
setEditor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVisual = (visualId: string) => {
|
||||||
|
setVisualElements((current) => current.filter((item) => item.id !== visualId));
|
||||||
|
setDraftSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
const saveDraftToServer = () => {
|
const saveDraftToServer = () => {
|
||||||
if (hasConfigErrors) {
|
if (hasConfigErrors) {
|
||||||
setShowValidation(true);
|
setShowValidation(true);
|
||||||
@@ -650,10 +904,15 @@ export default function RulesTestDetail() {
|
|||||||
setSaveMessage('');
|
setSaveMessage('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (serializedYamlResult.error) {
|
||||||
|
setSaveError(serializedYamlResult.error);
|
||||||
|
setSaveMessage('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('ruleType', pack.metadata.typeId || '');
|
formData.append('ruleType', pack.metadata.typeId || '');
|
||||||
formData.append('yamlText', fullYamlText);
|
formData.append('yamlText', prepareDraftYamlForSave(fullYamlText));
|
||||||
formData.append('changeNote', `保存 ${pack.documentType}/${pack.mainType}/${pack.subtype} 规则配置`);
|
formData.append('changeNote', `保存 ${pack.documentType}/${pack.mainType}/${pack.subtype} 规则配置`);
|
||||||
formData.append('intent', 'save');
|
formData.append('intent', 'save');
|
||||||
saveFetcher.submit(formData, { method: 'post' });
|
saveFetcher.submit(formData, { method: 'post' });
|
||||||
@@ -689,6 +948,9 @@ export default function RulesTestDetail() {
|
|||||||
|
|
||||||
const resetDraft = () => {
|
const resetDraft = () => {
|
||||||
setRules(pack.rules);
|
setRules(pack.rules);
|
||||||
|
setFields(pack.fields);
|
||||||
|
setSubDocuments(pack.subDocuments);
|
||||||
|
setVisualElements(pack.visualElements);
|
||||||
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
||||||
setDependencyDialogOpen(false);
|
setDependencyDialogOpen(false);
|
||||||
setDependencySearch('');
|
setDependencySearch('');
|
||||||
@@ -761,6 +1023,20 @@ export default function RulesTestDetail() {
|
|||||||
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !latestDraftVersion} onClick={publishDraftVersion}>
|
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !latestDraftVersion} onClick={publishDraftVersion}>
|
||||||
<i className="ri-upload-cloud-line mr-1.5"></i>发布版本
|
<i className="ri-upload-cloud-line mr-1.5"></i>发布版本
|
||||||
</button>
|
</button>
|
||||||
|
<select
|
||||||
|
className="rules-version-select"
|
||||||
|
value={rollbackTargetVersion ? String(rollbackTargetVersion.id) : selectedRollbackVersionId}
|
||||||
|
onChange={(event) => setSelectedRollbackVersionId(event.target.value)}
|
||||||
|
disabled={saveButtonBusy || rollbackOptions.length === 0}
|
||||||
|
>
|
||||||
|
{rollbackOptions.length === 0 ? (
|
||||||
|
<option value="">暂无可回滚版本</option>
|
||||||
|
) : rollbackOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.versionNo} · {versionStatusLabel(item.status)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !rollbackTargetVersion} onClick={rollbackRuleVersion}>
|
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !rollbackTargetVersion} onClick={rollbackRuleVersion}>
|
||||||
<i className="ri-history-line mr-1.5"></i>回滚版本
|
<i className="ri-history-line mr-1.5"></i>回滚版本
|
||||||
</button>
|
</button>
|
||||||
@@ -859,13 +1135,97 @@ export default function RulesTestDetail() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{(pack.fields.length > 0 || pack.subDocuments.length > 0 || pack.visualElements.length > 0) && (
|
{(fields.length > 0 || subDocuments.length > 0 || visualElements.length > 0) && (
|
||||||
<>
|
<>
|
||||||
{pack.fields.length > 0 && <span id="fields" className="section-anchor" aria-hidden="true"></span>}
|
{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>}
|
{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>}
|
{visualElements.length > 0 && <span id="visual-elements" className="section-anchor" aria-hidden="true"></span>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Card className="ant-card" title="抽取字段">
|
||||||
|
<div className="config-section-tools">
|
||||||
|
<span className="config-section-tip">这里只显示当前评查点实际引用到的抽取字段。</span>
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => openFieldEditor()}>
|
||||||
|
<i className="ri-add-line mr-1.5"></i>新增字段
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{currentRuleFields.length > 0 ? (
|
||||||
|
<div className="config-item-list">
|
||||||
|
{currentRuleFields.map((field) => (
|
||||||
|
<div key={field.id} className="config-item-card">
|
||||||
|
<div className="config-item-main">
|
||||||
|
<strong>{field.name}</strong>
|
||||||
|
<span>{field.group || '未分组'} / {fieldTypeLabel(field.type)}</span>
|
||||||
|
<span>{requiredFromLabel(field.requiredFrom || '-')}{field.description ? ` · ${field.description}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-item-actions">
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => openFieldEditor(field)}>编辑</button>
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => removeField(field.id)}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">当前评查点没有引用抽取字段;新增后需在该评查点“依赖字段”中引用,才会显示在这里。</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="ant-card" title="子文档 / 文书">
|
||||||
|
<div className="config-section-tools">
|
||||||
|
<span className="config-section-tip">这里只显示当前评查点实际引用到的子文档。</span>
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => openDocumentEditor()}>
|
||||||
|
<i className="ri-add-line mr-1.5"></i>新增子文档
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{currentRuleSubDocuments.length > 0 ? (
|
||||||
|
<div className="config-item-list">
|
||||||
|
{currentRuleSubDocuments.map((document) => (
|
||||||
|
<div key={document.id} className="config-item-card">
|
||||||
|
<div className="config-item-main">
|
||||||
|
<strong>{document.name}</strong>
|
||||||
|
<span>{document.id} / {requiredFromLabel(document.required || '-')}</span>
|
||||||
|
<span>{document.fields.length} 个字段{document.groups.length > 0 ? ` · ${document.groups.join('、')}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-item-actions">
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => openDocumentEditor(document)}>编辑</button>
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => removeDocument(document.id)}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">当前评查点没有引用任何子文档;在依赖字段中引用文书名称或 `文书.字段` 后,这里才会显示。</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="ant-card" title="视觉要素">
|
||||||
|
<div className="config-section-tools">
|
||||||
|
<span className="config-section-tip">这里只显示当前评查点实际引用到的视觉要素。</span>
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => openVisualEditor()}>
|
||||||
|
<i className="ri-add-line mr-1.5"></i>新增视觉要素
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{currentRuleVisualElements.length > 0 ? (
|
||||||
|
<div className="config-item-list">
|
||||||
|
{currentRuleVisualElements.map((item) => (
|
||||||
|
<div key={item.id} className="config-item-card">
|
||||||
|
<div className="config-item-main">
|
||||||
|
<strong>{item.name || item.id}</strong>
|
||||||
|
<span>{item.type} / {item.id}</span>
|
||||||
|
<span>{requiredFromLabel(String(item.required))}{item.signatureTypes && item.signatureTypes.length > 0 ? ` · ${item.signatureTypes.join('、')}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-item-actions">
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => openVisualEditor(item)}>编辑</button>
|
||||||
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => removeVisual(item.id)}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">当前评查点没有引用视觉要素;在依赖字段中引用印章、签名、骑缝章或 `visual.xxx` 后,这里才会显示。</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
className="ant-card"
|
className="ant-card"
|
||||||
title={`依赖字段 (${currentRule.dependencies.length}项)`}
|
title={`依赖字段 (${currentRule.dependencies.length}项)`}
|
||||||
@@ -979,155 +1339,295 @@ export default function RulesTestDetail() {
|
|||||||
<aside className="rules-drawer" aria-label="评查点编辑">
|
<aside className="rules-drawer" aria-label="评查点编辑">
|
||||||
<div className="rules-drawer-header">
|
<div className="rules-drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>{editor.mode === 'edit' ? '编辑评查点' : '新增评查点'}</h3>
|
<h3>
|
||||||
|
{editor.kind === 'rule' ? (editor.mode === 'edit' ? '编辑评查点' : '新增评查点') : ''}
|
||||||
|
{editor.kind === 'field' ? (editor.mode === 'edit' ? '编辑抽取字段' : '新增抽取字段') : ''}
|
||||||
|
{editor.kind === 'document' ? (editor.mode === 'edit' ? '编辑子文档' : '新增子文档') : ''}
|
||||||
|
{editor.kind === 'visual' ? (editor.mode === 'edit' ? '编辑视觉要素' : '新增视觉要素') : ''}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="drawer-close" onClick={() => setEditor(null)}><i className="ri-close-line"></i></button>
|
<button type="button" className="drawer-close" onClick={() => setEditor(null)}><i className="ri-close-line"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="drawer-form">
|
{editor.kind === 'rule' && (
|
||||||
<label>
|
<div className="drawer-form">
|
||||||
<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>
|
<label>
|
||||||
<span>风险等级</span>
|
<span>评查点名称</span>
|
||||||
<select value={ruleDraft.risk} onChange={event => setRuleDraft({ ...ruleDraft, risk: event.target.value })}>
|
<input value={ruleDraft.name} onChange={event => setRuleDraft({ ...ruleDraft, name: event.target.value })} placeholder="如:合同金额不得为空" />
|
||||||
<option value="high">高</option>
|
</label>
|
||||||
<option value="medium">中</option>
|
<label>
|
||||||
<option value="low">低</option>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editor.kind === 'field' && (
|
||||||
|
<div className="drawer-form">
|
||||||
|
<label>
|
||||||
|
<span>字段名称</span>
|
||||||
|
<input value={fieldDraft.name} onChange={(event) => setFieldDraft({ ...fieldDraft, name: event.target.value })} placeholder="如:合同金额" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>所属分组</span>
|
||||||
|
<input value={fieldDraft.group} onChange={(event) => setFieldDraft({ ...fieldDraft, group: event.target.value })} placeholder="如:基础信息" />
|
||||||
|
</label>
|
||||||
|
<div className="drawer-grid">
|
||||||
|
<label>
|
||||||
|
<span>字段类型</span>
|
||||||
|
<select value={fieldDraft.type} onChange={(event) => setFieldDraft({ ...fieldDraft, type: event.target.value })}>
|
||||||
|
{['verbatim', 'string', 'money', 'date', 'enum', 'number'].map((type) => <option key={type} value={type}>{fieldTypeLabel(type)}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>要求阶段</span>
|
||||||
|
<select value={fieldDraft.requiredFrom} onChange={(event) => setFieldDraft({ ...fieldDraft, requiredFrom: event.target.value })}>
|
||||||
|
{['draft', 'executed', '-'].map((phase) => <option key={phase} value={phase}>{requiredFromLabel(phase)}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>字段说明</span>
|
||||||
|
<textarea value={fieldDraft.description} onChange={(event) => setFieldDraft({ ...fieldDraft, description: event.target.value })} placeholder="描述字段如何抽取、给规则如何引用" />
|
||||||
|
</label>
|
||||||
|
<div className="drawer-actions">
|
||||||
|
<Button type="default" onClick={() => setEditor(null)}>取消</Button>
|
||||||
|
<Button type="primary" onClick={saveField}>保存字段</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editor.kind === 'document' && (
|
||||||
|
<div className="drawer-form">
|
||||||
|
<label>
|
||||||
|
<span>子文档编码</span>
|
||||||
|
<input value={documentDraft.id} onChange={(event) => setDocumentDraft({ ...documentDraft, id: event.target.value })} placeholder="如:处罚决定书" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>子文档名称</span>
|
||||||
|
<input value={documentDraft.name} onChange={(event) => setDocumentDraft({ ...documentDraft, name: event.target.value })} placeholder="如:处罚决定书" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>必需</span>
|
||||||
|
<select value={documentDraft.required} onChange={(event) => setDocumentDraft({ ...documentDraft, required: event.target.value })}>
|
||||||
|
{['true', 'false', 'conditional'].map((value) => <option key={value} value={value}>{requiredFromLabel(value)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>分值</span>
|
<span>内部字段</span>
|
||||||
<input value={ruleDraft.score} onChange={event => setRuleDraft({ ...ruleDraft, score: event.target.value })} />
|
<div className="document-draft-fields">
|
||||||
</label>
|
{(documentDraft.fields || []).map((field) => (
|
||||||
</div>
|
<div key={field.id} className="document-draft-field-row">
|
||||||
<label>
|
<input
|
||||||
<span>检查方式</span>
|
value={field.group || ''}
|
||||||
<select
|
onChange={(event) => updateDocumentField(field.id, { group: event.target.value })}
|
||||||
value={ruleDraft.type}
|
placeholder="分组"
|
||||||
onChange={event => {
|
/>
|
||||||
const nextType = event.target.value;
|
<input
|
||||||
setRuleDraft({
|
value={field.name || ''}
|
||||||
...ruleDraft,
|
onChange={(event) => updateDocumentField(field.id, { name: event.target.value })}
|
||||||
type: nextType,
|
placeholder="字段名称"
|
||||||
checkTypes: nextType === 'ai_rule'
|
/>
|
||||||
? uniqueOptions([...ruleDraft.checkTypes, 'ai'])
|
<select
|
||||||
: ruleDraft.checkTypes.filter(type => type !== 'ai')
|
value={field.type || 'verbatim'}
|
||||||
});
|
onChange={(event) => updateDocumentField(field.id, { type: event.target.value })}
|
||||||
}}
|
>
|
||||||
>
|
{['verbatim', 'string', 'money', 'date', 'enum', 'number'].map((type) => (
|
||||||
{uniqueOptions([ruleDraft.type, ...ruleTypeOptions]).map(type => (
|
<option key={type} value={type}>{fieldTypeLabel(type)}</option>
|
||||||
<option key={type} value={type}>{ruleTypeLabel(type)}</option>
|
))}
|
||||||
))}
|
</select>
|
||||||
</select>
|
<input
|
||||||
</label>
|
value={field.description || ''}
|
||||||
{isSmartRuleDraft && (
|
onChange={(event) => updateDocumentField(field.id, { description: event.target.value })}
|
||||||
<label>
|
placeholder="字段说明"
|
||||||
<span>提示词</span>
|
/>
|
||||||
<textarea
|
<button type="button" className="ant-btn ant-btn-default" onClick={() => removeDocumentField(field.id)}>删除</button>
|
||||||
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>
|
</div>
|
||||||
)) : ruleDraft.subRuleIds.length > 0 ? ruleDraft.subRuleIds.map(ruleId => {
|
))}
|
||||||
const referencedRule = rulesById.get(ruleId);
|
<button type="button" className="ant-btn ant-btn-default" onClick={addDocumentField}>
|
||||||
return (
|
<i className="ri-add-line mr-1.5"></i>新增内部字段
|
||||||
<div key={ruleId} className="subrule-item">
|
</button>
|
||||||
<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>
|
||||||
|
</label>
|
||||||
|
<div className="drawer-actions">
|
||||||
|
<Button type="default" onClick={() => setEditor(null)}>取消</Button>
|
||||||
|
<Button type="primary" onClick={saveDocument}>保存子文档</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editor.kind === 'visual' && (
|
||||||
|
<div className="drawer-form">
|
||||||
|
<label>
|
||||||
|
<span>视觉要素编码</span>
|
||||||
|
<input value={visualDraft.id} onChange={(event) => setVisualDraft({ ...visualDraft, id: event.target.value })} placeholder="如:骑缝章" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>视觉要素名称</span>
|
||||||
|
<input value={visualDraft.name} onChange={(event) => setVisualDraft({ ...visualDraft, name: event.target.value })} placeholder="如:合同骑缝章" />
|
||||||
|
</label>
|
||||||
|
<div className="drawer-grid">
|
||||||
<label>
|
<label>
|
||||||
<span>逻辑运算式</span>
|
<span>要素类型</span>
|
||||||
<input
|
<select value={visualDraft.type} onChange={(event) => setVisualDraft({ ...visualDraft, type: event.target.value })}>
|
||||||
value={ruleDraft.logic}
|
{['签章', '签名', '骑缝章'].map((type) => <option key={type} value={type}>{type}</option>)}
|
||||||
onChange={event => setRuleDraft({ ...ruleDraft, logic: event.target.value })}
|
</select>
|
||||||
placeholder="如:1 AND 2,或 JK-002 AND JK-005"
|
</label>
|
||||||
/>
|
<label>
|
||||||
|
<span>是否必需</span>
|
||||||
|
<select value={visualDraft.required} onChange={(event) => setVisualDraft({ ...visualDraft, required: event.target.value })}>
|
||||||
|
{['true', 'false'].map((value) => <option key={value} value={value}>{requiredFromLabel(value)}</option>)}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<label>
|
||||||
<label>
|
<span>签章类型(逗号分隔)</span>
|
||||||
<span>依赖字段</span>
|
<input
|
||||||
<div className="selected-dependencies">
|
value={(visualDraft.signatureTypes || []).join(',')}
|
||||||
{selectedDependencyOptions.length === 0 ? (
|
onChange={(event) => setVisualDraft({ ...visualDraft, signatureTypes: event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean) })}
|
||||||
<div className="drawer-empty">暂未添加依赖字段。</div>
|
placeholder="如:合同专用章,公章"
|
||||||
) : selectedDependencyOptions.map(option => (
|
/>
|
||||||
<span
|
</label>
|
||||||
key={option.value}
|
<div className="drawer-actions">
|
||||||
className="dependency-variable-button"
|
<Button type="default" onClick={() => setEditor(null)}>取消</Button>
|
||||||
>
|
<Button type="primary" onClick={saveVisual}>保存视觉要素</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>
|
</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>
|
||||||
</div>
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
{dependencyDialogOpen && (
|
{dependencyDialogOpen && (
|
||||||
<div className="dependency-dialog-shell" role="dialog" aria-modal="true" aria-label="追加依赖字段">
|
<div className="dependency-dialog-shell" role="dialog" aria-modal="true" aria-label="追加依赖字段">
|
||||||
|
|||||||
@@ -84,10 +84,6 @@ function riskColor(risk: string): TagColor {
|
|||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const { getUserSession } = await import("~/api/login/auth.server");
|
|
||||||
const { frontendJWT, userInfo } = await getUserSession(request);
|
|
||||||
const { requireRoutePermission } = await import("~/api/auth/check-route-permission.server");
|
|
||||||
await requireRoutePermission("/rulesTest/list", userInfo?.role || "", frontendJWT || undefined);
|
|
||||||
const requestedMainType = url.searchParams.get('mainType') || url.searchParams.get('ruleTypeName') || '';
|
const requestedMainType = url.searchParams.get('mainType') || url.searchParams.get('ruleTypeName') || '';
|
||||||
const requestedSubtype = url.searchParams.get('subtype') || url.searchParams.get('documentAttributeType') || '';
|
const requestedSubtype = url.searchParams.get('subtype') || url.searchParams.get('documentAttributeType') || '';
|
||||||
const requestedRuleGroup = url.searchParams.get('ruleGroup') || url.searchParams.getAll('ruleGroups')[0] || '';
|
const requestedRuleGroup = url.searchParams.get('ruleGroup') || url.searchParams.getAll('ruleGroups')[0] || '';
|
||||||
|
|||||||
@@ -433,6 +433,89 @@
|
|||||||
color: #34483e;
|
color: #34483e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-section-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-section-tip {
|
||||||
|
color: #61756b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-item-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #dbe7e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfdfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-item-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-item-main strong {
|
||||||
|
color: #173d2f;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-item-main span {
|
||||||
|
color: #63766d;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .config-item-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .rules-version-select {
|
||||||
|
min-width: 168px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #d5ded9;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #20352c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .document-draft-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .document-draft-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1.2fr 110px 1.4fr 72px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-test-page .document-draft-field-row input,
|
||||||
|
.rules-test-page .document-draft-field-row select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.rules-test-page .document-fields-card {
|
.rules-test-page .document-fields-card {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import YAML from 'yaml';
|
||||||
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
|
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
|
||||||
|
|
||||||
|
export type VisualElementSummary = RuleYamlPack['visualElements'][number];
|
||||||
|
|
||||||
export type DependencyOption = {
|
export type DependencyOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,12 +20,13 @@ export type ValidationIssue = {
|
|||||||
|
|
||||||
export type EditableRuleConfig = {
|
export type EditableRuleConfig = {
|
||||||
metadata: RuleYamlPack['metadata'];
|
metadata: RuleYamlPack['metadata'];
|
||||||
|
yamlSource: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
mainType: string;
|
mainType: string;
|
||||||
subtype: string;
|
subtype: string;
|
||||||
fields: ExtractFieldSummary[];
|
fields: ExtractFieldSummary[];
|
||||||
subDocuments: SubDocumentSummary[];
|
subDocuments: SubDocumentSummary[];
|
||||||
visualElements: RuleYamlPack['visualElements'];
|
visualElements: VisualElementSummary[];
|
||||||
rules: RuleSummary[];
|
rules: RuleSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,6 +291,234 @@ function yamlValue(value: string | number | boolean | undefined): string {
|
|||||||
return text ? `'${text}'` : "''";
|
return text ? `'${text}'` : "''";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deepClone<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBy<T>(items: T[], keyGetter: (item: T) => string): Map<string, T[]> {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const key = keyGetter(item);
|
||||||
|
const list = groups.get(key) || [];
|
||||||
|
list.push(item);
|
||||||
|
groups.set(key, list);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBooleanText(value: string | boolean | undefined): boolean {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return String(value || '').trim().toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteExtractNodes(fields: ExtractFieldSummary[]): Array<Record<string, unknown>> {
|
||||||
|
const topLevelFields = fields.filter((field) => !field.name.includes('[*].'));
|
||||||
|
return Array.from(groupBy(topLevelFields, (field) => field.group || '未分组').entries()).map(([group, items]) => ({
|
||||||
|
group,
|
||||||
|
fields: items.map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
type: field.multipleEntities ? 'multi_entity' : field.type || 'verbatim',
|
||||||
|
required_from: field.requiredFrom || 'draft',
|
||||||
|
desc: field.description || '',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSubDocumentNodes(subDocuments: SubDocumentSummary[]): Array<Record<string, unknown>> {
|
||||||
|
return subDocuments.map((document) => ({
|
||||||
|
id: document.id,
|
||||||
|
name: document.name,
|
||||||
|
required: document.required || 'false',
|
||||||
|
extract: Array.from(groupBy(document.fields || [], (field) => field.group || '未分组').entries()).map(([group, fields]) => ({
|
||||||
|
group,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
type: field.multipleEntities ? 'multi_entity' : field.type || 'verbatim',
|
||||||
|
desc: field.description || '',
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteVisualElementNodes(visualElements: VisualElementSummary[]): Record<string, unknown> {
|
||||||
|
const sections = {
|
||||||
|
seals: [] as Array<Record<string, unknown>>,
|
||||||
|
signatures: [] as Array<Record<string, unknown>>,
|
||||||
|
cross_page_seals: [] as Array<Record<string, unknown>>,
|
||||||
|
};
|
||||||
|
|
||||||
|
visualElements.forEach((item) => {
|
||||||
|
const node: Record<string, unknown> = {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
required: normalizeBooleanText(item.required),
|
||||||
|
};
|
||||||
|
if (item.signerRoles && item.signerRoles.length > 0) node.signer_roles = [...item.signerRoles];
|
||||||
|
if (item.signatureTypes && item.signatureTypes.length > 0) node.signature_types = [...item.signatureTypes];
|
||||||
|
if (item.privateSealRestricted) node.private_seal_restricted = true;
|
||||||
|
|
||||||
|
if (item.type === '签名') {
|
||||||
|
sections.signatures.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.type === '骑缝章') {
|
||||||
|
sections.cross_page_seals.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sections.seals.push(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleLookupKey(rule: Pick<RuleSummary, 'ruleId' | 'name' | 'id'>): string {
|
||||||
|
return rule.ruleId || rule.name || rule.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAiStage(stages: Array<Record<string, unknown>>): Record<string, unknown> | undefined {
|
||||||
|
return stages.find((stage) => String(stage?.check || stage?.type || '').trim() === 'ai');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMinimalRuleNode(rule: RuleSummary): Record<string, unknown> {
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
rule_id: rule.ruleId,
|
||||||
|
name: rule.name,
|
||||||
|
risk: rule.risk || 'medium',
|
||||||
|
score: rule.score || '1',
|
||||||
|
type: rule.type || 'deterministic',
|
||||||
|
desc: rule.description || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rule.appliesIn.length > 0) {
|
||||||
|
base.applies_in = [...rule.appliesIn];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.type === 'rule_group') {
|
||||||
|
base.logic = rule.logic || '';
|
||||||
|
base.rules = [...rule.subRuleIds];
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) {
|
||||||
|
base.stages = [{
|
||||||
|
id: '1',
|
||||||
|
check: 'ai',
|
||||||
|
prompt: rule.prompt || '请根据规则要求检查文档内容并输出结论。',
|
||||||
|
}];
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.dependencies.length > 0) {
|
||||||
|
base.stages = [{
|
||||||
|
id: '1',
|
||||||
|
check: rule.checkTypes[0] || 'required',
|
||||||
|
field: rule.dependencies[0],
|
||||||
|
}];
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`评查点【${rule.name || rule.ruleId || rule.id}】缺少可生成的正式 stages 结构,请先补充依赖字段或改为 AI/规则组合类型。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRuleNode(baseRule: Record<string, unknown> | undefined, rule: RuleSummary): Record<string, unknown> {
|
||||||
|
const nextRule = baseRule ? deepClone(baseRule) : buildMinimalRuleNode(rule);
|
||||||
|
|
||||||
|
nextRule.rule_id = rule.ruleId;
|
||||||
|
nextRule.name = rule.name;
|
||||||
|
nextRule.risk = rule.risk || 'medium';
|
||||||
|
nextRule.score = rule.score || '1';
|
||||||
|
nextRule.type = rule.type || 'deterministic';
|
||||||
|
nextRule.desc = rule.description || '';
|
||||||
|
|
||||||
|
if (rule.appliesIn.length > 0) nextRule.applies_in = [...rule.appliesIn];
|
||||||
|
else delete nextRule.applies_in;
|
||||||
|
|
||||||
|
delete nextRule.dependencies;
|
||||||
|
|
||||||
|
if (rule.type === 'rule_group') {
|
||||||
|
nextRule.logic = rule.logic || '';
|
||||||
|
nextRule.rules = [...rule.subRuleIds];
|
||||||
|
delete nextRule.stages;
|
||||||
|
return nextRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete nextRule.rules;
|
||||||
|
delete nextRule.logic;
|
||||||
|
|
||||||
|
const existingStages = Array.isArray(nextRule.stages) ? (nextRule.stages as Array<Record<string, unknown>>) : [];
|
||||||
|
const stages = existingStages.length > 0 ? deepClone(existingStages) : (buildMinimalRuleNode(rule).stages as Array<Record<string, unknown>>);
|
||||||
|
|
||||||
|
if (rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) {
|
||||||
|
const aiStage = findAiStage(stages);
|
||||||
|
if (aiStage) {
|
||||||
|
aiStage.check = 'ai';
|
||||||
|
aiStage.prompt = rule.prompt || aiStage.prompt || '请根据规则要求检查文档内容并输出结论。';
|
||||||
|
} else {
|
||||||
|
stages.unshift({
|
||||||
|
id: '1',
|
||||||
|
check: 'ai',
|
||||||
|
prompt: rule.prompt || '请根据规则要求检查文档内容并输出结论。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRule.stages = stages;
|
||||||
|
return nextRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeEditableRuleConfig(config: EditableRuleConfig): string {
|
||||||
|
const parsed = YAML.parse(config.yamlSource || '') as Record<string, unknown> | null;
|
||||||
|
const root = parsed && typeof parsed === 'object' ? deepClone(parsed) : {};
|
||||||
|
const metadata = (root.metadata && typeof root.metadata === 'object' ? deepClone(root.metadata) : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
metadata.type_id = config.metadata.typeId || metadata.type_id || 'pending.internal.document';
|
||||||
|
metadata.name = config.metadata.name || metadata.name || `${config.subtype}规则配置`;
|
||||||
|
metadata.version = config.metadata.version || metadata.version || 'v1';
|
||||||
|
metadata.last_updated = new Date().toISOString().slice(0, 10);
|
||||||
|
if (config.metadata.parent || metadata.parent) metadata.parent = config.metadata.parent || metadata.parent;
|
||||||
|
if (config.metadata.description || metadata.description) metadata.description = config.metadata.description || metadata.description;
|
||||||
|
if (Array.isArray(config.metadata.keywords) && config.metadata.keywords.length > 0) metadata.classification_keywords = [...config.metadata.keywords];
|
||||||
|
if (Array.isArray(config.metadata.inheritsFrom) && config.metadata.inheritsFrom.length > 0) metadata.inherits_from = [...config.metadata.inheritsFrom];
|
||||||
|
root.metadata = metadata;
|
||||||
|
root.extract = rewriteExtractNodes(config.fields);
|
||||||
|
root.sub_documents = rewriteSubDocumentNodes(config.subDocuments);
|
||||||
|
root.visual_elements = rewriteVisualElementNodes(config.visualElements);
|
||||||
|
|
||||||
|
const existingGroups = Array.isArray(root.rules) ? (root.rules as Array<Record<string, unknown>>) : [];
|
||||||
|
const existingRuleMap = new Map<string, Record<string, unknown>>();
|
||||||
|
existingGroups.forEach((groupBlock) => {
|
||||||
|
const groupRules = Array.isArray(groupBlock?.rules) ? (groupBlock.rules as Array<Record<string, unknown>>) : [];
|
||||||
|
groupRules.forEach((ruleNode) => {
|
||||||
|
const key = String(ruleNode?.rule_id || ruleNode?.name || '').trim();
|
||||||
|
if (key) existingRuleMap.set(key, ruleNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextGroups = new Map<string, Array<Record<string, unknown>>>();
|
||||||
|
config.rules.forEach((rule) => {
|
||||||
|
const groupName = rule.group || '未分组';
|
||||||
|
const list = nextGroups.get(groupName) || [];
|
||||||
|
const existingRule = existingRuleMap.get(getRuleLookupKey(rule));
|
||||||
|
list.push(rewriteRuleNode(existingRule, rule));
|
||||||
|
nextGroups.set(groupName, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.rules = Array.from(nextGroups.entries()).map(([group, rules]) => ({ group, rules }));
|
||||||
|
return YAML.stringify(root).replace(
|
||||||
|
/^(\s*last_updated:\s*)(.+)$/m,
|
||||||
|
(_match, prefix, value) => `${prefix}'${String(value).replace(/^['"]|['"]$/g, '')}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareDraftYamlForSave(yamlText: string): string {
|
||||||
|
return yamlText
|
||||||
|
.replace(/^(\s*version:\s*)(.+)$/m, `${'$1'}''`)
|
||||||
|
.replace(
|
||||||
|
/^(\s*last_updated:\s*)(.+)$/m,
|
||||||
|
(_match, prefix, value) => `${prefix}'${String(value).replace(/^['"]|['"]$/g, '')}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSummary): string {
|
export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSummary): string {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`# ${config.documentType} / ${config.mainType} / ${config.subtype}`,
|
`# ${config.documentType} / ${config.mainType} / ${config.subtype}`,
|
||||||
@@ -331,6 +563,11 @@ export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSumma
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildYamlPreview(config: EditableRuleConfig): string {
|
export function buildYamlPreview(config: EditableRuleConfig): string {
|
||||||
|
try {
|
||||||
|
return serializeEditableRuleConfig(config);
|
||||||
|
} catch {
|
||||||
|
// 预览失败时仍回退旧实现,避免页面直接白屏;保存时会使用正式序列化并给出错误。
|
||||||
|
}
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'metadata:',
|
'metadata:',
|
||||||
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
|
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
|
||||||
|
|||||||
@@ -54,13 +54,9 @@ function getMessage(payload: unknown, fallback: string): string {
|
|||||||
|
|
||||||
function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack {
|
function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack {
|
||||||
const ruleTypeCode = String(item.ruleType || '').trim();
|
const ruleTypeCode = String(item.ruleType || '').trim();
|
||||||
const businessType = (() => {
|
// 业务类型必须以后端 pack 聚合返回的 mainType 为准。
|
||||||
const segments = ruleTypeCode.split('.').map(segment => segment.trim()).filter(Boolean);
|
// 不能再从 ruleType 第二段硬拆;例如 contract.entrust 会被错误显示成 entrust。
|
||||||
if (segments.length >= 2) {
|
const businessType = item.mainType || item.documentType || '';
|
||||||
return segments[1];
|
|
||||||
}
|
|
||||||
return item.mainType || item.documentType || '';
|
|
||||||
})();
|
|
||||||
const yamlSource = (item.yamlText || '').trim() ? String(item.yamlText) : EMPTY_RULE_YAML;
|
const yamlSource = (item.yamlText || '').trim() ? String(item.yamlText) : EMPTY_RULE_YAML;
|
||||||
const sourceStatus = item.sourceStatus || ((item.yamlText || '').trim() ? 'ready' : 'empty');
|
const sourceStatus = item.sourceStatus || ((item.yamlText || '').trim() ? 'ready' : 'empty');
|
||||||
|
|
||||||
@@ -74,6 +70,9 @@ function mapApiPackToRuleYamlPack(item: RuleConfigPackApi): RuleYamlPack {
|
|||||||
subtype: item.subtype || '通用',
|
subtype: item.subtype || '通用',
|
||||||
businessType,
|
businessType,
|
||||||
ruleTypeCode,
|
ruleTypeCode,
|
||||||
|
currentVersionId: item.currentVersionId ?? null,
|
||||||
|
fallbackVersionId: item.fallbackVersionId ?? null,
|
||||||
|
resolvedVersionId: item.resolvedVersionId ?? null,
|
||||||
},
|
},
|
||||||
yamlSource,
|
yamlSource,
|
||||||
sourceStatus,
|
sourceStatus,
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export type RuleYamlPack = RulePackScope & {
|
|||||||
yamlPath: string | null;
|
yamlPath: string | null;
|
||||||
yamlSource: string;
|
yamlSource: string;
|
||||||
sourceStatus: 'ready' | 'empty' | 'missing';
|
sourceStatus: 'ready' | 'empty' | 'missing';
|
||||||
|
currentVersionId?: number | null;
|
||||||
|
fallbackVersionId?: number | null;
|
||||||
|
resolvedVersionId?: number | null;
|
||||||
metadata: {
|
metadata: {
|
||||||
typeId: string;
|
typeId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -195,10 +198,10 @@ function splitBlocks(section: string, marker: RegExp): string[] {
|
|||||||
|
|
||||||
function parseRules(source: string): RuleSummary[] {
|
function parseRules(source: string): RuleSummary[] {
|
||||||
const section = getTopLevelSection(source, 'rules');
|
const section = getTopLevelSection(source, 'rules');
|
||||||
const groups = splitBlocks(section, /^-\s+group:\s*/);
|
const groups = splitBlocks(section, /^\s*-\s+group:\s*/);
|
||||||
const readExplicitDependencies = (block: string): string[] => {
|
const readExplicitDependencies = (block: string): string[] => {
|
||||||
const lines = block.split('\n');
|
const lines = block.split('\n');
|
||||||
const start = lines.findIndex(line => /^\s{4}dependencies:\s*$/.test(line));
|
const start = lines.findIndex(line => /^\s+dependencies:\s*$/.test(line));
|
||||||
|
|
||||||
if (start === -1) {
|
if (start === -1) {
|
||||||
return [];
|
return [];
|
||||||
@@ -207,10 +210,10 @@ function parseRules(source: string): RuleSummary[] {
|
|||||||
const dependencies: string[] = [];
|
const dependencies: string[] = [];
|
||||||
for (let index = start + 1; index < lines.length; index += 1) {
|
for (let index = start + 1; index < lines.length; index += 1) {
|
||||||
const line = lines[index];
|
const line = lines[index];
|
||||||
if (/^\s{4}[a-zA-Z_][^:]*:\s*/.test(line)) {
|
if (/^\s+[a-zA-Z_][^:]*:\s*/.test(line) && !/^\s*-\s+/.test(line)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const match = line.match(/^\s{4}-\s+(.+)$/);
|
const match = line.match(/^\s*-\s+(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
dependencies.push(stripYamlValue(match[1]));
|
dependencies.push(stripYamlValue(match[1]));
|
||||||
}
|
}
|
||||||
@@ -254,20 +257,28 @@ function parseRules(source: string): RuleSummary[] {
|
|||||||
|
|
||||||
return prompts.filter(Boolean);
|
return prompts.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
const readPromptDependencies = (prompts: string[]): string[] => {
|
||||||
|
return Array.from(new Set(
|
||||||
|
prompts.flatMap((prompt) => Array.from(prompt.matchAll(/\{\{\s*([^{}]+?)\s*\}\}/g)).map((match) => stripYamlValue(match[1])))
|
||||||
|
));
|
||||||
|
};
|
||||||
const readList = (block: string, key: string, indent = 4): string[] => {
|
const readList = (block: string, key: string, indent = 4): string[] => {
|
||||||
const lines = block.split('\n');
|
const lines = block.split('\n');
|
||||||
const start = lines.findIndex(line => new RegExp(`^\\s{${indent}}${key}:\\s*$`).test(line));
|
const start = lines.findIndex(line => new RegExp(`^\\s+${key}:\\s*$`).test(line));
|
||||||
if (start === -1) {
|
if (start === -1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseIndent = lines[start].match(/^\s*/)?.[0].length || indent;
|
||||||
|
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
for (let index = start + 1; index < lines.length; index += 1) {
|
for (let index = start + 1; index < lines.length; index += 1) {
|
||||||
const line = lines[index];
|
const line = lines[index];
|
||||||
if (new RegExp(`^\\s{${indent}}[a-zA-Z_][^:]*:\\s*`).test(line)) {
|
const lineIndent = line.match(/^\s*/)?.[0].length || 0;
|
||||||
|
if (line.trim() && lineIndent <= baseIndent) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const match = line.match(new RegExp(`^\\s{${indent}}-\\s+(.+)$`));
|
const match = line.match(/^\s*-\s+(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
values.push(stripYamlValue(match[1]));
|
values.push(stripYamlValue(match[1]));
|
||||||
}
|
}
|
||||||
@@ -296,25 +307,28 @@ function parseRules(source: string): RuleSummary[] {
|
|||||||
};
|
};
|
||||||
const readStageList = (block: string, key: string): string[] => {
|
const readStageList = (block: string, key: string): string[] => {
|
||||||
const lines = block.split('\n');
|
const lines = block.split('\n');
|
||||||
const start = lines.findIndex(line => new RegExp(`^\\s{6}${key}:\\s*$`).test(line));
|
const start = lines.findIndex(line => new RegExp(`^\\s+${key}:\\s*$`).test(line));
|
||||||
if (start === -1) {
|
if (start === -1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseIndent = lines[start].match(/^\s*/)?.[0].length || 6;
|
||||||
|
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
for (let index = start + 1; index < lines.length; index += 1) {
|
for (let index = start + 1; index < lines.length; index += 1) {
|
||||||
const line = lines[index];
|
const line = lines[index];
|
||||||
if (/^\s{6}[a-zA-Z_][^:]*:\s*/.test(line)) {
|
const lineIndent = line.match(/^\s*/)?.[0].length || 0;
|
||||||
|
if (line.trim() && lineIndent <= baseIndent) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const match = line.match(/^\s{6}-\s+(.+)$/);
|
const match = line.match(/^\s*-\s+(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
values.push(stripYamlValue(match[1]));
|
values.push(stripYamlValue(match[1]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
};
|
};
|
||||||
const readStageScalar = (block: string, key: string): string => stripYamlValue(block.match(new RegExp(`^\\s{6}${key}:\\s*(.+)$`, 'm'))?.[1] || '');
|
const readStageScalar = (block: string, key: string): string => stripYamlValue(block.match(new RegExp(`^\\s+${key}:\\s*(.+)$`, 'm'))?.[1] || '');
|
||||||
const summarizeStage = (stageBlock: string): string => {
|
const summarizeStage = (stageBlock: string): string => {
|
||||||
const fields = readStageList(stageBlock, 'fields');
|
const fields = readStageList(stageBlock, 'fields');
|
||||||
const field = readStageScalar(stageBlock, 'field');
|
const field = readStageScalar(stageBlock, 'field');
|
||||||
@@ -334,7 +348,7 @@ function parseRules(source: string): RuleSummary[] {
|
|||||||
return stageBlock.split('\n').map(line => line.trim()).filter(Boolean).slice(1, 4).join(';') || '未配置内容';
|
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 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 id = stripYamlValue(stageBlock.match(/^\s*-\s+id:\s*(.+)$/m)?.[1] || '');
|
||||||
const check = readStageScalar(stageBlock, 'check') || readStageScalar(stageBlock, 'type') || '-';
|
const check = readStageScalar(stageBlock, 'check') || readStageScalar(stageBlock, 'type') || '-';
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -344,16 +358,17 @@ function parseRules(source: string): RuleSummary[] {
|
|||||||
}).filter(stage => stage.id);
|
}).filter(stage => stage.id);
|
||||||
|
|
||||||
return groups.flatMap(groupBlock => {
|
return groups.flatMap(groupBlock => {
|
||||||
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
const group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
||||||
return splitBlocks(groupBlock, /^\s{2}-\s+rule_id:\s*/).map(ruleBlock => {
|
return splitBlocks(groupBlock, /^\s*-\s+rule_id:\s*/).map(ruleBlock => {
|
||||||
const ruleId = stripYamlValue(ruleBlock.match(/^\s{2}-\s+rule_id:\s*(.+)$/m)?.[1] || '');
|
const ruleId = stripYamlValue(ruleBlock.match(/^\s*-\s+rule_id:\s*(.+)$/m)?.[1] || '');
|
||||||
const name = stripYamlValue(ruleBlock.match(/^\s{4}name:\s*(.+)$/m)?.[1] || '未命名规则');
|
const name = stripYamlValue(ruleBlock.match(/^\s+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 checkTypes = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s+(?: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))
|
const stageDependencies = Array.from(ruleBlock.matchAll(/^\s+(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id):\s*(.+)$/gm))
|
||||||
.map(match => normalizeDependency(match[1]));
|
.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 prompts = readPrompts(ruleBlock);
|
||||||
|
const promptDependencies = readPromptDependencies(prompts).map(normalizeDependency);
|
||||||
|
const dependencies = Array.from(new Set([...readExplicitDependencies(ruleBlock), ...stageDependencies, ...promptDependencies]));
|
||||||
|
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 subRules = readSubRules(ruleBlock);
|
const subRules = readSubRules(ruleBlock);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -361,19 +376,19 @@ function parseRules(source: string): RuleSummary[] {
|
|||||||
ruleId,
|
ruleId,
|
||||||
name,
|
name,
|
||||||
group,
|
group,
|
||||||
risk: stripYamlValue(ruleBlock.match(/^\s{4}risk:\s*(.+)$/m)?.[1] || 'medium'),
|
risk: stripYamlValue(ruleBlock.match(/^\s+risk:\s*(.+)$/m)?.[1] || 'medium'),
|
||||||
score: stripYamlValue(ruleBlock.match(/^\s{4}score:\s*(.+)$/m)?.[1] || '-'),
|
score: stripYamlValue(ruleBlock.match(/^\s+score:\s*(.+)$/m)?.[1] || '-'),
|
||||||
type: stripYamlValue(ruleBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || 'deterministic'),
|
type: stripYamlValue(ruleBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'deterministic'),
|
||||||
checkTypes,
|
checkTypes,
|
||||||
logic: stripYamlValue(ruleBlock.match(/^\s{4}logic:\s*(.+)$/m)?.[1] || ''),
|
logic: stripYamlValue(ruleBlock.match(/^\s+logic:\s*(.+)$/m)?.[1] || ''),
|
||||||
subRules,
|
subRules,
|
||||||
subRuleIds: readList(ruleBlock, 'rules'),
|
subRuleIds: readList(ruleBlock, 'rules'),
|
||||||
scope: scope.slice(0, 8),
|
scope: scope.slice(0, 8),
|
||||||
dependencies: dependencies.slice(0, 8),
|
dependencies,
|
||||||
stageCount: subRules.length,
|
stageCount: subRules.length,
|
||||||
appliesIn: readFlexibleList(ruleBlock, 'applies_in'),
|
appliesIn: readFlexibleList(ruleBlock, 'applies_in'),
|
||||||
prompt: prompts.join('\n\n'),
|
prompt: prompts.join('\n\n'),
|
||||||
description: stripYamlValue(ruleBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '')
|
description: stripYamlValue(ruleBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || '')
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -385,34 +400,34 @@ export function parseRuleSummariesFromYaml(source: string): RuleSummary[] {
|
|||||||
|
|
||||||
function parseTopLevelFields(source: string): ExtractFieldSummary[] {
|
function parseTopLevelFields(source: string): ExtractFieldSummary[] {
|
||||||
const section = getTopLevelSection(source, 'extract');
|
const section = getTopLevelSection(source, 'extract');
|
||||||
const extractedFields = splitBlocks(section, /^-\s+group:\s*/).flatMap(groupBlock => {
|
const extractedFields = splitBlocks(section, /^\s*-\s+group:\s*/).flatMap(groupBlock => {
|
||||||
const group = stripYamlValue(groupBlock.match(/^-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
const group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
||||||
return splitBlocks(groupBlock, /^\s{2}-\s+name:\s*/).flatMap(fieldBlock => {
|
return splitBlocks(groupBlock, /^\s*-\s+name:\s*/).flatMap(fieldBlock => {
|
||||||
const name = stripYamlValue(fieldBlock.match(/^\s{2}-\s+name:\s*(.+)$/m)?.[1] || '');
|
const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || '');
|
||||||
const rawType = stripYamlValue(fieldBlock.match(/^\s{4}type:\s*(.+)$/m)?.[1] || '-');
|
const rawType = stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || '-');
|
||||||
const parentField = {
|
const parentField = {
|
||||||
id: `${group}-${name}`,
|
id: `${group}-${name}`,
|
||||||
group,
|
group,
|
||||||
name,
|
name,
|
||||||
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
|
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
|
||||||
multipleEntities: rawType === 'multi_entity',
|
multipleEntities: rawType === 'multi_entity',
|
||||||
requiredFrom: stripYamlValue(fieldBlock.match(/^\s{4}required_from:\s*(.+)$/m)?.[1] || '-'),
|
requiredFrom: stripYamlValue(fieldBlock.match(/^\s+required_from:\s*(.+)$/m)?.[1] || '-'),
|
||||||
description: stripYamlValue(fieldBlock.match(/^\s{4}desc:\s*(.+)$/m)?.[1] || '')
|
description: stripYamlValue(fieldBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || '')
|
||||||
};
|
};
|
||||||
const childFields = Array.from(fieldBlock.matchAll(/^\s{4}-\s+name:\s*(.+)$/gm)).map(match => {
|
const childFields = Array.from(fieldBlock.matchAll(/^\s{2,}-\s+name:\s*(.+)$/gm)).map(match => {
|
||||||
const childName = stripYamlValue(match[1]);
|
const childName = stripYamlValue(match[1]);
|
||||||
const start = fieldBlock.indexOf(match[0]);
|
const start = fieldBlock.indexOf(match[0]);
|
||||||
const next = fieldBlock.slice(start + match[0].length).search(/^\s{4}-\s+name:\s*/m);
|
const next = fieldBlock.slice(start + match[0].length).search(/^\s{2,}-\s+name:\s*/m);
|
||||||
const childBlock = next === -1 ? fieldBlock.slice(start) : fieldBlock.slice(start, start + match[0].length + next);
|
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');
|
const childType = stripYamlValue(childBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'verbatim');
|
||||||
return {
|
return {
|
||||||
id: `${group}-${name}-${childName}`,
|
id: `${group}-${name}-${childName}`,
|
||||||
group,
|
group,
|
||||||
name: `${name}[*].${childName}`,
|
name: `${name}[*].${childName}`,
|
||||||
type: childType,
|
type: childType,
|
||||||
multipleEntities: false,
|
multipleEntities: false,
|
||||||
requiredFrom: stripYamlValue(childBlock.match(/^\s{6}required_from:\s*(.+)$/m)?.[1] || parentField.requiredFrom),
|
requiredFrom: stripYamlValue(childBlock.match(/^\s+required_from:\s*(.+)$/m)?.[1] || parentField.requiredFrom),
|
||||||
description: stripYamlValue(childBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || `${name}的子字段`)
|
description: stripYamlValue(childBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || `${name}的子字段`)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return [parentField, ...childFields];
|
return [parentField, ...childFields];
|
||||||
@@ -420,16 +435,16 @@ function parseTopLevelFields(source: string): ExtractFieldSummary[] {
|
|||||||
}).filter(field => field.name);
|
}).filter(field => field.name);
|
||||||
|
|
||||||
const derivedSection = getTopLevelSection(source, 'derived_fields');
|
const derivedSection = getTopLevelSection(source, 'derived_fields');
|
||||||
const derivedFields = splitBlocks(derivedSection, /^-\s+name:\s*/).map(fieldBlock => {
|
const derivedFields = splitBlocks(derivedSection, /^\s*-\s+name:\s*/).map(fieldBlock => {
|
||||||
const name = stripYamlValue(fieldBlock.match(/^-\s+name:\s*(.+)$/m)?.[1] || '');
|
const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || '');
|
||||||
return {
|
return {
|
||||||
id: `derived-${name}`,
|
id: `derived-${name}`,
|
||||||
group: '派生字段',
|
group: '派生字段',
|
||||||
name,
|
name,
|
||||||
type: stripYamlValue(fieldBlock.match(/^\s{2}type:\s*(.+)$/m)?.[1] || 'computed'),
|
type: stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'computed'),
|
||||||
multipleEntities: false,
|
multipleEntities: false,
|
||||||
requiredFrom: '-',
|
requiredFrom: '-',
|
||||||
description: stripYamlValue(fieldBlock.match(/^\s{2}compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
|
description: stripYamlValue(fieldBlock.match(/^\s+compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
|
||||||
};
|
};
|
||||||
}).filter(field => field.name);
|
}).filter(field => field.name);
|
||||||
|
|
||||||
@@ -437,11 +452,11 @@ function parseTopLevelFields(source: string): ExtractFieldSummary[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseDocumentFields(docBlock: string, documentId: string): ExtractFieldSummary[] {
|
function parseDocumentFields(docBlock: string, documentId: string): ExtractFieldSummary[] {
|
||||||
return splitBlocks(docBlock, /^\s{2}-\s+group:\s*/).flatMap(groupBlock => {
|
return splitBlocks(docBlock, /^\s*-\s+group:\s*/).flatMap(groupBlock => {
|
||||||
const group = stripYamlValue(groupBlock.match(/^\s{2}-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
const group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
||||||
return splitBlocks(groupBlock, /^\s{4}-\s+name:\s*/).map(fieldBlock => {
|
return splitBlocks(groupBlock, /^\s*-\s+name:\s*/).map(fieldBlock => {
|
||||||
const name = stripYamlValue(fieldBlock.match(/^\s{4}-\s+name:\s*(.+)$/m)?.[1] || '');
|
const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || '');
|
||||||
const rawType = stripYamlValue(fieldBlock.match(/^\s{6}type:\s*(.+)$/m)?.[1] || '-');
|
const rawType = stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || '-');
|
||||||
return {
|
return {
|
||||||
id: `${documentId}-${group}-${name}`,
|
id: `${documentId}-${group}-${name}`,
|
||||||
group,
|
group,
|
||||||
@@ -449,7 +464,7 @@ function parseDocumentFields(docBlock: string, documentId: string): ExtractField
|
|||||||
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
|
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
|
||||||
multipleEntities: rawType === 'multi_entity',
|
multipleEntities: rawType === 'multi_entity',
|
||||||
requiredFrom: '-',
|
requiredFrom: '-',
|
||||||
description: stripYamlValue(fieldBlock.match(/^\s{6}desc:\s*(.+)$/m)?.[1] || '')
|
description: stripYamlValue(fieldBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || '')
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}).filter(field => field.name);
|
}).filter(field => field.name);
|
||||||
@@ -457,9 +472,9 @@ function parseDocumentFields(docBlock: string, documentId: string): ExtractField
|
|||||||
|
|
||||||
function parseSubDocuments(source: string): SubDocumentSummary[] {
|
function parseSubDocuments(source: string): SubDocumentSummary[] {
|
||||||
const section = getTopLevelSection(source, 'sub_documents');
|
const section = getTopLevelSection(source, 'sub_documents');
|
||||||
return splitBlocks(section, /^-\s+id:\s*/).map(docBlock => {
|
return splitBlocks(section, /^\s*-\s+id:\s*/).map(docBlock => {
|
||||||
const id = stripYamlValue(docBlock.match(/^-\s+id:\s*(.+)$/m)?.[1] || '');
|
const id = stripYamlValue(docBlock.match(/^\s*-\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 groups = Array.from(new Set(Array.from(docBlock.matchAll(/^\s*-\s+group:\s*(.+)$/gm)).map(match => stripYamlValue(match[1]))));
|
||||||
const fields = parseDocumentFields(docBlock, id);
|
const fields = parseDocumentFields(docBlock, id);
|
||||||
const classifier = docBlock.match(/^\s{2}classifier:\s*$/m);
|
const classifier = docBlock.match(/^\s{2}classifier:\s*$/m);
|
||||||
let description = '';
|
let description = '';
|
||||||
@@ -472,8 +487,8 @@ function parseSubDocuments(source: string): SubDocumentSummary[] {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: stripYamlValue(docBlock.match(/^\s{2}name:\s*(.+)$/m)?.[1] || id),
|
name: stripYamlValue(docBlock.match(/^\s+name:\s*(.+)$/m)?.[1] || id),
|
||||||
required: stripYamlValue(docBlock.match(/^\s{2}required:\s*(.+)$/m)?.[1] || '-'),
|
required: stripYamlValue(docBlock.match(/^\s+required:\s*(.+)$/m)?.[1] || '-'),
|
||||||
fieldCount: fields.length,
|
fieldCount: fields.length,
|
||||||
groups,
|
groups,
|
||||||
description,
|
description,
|
||||||
@@ -492,7 +507,7 @@ function parseVisualElements(source: string): RuleYamlPack['visualElements'] {
|
|||||||
|
|
||||||
return typedSections.flatMap(({ key, label }) => {
|
return typedSections.flatMap(({ key, label }) => {
|
||||||
const lines = section.split('\n');
|
const lines = section.split('\n');
|
||||||
const start = lines.findIndex(line => new RegExp(`^\\s{2}${key}:`).test(line));
|
const start = lines.findIndex(line => new RegExp(`^\\s+${key}:`).test(line));
|
||||||
if (start === -1) {
|
if (start === -1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -500,27 +515,33 @@ function parseVisualElements(source: string): RuleYamlPack['visualElements'] {
|
|||||||
// 找到下一个同级分类的起始位置(2空格+字母+冒号)
|
// 找到下一个同级分类的起始位置(2空格+字母+冒号)
|
||||||
let end = lines.length;
|
let end = lines.length;
|
||||||
for (let i = start + 1; i < lines.length; i++) {
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
if (/^\s{2}[a-zA-Z_][\w-]*:/.test(lines[i])) {
|
if (/^\s+[a-zA-Z_][\w-]*:/.test(lines[i])) {
|
||||||
end = i;
|
end = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const subSection = lines.slice(start + 1, end).join('\n');
|
const subSection = lines.slice(start + 1, end).join('\n');
|
||||||
return splitBlocks(subSection, /^\s{2}-\s+id:\s*/).map(block => ({
|
return splitBlocks(subSection, /^\s*-\s+id:\s*/).map(block => ({
|
||||||
id: stripYamlValue(block.match(/^\s{2}-\s+id:\s*(.+)$/m)?.[1] || ''),
|
id: stripYamlValue(block.match(/^\s*-\s+id:\s*(.+)$/m)?.[1] || ''),
|
||||||
name: stripYamlValue(block.match(/^\s{4}name:\s*(.+)$/m)?.[1] || ''),
|
name: stripYamlValue(block.match(/^\s+name:\s*(.+)$/m)?.[1] || ''),
|
||||||
type: label,
|
type: label,
|
||||||
required: stripYamlValue(block.match(/^\s{4}required:\s*(.+)$/m)?.[1] || '-'),
|
required: stripYamlValue(block.match(/^\s+required:\s*(.+)$/m)?.[1] || '-'),
|
||||||
signerRoles: block.match(/^\s{4}signer_roles:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [],
|
signerRoles: block.match(/^\s+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()) || [],
|
signatureTypes: block.match(/^\s+signature_types:\s*\[(.+)\]$/m)?.[1].split(',').map(s => s.trim()) || [],
|
||||||
privateSealRestricted: block.match(/^\s{4}private_seal_restricted:\s*(.+)$/m)?.[1] === 'true'
|
privateSealRestricted: block.match(/^\s+private_seal_restricted:\s*(.+)$/m)?.[1] === 'true'
|
||||||
}));
|
}));
|
||||||
}).filter(item => item.id);
|
}).filter(item => item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRuleYamlPack(
|
export function buildRuleYamlPack(
|
||||||
config: RulePackScope & { id: string; yamlPath: string | null },
|
config: RulePackScope & {
|
||||||
|
id: string;
|
||||||
|
yamlPath: string | null;
|
||||||
|
currentVersionId?: number | null;
|
||||||
|
fallbackVersionId?: number | null;
|
||||||
|
resolvedVersionId?: number | null;
|
||||||
|
},
|
||||||
yamlSource: string,
|
yamlSource: string,
|
||||||
sourceStatus: RuleYamlPack['sourceStatus']
|
sourceStatus: RuleYamlPack['sourceStatus']
|
||||||
): RuleYamlPack {
|
): RuleYamlPack {
|
||||||
|
|||||||
Reference in New Issue
Block a user