diff --git a/app/routes/rulesTest.detail.tsx b/app/routes/rulesTest.detail.tsx index 46ede72..2ced69a 100644 --- a/app/routes/rulesTest.detail.tsx +++ b/app/routes/rulesTest.detail.tsx @@ -148,7 +148,9 @@ function matchesCurrentRuleDependency(currentRule: RuleSummary | undefined, cand if (deps.size === 0) return false; return candidates.some((candidate) => { const value = String(candidate || '').trim(); - return value ? deps.has(value) : false; + if (!value) return false; + if (deps.has(value)) return true; + return Array.from(deps).some((dependency) => dependency.startsWith(`${value}.`)); }); } @@ -187,6 +189,13 @@ function makeId(prefix: string): string { return `${prefix}-${Date.now()}`; } +function rewriteDependencyPrefix(dependency: string, from: string, to: string): string { + if (!from || !to || from === to) return dependency; + if (dependency === from) return to; + if (dependency.startsWith(`${from}.`)) return `${to}${dependency.slice(from.length)}`; + return dependency; +} + function emptyRuleDraft(group = '未分组'): RuleDraft { return { id: makeId('rule'), @@ -634,9 +643,13 @@ export default function RulesTestDetail() { () => versions.find((item) => !['published', 'rollback'].includes(item.status)), [versions], ); + const rollbackVersionOptions = useMemo( + () => versions, + [versions], + ); const rollbackOptions = useMemo( - () => versions.filter((item) => item.id !== pack.currentVersionId), - [versions, pack.currentVersionId], + () => rollbackVersionOptions.filter((item) => item.id !== pack.currentVersionId), + [rollbackVersionOptions, pack.currentVersionId], ); const rollbackTargetVersion = useMemo( () => rollbackOptions.find((item) => String(item.id) === selectedRollbackVersionId) || rollbackOptions[0] || null, @@ -644,7 +657,15 @@ export default function RulesTestDetail() { ); const packFilterMainType = pack.businessType || pack.mainType; const currentResolvedVersion = useMemo( - () => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null, + () => { + if (pack.currentVersionId) { + return versions.find((item) => item.id === pack.currentVersionId) || null; + } + if (pack.fallbackVersionId) { + return versions.find((item) => item.id === pack.fallbackVersionId) || null; + } + return null; + }, [pack.currentVersionId, pack.fallbackVersionId, versions], ); const versionStatusLabel = (status: string | undefined) => { @@ -743,6 +764,30 @@ export default function RulesTestDetail() { }); }; + const patchCurrentRuleDependencies = ( + replacements: Array<{ from: string; to: string }>, + appendedDependencies: string[], + ) => { + if (!currentRule) return; + setRules((current) => current.map((rule) => { + if (rule.id !== currentRule.id) return rule; + const rewritten = rule.dependencies.map((dependency) => ( + replacements.reduce( + (nextValue, item) => rewriteDependencyPrefix(nextValue, item.from, item.to), + dependency, + ) + )); + const merged = Array.from(new Set([ + ...rewritten, + ...appendedDependencies.filter(Boolean), + ])); + return { + ...rule, + dependencies: merged, + }; + })); + }; + const saveRule = () => { if (!editor || editor.kind !== 'rule') return; const existingRule = editor.id ? rules.find(rule => rule.id === editor.id) : undefined; @@ -802,6 +847,9 @@ export default function RulesTestDetail() { const saveDocument = () => { if (!editor || editor.kind !== 'document') return; + const previousDocument = editor.mode === 'edit' + ? subDocuments.find((document) => document.id === editor.id) + : undefined; const normalizedDocument: SubDocumentSummary = { ...documentDraft, id: documentDraft.id || makeId('document'), @@ -824,6 +872,13 @@ export default function RulesTestDetail() { setDraftSaved(false); setSaveMessage(''); setSaveError(''); + patchCurrentRuleDependencies( + [ + previousDocument ? { from: previousDocument.id, to: normalizedDocument.id } : null, + previousDocument ? { from: previousDocument.name, to: normalizedDocument.name } : null, + ].filter(Boolean) as Array<{ from: string; to: string }>, + [normalizedDocument.id], + ); setEditor(null); }; @@ -873,6 +928,9 @@ export default function RulesTestDetail() { const saveVisual = () => { if (!editor || editor.kind !== 'visual') return; + const previousVisual = editor.mode === 'edit' + ? visualElements.find((item) => item.id === editor.id) + : undefined; const normalizedVisual: VisualElementSummary = { ...visualDraft, id: visualDraft.id || makeId('visual'), @@ -889,6 +947,15 @@ export default function RulesTestDetail() { setDraftSaved(false); setSaveMessage(''); setSaveError(''); + patchCurrentRuleDependencies( + [ + previousVisual ? { from: previousVisual.id, to: normalizedVisual.id } : null, + previousVisual ? { from: previousVisual.name, to: normalizedVisual.name } : null, + previousVisual ? { from: `visual.${previousVisual.id}`, to: `visual.${normalizedVisual.id}` } : null, + previousVisual ? { from: `visual.${previousVisual.name || previousVisual.id}`, to: `visual.${normalizedVisual.name || normalizedVisual.id}` } : null, + ].filter(Boolean) as Array<{ from: string; to: string }>, + [`visual.${normalizedVisual.id}`], + ); setEditor(null); }; @@ -1027,13 +1094,13 @@ export default function RulesTestDetail() { className="rules-version-select" value={rollbackTargetVersion ? String(rollbackTargetVersion.id) : selectedRollbackVersionId} onChange={(event) => setSelectedRollbackVersionId(event.target.value)} - disabled={saveButtonBusy || rollbackOptions.length === 0} + disabled={saveButtonBusy || rollbackVersionOptions.length === 0} > - {rollbackOptions.length === 0 ? ( + {rollbackVersionOptions.length === 0 ? ( - ) : rollbackOptions.map((item) => ( - ))} diff --git a/app/utils/rules-yaml-mock.server.ts b/app/utils/rules-yaml-mock.server.ts index 49f54e6..1f7446a 100644 --- a/app/utils/rules-yaml-mock.server.ts +++ b/app/utils/rules-yaml-mock.server.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import YAML from 'yaml'; const LEAUDIT_RULES_ROOT = `${process.cwd()}/mock-data/leaudit-rules/packs/yc`; @@ -451,87 +452,118 @@ function parseTopLevelFields(source: string): ExtractFieldSummary[] { return [...extractedFields, ...derivedFields]; } -function parseDocumentFields(docBlock: string, documentId: string): ExtractFieldSummary[] { - return splitBlocks(docBlock, /^\s*-\s+group:\s*/).flatMap(groupBlock => { - const group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组'); - return splitBlocks(groupBlock, /^\s*-\s+name:\s*/).map(fieldBlock => { - const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || ''); - const rawType = stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || '-'); - return { - id: `${documentId}-${group}-${name}`, - group, - name, - type: rawType === 'multi_entity' ? 'verbatim' : rawType, - multipleEntities: rawType === 'multi_entity', - requiredFrom: '-', - description: stripYamlValue(fieldBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || '') - }; - }); - }).filter(field => field.name); -} - function parseSubDocuments(source: string): SubDocumentSummary[] { - const section = getTopLevelSection(source, 'sub_documents'); - return splitBlocks(section, /^\s*-\s+id:\s*/).map(docBlock => { - const id = stripYamlValue(docBlock.match(/^\s*-\s+id:\s*(.+)$/m)?.[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 classifier = docBlock.match(/^\s{2}classifier:\s*$/m); - let description = ''; - if (classifier) { - const keywordsMatch = docBlock.match(/keywords:\s*\n((?:\s{4}-\s+.+\n)+)/m); - if (keywordsMatch) { - const keywords = Array.from(keywordsMatch[1].matchAll(/^\s{4}-\s+(.+)$/gm)).map(match => stripYamlValue(match[1])).slice(0, 3); - description = keywords.join('、'); - } + const parsed = YAML.parse(source || '') as Record | null; + const subDocuments = parsed?.sub_documents; + if (!Array.isArray(subDocuments)) { + return []; + } + + return subDocuments.flatMap((documentNode) => { + if (!documentNode || typeof documentNode !== 'object') { + return []; } - return { - id, - name: stripYamlValue(docBlock.match(/^\s+name:\s*(.+)$/m)?.[1] || id), - required: stripYamlValue(docBlock.match(/^\s+required:\s*(.+)$/m)?.[1] || '-'), - fieldCount: fields.length, - groups, - description, - fields - }; - }).filter(doc => doc.id); -} - -function parseVisualElements(source: string): RuleYamlPack['visualElements'] { - const section = getTopLevelSection(source, 'visual_elements'); - const typedSections = [ - { key: 'seals', label: '签章' }, - { key: 'signatures', label: '签名' }, - { key: 'cross_page_seals', label: '骑缝章' } - ]; - - return typedSections.flatMap(({ key, label }) => { - const lines = section.split('\n'); - const start = lines.findIndex(line => new RegExp(`^\\s+${key}:`).test(line)); - if (start === -1) { + const document = documentNode as Record; + const id = String(document.id || '').trim(); + if (!id) { return []; } - // 找到下一个同级分类的起始位置(2空格+字母+冒号) - let end = lines.length; - for (let i = start + 1; i < lines.length; i++) { - if (/^\s+[a-zA-Z_][\w-]*:/.test(lines[i])) { - end = i; - break; + const extractGroups = Array.isArray(document.extract) ? document.extract : []; + const fields = extractGroups.flatMap((groupNode) => { + if (!groupNode || typeof groupNode !== 'object') { + return []; } + const groupObject = groupNode as Record; + const group = String(groupObject.group || '未分组').trim() || '未分组'; + const groupFields = Array.isArray(groupObject.fields) ? groupObject.fields : []; + return groupFields.flatMap((fieldNode) => { + if (!fieldNode || typeof fieldNode !== 'object') { + return []; + } + const field = fieldNode as Record; + const name = String(field.name || '').trim(); + if (!name) { + return []; + } + const rawType = String(field.type || '-').trim(); + return [{ + id: `${id}-${group}-${name}`, + group, + name, + type: rawType === 'multi_entity' ? 'verbatim' : rawType, + multipleEntities: rawType === 'multi_entity', + requiredFrom: '-', + description: String(field.desc || '').trim(), + }]; + }); + }); + + const groups = Array.from(new Set(fields.map((field) => field.group).filter(Boolean))); + const classifier = document.classifier && typeof document.classifier === 'object' + ? (document.classifier as Record) + : null; + const description = Array.isArray(classifier?.keywords) + ? classifier!.keywords.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 3).join('、') + : ''; + + return [{ + id, + name: String(document.name || id).trim(), + required: String(document.required ?? '-').trim(), + fieldCount: fields.length, + groups, + description, + fields, + }]; + }); +} + +function parseVisualElements(source: string): RuleYamlPack['visualElements'] { + const parsed = YAML.parse(source || '') as Record | null; + const visualRoot = parsed?.visual_elements; + if (!visualRoot || typeof visualRoot !== 'object') { + return []; + } + + const typedSections = [ + { key: 'seals', label: '签章' }, + { key: 'signatures', label: '签名' }, + { key: 'cross_page_seals', label: '骑缝章' }, + ] as const; + + return typedSections.flatMap(({ key, label }) => { + const bucket = (visualRoot as Record)[key]; + if (!Array.isArray(bucket)) { + return []; } - const subSection = lines.slice(start + 1, end).join('\n'); - return splitBlocks(subSection, /^\s*-\s+id:\s*/).map(block => ({ - id: stripYamlValue(block.match(/^\s*-\s+id:\s*(.+)$/m)?.[1] || ''), - name: stripYamlValue(block.match(/^\s+name:\s*(.+)$/m)?.[1] || ''), - type: label, - required: stripYamlValue(block.match(/^\s+required:\s*(.+)$/m)?.[1] || '-'), - signerRoles: block.match(/^\s+signer_roles:\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+private_seal_restricted:\s*(.+)$/m)?.[1] === 'true' - })); - }).filter(item => item.id); + return bucket.flatMap((item) => { + if (!item || typeof item !== 'object') { + return []; + } + const node = item as Record; + const id = String(node.id || '').trim(); + if (!id) { + return []; + } + const toStringList = (value: unknown): string[] => ( + Array.isArray(value) + ? value.map((entry) => String(entry || '').trim()).filter(Boolean) + : [] + ); + + return [{ + id, + name: String(node.name || id).trim(), + type: label, + required: String(node.required ?? '-').trim(), + signerRoles: toStringList(node.signer_roles), + signatureTypes: toStringList(node.signature_types), + privateSealRestricted: Boolean(node.private_seal_restricted), + }]; + }); + }); } export function buildRuleYamlPack(