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(